diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..7592fc1 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Quality checks +on: + pull_request: + types: [opened, reopened, synchronize, closed] +jobs: + quality_checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.12 + - name: Install Poetry + shell: bash + run: pipx install poetry~=2.0 + - name: Install project + shell: bash + run: poetry install + - name: Run pre-commit + shell: bash + run: poetry run pre-commit run --all-files + - name: Run unit and integration tests + shell: bash + run: poetry run pytest tests diff --git a/.gitignore b/.gitignore index 307abe8..eb850f4 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ dmypy.json # Pycharm .idea _viminfo + +# VS Code +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8af37a8..99b718e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -22,17 +22,17 @@ repos: args: ['--unsafe'] - id: name-tests-test - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.6 + rev: v0.15.12 hooks: - - id: ruff - args: ["--fix"] + - id: ruff-check + args: [ --fix ] - id: ruff-format - repo: https://github.com/fsfe/reuse-tool - rev: v5.0.2 + rev: v6.2.0 hooks: - id: reuse-lint-file - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v1.20.2 hooks: - id: mypy args: [--ignore-missing-imports] diff --git a/PROJECT_GOVERNANCE.md b/PROJECT_GOVERNANCE.md index 12099eb..e2c6376 100644 --- a/PROJECT_GOVERNANCE.md +++ b/PROJECT_GOVERNANCE.md @@ -1,5 +1,5 @@ diff --git a/README.md b/README.md index 9ea3760..724ab83 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ requests to us. # License This project is licensed under the Apache-2.0 - see [LICENSE](https://github.com/alliander-opensource/utility-route-planner/blob/main/LICENSE) for details. -## Licenses data +## Licenses data This project is largely dependent on data. Data is incorporated in the example folder and is licensed under their own respective Open-Source licenses: - BGT: [CC PDM 1.0](https://creativecommons.org/publicdomain/mark/1.0/deed.en) downloaded from [PDOK](https://www.nationaalgeoregister.nl/geonetwork/srv/dut/catalog.search#/metadata/e01e63cd-6b3d-4c58-b34e-8d343a3c264b) diff --git a/data/examples/pytest_example_suitability_raster.tif b/data/examples/pytest_example_suitability_raster.tif index 5c69917..8de9dc5 100644 Binary files a/data/examples/pytest_example_suitability_raster.tif and b/data/examples/pytest_example_suitability_raster.tif differ diff --git a/data/examples/pytest_osm_graph.pkl b/data/examples/pytest_osm_graph.pkl new file mode 100644 index 0000000..51d4a3e Binary files /dev/null and b/data/examples/pytest_osm_graph.pkl differ diff --git a/data/examples/pytest_osm_graph.pkl.license b/data/examples/pytest_osm_graph.pkl.license new file mode 100644 index 0000000..986fdb8 --- /dev/null +++ b/data/examples/pytest_osm_graph.pkl.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. + +SPDX-License-Identifier: Apache-2.0 diff --git a/main.py b/main.py index 597eb83..83c1e5f 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,13 @@ # SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. # # SPDX-License-Identifier: Apache-2.0 - import pathlib import time import shapely import structlog +from utility_route_planner.models.benchmark_routes import BenchmarkRouteCollection from utility_route_planner.models.lcpa.lcpa_engine import LcpaUtilityRouteEngine from settings import Config from utility_route_planner.models.mcda.mcda_engine import McdaCostSurfaceEngine @@ -27,6 +27,7 @@ def run_mcda_lcpa( human_designed_route: shapely.LineString, raster_name_prefix: str, compute_rasters_in_parallel: bool, + compute_metrics: bool = True, ): reset_geopackage(Config.PATH_GEOPACKAGE_MCDA_OUTPUT, truncate=False) @@ -49,71 +50,43 @@ def run_mcda_lcpa( ) logger.info(f"Route CPU time: {(time.process_time_ns() - start_cpu_time) / 1e9:.2f} seconds.") - route_evaluation_metrics = RouteEvaluationMetrics( - lcpa_engine.lcpa_result, - path_suitability_raster, - human_designed_route, - project_area_geometry, - mcda_engine.processed_vector_metrics, - ) - route_evaluation_metrics.get_route_evaluation_metrics() + if compute_metrics: + route_evaluation_metrics = RouteEvaluationMetrics( + lcpa_engine.lcpa_result, + path_suitability_raster, + human_designed_route, + project_area_geometry, + mcda_engine.processed_vector_metrics, + ) + route_evaluation_metrics.get_route_evaluation_metrics() if __name__ == "__main__": - cases = [ - ( - Config.PATH_GEOPACKAGE_CASE_01, - Config.LAYER_NAME_PROJECT_AREA_CASE_01, - Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_01, - "route_1_", - [], - ), - ( - Config.PATH_GEOPACKAGE_CASE_02, - Config.LAYER_NAME_PROJECT_AREA_CASE_02, - Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_02, - "route_2_", - [], - ), - ( - Config.PATH_GEOPACKAGE_CASE_03, - Config.LAYER_NAME_PROJECT_AREA_CASE_03, - Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_03, - "route_3_", - [], - ), - ( - Config.PATH_GEOPACKAGE_CASE_04, - Config.LAYER_NAME_PROJECT_AREA_CASE_04, - Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_04, - "route_4_", - [], - ), - ( - Config.PATH_GEOPACKAGE_CASE_05, - Config.LAYER_NAME_PROJECT_AREA_CASE_05, - Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_05, - "route_5_", - [[121462.8, 487153.4]], - ), - ] - reset_geopackage(Config.PATH_GEOPACKAGE_LCPA_OUTPUT, truncate=True) + route_collection = BenchmarkRouteCollection() - cases_to_run = [0, 1, 2, 3, 4] # 0/1/2/3/4 - for case in cases_to_run: - geopackage, layer_project_area, human_designed_route_name, raster_name_prefix, stops = cases[case] - human_designed_route = gpd.read_file(geopackage, layer=human_designed_route_name).iloc[0].geometry + cases_to_run = [1, 2, 3, 4, 5] + for case_id in cases_to_run: + benchmark_route = route_collection.get_case(case_id) + human_designed_route = ( + gpd.read_file(benchmark_route.path_geopackage, layer=benchmark_route.layer_name_human_designed_route) + .iloc[0] + .geometry + ) route_stops = get_first_last_point_from_linestring(human_designed_route) - if stops: - route_stops = list(route_stops)[:1] + [shapely.Point(i) for i in stops] + list(route_stops)[1:] # type: ignore + if benchmark_route.stops: + route_stops = ( + list(route_stops)[:1] + [shapely.Point(i) for i in benchmark_route.stops] + list(route_stops)[1:] # type: ignore + ) run_mcda_lcpa( "preset_benchmark_raw", - geopackage, - gpd.read_file(geopackage, layer=layer_project_area).iloc[0].geometry, + benchmark_route.path_geopackage, + gpd.read_file(benchmark_route.path_geopackage, layer=benchmark_route.layer_name_project_area) + .iloc[0] + .geometry, route_stops, human_designed_route, - raster_name_prefix, + benchmark_route.raster_name_prefix, compute_rasters_in_parallel=True, ) diff --git a/multilayer_main.py b/multilayer_main.py new file mode 100644 index 0000000..17777b4 --- /dev/null +++ b/multilayer_main.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pathlib + +import shapely +import geopandas as gpd +import time + +import structlog +import typer +from structlog.contextvars import bound_contextvars + +from settings import Config +from utility_route_planner.models.benchmark_routes import BenchmarkRouteCollection +from utility_route_planner.models.mcda.mcda_engine import McdaCostSurfaceEngine +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_graph_builder import HexagonGraphBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import HexagonGridBuilder +from utility_route_planner.models.multilayer_network.multilayer_route_planner import MultilayerRouteEngine +from utility_route_planner.models.multilayer_network.osm_graph_downloader import OSMGraphDownloader +from utility_route_planner.models.multilayer_network.osm_graph_preprocessing import OSMGraphPreprocessor +from utility_route_planner.models.multilayer_network.pipe_ramming import GetPotentialPipeRammingCrossings +from utility_route_planner.util.write import reset_geopackage + +logger = structlog.get_logger(__name__) +app = typer.Typer(pretty_exceptions_enable=False) + + +def run_multilayer_network( + preset: str, + path_geopackage_mcda_input: pathlib.Path, + start_mid_end_points: shapely.LineString, + project_area_geometry: shapely.Polygon, + write_output: bool, +): + reset_geopackage(Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, truncate=False) + reset_geopackage(Config.PATH_GEOPACKAGE_MCDA_OUTPUT, truncate=False) + + start_cpu_time = time.process_time_ns() + + raw_graph = OSMGraphDownloader(project_area_geometry).download_graph() + osm_graph_preprocessed = OSMGraphPreprocessor(raw_graph).preprocess_graph() + + mcda_engine = McdaCostSurfaceEngine(preset, path_geopackage_mcda_input, project_area_geometry) + mcda_engine.preprocess_vectors() + + raster_groups = { + criteria_key: criteria.group for criteria_key, criteria in mcda_engine.raster_preset.criteria.items() + } + grid_constructor = HexagonGridBuilder(hexagon_size=Config.HEXAGON_SIZE, block_size=Config.HEXAGON_BLOCK_SIZE) + hexagon_edge_generator = HexagonEdgeGenerator() + hexagon_graph_builder = HexagonGraphBuilder(grid_builder=grid_constructor, edge_generator=hexagon_edge_generator) + cost_surface_graph, cost_surface_nodes = hexagon_graph_builder.build_graph( + mcda_engine.project_area_geometry, + raster_groups, + mcda_engine.processed_vectors, + ) + pipe_ramming = GetPotentialPipeRammingCrossings(osm_graph_preprocessed, cost_surface_graph, cost_surface_nodes) + pipe_ramming.get_crossings() + + multi_layer_route_engine = MultilayerRouteEngine( + pipe_ramming.cost_surface_graph, + pipe_ramming.osm_graph, + pipe_ramming.cost_surface_nodes, + Config.HEXAGON_SIZE, + prefix="testing_", + write_output=write_output, + ) + multi_layer_route_engine.find_route(start_mid_end_points) + + logger.info(f"Multilayer route CPU time: {(time.process_time_ns() - start_cpu_time) / 1e9:.2f} seconds.") + + +@app.command() +def run_debug_case(write_routing_output: bool = False): + with bound_contextvars(project_area="Componistenbuurt"): + logger.info("Running multilayer routing engine for debug project area") + run_multilayer_network( + Config.RASTER_PRESET_NAME_BENCHMARK, + Config.PYTEST_PATH_GEOPACKAGE_MCDA, + shapely.LineString([(174847.18, 451178.43), (175746.347, 450435.534)]), + gpd.read_file(Config.PYTEST_PATH_GEOPACKAGE_MCDA, layer=Config.PYTEST_LAYER_NAME_PROJECT_AREA) + .iloc[0] + .geometry, + write_routing_output, + ) + + +@app.command() +def run_benchmark_case(benchmark_case_id: int, write_routing_output: bool = False): + benchmark_routes = BenchmarkRouteCollection() + with bound_contextvars(benchmark_id=benchmark_case_id): + benchmark_route = benchmark_routes.get_case(benchmark_case_id) + run_multilayer_network( + Config.RASTER_PRESET_NAME_BENCHMARK, + benchmark_route.path_geopackage, + gpd.read_file(benchmark_route.path_geopackage, layer=benchmark_route.layer_name_human_designed_route) + .iloc[0] + .geometry, + gpd.read_file(benchmark_route.path_geopackage, layer=benchmark_route.layer_name_project_area) + .iloc[0] + .geometry, + write_routing_output, + ) + + +if __name__ == "__main__": + app() diff --git a/poetry.lock b/poetry.lock index 9596c73..ff8ad8e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "affine" @@ -7,7 +7,6 @@ description = "Matrices describing affine transformation of the plane" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "affine-2.4.0-py3-none-any.whl", hash = "sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92"}, {file = "affine-2.4.0.tar.gz", hash = "sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea"}, @@ -17,6 +16,18 @@ files = [ dev = ["coveralls", "flake8", "pydocstyle"] test = ["pytest (>=4.6)", "pytest-cov"] +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -24,7 +35,6 @@ description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -37,7 +47,6 @@ description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, @@ -50,7 +59,6 @@ description = "cffi-based cairo bindings for Python" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f"}, {file = "cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b"}, @@ -66,15 +74,14 @@ xcb = ["xcffib (>=1.4.0)"] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, - {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, + {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, + {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, ] [[package]] @@ -84,7 +91,6 @@ description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -182,7 +188,6 @@ description = "Validate configuration and produce human readable error messages. optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, @@ -195,7 +200,6 @@ description = "The Real First Universal Charset Detector. Open, modern and activ optional = false python-versions = ">=3.7" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -330,15 +334,14 @@ files = [ [[package]] name = "click" -version = "8.3.1" +version = "8.3.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, + {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, + {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, ] [package.dependencies] @@ -351,7 +354,6 @@ description = "An extension module for click to enable registering CLI commands optional = false python-versions = "*" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, @@ -370,7 +372,6 @@ description = "Click params for commmand line interfaces to GeoJSON" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, <4" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df"}, {file = "cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27"}, @@ -389,7 +390,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "(sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\") and (platform_system == \"Windows\" or sys_platform == \"win32\")" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -402,7 +403,6 @@ description = "Python library for calculating contours of 2D quadrilateral grids optional = false python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"}, {file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"}, @@ -495,7 +495,6 @@ description = "Composable style cycles" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, @@ -512,7 +511,6 @@ description = "Distribution utilities" optional = false python-versions = "*" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, @@ -520,15 +518,14 @@ files = [ [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, - {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, + {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, + {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, ] [[package]] @@ -538,7 +535,6 @@ description = "Fiona reads and writes spatial data files" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "fiona-1.10.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:6e2a94beebda24e5db8c3573fe36110d474d4a12fac0264a3e083c75e9d63829"}, {file = "fiona-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc7366f99bdc18ec99441b9e50246fdf5e72923dc9cbb00267b2bf28edd142ba"}, @@ -587,7 +583,6 @@ description = "Tools to manipulate font files" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, @@ -661,7 +656,6 @@ description = "Geographic pandas extensions" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "geopandas-1.1.3-py3-none-any.whl", hash = "sha256:90d62a64f95eaa3be2ccc115c5f3d6e24208bb11983b390fdc0621a3eccd0230"}, {file = "geopandas-1.1.3.tar.gz", hash = "sha256:91a31989b6f566012838d21d5f8033f37dce882079ccb7cfdc40d5ccce7f284f"}, @@ -681,15 +675,14 @@ dev = ["codecov", "pre-commit", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist" [[package]] name = "identify" -version = "2.6.18" +version = "2.6.19" description = "File identification library for Python" optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737"}, - {file = "identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd"}, + {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, + {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, ] [package.extras] @@ -697,19 +690,18 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.11" +version = "3.13" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"}, + {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "imageio" @@ -718,7 +710,6 @@ description = "Read and write images and video across all major formats. Support optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0"}, {file = "imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451"}, @@ -753,7 +744,6 @@ description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -766,7 +756,6 @@ description = "A fast implementation of the Cassowary constraint solver" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374"}, {file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd"}, @@ -894,7 +883,6 @@ description = "Makes it easy to load subpackages and functions on demand." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005"}, {file = "lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3"}, @@ -910,169 +898,192 @@ test = ["coverage[toml] (>=7.2)", "pytest (>=8.0)", "pytest-cov (>=5.0)"] [[package]] name = "librt" -version = "0.8.1" +version = "0.9.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["linting"] -markers = "(sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\") and platform_python_implementation != \"PyPy\"" -files = [ - {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, - {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, - {file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"}, - {file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"}, - {file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"}, - {file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"}, - {file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"}, - {file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"}, - {file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"}, - {file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"}, - {file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"}, - {file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"}, - {file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"}, - {file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"}, - {file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"}, - {file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"}, - {file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"}, - {file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"}, - {file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"}, - {file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"}, - {file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"}, - {file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"}, - {file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"}, - {file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"}, - {file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"}, - {file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"}, - {file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"}, - {file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"}, - {file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"}, - {file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"}, - {file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"}, - {file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"}, - {file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"}, - {file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"}, - {file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"}, - {file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"}, - {file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"}, - {file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"}, - {file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"}, - {file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"}, - {file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"}, - {file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"}, - {file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"}, - {file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"}, - {file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"}, - {file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"}, - {file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"}, - {file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"}, - {file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"}, - {file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"}, - {file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"}, - {file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"}, - {file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"}, - {file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"}, - {file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"}, - {file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"}, - {file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"}, - {file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"}, +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443"}, + {file = "librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c"}, + {file = "librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e"}, + {file = "librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285"}, + {file = "librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2"}, + {file = "librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b"}, + {file = "librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774"}, + {file = "librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8"}, + {file = "librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671"}, + {file = "librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d"}, + {file = "librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6"}, + {file = "librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1"}, + {file = "librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882"}, + {file = "librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a"}, + {file = "librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6"}, + {file = "librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8"}, + {file = "librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a"}, + {file = "librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4"}, + {file = "librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d"}, + {file = "librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f"}, + {file = "librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27"}, + {file = "librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2"}, + {file = "librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f"}, + {file = "librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f"}, + {file = "librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745"}, + {file = "librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9"}, + {file = "librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e"}, + {file = "librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22"}, + {file = "librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a"}, + {file = "librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5"}, + {file = "librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11"}, + {file = "librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d"}, + {file = "librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd"}, + {file = "librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519"}, + {file = "librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5"}, + {file = "librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb"}, + {file = "librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499"}, + {file = "librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f"}, + {file = "librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1"}, + {file = "librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f"}, + {file = "librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b"}, + {file = "librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9"}, + {file = "librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e"}, + {file = "librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f"}, + {file = "librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4"}, + {file = "librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15"}, + {file = "librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40"}, + {file = "librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118"}, + {file = "librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61"}, + {file = "librt-0.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5112c2fb7c2eefefaeaf5c97fec81343ef44ee86a30dcfaa8223822fba6467b4"}, + {file = "librt-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a81eea9b999b985e4bacc650c4312805ea7008fd5e45e1bf221310176a7bcb3a"}, + {file = "librt-0.9.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eea1b54943475f51698f85fa230c65ccac769f1e603b981be060ac5763d90927"}, + {file = "librt-0.9.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81107843ed1836874b46b310f9b1816abcb89912af627868522461c3b7333c0f"}, + {file = "librt-0.9.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa95738a68cedd3a6f5492feddc513e2e166b50602958139e47bbdd82da0f5a7"}, + {file = "librt-0.9.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6788207daa0c19955d2b668f3294a368d19f67d9b5f274553fd073c1260cbb9f"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f48c963a76d71b9d7927eb817b543d0dccd52ab6648b99d37bd54f4cd475d856"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:42ff8a962554c350d4a83cf47d9b7b78b0e6ff7943e87df7cdfc97c07f3c016f"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:657f8ba7b9eaaa82759a104137aed2a3ef7bc46ccfd43e0d89b04005b3e0a4cc"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d03fa4fd277a7974c1978c92c374c57f44edeee163d147b477b143446ad1bf6"}, + {file = "librt-0.9.0-cp39-cp39-win32.whl", hash = "sha256:d9da80e5b04acce03ced8ba6479a71c2a2edf535c2acc0d09c80d2f80f3bad15"}, + {file = "librt-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:54d412e47c21b85865676ed0724e37a89e9593c2eee1e7367adf85bfad56ffb1"}, + {file = "librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d"}, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, ] +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.10.9" description = "Python plotting package" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" -files = [ - {file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, - {file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, - {file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, - {file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, - {file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, - {file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, - {file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, - {file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008"}, - {file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1"}, - {file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, - {file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, +files = [ + {file = "matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217"}, + {file = "matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b"}, + {file = "matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37"}, + {file = "matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294"}, + {file = "matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65"}, + {file = "matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda"}, + {file = "matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb"}, + {file = "matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb"}, + {file = "matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb"}, + {file = "matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9"}, + {file = "matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb"}, + {file = "matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f"}, + {file = "matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80"}, + {file = "matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1"}, + {file = "matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320"}, + {file = "matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285"}, + {file = "matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2"}, + {file = "matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf"}, + {file = "matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6"}, + {file = "matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42"}, + {file = "matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f"}, + {file = "matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e"}, + {file = "matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f"}, + {file = "matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838"}, + {file = "matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2"}, + {file = "matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921"}, + {file = "matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8"}, + {file = "matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9"}, + {file = "matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4"}, + {file = "matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc"}, + {file = "matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99"}, + {file = "matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d"}, + {file = "matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8"}, + {file = "matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38"}, + {file = "matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d"}, + {file = "matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f"}, + {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b"}, + {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2"}, + {file = "matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716"}, + {file = "matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f"}, + {file = "matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456"}, + {file = "matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe"}, + {file = "matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6"}, + {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c"}, + {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4"}, + {file = "matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf"}, + {file = "matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39"}, + {file = "matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4"}, + {file = "matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358"}, ] [package.dependencies] @@ -1087,68 +1098,82 @@ pyparsing = ">=3" python-dateutil = ">=2.7" [package.extras] -dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] +dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7,<10)"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] [[package]] name = "mypy" -version = "1.20.0" +version = "1.20.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" -files = [ - {file = "mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8"}, - {file = "mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a"}, - {file = "mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865"}, - {file = "mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca"}, - {file = "mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018"}, - {file = "mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13"}, - {file = "mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281"}, - {file = "mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b"}, - {file = "mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367"}, - {file = "mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62"}, - {file = "mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0"}, - {file = "mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f"}, - {file = "mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e"}, - {file = "mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442"}, - {file = "mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214"}, - {file = "mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e"}, - {file = "mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651"}, - {file = "mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5"}, - {file = "mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78"}, - {file = "mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489"}, - {file = "mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33"}, - {file = "mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134"}, - {file = "mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c"}, - {file = "mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe"}, - {file = "mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f"}, - {file = "mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726"}, - {file = "mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69"}, - {file = "mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e"}, - {file = "mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948"}, - {file = "mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5"}, - {file = "mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188"}, - {file = "mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83"}, - {file = "mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2"}, - {file = "mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732"}, - {file = "mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef"}, - {file = "mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1"}, - {file = "mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436"}, - {file = "mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6"}, - {file = "mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526"}, - {file = "mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787"}, - {file = "mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb"}, - {file = "mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd"}, - {file = "mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e"}, - {file = "mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3"}, +files = [ + {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, + {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"}, + {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"}, + {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"}, + {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"}, + {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"}, + {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"}, + {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"}, + {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"}, + {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"}, + {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"}, + {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"}, + {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"}, + {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"}, + {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"}, + {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"}, + {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"}, + {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"}, + {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"}, + {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"}, + {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"}, + {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"}, ] [package.dependencies] librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=1.0.0" -typing_extensions = ">=4.6.0" +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] [package.extras] dmypy = ["psutil (>=4.0)"] @@ -1165,7 +1190,6 @@ description = "Type system extensions for programs checked with the mypy type ch optional = false python-versions = ">=3.8" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -1178,7 +1202,7 @@ description = "Python package for creating and manipulating graphs and networks" optional = false python-versions = ">=3.11" groups = ["main"] -markers = "(sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\") and python_version >= \"3.14\"" +markers = "python_version == \"3.14\"" files = [ {file = "networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f"}, {file = "networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad"}, @@ -1202,7 +1226,7 @@ description = "Python package for creating and manipulating graphs and networks" optional = false python-versions = "!=3.14.1,>=3.11" groups = ["main"] -markers = "(sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\") and python_version < \"3.14\"" +markers = "python_version < \"3.14\" or python_version >= \"3.15\"" files = [ {file = "networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762"}, {file = "networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509"}, @@ -1226,7 +1250,6 @@ description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, @@ -1239,7 +1262,6 @@ description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db"}, {file = "numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0"}, @@ -1322,7 +1344,6 @@ description = "Download, model, analyze, and visualize street networks and other optional = false python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "osmnx-2.1.0-py3-none-any.whl", hash = "sha256:9d6e7692321a4e40c5ca68ef8b85fbfb550244c4135fe9b55cfd4f810a5c8e2e"}, {file = "osmnx-2.1.0.tar.gz", hash = "sha256:0175ab8710bb973cb7cc76c33eeddd670d9d2f4b444da08a1080c470844add2b"}, @@ -1345,15 +1366,14 @@ visualization = ["matplotlib (>=3.6)"] [[package]] name = "packaging" -version = "26.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -1363,7 +1383,6 @@ description = "Powerful data structures for data analysis, time series, and stat optional = false python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0"}, {file = "pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c"}, @@ -1451,22 +1470,20 @@ xml = ["lxml (>=5.3.0)"] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] [package.extras] hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pillow" @@ -1475,7 +1492,6 @@ description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, @@ -1580,15 +1596,14 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.9.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, - {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, + {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, + {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, ] [[package]] @@ -1598,7 +1613,6 @@ description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1608,17 +1622,79 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "polars" +version = "1.40.1" +description = "Blazingly fast DataFrame library" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd"}, + {file = "polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8"}, +] + +[package.dependencies] +polars-runtime-32 = "1.40.1" + +[package.extras] +adbc = ["adbc-driver-manager[dbapi]", "adbc-driver-sqlite[dbapi]"] +all = ["polars[async,cloudpickle,database,deltalake,excel,fsspec,graph,iceberg,numpy,pandas,plot,pyarrow,pydantic,style,timezone]"] +async = ["gevent"] +calamine = ["fastexcel (>=0.9)"] +cloudpickle = ["cloudpickle"] +connectorx = ["connectorx (>=0.3.2)"] +database = ["polars[adbc,connectorx,sqlalchemy]"] +deltalake = ["deltalake (>=1.0.0)"] +excel = ["polars[calamine,openpyxl,xlsx2csv,xlsxwriter]"] +fsspec = ["fsspec"] +gpu = ["cudf-polars-cu12"] +graph = ["matplotlib"] +iceberg = ["pyiceberg (>=0.7.1)"] +numpy = ["numpy (>=1.16.0)"] +openpyxl = ["openpyxl (>=3.0.0)"] +pandas = ["pandas", "polars[pyarrow]"] +plot = ["altair (>=5.4.0)"] +polars-cloud = ["polars_cloud (>=0.4.0)"] +pyarrow = ["pyarrow (>=7.0.0)"] +pydantic = ["pydantic"] +rt64 = ["polars-runtime-64 (==1.40.1)"] +rtcompat = ["polars-runtime-compat (==1.40.1)"] +sqlalchemy = ["polars[pandas]", "sqlalchemy"] +style = ["great-tables (>=0.8.0)"] +timezone = ["tzdata ; platform_system == \"Windows\""] +xlsx2csv = ["xlsx2csv (>=0.8.0)"] +xlsxwriter = ["xlsxwriter"] + +[[package]] +name = "polars-runtime-32" +version = "1.40.1" +description = "Blazingly fast DataFrame library" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537"}, + {file = "polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814"}, +] + [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, - {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, + {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, + {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, ] [package.dependencies] @@ -1628,6 +1704,66 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pyarrow" +version = "24.0.0" +description = "Python library for Apache Arrow" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb"}, + {file = "pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147"}, + {file = "pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c"}, + {file = "pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041"}, + {file = "pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491"}, + {file = "pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1"}, + {file = "pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591"}, + {file = "pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74"}, + {file = "pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3"}, + {file = "pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868"}, + {file = "pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e"}, + {file = "pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57"}, + {file = "pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c"}, + {file = "pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981"}, + {file = "pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810"}, + {file = "pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a"}, + {file = "pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66"}, + {file = "pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb"}, + {file = "pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e"}, + {file = "pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6"}, + {file = "pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826"}, + {file = "pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba"}, + {file = "pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68"}, + {file = "pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2"}, + {file = "pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0"}, + {file = "pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495"}, + {file = "pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f"}, + {file = "pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91"}, + {file = "pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275"}, + {file = "pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b"}, + {file = "pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42"}, + {file = "pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b"}, + {file = "pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37"}, + {file = "pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca"}, + {file = "pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d"}, + {file = "pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838"}, + {file = "pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b"}, + {file = "pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795"}, + {file = "pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26"}, + {file = "pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde"}, + {file = "pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76"}, + {file = "pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e"}, + {file = "pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05"}, + {file = "pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a"}, + {file = "pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072"}, + {file = "pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931"}, + {file = "pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699"}, + {file = "pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136"}, + {file = "pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19"}, + {file = "pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83"}, +] + [[package]] name = "pycparser" version = "3.0" @@ -1635,7 +1771,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "(sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\") and implementation_name != \"PyPy\"" +markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, @@ -1643,20 +1779,19 @@ files = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, + {file = "pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927"}, + {file = "pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" +pydantic-core = "2.46.3" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -1666,139 +1801,159 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +files = [ + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win32.whl", hash = "sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win_amd64.whl", hash = "sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff"}, + {file = "pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c"}, ] [package.dependencies] typing-extensions = ">=4.14.1" +[[package]] +name = "pygeoops" +version = "0.6.0" +description = "Library with some less common or extended spatial functions" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pygeoops-0.6.0-py3-none-any.whl", hash = "sha256:570d6a1e2252801d3b8fba40f6b63326ea24f130e71ae37138940a35c02675d8"}, + {file = "pygeoops-0.6.0.tar.gz", hash = "sha256:062282752fd6c6f1255fb6b31c702379b1466bdb0fd2579b08d21496df35ac82"}, +] + +[package.dependencies] +geopandas = ">=0.12.1" +numpy = "*" +pyproj = "*" +shapely = ">1" +topojson = "*" + +[package.extras] +full = ["simplification"] + [[package]] name = "pygments" version = "2.20.0" @@ -1806,7 +1961,6 @@ description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -1817,44 +1971,55 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyogrio" -version = "0.10.0" +version = "0.12.1" description = "Vectorized spatial vector file format I/O using GDAL/OGR" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" -files = [ - {file = "pyogrio-0.10.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:046eeeae12a03a3ebc3dc5ff5a87664e4f5fc0a4fb1ea5d5c45d547fa941072b"}, - {file = "pyogrio-0.10.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:44380f4d9245c776f432526e29ce4d29238aea26adad991803c4f453474f51d3"}, - {file = "pyogrio-0.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14fd3b72b4e2dc59e264607b265c742b0c5ec2ea9e748b115f742381b28dd373"}, - {file = "pyogrio-0.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1fea7892f4633cab04d13563e47ec2e87dc2b5cd71b9546018d123184528c151"}, - {file = "pyogrio-0.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3539596a76eb8a9d166d6f9d3f36731a8c5bd5c43901209d89dc66b9dc00f079"}, - {file = "pyogrio-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:eac90b2501656892c63bc500c12e71f3dbf7d66ddc5a7fb05cd480d25d1b7022"}, - {file = "pyogrio-0.10.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b1a51431a27a1cb3e4e19558939c1423106e06e7b67d6285f4fba9c2d0a91b9"}, - {file = "pyogrio-0.10.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:216d69cd77b2b4a0c9d7d449bc239f8b77f3d73f4a05d9c738a0745b236902d8"}, - {file = "pyogrio-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2f0b75f0077ce33256aec6278c2a9c3b79bf0637ddf4f93d3ab2609f0501d96"}, - {file = "pyogrio-0.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0a47f702d29808c557d2ebea8542c23903f021eae44e16838adef2ab4281c71b"}, - {file = "pyogrio-0.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:11e6c71d12da6b445e77d0fc0198db1bd35a77e03a0685e45338cbab9ce02add"}, - {file = "pyogrio-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:d0d74e91a9c0ff2f9abe01b556ff663977193b2d6922208406172d0fc833beff"}, - {file = "pyogrio-0.10.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d6558b180e020f71ab7aa7f82d592ed3305c9f698d98f6d0a4637ec7a84c4ce"}, - {file = "pyogrio-0.10.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:a99102037eead8ba491bc57825c1e395ee31c9956d7bff7b4a9e4fdbff3a13c2"}, - {file = "pyogrio-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a4c373281d7cbf560c5b61f8f3c7442103ad7f1c7ac4ef3a84572ed7a5dd2f6"}, - {file = "pyogrio-0.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:19f18411bdf836d24cdc08b9337eb3ec415e4ac4086ba64516b36b73a2e88622"}, - {file = "pyogrio-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1abbcdd9876f30bebf1df8a0273f6cdeb29d03259290008275c7fddebe139f20"}, - {file = "pyogrio-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a3e09839590d71ff832aa95c4f23fa00a2c63c3de82c1fbd4fb8d265792acfc"}, - {file = "pyogrio-0.10.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c90478209537a31dcc65664a87a04c094bb0e08efe502908a6682b8cec0259bf"}, - {file = "pyogrio-0.10.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:fec45e1963b7058e5a1aa98598aed07c0858512c833d6aad2c672c3ec98bbf04"}, - {file = "pyogrio-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28cb139f8a5d0365ede602230104b407ae52bb6b55173c8d5a35424d28c4a2c5"}, - {file = "pyogrio-0.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:cea0187fcc2d574e52af8cfab041fa0a7ad71d5ef6b94b49a3f3d2a04534a27e"}, - {file = "pyogrio-0.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:7c02b207ea8cf09c501ea3e95d29152781a00d3c32267286bc36fa457c332205"}, - {file = "pyogrio-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:02e54bcfb305af75f829044b0045f74de31b77c2d6546f7aaf96822066147848"}, - {file = "pyogrio-0.10.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:ea96a1338ed7991735b955d3f84ad5f71b3bc070b6a7a42449941aedecc71768"}, - {file = "pyogrio-0.10.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:32d349600561459791a43f528a92f3e9343a59bdc9bc30b1be9376f0b80cbf16"}, - {file = "pyogrio-0.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82f7bd6a87bd2e9484bcb4c87ab94eee4c2f573ad148707431c8b341d7f13d99"}, - {file = "pyogrio-0.10.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6166ae81462c257ed8e151c404e316642703813cf771c95ef8e11dcdf2581e47"}, - {file = "pyogrio-0.10.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:22d57495e835fe51b88da43dfbda606c07e1f6c3b849af0c3cfc18e17467641c"}, - {file = "pyogrio-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:eea82171bfc07fc778b8dc87b0cdc9ac06c389bc56b0c0b6f34bf9e45fb78c0e"}, - {file = "pyogrio-0.10.0.tar.gz", hash = "sha256:ec051cb568324de878828fae96379b71858933413e185148acb6c162851ab23c"}, +files = [ + {file = "pyogrio-0.12.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c4735235ca0d8dcdb4ecd69bd73e66762d161bce913b10d4458a18137cc7062"}, + {file = "pyogrio-0.12.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3249d06c2520857b622f3ff0f1b7b4849291ee1fb72f21587825f5fd0f24b787"}, + {file = "pyogrio-0.12.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4011b63f9d6c278ee6605971ffabe30b0e8f5992ec2c6df8c70ecfa68a5d02b"}, + {file = "pyogrio-0.12.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:940857c45051e1e19608ebfe8338bcdf7dd005389057431a3c7b5bff5beb0a5f"}, + {file = "pyogrio-0.12.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0fd86bcd69126739325a543a489f312b5fd86db092d2dead682772ae4ee434f3"}, + {file = "pyogrio-0.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:dcf9cca273ead32beba7c002dd3db8a304105f52dd66200d48fa1ef30d0676af"}, + {file = "pyogrio-0.12.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:01b322dac2a258d24b024d1028dcaa03c9bb6d9c3988b86d298a64873d10dc65"}, + {file = "pyogrio-0.12.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:e10087abcbd6b7e8212560a7002984e5078ac7b3a969ddc2c9929044dbb0d403"}, + {file = "pyogrio-0.12.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f6c621972b09fd81a32317e742c69ff4a7763a803da211361a78317f9577765"}, + {file = "pyogrio-0.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c38253427b688464caad5316d4ebcec116b5e13f1f02cc4e3588502f136ca1b4"}, + {file = "pyogrio-0.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5f47787251de7ce13cc06038da93a1214dc283cbccf816be6e03c080358226c8"}, + {file = "pyogrio-0.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:c1d756cf2da4cdf5609779f260d1e1e89be023184225855d6f3dcd33bbe17cb0"}, + {file = "pyogrio-0.12.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:7a0d5ca39184030aec4cde30f4258f75b227a854530d2659babc8189d76e657d"}, + {file = "pyogrio-0.12.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:feaff42bbe8087ca0b30e33b09d1ce049ca55fe83ad83db1139ef37d1d04f30c"}, + {file = "pyogrio-0.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81096a5139532de5a8003ef02b41d5d2444cb382a9aecd1165b447eb549180d3"}, + {file = "pyogrio-0.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:41b78863f782f7a113ed0d36a5dc74d59735bd3a82af53510899bb02a18b06bb"}, + {file = "pyogrio-0.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8b65be8c4258b27cc8f919b21929cecdadda4c353e3637fa30850339ef4d15c5"}, + {file = "pyogrio-0.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:1291b866c2c81d991bda15021b08b3621709b40ee3a85689229929e9465788bf"}, + {file = "pyogrio-0.12.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:ec0e47a5a704e575092b2fd5c83fa0472a1d421e590f94093eb837bb0a11125d"}, + {file = "pyogrio-0.12.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:b4c888fc08f388be4dd99dfca5e84a5cdc5994deeec0230cc45144d3460e2b21"}, + {file = "pyogrio-0.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:73a88436f9962750d782853727897ac2722cac5900d920e39fab3e56d7a6a7f1"}, + {file = "pyogrio-0.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b5d248a0d59fe9bbf9a35690b70004c67830ee0ebe7d4f7bb8ffd8659f684b3a"}, + {file = "pyogrio-0.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0622bc1a186421547660271083079b38d42e6f868802936d8538c0b379f1ab6b"}, + {file = "pyogrio-0.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:207bd60c7ffbcea84584596e3637653aa7095e9ee20fa408f90c7f9460392613"}, + {file = "pyogrio-0.12.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1511b39a283fa27cda906cd187a791578942a87a40b6a06697d9b43bb8ac80b0"}, + {file = "pyogrio-0.12.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:e486cd6aa9ea8a15394a5f84e019d61ec18f257eeeb642348bd68c3d1e57280b"}, + {file = "pyogrio-0.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3f1a19f63bfd1d3042e45f37ad1d6598123a5a604b6c4ba3f38b419273486cd"}, + {file = "pyogrio-0.12.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f3dcc59b3316b8a0f59346bcc638a4d69997864a4d21da839192f50c4c92369a"}, + {file = "pyogrio-0.12.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a0643e041dee3e8e038fce69f52a915ecb486e6d7b674c0f9919f3c9e9629689"}, + {file = "pyogrio-0.12.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5881017f29e110d3613819667657844d8e961b747f2d35cf92f273c27af6d068"}, + {file = "pyogrio-0.12.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5a1b0453d1c9e7b03715dd57296c8f3790acb8b50d7e3b5844b3074a18f50709"}, + {file = "pyogrio-0.12.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e7ee560422239dd09ca7f8284cc8483a8919c30d25f3049bb0249bff4c38dec4"}, + {file = "pyogrio-0.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:648c6f7f5f214d30e6cf493b4af1d59782907ac068af9119ca35f18153d6865a"}, + {file = "pyogrio-0.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:58042584f3fd4cabb0f55d26c1405053f656be8a5c266c38140316a1e981aca0"}, + {file = "pyogrio-0.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b438e38e4ccbaedaa5cb5824ff5de5539315d9b2fde6547c1e816576924ee8ca"}, + {file = "pyogrio-0.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f1d8d8a2fea3781dc2a05982c050259261ebc0f6c5e03732d6d79d582adf9363"}, + {file = "pyogrio-0.12.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9fe7286946f35a73e6370dc5855bc7a5e8e7babf9e4a8bad7a3279a1d94c7ea9"}, + {file = "pyogrio-0.12.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2c50345b382f1be801d654ec22c70ee974d6057d4ba7afe984b55f2192bc94ee"}, + {file = "pyogrio-0.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0db95765ac0ca935c7fe579e29451294e3ab19c317b0c59c31fbe92a69155e0"}, + {file = "pyogrio-0.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fc882779075982b93064b3bf3d8642514a6df00d9dd752493b104817072cfb01"}, + {file = "pyogrio-0.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:806f620e0c54b54dbdd65e9b6368d24f344cda84c9343364b40a57eb3e1c4dca"}, + {file = "pyogrio-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5399f66730978d8852ef5f44dbafa0f738e7f28f4f784349f36830b69a9d2134"}, + {file = "pyogrio-0.12.1.tar.gz", hash = "sha256:e548ab705bb3e5383693717de1e6c76da97f3762ab92522cb310f93128a75ff1"}, ] [package.dependencies] @@ -1864,7 +2029,7 @@ packaging = "*" [package.extras] benchmark = ["pytest-benchmark"] -dev = ["cython"] +dev = ["cython (>=3.1)"] geopandas = ["geopandas"] test = ["pytest", "pytest-cov"] @@ -1875,7 +2040,6 @@ description = "pyparsing - Classes and methods to define and execute parsing gra optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, @@ -1891,7 +2055,6 @@ description = "Python interface to PROJ (cartographic projections and coordinate optional = false python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5"}, {file = "pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a"}, @@ -1955,21 +2118,20 @@ certifi = "*" [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -1983,7 +2145,6 @@ description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1994,15 +2155,14 @@ six = ">=1.5" [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.2.2" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502"}, - {file = "python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e"}, + {file = "python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a"}, + {file = "python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb"}, ] [package.dependencies] @@ -2020,7 +2180,6 @@ description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -2104,7 +2263,6 @@ description = "Fast and direct raster I/O for use with NumPy" optional = false python-versions = ">=3.12" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "rasterio-1.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:420656074897a460f5ef46f657b3061d2e004f9d99e613914b0671643e69d92c"}, {file = "rasterio-1.5.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:c5c3597a783857e760550e8f26365d928b0377ac5ffc3e12ba447ac65ca5406d"}, @@ -2163,7 +2321,6 @@ description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, @@ -2179,6 +2336,25 @@ urllib3 = ">=1.26,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] +[[package]] +name = "rich" +version = "15.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "ruff" version = "0.9.10" @@ -2186,7 +2362,6 @@ description = "An extremely fast Python linter and code formatter, written in Ru optional = false python-versions = ">=3.7" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, @@ -2208,37 +2383,93 @@ files = [ {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, ] +[[package]] +name = "rustworkx" +version = "0.17.1" +description = "A High-Performance Graph Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rustworkx-0.17.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c08fb8db041db052da404839b064ebfb47dcce04ba9a3e2eb79d0c65ab011da4"}, + {file = "rustworkx-0.17.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4ef8e327dadf6500edd76fedb83f6d888b9266c58bcdbffd5a40c33835c9dd26"}, + {file = "rustworkx-0.17.1-cp39-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b809e0aa2927c68574b196f993233e269980918101b0dd235289c4f3ddb2115"}, + {file = "rustworkx-0.17.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7e82c46a92fb0fd478b7372e15ca524c287485fdecaed37b8bb68f4df2720f2"}, + {file = "rustworkx-0.17.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42170075d8a7319e89ff63062c2f1d1116ced37b6f044f3bf36d10b60a107aa4"}, + {file = "rustworkx-0.17.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65cba97fa95470239e2d65eb4db1613f78e4396af9f790ff771b0e5476bfd887"}, + {file = "rustworkx-0.17.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246cc252053f89e36209535b9c58755960197e6ae08d48d3973760141c62ac95"}, + {file = "rustworkx-0.17.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c10d25e9f0e87d6a273d1ea390b636b4fb3fede2094bf0cb3fe565d696a91b48"}, + {file = "rustworkx-0.17.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48784a673cf8d04f3cd246fa6b53fd1ccc4d83304503463bd561c153517bccc1"}, + {file = "rustworkx-0.17.1-cp39-abi3-win32.whl", hash = "sha256:5dbc567833ff0a8ad4580a4fe4bde92c186d36b4c45fca755fb1792e4fafe9b5"}, + {file = "rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea"}, + {file = "rustworkx-0.17.1.tar.gz", hash = "sha256:59ea01b4e603daffa4e8827316c1641eef18ae9032f0b1b14aa0181687e3108e"}, +] + +[package.dependencies] +numpy = ">=1.16.0,<3" + +[package.extras] +all = ["matplotlib (>=3.0)", "pillow (>=5.4)"] +graphviz = ["pillow (>=5.4)"] +mpl = ["matplotlib (>=3.0)"] + [[package]] name = "scikit-image" -version = "0.25.2" +version = "0.26.0" description = "Image processing in Python" optional = false -python-versions = ">=3.10" +python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" -files = [ - {file = "scikit_image-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3278f586793176599df6a4cf48cb6beadae35c31e58dc01a98023af3dc31c78"}, - {file = "scikit_image-0.25.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c311069899ce757d7dbf1d03e32acb38bb06153236ae77fcd820fd62044c063"}, - {file = "scikit_image-0.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be455aa7039a6afa54e84f9e38293733a2622b8c2fb3362b822d459cc5605e99"}, - {file = "scikit_image-0.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c464b90e978d137330be433df4e76d92ad3c5f46a22f159520ce0fdbea8a09"}, - {file = "scikit_image-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:60516257c5a2d2f74387c502aa2f15a0ef3498fbeaa749f730ab18f0a40fd054"}, - {file = "scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17"}, - {file = "scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0"}, - {file = "scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173"}, - {file = "scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641"}, - {file = "scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b"}, - {file = "scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb"}, - {file = "scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed"}, - {file = "scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d"}, - {file = "scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824"}, - {file = "scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2"}, - {file = "scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da"}, - {file = "scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc"}, - {file = "scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341"}, - {file = "scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147"}, - {file = "scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f"}, - {file = "scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd"}, - {file = "scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde"}, +files = [ + {file = "scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0"}, + {file = "scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd"}, + {file = "scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953"}, + {file = "scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af"}, + {file = "scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d"}, + {file = "scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d"}, + {file = "scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7"}, + {file = "scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581"}, + {file = "scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c"}, + {file = "scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37"}, + {file = "scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44"}, + {file = "scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466"}, + {file = "scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932"}, + {file = "scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70"}, + {file = "scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0"}, + {file = "scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8"}, + {file = "scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7"}, + {file = "scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5"}, + {file = "scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21"}, + {file = "scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b"}, + {file = "scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb"}, + {file = "scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd"}, + {file = "scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1"}, + {file = "scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219"}, + {file = "scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49"}, + {file = "scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3"}, + {file = "scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604"}, + {file = "scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37"}, + {file = "scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc"}, + {file = "scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9"}, + {file = "scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a"}, + {file = "scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e"}, + {file = "scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c"}, + {file = "scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd"}, + {file = "scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab"}, + {file = "scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505"}, + {file = "scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331"}, + {file = "scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578"}, + {file = "scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650"}, + {file = "scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f"}, + {file = "scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1"}, + {file = "scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f"}, + {file = "scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59"}, + {file = "scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394"}, + {file = "scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62"}, + {file = "scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2"}, + {file = "scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4"}, + {file = "scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf"}, + {file = "scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa"}, ] [package.dependencies] @@ -2252,12 +2483,14 @@ scipy = ">=1.11.4" tifffile = ">=2022.8.12" [package.extras] -build = ["Cython (>=3.0.8)", "build (>=1.2.1)", "meson-python (>=0.16)", "ninja (>=1.11.1.1)", "numpy (>=2.0)", "pythran (>=0.16)", "spin (==0.13)"] +asv = ["asv ; sys_platform != \"emscripten\""] +build = ["Cython (>=3.0.8,!=3.2.0b1)", "build (>=1.2.1)", "meson-python (>=0.16)", "ninja (>=1.11.1.1)", "numpy (>=2.0)", "pythran (>=0.16)", "spin (==0.13)"] data = ["pooch (>=1.6.0)"] -developer = ["ipython", "pre-commit", "tomli ; python_version < \"3.11\""] -docs = ["PyWavelets (>=1.6)", "dask[array] (>=2023.2.0)", "intersphinx-registry (>=0.2411.14)", "ipykernel", "ipywidgets", "kaleido (==0.2.1)", "matplotlib (>=3.7)", "myst-parser", "numpydoc (>=1.7)", "pandas (>=2.0)", "plotly (>=5.20)", "pooch (>=1.6)", "pydata-sphinx-theme (>=0.16)", "pytest-doctestplus", "scikit-learn (>=1.2)", "seaborn (>=0.11)", "sphinx (>=8.0)", "sphinx-copybutton", "sphinx-gallery[parallel] (>=0.18)", "sphinx_design (>=0.5)", "tifffile (>=2022.8.12)"] -optional = ["PyWavelets (>=1.6)", "SimpleITK", "astropy (>=5.0)", "cloudpickle (>=1.1.1)", "dask[array] (>=2023.2.0)", "matplotlib (>=3.7)", "pooch (>=1.6.0)", "pyamg (>=5.2)", "scikit-learn (>=1.2)"] -test = ["asv", "numpydoc (>=1.7)", "pooch (>=1.6.0)", "pytest (>=8)", "pytest-cov (>=2.11.0)", "pytest-doctestplus", "pytest-faulthandler", "pytest-localserver"] +developer = ["docstub (==0.3.0.post0)", "ipython", "pre-commit", "scikit-image[asv]"] +docs = ["PyWavelets (>=1.6)", "dask[array] (>=2023.2.0)", "intersphinx-registry (>=0.2411.14)", "ipykernel", "ipywidgets", "kaleido (==0.2.1)", "matplotlib (>=3.7)", "myst-parser", "numpydoc (>=1.7)", "pandas (>=2.0)", "plotly (>=5.20)", "pooch (>=1.6)", "pydata-sphinx-theme (>=0.16)", "pytest-doctestplus (>=1.6.0)", "scikit-learn (>=1.2)", "seaborn (>=0.11)", "sphinx (>=8.0)", "sphinx-copybutton", "sphinx-gallery[parallel] (>=0.18)", "sphinx_design (>=0.5)", "tifffile (>=2022.8.12)"] +optional = ["SimpleITK ; sys_platform != \"emscripten\"", "pyamg (>=5.2) ; sys_platform != \"emscripten\" and python_version < \"3.14\"", "scikit-image[optional-free-threaded]", "scikit-learn (>=1.2)"] +optional-free-threaded = ["PyWavelets (>=1.6)", "astropy (>=6.0)", "dask[array] (>=2023.2.0)", "matplotlib (>=3.7)", "pooch (>=1.6.0) ; sys_platform != \"emscripten\""] +test = ["numpydoc (>=1.7)", "pooch (>=1.6.0) ; sys_platform != \"emscripten\"", "pytest (>=8.3)", "pytest-cov (>=2.11.0)", "pytest-doctestplus (>=1.6.0)", "pytest-faulthandler", "pytest-localserver", "pytest-pretty"] [[package]] name = "scipy" @@ -2266,7 +2499,6 @@ description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.11" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec"}, {file = "scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696"}, @@ -2346,7 +2578,6 @@ description = "Manipulation and analysis of geometric objects" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f"}, {file = "shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea"}, @@ -2414,6 +2645,18 @@ numpy = ">=1.21" docs = ["matplotlib", "numpydoc (==1.1.*)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] test = ["pytest", "pytest-cov", "scipy-doctest"] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.17.0" @@ -2421,7 +2664,6 @@ description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2434,60 +2676,132 @@ description = "Structured Logging for Python" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"}, {file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"}, ] +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tifffile" -version = "2026.3.3" +version = "2026.4.11" description = "Read and write TIFF files" optional = false -python-versions = ">=3.11" +python-versions = ">=3.12" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170"}, - {file = "tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7"}, + {file = "tifffile-2026.4.11-py3-none-any.whl", hash = "sha256:9b94ffeddb39e97601af646345e8808f885773de01b299e480ed6d3a41509ec9"}, + {file = "tifffile-2026.4.11.tar.gz", hash = "sha256:17758ff0c0d4db385792a083ad3ca51fcb0f4d942642f4d8f8bc1287fdcf17bc"}, ] [package.dependencies] -numpy = "*" +numpy = ">=2.0" [package.extras] -all = ["defusedxml", "fsspec", "imagecodecs (>=2025.11.11)", "kerchunk", "lxml", "matplotlib", "zarr (>=3.1.5)"] -codecs = ["imagecodecs (>=2025.11.11)"] +all = ["fsspec", "imagecodecs (>=2026.3.6)", "kerchunk", "lxml", "matplotlib", "zarr (>=3.1.5)"] +codecs = ["imagecodecs (>=2026.3.6)"] plot = ["matplotlib"] -test = ["cmapfile", "czifile", "dask", "defusedxml", "fsspec", "imagecodecs", "kerchunk", "lfdfiles", "lxml", "ndtiff", "oiffile", "psdtags", "pytest", "requests", "roifile", "xarray", "zarr (>=3.1.5)"] -xml = ["defusedxml", "lxml"] +test = ["cmapfile", "czifile", "dask", "fsspec", "imagecodecs", "kerchunk", "lfdfiles", "lxml", "ndtiff", "oiffile", "psdtags", "pytest", "requests", "roifile", "xarray", "zarr (>=3.1.5)"] +xml = ["lxml"] zarr = ["fsspec", "kerchunk", "zarr (>=3.1.5)"] +[[package]] +name = "topojson" +version = "1.10" +description = "topojson - a powerful library to encode geographic data as topology in Python!🌍" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "topojson-1.10-py3-none-any.whl", hash = "sha256:0879d727c7798939e3268e8969fa87c2cd23274189fe3d8038a0fb11ff263925"}, + {file = "topojson-1.10.tar.gz", hash = "sha256:a7f53406324061a0310bec46740a6609147c24daeb354596c68345b9527b38c1"}, +] + +[package.dependencies] +numpy = "*" +packaging = "*" +shapely = "*" + +[package.extras] +dev = ["altair", "fiona", "geojson", "geopandas", "ipywidgets", "pyshp", "simplification"] + +[[package]] +name = "tqdm" +version = "4.67.3" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"}, + {file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typer" +version = "0.25.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc"}, + {file = "typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +click = ">=8.2.1" +rich = ">=13.8.0" +shellingham = ">=1.3.0" + [[package]] name = "types-pyyaml" -version = "6.0.12.20250915" +version = "6.0.12.20260408" description = "Typing stubs for PyYAML" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, - {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, + {file = "types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384"}, + {file = "types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307"}, ] [[package]] name = "types-requests" -version = "2.33.0.20260402" +version = "2.33.0.20260408" description = "Typing stubs for requests" optional = false python-versions = ">=3.10" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "types_requests-2.33.0.20260402-py3-none-any.whl", hash = "sha256:c98372d7124dd5d10af815ee25c013897592ff92af27b27e22c98984102c3254"}, - {file = "types_requests-2.33.0.20260402.tar.gz", hash = "sha256:1bdd3ada9b869741c5c4b887d2c8b4e38284a1449751823b5ebbccba3eefd9da"}, + {file = "types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f"}, + {file = "types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b"}, ] [package.dependencies] @@ -2500,7 +2814,6 @@ description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -2513,7 +2826,6 @@ description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -2524,15 +2836,15 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\"" files = [ - {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, - {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] @@ -2542,7 +2854,6 @@ description = "HTTP library with thread-safe connection pooling, file post, and optional = false python-versions = ">=3.9" groups = ["main", "linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -2556,24 +2867,23 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.3.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["linting"] -markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ - {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, - {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, + {file = "virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7"}, + {file = "virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1" +python-discovery = ">=1.2.2" [metadata] lock-version = "2.1" python-versions = "~=3.12" -content-hash = "5a692b428e617a0720feebbf92c7668a8c45b482d89f0d012dcfc72cae20e294" +content-hash = "9d579a0224620ce6f15ac3fb68055df65eacfde0712a493e0c7be6ec14230e5d" diff --git a/pyproject.toml b/pyproject.toml index 3ccda2f..f3e01ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,16 +16,23 @@ readme = "README.md" dependencies = [ "geopandas (>=1.0.1,<2.0.0)", "structlog (>=25.1.0,<26.0.0)", - "scikit-image (>=0.25.1,<0.26.0)", - "pytest (>=8.3.4,<9.0.0)", + "scikit-image (>=0.26.0,<0.27.0)", + "pytest (>=9.0.3,<10.0.0)", "networkx (>=3.4.2,<4.0.0)", "cairocffi (>=1.7.1,<2.0.0)", "matplotlib (>=3.10.0,<4.0.0)", "osmnx (>=2.0.1,<3.0.0)", "pydantic (>=2.10.6,<3.0.0)", - "pyogrio (>=0.10.0,<0.11.0)", + "pyogrio (>=0.12.1,<0.13.0)", "rasterio (>=1.4.3,<2.0.0)", "fiona (>=1.10.1,<2.0.0)", + "tenacity (>=9.1.2,<10.0.0)", + "rustworkx (>=0.17.1,<1.0.0)", + "polars (>=1.37.1,<2.0.0)", + "pyarrow (>=24.0.0,<25.0.0)", + "pygeoops (>=0.6.0,<0.7.0)", + "tqdm (>=4.67.3,<5.0.0)", + "typer (>=0.25.0,<0.26.0)", ] [tool.poetry] diff --git a/settings.py b/settings.py index f3d334f..4d33705 100644 --- a/settings.py +++ b/settings.py @@ -29,15 +29,34 @@ class Config: FINAL_RASTER_VALUE_LIMIT_LOWER = 1 FINAL_RASTER_VALUE_LIMIT_UPPER = 126 + # Multilayer network + OSM_API_TIMEOUT_IN_SECONDS = 20 + MIN_NODE_SUITABILITY_VALUE = 1 + MAX_NODE_SUITABILITY_VALUE = 126 + HEXAGON_SIZE = 0.5 + HEXAGON_BLOCK_SIZE = 512 + THRESHOLD_EDGE_LENGTH_CROSSING_M: float = 30 + MAX_PIPE_RAMMING_LENGTH_M: float = 15 + MIN_PIPE_RAMMING_LENGTH_M: float = 3 + SUITABILITY_VALUE_CROSSING_THRESHOLD: float = 10 + SUITABILITY_VALUE_OBSTACLES_THRESHOLD: float = 76 + # References: + # - e.g. Low voltage https://www.waskoenig.de/nl/producten/v-vmvksas#variants + # - e.g. mid voltage https://www.waskoenig.de/nl/producten/ymekrvaslqwd-10kv#variants + MINIMUM_BENDING_RADIUS: float = 0.75 # this might give problems if < cell size + # input/output paths. PATH_RESULTS = BASEDIR / "data/processed" PATH_GEOPACKAGE_MCDA_OUTPUT = BASEDIR / "data/processed/mcda_output.gpkg" PATH_GEOPACKAGE_LCPA_OUTPUT = BASEDIR / "data/processed/lcpa_results.gpkg" + PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT = BASEDIR / "data/processed/multilayer_network.gpkg" + PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT = BASEDIR / "data/processed/hexagon_graph.gpkg" # Testing paths. PATH_EXAMPLE_RASTER = BASEDIR / "data/examples/pytest_example_suitability_raster.tif" PYTEST_PATH_GEOPACKAGE_MCDA = BASEDIR / "data/examples/pytest_data.gpkg" PYTEST_LAYER_NAME_PROJECT_AREA = "project_area_ede" + PYTEST_OSM_GRAPH_PICKLE = BASEDIR / "data/examples/pytest_osm_graph.pkl" # Research question 1 data paths. PATH_GEOPACKAGE_CASE_01 = BASEDIR / "data/examples/case_01.gpkg" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5ce3f82..b9c5b46 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -2,7 +2,10 @@ # # SPDX-License-Identifier: Apache-2.0 +from typing import Callable import pytest +import geopandas as gpd +import shapely from settings import Config from utility_route_planner.util.write import reset_geopackage @@ -13,3 +16,145 @@ def setup_mcda_lcpa_testing(monkeypatch): reset_geopackage(Config.PATH_GEOPACKAGE_LCPA_OUTPUT) reset_geopackage(Config.PATH_GEOPACKAGE_MCDA_OUTPUT, truncate=False) monkeypatch.setattr(Config, "DEBUG", True) + + +@pytest.fixture +def single_criterion_vectors() -> Callable: + def __vectors(max_value: int, min_value: int, no_data: int) -> gpd.GeoDataFrame: + return gpd.GeoDataFrame( + data=[ + # These layers all overlap each other. + [1, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], + [10, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], + [10, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], + [1, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], + # One larger partly overlapping polygon with a unique value. + [ + 5, + shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]]).buffer( + 50 + ), + ], + # These values should be reset to the min/max of the intermediate raster values + [ + min_value - 1000, + shapely.Polygon([[175091, 450919], [175091, 450911], [175105, 450911], [175091, 450919]]), + ], + [ + max_value + 1000, + shapely.Polygon([[175012, 450920], [175011, 450907], [175019, 450906], [175012, 450920]]), + ], + [no_data, shapely.Polygon([[174917, 450965], [174937, 450962], [174916, 450952], [174917, 450965]])], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + + return __vectors + + +@pytest.fixture +def multi_criteria_vectors() -> Callable: + def __vectors(max_value: int, min_value: int) -> list[tuple[str, gpd.GeoDataFrame, str]]: + # 4 rasters: + # 1. group a - partial overlap + criterion_a_1 = gpd.GeoDataFrame( + data=[ + # These layers all overlap each other. + [1, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], + [10, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], + # One larger partly overlapping polygon with a unique value. + [ + 5, + shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]]).buffer( + 50 + ), + ], + # These values should be reset to the min/max of the intermediate raster values + [ + min_value - 1000, + shapely.Polygon([[175091, 450919], [175091, 450911], [175105, 450911], [175091, 450919]]), + ], + [ + max_value + 1000, + shapely.Polygon([[175012, 450920], [175011, 450907], [175019, 450906], [175012, 450920]]), + ], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + # 2. group a - partial overlap + criterion_a_2 = gpd.GeoDataFrame( + data=[ + # Overlaps criterion a 1 with a higher value + [50, shapely.Polygon([[174797, 451107], [174944, 451090], [174807, 451129], [174797, 451107]])], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + # 3. group b - partial overlap + criterion_b_1 = gpd.GeoDataFrame( + data=[ + # Overlaps criterion a 1 with a higher value + [20, shapely.Point([174813.3770, 451113.8469]).buffer(2)], + [1000, shapely.Point([174870.46, 451051.07])], + [-20, shapely.Point([175013.310, 450910.294])], + [-1, shapely.Polygon([[175087, 450911], [175107, 450912], [175087, 450915], [175087, 450911]])], + # Overlaps criterion a 1 with the same value but signed. + [-5, shapely.Polygon([[174830, 451074], [174842, 451069], [174831, 451061], [174830, 451074]])], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + # 4. group b - overlaps criterion b1 and a1 + criterion_b_2 = gpd.GeoDataFrame( + data=[ + [15, shapely.Polygon([[175096, 450908], [175089, 450908], [175091, 450921], [175096, 450908]])], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + # 5. group b - tree on the edge of the project area. The buffer exceeds the project area boundary. + criterion_b_3 = gpd.GeoDataFrame( + data=[[10, shapely.Point(174741.950, 451113.084).buffer(10)]], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + # 6. group c - overlapping a1 + criterion_c_1 = gpd.GeoDataFrame( + data=[ + [1, shapely.Polygon([[174729, 451158], [174940, 451115], [174841, 451195], [174729, 451158]])], + [10, shapely.Polygon([[174915, 451128], [174924, 451135], [174926, 451109], [174915, 451128]])], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + # 7. group c - overlapping b1 and c1 + criterion_c_2 = gpd.GeoDataFrame( + data=[ + [1, shapely.Polygon([[175090, 450906], [175103, 450905], [175096, 450918], [175090, 450906]])], + [39, shapely.Polygon([[174811, 451226], [174834, 451155], [174909, 451174], [174811, 451226]])], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "geometry"], + ) + + return [ + ("criterion_a1", "a", criterion_a_1), + ("criterion_a2", "a", criterion_a_2), + ("criterion_b1", "b", criterion_b_1), + ("criterion_b2", "b", criterion_b_2), + ("criterion_b3", "b", criterion_b_3), + ("criterion_c1", "c", criterion_c_1), + ("criterion_c2", "c", criterion_c_2), + ] + + return __vectors diff --git a/tests/integration/mcda/mcda_raster_test.py b/tests/integration/mcda/mcda_raster_test.py index d40fb4c..492c381 100644 --- a/tests/integration/mcda/mcda_raster_test.py +++ b/tests/integration/mcda/mcda_raster_test.py @@ -147,36 +147,12 @@ def test_rasterize_vector_data_cell_size_error(): get_raster_settings(project_area, cell_size=500000) -def test_rasterize_single_criterion(debug=False): +def test_rasterize_single_criterion(single_criterion_vectors: gpd.GeoDataFrame, debug=False): max_value = Config.INTERMEDIATE_RASTER_VALUE_LIMIT_UPPER min_value = Config.INTERMEDIATE_RASTER_VALUE_LIMIT_LOWER no_data = Config.INTERMEDIATE_RASTER_NO_DATA + gdf = single_criterion_vectors(max_value, min_value, no_data) - gdf = gpd.GeoDataFrame( - data=[ - # These layers all overlap each other. - [1, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], - [10, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], - [10, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], - [1, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], - # One larger partly overlapping polygon with a unique value. - [5, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]]).buffer(50)], - # These values should be reset to the min/max of the intermediate raster values - [ - min_value - 1000, - shapely.Polygon([[175091, 450919], [175091, 450911], [175105, 450911], [175091, 450919]]), - ], - [ - max_value + 1000, - shapely.Polygon([[175012, 450920], [175011, 450907], [175019, 450906], [175012, 450920]]), - ], - # This value is equal to no-data and should be reset to a "safe" value (+1 it) - [no_data, shapely.Polygon([[174917, 450965], [174937, 450962], [174916, 450952], [174917, 450965]])], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) points_to_sample = gpd.GeoDataFrame( data=[ [1, 10, shapely.Point(174872.396, 451084.460)], @@ -185,6 +161,7 @@ def test_rasterize_single_criterion(debug=False): [4, no_data, shapely.Point(174686.5, 451164.9)], [5, max_value, shapely.Point(175013, 450909)], [6, min_value, shapely.Point(175094, 450913)], + # This value is equal to no-data and should be reset to a "safe" value (+1 it) [7, no_data + 1, shapely.Point(174923.49, 450959.17)], ], geometry="geometry", @@ -215,94 +192,12 @@ def test_rasterize_single_criterion(debug=False): assert rasterized_vector[int(row_index)][int(col_index)] == row.expected_suitability_value -def test_sum_rasters(debug=False): +def test_sum_rasters(multi_criteria_vectors, debug: bool = False): max_value = Config.FINAL_RASTER_VALUE_LIMIT_UPPER min_value = Config.FINAL_RASTER_VALUE_LIMIT_LOWER no_data = Config.FINAL_RASTER_NO_DATA - # 4 rasters: - # 1. group a - partial overlap - criterion_a_1 = gpd.GeoDataFrame( - data=[ - # These layers all overlap each other. - [1, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], - [10, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]])], - # One larger partly overlapping polygon with a unique value. - [5, shapely.Polygon([[174872, 451093], [174870, 451082], [174876, 451081], [174872, 451093]]).buffer(50)], - # These values should be reset to the min/max of the intermediate raster values - [ - min_value - 1000, - shapely.Polygon([[175091, 450919], [175091, 450911], [175105, 450911], [175091, 450919]]), - ], - [ - max_value + 1000, - shapely.Polygon([[175012, 450920], [175011, 450907], [175019, 450906], [175012, 450920]]), - ], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) - # 2. group a - partial overlap - criterion_a_2 = gpd.GeoDataFrame( - data=[ - # Overlaps criterion a 1 with a higher value - [50, shapely.Polygon([[174797, 451107], [174944, 451090], [174807, 451129], [174797, 451107]])], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) - # 3. group b - partial overlap - criterion_b_1 = gpd.GeoDataFrame( - data=[ - # Overlaps criterion a 1 with a higher value - [20, shapely.Point([174813.28, 451113.88])], - [1000, shapely.Point([174870.46, 451051.07])], - [-20, shapely.Point([175013.310, 450910.294])], - [-1, shapely.Polygon([[175087, 450911], [175107, 450912], [175087, 450915], [175087, 450911]])], - # Overlaps criterion a 1 with the same value but signed. - [-5, shapely.Polygon([[174830, 451074], [174842, 451069], [174831, 451061], [174830, 451074]])], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) - # 4. group b - overlaps criterion b1 and a1 - criterion_b_2 = gpd.GeoDataFrame( - data=[ - [15, shapely.Polygon([[175096, 450908], [175089, 450908], [175091, 450921], [175096, 450908]])], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) - # 5. group b - tree on the edge of the project area. The buffer exceeds the project area boundary. - criterion_b_3 = gpd.GeoDataFrame( - data=[[10, shapely.Point(174741.950, 451113.084).buffer(10)]], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) - # 6. group c - overlapping a1 - criterion_c_1 = gpd.GeoDataFrame( - data=[ - [1, shapely.Polygon([[174729, 451158], [174940, 451115], [174841, 451195], [174729, 451158]])], - [10, shapely.Polygon([[174915, 451128], [174924, 451135], [174926, 451109], [174915, 451128]])], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) - # 7. group c - overlapping b1 and c1 - criterion_c_2 = gpd.GeoDataFrame( - data=[ - [1, shapely.Polygon([[175090, 450906], [175103, 450905], [175096, 450918], [175090, 450906]])], - [39, shapely.Polygon([[174811, 451226], [174834, 451155], [174909, 451174], [174811, 451226]])], - ], - geometry="geometry", - crs=Config.CRS, - columns=["suitability_value", "geometry"], - ) + all_criteria_vectors: list[tuple[str, str, gpd.GeoDataFrame]] = multi_criteria_vectors(max_value, min_value) + points_to_sample = gpd.GeoDataFrame( data=[ [1, 14, shapely.Point(175090.35, 450911.67)], # overlap between b1 and b2 @@ -332,13 +227,9 @@ def test_sum_rasters(debug=False): ) if debug: # Using QGIS, it is easier doublecheck what values we are expecting in this test. - criterion_a_1.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_a1.geojson") - criterion_a_2.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_a2.geojson") - criterion_b_1.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_b1.geojson") - criterion_b_2.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_b2.geojson") - criterion_b_3.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_b3.geojson") - criterion_c_1.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_c1.geojson") - criterion_c_2.to_file(Config.PATH_RESULTS / "pytest_sum_criterion_c2.geojson") + for criterion in all_criteria_vectors: + criterion_name, _, criterion_gdf = criterion + criterion_gdf.to_file(Config.PATH_RESULTS / f"pytest_sum_{criterion_name}.geojson") points_to_sample.to_file(Config.PATH_RESULTS / "pytest_sum_points_to_sample.geojson") rasters_to_merge = [] @@ -347,18 +238,10 @@ def test_sum_rasters(debug=False): ) raster_settings = get_raster_settings(project_area, 0.5) for ( + criterion_name, group, criterion_gdf, - criterion_name, - ) in [ - ["a", criterion_a_1, "criterion_a1"], - ["a", criterion_a_2, "criterion_a2"], - ["b", criterion_b_1, "criterion_b1"], - ["b", criterion_b_2, "criterion_b2"], - ["b", criterion_b_3, "criterion_b3"], - ["c", criterion_c_1, "criterion_c1"], - ["c", criterion_c_2, "criterion_c2"], - ]: + ) in all_criteria_vectors: rasterized_vector = rasterize_vector_data(criterion_name, criterion_gdf, raster_settings) rasters_to_merge.append(RasterizedCriterion(criterion_name, rasterized_vector, group)) diff --git a/tests/integration/mcda/mcda_vector_test.py b/tests/integration/mcda/mcda_vector_test.py index 44789e5..f787511 100644 --- a/tests/integration/mcda/mcda_vector_test.py +++ b/tests/integration/mcda/mcda_vector_test.py @@ -82,19 +82,31 @@ def test_process_waterdeel(self): } input_gdf = gpd.GeoDataFrame( [ - ["greppel, droge sloot", "WaardeOnbekend", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "WaardeOnbekend", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "rivier", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "sloot", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "kanaal", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "beek", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "gracht", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waterloop", "bron", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["watervlakte", "haven", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["watervlakte", "meer, plas, ven, vijver", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["zee", "WaardeOnbekend", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + [ + "greppel, droge sloot", + "WaardeOnbekend", + 0, + shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + ], + ["waterloop", "WaardeOnbekend", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "rivier", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "sloot", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "kanaal", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "beek", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "gracht", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "gracht", 1, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["waterloop", "bron", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["watervlakte", "haven", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + [ + "watervlakte", + "meer, plas, ven, vijver", + 0, + shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + ], + ["zee", "WaardeOnbekend", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], + ["zee", "WaardeOnbekend", 0, shapely.Polygon([[0, 0], [2, 0], [2, 2], [0, 1], [0, 0]])], ], - columns=["class", "plus-type", "geometry"], + columns=["class", "plus-type", "relatieveHoogteligging", "geometry"], crs=Config.CRS, geometry="geometry", ) @@ -102,28 +114,45 @@ def test_process_waterdeel(self): reclassified_gdf = Waterdeel._set_suitability_values(input_gdf, weight_values) pd.testing.assert_series_equal( reclassified_gdf["sv_1"], - pd.Series([-10, 1, 1, 1, 1, 1, 1, 1, 2, 2, 3]), + pd.Series([-10, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 3, 3]), check_names=False, check_exact=True, check_dtype=False, ) pd.testing.assert_series_equal( reclassified_gdf["sv_2"], - pd.Series(["WaardeOnbekend", "WaardeOnbekend", 40, 50, 60, 70, 80, 90, 100, 110, "WaardeOnbekend"]), + pd.Series( + [ + "WaardeOnbekend", + "WaardeOnbekend", + 40, + 50, + 60, + 70, + 80, + 80, + 90, + 100, + 110, + "WaardeOnbekend", + "WaardeOnbekend", + ] + ), check_names=False, check_exact=True, ) pd.testing.assert_series_equal( reclassified_gdf["suitability_value"], - pd.Series([-10, 1, 40, 50, 60, 70, 80, 90, 100, 110, 3]), + pd.Series([-10, 1, 40, 50, 60, 70, 80, 80, 90, 100, 110, 3, 3]), check_names=False, check_exact=True, check_dtype=False, ) buffered_gdf = Waterdeel._update_geometry_values(reclassified_gdf, {"zee": 20}) - assert buffered_gdf.iloc[:10].area.round(1).unique().tolist() == [1.0] - assert buffered_gdf.iloc[[10]].area.round(1).tolist() == [1335.6] + assert len(buffered_gdf) == 12 # Zee is dissolved into one geometry. + assert buffered_gdf.iloc[:11].area.round(1).unique().tolist() == [1.0] + assert buffered_gdf.iloc[[11]].area.round(1).tolist() == [1402.4] def test_begroeid_terreindeel(self): weight_values = { @@ -1056,26 +1085,29 @@ def test_vegetation_object(self): } gdf_1 = gpd.GeoDataFrame( [ - ["haag", shapely.LineString([[0, 0], [1, 0], [1, 1]])], - ["haag", shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], - ["waardeOnbekend", shapely.Polygon()], + ["haag", 0, shapely.Polygon([[0, 0], [5, 0], [5, 5], [0, 0]])], # will be merged + ["haag", 0, shapely.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])], # will be merged + ["waardeOnbekend", 0, shapely.Polygon()], ], - columns=["plus-type", "geometry"], + columns=["plus-type", "relatieveHoogteligging", "geometry"], crs=Config.CRS, geometry="geometry", ) gdf_2 = gpd.GeoDataFrame( [ - ["boom", shapely.Point(1, 1)], + ["boom", 0, shapely.Point(1, 1)], # will be merged + ["boom", 0, shapely.Point(2, 2)], # will be merged + ["boom", 1, shapely.Point(2, 2)], # will not be merged + ["boom", 0, shapely.Point(100, 100)], ], - columns=["plus-type", "geometry"], + columns=["plus-type", "relatieveHoogteligging", "geometry"], crs=Config.CRS, geometry="geometry", ) reclassified_gdf = VegetationObject._set_suitability_values([gdf_1, gdf_2], weight_values) pd.testing.assert_series_equal( reclassified_gdf["sv_1"], - pd.Series([1, 1, 2]), + pd.Series([1, 1, 2, 2, 2, 2]), check_names=False, check_exact=True, check_dtype=False, @@ -1083,7 +1115,7 @@ def test_vegetation_object(self): ) pd.testing.assert_series_equal( reclassified_gdf["suitability_value"], - pd.Series([1, 1, 2]), + pd.Series([1, 1, 2, 2, 2, 2]), check_names=False, check_exact=True, check_dtype=False, @@ -1092,9 +1124,9 @@ def test_vegetation_object(self): assert "waardeOnbekend" not in reclassified_gdf["plus-type"] reclassified_gdf = VegetationObject._update_geometry_values(reclassified_gdf, {"boom": 5}) - assert reclassified_gdf.is_empty.value_counts().get(False) == 3 + assert reclassified_gdf.is_empty.value_counts().get(False) == 4 assert reclassified_gdf.is_empty.value_counts().get(True) is None - assert reclassified_gdf.geom_type.unique().tolist() == ["LineString", "Polygon"] + assert reclassified_gdf.geom_type.unique().tolist() == ["Polygon"] def test_wegdeel(self): weight_values = { diff --git a/tests/integration/multilayer_network/hexagon_graph/__init__.py b/tests/integration/multilayer_network/hexagon_graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/multilayer_network/hexagon_graph/hexagon_graph_builder_test.py b/tests/integration/multilayer_network/hexagon_graph/hexagon_graph_builder_test.py new file mode 100644 index 0000000..fbe4e2f --- /dev/null +++ b/tests/integration/multilayer_network/hexagon_graph/hexagon_graph_builder_test.py @@ -0,0 +1,743 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 + +from typing import Callable +import geopandas as gpd +import pytest +import shapely +import rustworkx as rx + +from settings import Config +from utility_route_planner.models.benchmark_routes import BenchmarkRouteCollection +from utility_route_planner.models.mcda.mcda_engine import McdaCostSurfaceEngine +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_graph_builder import HexagonGraphBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_graph_composer import ( + HexagonGraphComposer, + HeightLevelGraph, +) +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import HexagonGridBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import convert_hexagon_edges_to_gdf +from utility_route_planner.models.multilayer_network.multilayer_route_planner import MultilayerRouteEngine +from utility_route_planner.util.write import reset_geopackage, write_results_to_geopackage + + +@pytest.fixture() +def hexagon_graph_builder() -> HexagonGraphBuilder: + hexagon_size = 1 + grid_constructor = HexagonGridBuilder(hexagon_size=hexagon_size, block_size=Config.HEXAGON_BLOCK_SIZE) + hexagon_edge_generator = HexagonEdgeGenerator() + _hexagon_graph_builder = HexagonGraphBuilder(grid_builder=grid_constructor, edge_generator=hexagon_edge_generator) + return _hexagon_graph_builder + + +class TestHexagonGraphBuilder: + """ + This integration test tests whether artificially created vectors within a predefined project area are properly reflected + in the hexagonal grid. First, the hexagonal grid for a single criterion is tested. Next, multiple criteria are used + as input which enables more advanced testing with overlapping criteria. + """ + + @pytest.fixture() + def ede_project_area(self) -> shapely.Polygon: + return shapely.get_geometry( + gpd.read_file(Config.PYTEST_PATH_GEOPACKAGE_MCDA, layer=Config.PYTEST_LAYER_NAME_PROJECT_AREA) + .iloc[0] + .geometry, + 0, + ) + + def test_build_graph_for_single_criterion( + self, + single_criterion_vectors: Callable, + ede_project_area: shapely.Polygon, + hexagon_graph_builder: HexagonGraphBuilder, + debug: bool = False, + ): + max_value = Config.MAX_NODE_SUITABILITY_VALUE + min_value = Config.MIN_NODE_SUITABILITY_VALUE + single_criterion_vectors = single_criterion_vectors(max_value, min_value, max_value) + + # Create a simple vector dict for the single criterion. + preprocessed_vectors = {"test": single_criterion_vectors} + raster_criteria_groups = {"test": "a"} + + graph, nodes_gdf = hexagon_graph_builder.build_graph( + ede_project_area, raster_criteria_groups, preprocessed_vectors + ) + edges_gdf = convert_hexagon_edges_to_gdf(graph, nodes_gdf) + + sample_points = gpd.GeoDataFrame( + data=[ + # Multiple overlapping values, take the max value + [1, 10, shapely.Point(174871.877, 451084.402)], + # Single vector, must be equal to vector suitability value + [2, 5, shapely.Point(174868.877, 451086.134)], + # Vector value exceeds max node value, must be reset to max value + [3, max_value, shapely.Point(175012.877, 450908.599)], + # Vector value lower than node min value, must be reset to min value + [4, min_value, shapely.Point(175093.877, 450912.929)], + # Vector value equal to max value, must remain the same + [5, max_value, shapely.Point(174923.627, 450959.261)], + ], + geometry="geometry", + crs=Config.CRS, + columns=["sample_id", "expected_suitability_value", "geometry"], + ).astype({"expected_suitability_value": "int16"}) + + # Verify that the nodes near the sample points are equal to the expected value on the sample points. + joined_sample_points = sample_points.sjoin_nearest(nodes_gdf) + assert joined_sample_points["expected_suitability_value"].equals(joined_sample_points["suitability_value"]) + + if debug: + self.write_debug_output( + ede_project_area, preprocessed_vectors, nodes_gdf, edges_gdf, sample_points, suffix="multiple_criterion" + ) + + def test_build_graph_for_multiple_criteria( + self, + multi_criteria_vectors: Callable, + ede_project_area: shapely.MultiPolygon, + hexagon_graph_builder: HexagonGraphBuilder, + debug: bool = False, + ): + max_value = Config.MAX_NODE_SUITABILITY_VALUE + min_value = Config.MIN_NODE_SUITABILITY_VALUE + multiple_criteria_vectors = multi_criteria_vectors(max_value, min_value) + + raster_criteria_groups = {criterion_name: group for criterion_name, group, _ in multiple_criteria_vectors} + preprocessed_vectors = { + criterion_name: criterion_gdf for criterion_name, _, criterion_gdf in multiple_criteria_vectors + } + + graph, nodes_gdf = hexagon_graph_builder.build_graph( + ede_project_area, raster_criteria_groups, preprocessed_vectors + ) + edges_gdf = convert_hexagon_edges_to_gdf(graph, nodes_gdf) + + sample_points = gpd.GeoDataFrame( + data=[ + # Overlap between b1 and b2 + [1, 14.0, shapely.Point(175090.35, 450911.67)], + # Overlap between a1, b1 and b2 + [2, min_value, shapely.Point(175091.8234, 450911.7488)], + # Only b1 + [3, 1.0, shapely.Point(175088.2180, 450912.7950)], + # Overlap between b1 and a1 + [4, max_value, shapely.Point(175013.3110, 450910.3013)], + # Just a1 + [5, 5.0, shapely.Point(174839.089, 451050.785)], + # Overlap between b1 and a1 + [6, 70.0, shapely.Point(174813.2646, 451113.9146)], + # B1 and a1 sum is 0 here + [7, 1.0, shapely.Point(174833.90, 451067.57)], + # C1 overlaps a1 + [8, max_value, shapely.Point(174878.65, 451132.89)], + # C1 + [9, max_value, shapely.Point(174799.54, 451170.54)], + # C1 overlapping c1 + [10, max_value, shapely.Point(174921.44, 451123.59)], + # C1 outside the project area + [11, max_value, shapely.Point(174745.32, 451159.41)], + # C2 overlapping b2 + [12, max_value, shapely.Point(175092.267, 450908.932)], + # C2 overlapping b2, a1 + [13, max_value, shapely.Point(175097.673, 450912.390)], + # C2 overlapping c1 + [14, max_value, shapely.Point(174847.32, 451177.96)], + ], + geometry="geometry", + crs=Config.CRS, + columns=["sample_id", "expected_suitability_value", "geometry"], + ).astype({"expected_suitability_value": "int16"}) + + # Verify that the nodes near the sample points are equal to the expected value on the sample points. + joined_sample_points = sample_points.sjoin_nearest(nodes_gdf) + assert joined_sample_points["expected_suitability_value"].equals(joined_sample_points["suitability_value"]) + + if debug: + self.write_debug_output( + ede_project_area, preprocessed_vectors, nodes_gdf, edges_gdf, sample_points, suffix="multiple_criteria" + ) + + @staticmethod + def write_debug_output( + project_area: shapely.MultiPolygon, + criteria_vectors: dict[str, gpd.GeoDataFrame], + nodes_gdf: gpd.GeoDataFrame, + edges_gdf: gpd.GeoDataFrame, + sample_points: gpd.GeoDataFrame, + suffix: str, + ): + reset_geopackage(Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, project_area, "pytest_project_area", overwrite=True + ) + for name, gdf in criteria_vectors.items(): + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, + gdf, + f"pytest_vector_{name}", + overwrite=True, + ) + + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, + nodes_gdf, + f"pytest_graph_builder_integration_nodes_{suffix}", + overwrite=True, + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, + edges_gdf, + f"pytest_graph_builder_integration_edges_{suffix}", + overwrite=True, + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, + sample_points, + f"pytest_graph_builder_sample_points_{suffix}", + overwrite=True, + ) + + +class TestHexagonGraphBuilderWithHeightLevels: + out = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT + debug: bool = False + + @pytest.fixture(autouse=True) + def clean_start(self): + if self.debug: + reset_geopackage(self.out, truncate=False) + + def test_build_graph_with_two_tunnels(self, hexagon_graph_builder: HexagonGraphBuilder): + """E.g., a road and a bicycle tunnel crossing each other.""" + project_area = shapely.Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]) + # Large road without sidewalks + road_geom = shapely.LineString([(0, 50), (100, 50)]) + + # Tunnel 1 + bicycle_road_north_geom_1 = shapely.LineString([(25, 80), (25, 100)]) + bicycle_tunnel_geom_1 = shapely.LineString([(25, 20), (25, 80)]) + bicycle_road_south_geom_1 = shapely.LineString([(25, 0), (25, 20)]) + + # Tunnel 2 + bicycle_road_north_geom_2 = shapely.LineString([(75, 80), (75, 100)]) + bicycle_tunnel_geom_2 = shapely.LineString([(75, 20), (75, 80)]) + bicycle_road_south_geom_2 = shapely.LineString([(75, 0), (75, 20)]) + + main_road = road_geom.buffer(10, cap_style="flat") + road = gpd.GeoDataFrame( + data=[ + [30, 0, main_road], + [5, 1, bicycle_tunnel_geom_1.buffer(3, cap_style="flat")], + [5, 0, bicycle_road_north_geom_1.buffer(3, cap_style="flat")], + [5, 0, bicycle_road_south_geom_1.buffer(3, cap_style="flat")], + [5, 1, bicycle_tunnel_geom_2.buffer(3, cap_style="flat")], + [5, 0, bicycle_road_north_geom_2.buffer(3, cap_style="flat")], + [5, 0, bicycle_road_south_geom_2.buffer(3, cap_style="flat")], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + grassland = ( + gpd.GeoDataFrame( + data=[ + [2, 0, project_area.difference(road[road["relatieveHoogteligging"] == 0].geometry.union_all())], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + .explode() + .reset_index(drop=True) + ) + processed_criteria_vectors = { + "road": road, + "grassland": grassland, + } + if self.debug: + self.debug_write_output_vectors(project_area, processed_criteria_vectors) + + processed_criteria_per_height_level = { + 0: ["road", "grassland"], # ground level + 1: ["road"], # tunnel level + } + raster_groups = { + "road": "a", + "grassland": "a", + } + + merged_graph, merged_nodes_gdf = self._build_and_merge_graphs( + hexagon_graph_builder, + processed_criteria_per_height_level, + processed_criteria_vectors, + project_area, + raster_groups, + ) + route_engine = MultilayerRouteEngine( + merged_graph, + rx.PyGraph(), + merged_nodes_gdf, + hexagon_size=hexagon_graph_builder.hexagon_size, + write_output=self.debug, + ) + + # assert that we have a fully connected graph and no dangling parts. + assert rx.number_connected_components(merged_graph) == 1 + + # Find a route through the western tunnel + route_engine.find_route(shapely.LineString([(6, 95), (6, 5)])) + assert route_engine.get_result_route_length() == pytest.approx(109, 0.5) + # assert we can route from north to south through a tunnel + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + # assert we did not cross the expensive road but used the tunnel + assert all(route_engine.results.result_route_edges.weight < 30) + # assert the number of connecting edges between height levels + e = convert_hexagon_edges_to_gdf(merged_graph, merged_nodes_gdf) + assert len(e[e.connects_height_levels]) == 48 + # assert we cannot skip halfway the tunnel to the main road. + assert not all(e[e.connects_height_levels].intersects(main_road)) + + # Check that the other tunnel is working + route_engine.find_route(shapely.LineString([(80, 95), (80, 5)])) + assert route_engine.get_result_route_length() == pytest.approx(91, 0.5) + assert not all(e[e.connects_height_levels].intersects(main_road)) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + + # Find a route which does not use a tunnel but crosses the field above it. + route_engine.find_route(shapely.LineString([(1, 65), (99, 65)])) + assert route_engine.get_result_route_length() == pytest.approx(112, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 0 + ) + assert all(route_engine.results.result_route_edges.weight <= 4) + + def test_build_graph_with_one_bridge(self, hexagon_graph_builder: HexagonGraphBuilder): + """E.g., a road on a bridge crossing water. Could also be an ecoduct crossing a motorway.""" + project_area = shapely.Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]) + # Road on a bridge with sidewalks + road_geom_west = shapely.LineString([(0, 50), (20, 50)]) + bridge_geom = shapely.LineString([(20, 50), (80, 50)]) + road_geom_east = shapely.LineString([(80, 50), (100, 50)]) + + road = gpd.GeoDataFrame( + data=[ + # west + [10, 0, road_geom_west.buffer(10, cap_style="flat")], + [5, 0, road_geom_west.offset_curve(12).buffer(2, cap_style="flat")], + [5, 0, road_geom_west.offset_curve(-12).buffer(2, cap_style="flat")], + # bridge + [10, 1, bridge_geom.buffer(10, cap_style="flat")], + [5, 1, bridge_geom.offset_curve(12).buffer(2, cap_style="flat")], + [5, 1, bridge_geom.offset_curve(-12).buffer(2, cap_style="flat")], + # east + [10, 0, road_geom_east.buffer(10, cap_style="flat")], + [5, 0, road_geom_east.offset_curve(12).buffer(2, cap_style="flat")], + [5, 0, road_geom_east.offset_curve(-12).buffer(2, cap_style="flat")], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + # Water under the bridge + water = gpd.GeoDataFrame( + data=[ + [100, 0, shapely.LineString([(50, 0), (50, 100)]).buffer(10, cap_style="flat")], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + # Surrounding grassland + gdf_street_height_0 = road[road["relatieveHoogteligging"] == 0] + grassland = ( + gpd.GeoDataFrame( + data=[ + [ + 2, + 0, + project_area.difference(gdf_street_height_0.geometry.union_all()).difference( + water.geometry.union_all() + ), + ], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + .explode() + .reset_index(drop=True) + ) + processed_criteria_vectors = { + "road": road, + "water": water, + "grassland": grassland, + } + if self.debug: + self.debug_write_output_vectors(project_area, processed_criteria_vectors) + + # Expected output from mcda engine after changes + processed_criteria_per_height_level = { + 0: ["road", "grassland", "water"], # ground level + 1: ["road"], # bridge level + } + raster_groups = { + "road": "a", + "water": "a", + "grassland": "a", + } + + merged_graph, merged_nodes_gdf = self._build_and_merge_graphs( + hexagon_graph_builder, + processed_criteria_per_height_level, + processed_criteria_vectors, + project_area, + raster_groups, + ) + + route_engine = MultilayerRouteEngine( + merged_graph, + rx.PyGraph(), + merged_nodes_gdf, + hexagon_size=hexagon_graph_builder.hexagon_size, + write_output=self.debug, + ) + assert rx.number_connected_components(merged_graph) == 1 + e = convert_hexagon_edges_to_gdf(merged_graph, merged_nodes_gdf) + assert len(e[e.connects_height_levels]) == 72 + + # Find a route under the bridge + route_engine.find_route(shapely.LineString([(6, 95), (6, 5)])) # route should go under the bridge here (grass) + assert route_engine.get_result_route_length() == pytest.approx(95, 0.5) + # assert we can route from north to south through a tunnel + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 0 + ) + # assert we did not cross the expensive road or water but used the grass underneath the bridge. + assert all(route_engine.results.result_route_edges.weight <= 4) + + # Find a route over the bridge + route_engine.find_route(shapely.LineString([(1, 75), (99, 25)])) + assert route_engine.get_result_route_length() == pytest.approx(147, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + # it should not cross water + assert all(route_engine.results.result_route_edges.weight < 100) + + def test_build_graph_with_s_shaped_bridge_and_tunnel(self, hexagon_graph_builder: HexagonGraphBuilder): + """E.g., a road tunnel, a road and a bicycle bridge crossing each other.""" + project_area = shapely.Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]) + # Large road without sidewalks + road_geom = shapely.LineString([(0, 50), (100, 50)]) + + # Tunnel crossing the road + tunnel_north = shapely.LineString([(50, 80), (50, 100)]) + tunnel_middle = shapely.LineString([(50, 20), (50, 80)]) + tunnel_south = shapely.LineString([(50, 0), (50, 20)]) + + # Bridge crossing the tunnel + bridge_north = shapely.LineString([(30, 80), (30, 100)]) + bridge_middle = shapely.LineString([(85, 20), (70, 20), (70, 50), (30, 50), (30, 80)]) + bridge_south = shapely.LineString([(85, 20), (100, 20)]) + + road = gpd.GeoDataFrame( + data=[ + [50, 0, road_geom.buffer(10, cap_style="flat")], + [5, 0, tunnel_north.buffer(3, cap_style="flat")], + [5, -1, tunnel_middle.buffer(3, cap_style="flat")], + [5, 0, tunnel_south.buffer(3, cap_style="flat")], + [5, 0, bridge_north.buffer(3, cap_style="flat")], + [5, 1, bridge_middle.buffer(3, cap_style="flat", quad_segs=4)], + [5, 0, bridge_south.buffer(3, cap_style="flat")], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ).clip(project_area) + # Make grass a bit more expensive to force tunnel usage + grassland = ( + gpd.GeoDataFrame( + data=[ + [6, 0, project_area.difference(road[road["relatieveHoogteligging"] == 0].geometry.union_all())], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + .explode() + .reset_index(drop=True) + ) + processed_criteria_vectors = { + "road": road, + "grassland": grassland, + } + if self.debug: + self.debug_write_output_vectors(project_area, processed_criteria_vectors) + + processed_criteria_per_height_level = { + 0: ["road", "grassland"], # ground level + 1: ["road"], # bridge level + -1: ["road"], # tunnel level + } + raster_groups = { + "road": "a", + "grassland": "a", + } + merged_graph, merged_nodes_gdf = self._build_and_merge_graphs( + hexagon_graph_builder, + processed_criteria_per_height_level, + processed_criteria_vectors, + project_area, + raster_groups, + ) + + route_engine = MultilayerRouteEngine( + merged_graph, + rx.PyGraph(), + merged_nodes_gdf, + hexagon_size=hexagon_graph_builder.hexagon_size, + write_output=self.debug, + ) + + assert rx.number_connected_components(merged_graph) == 1 + e = convert_hexagon_edges_to_gdf(merged_graph, merged_nodes_gdf) + assert len(e[e.connects_height_levels]) == 51 + + # find a route over the bridge, we do not cross grass + route_engine.find_route(shapely.LineString([(30, 100), (100, 20)])) + assert route_engine.get_result_route_length() == pytest.approx(145.3, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + assert all(route_engine.results.result_route_edges.weight <= 10) + + # find a route under the tunnel, we do not cross grass + route_engine.find_route(shapely.LineString([(50, 100), (50, 0)])) + assert route_engine.get_result_route_length() == pytest.approx(145.3, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + assert all(route_engine.results.result_route_edges.weight <= 10) + + # find a route with just grassland. + route_engine.find_route(shapely.LineString([(1, 90), (99, 90)])) + assert route_engine.get_result_route_length() == pytest.approx(112.4, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 0 + ) + assert all(route_engine.results.result_route_edges.weight <= 12) + + def test_build_graph_with_t_shaped_bridge(self, hexagon_graph_builder: HexagonGraphBuilder): + """E.g., a road and a bridge.""" + project_area = shapely.Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]) + # Road for cars + road = shapely.LineString([(0, 50), (100, 50)]) + # Road on a bridge + bridge_geom_south = shapely.LineString([(50, 20), (50, 50)]) + bridge_geom_north_west = shapely.LineString([(50, 50), (17, 80)]) + bridge_geom_north_east = shapely.LineString([(50, 50), (50, 80), (80, 80)]) + # Connecting parts to the bridge + bridge_geom_south_0 = shapely.LineString([(50, 20), (50, 0)]) + bridge_geom_north_west_0 = shapely.LineString([(17, 80), (-5, 100)]) + bridge_geom_north_east_0 = shapely.LineString([(80, 80), (100, 80)]) + + road = gpd.GeoDataFrame( + data=[ + # road + [50, 0, road.buffer(10, cap_style="flat")], + # bridge + [5, 1, bridge_geom_south.buffer(5, cap_style="flat")], + [5, 1, bridge_geom_north_west.buffer(5, cap_style="flat")], + [5, 1, bridge_geom_north_east.buffer(5, cap_style="flat")], + # connecting parts to bridge + [5, 0, bridge_geom_south_0.buffer(5, cap_style="flat")], + [5, 0, bridge_geom_north_west_0.buffer(5, cap_style="flat")], + [5, 0, bridge_geom_north_east_0.buffer(5, cap_style="flat")], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ).clip(project_area) + # Surrounding grassland + gdf_street_height_0 = road[road["relatieveHoogteligging"] == 0] + grassland = ( + gpd.GeoDataFrame( + data=[ + [6, 0, project_area.difference(gdf_street_height_0.geometry.union_all())], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + .explode() + .reset_index(drop=True) + ) + processed_criteria_vectors = { + "road": road, + "grassland": grassland, + } + if self.debug: + self.debug_write_output_vectors(project_area, processed_criteria_vectors) + + # Expected output from mcda engine after changes + processed_criteria_per_height_level = { + 0: ["road", "grassland"], # ground level + 1: ["road"], # bridge level + } + raster_groups = { + "road": "a", + "grassland": "a", + } + + merged_graph, merged_nodes_gdf = self._build_and_merge_graphs( + hexagon_graph_builder, + processed_criteria_per_height_level, + processed_criteria_vectors, + project_area, + raster_groups, + ) + + route_engine = MultilayerRouteEngine( + merged_graph, + rx.PyGraph(), + merged_nodes_gdf, + hexagon_size=hexagon_graph_builder.hexagon_size, + write_output=self.debug, + ) + assert rx.number_connected_components(merged_graph) == 1 + e = convert_hexagon_edges_to_gdf(merged_graph, merged_nodes_gdf) + assert len(e[e.connects_height_levels]) == 47 + + # Find a route over the bridge, both ways + route_engine.find_route(shapely.LineString([(0, 95), (50, 0)])) + assert route_engine.get_result_route_length() == pytest.approx(124.5, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + assert all(route_engine.results.result_route_edges.weight <= 10) + + route_engine.find_route(shapely.LineString([(100, 80), (50, 0)])) + assert route_engine.get_result_route_length() == pytest.approx(128, 0.5) + assert ( + len(route_engine.results.result_route_edges[route_engine.results.result_route_edges.connects_height_levels]) + == 2 + ) + assert all(route_engine.results.result_route_edges.weight <= 10) + + def test_example_data_integration(self, hexagon_graph_builder: HexagonGraphBuilder): + """Use for testing a specific area of the example geopackages with known bridges/tunnels.""" + reset_geopackage(Config.PATH_GEOPACKAGE_MCDA_OUTPUT, truncate=False) + # Case 4 situation with bridge + project_area = shapely.Point(187224.708, 429010.295).buffer(200) + + # Case 2 situation with narrow bridges + # project_area = shapely.Point(174247.66, 441869.33).buffer(200) + + mcda_engine = McdaCostSurfaceEngine( + Config.RASTER_PRESET_NAME_BENCHMARK, + BenchmarkRouteCollection().get_case(4).path_geopackage, + project_area, + raster_name_prefix="pytest_", + ) + mcda_engine.preprocess_vectors() + + raster_groups = { + criteria_key: criteria.group for criteria_key, criteria in mcda_engine.raster_preset.criteria.items() + } + merged_graph, merged_nodes_gdf = self._build_and_merge_graphs( + hexagon_graph_builder, + mcda_engine.processed_criteria_per_height_level, + mcda_engine.processed_vectors, + project_area, + raster_groups, + ) + + route_engine = MultilayerRouteEngine( + merged_graph, + rx.PyGraph(), + merged_nodes_gdf, + hexagon_size=hexagon_graph_builder.hexagon_size, + write_output=self.debug, + ) + route_engine.find_route(shapely.LineString([(187139.16, 429004.25), (187324.59, 428983.47)])) + + def debug_write_output_vectors( + self, + project_area: shapely.MultiPolygon | shapely.Polygon, + criteria_vectors: dict[str, gpd.GeoDataFrame], + ): + write_results_to_geopackage(self.out, project_area, "pytest_project_area", overwrite=True) + for name, gdf in criteria_vectors.items(): + write_results_to_geopackage( + self.out, + gdf, + f"pytest_vector_{name}", + overwrite=True, + ) + + def _build_and_merge_graphs( + self, + hexagon_graph_builder, + processed_criteria_per_height_level, + processed_criteria_vectors, + project_area, + raster_groups, + ) -> tuple[rx.PyGraph, gpd.GeoDataFrame]: + # TODO extract to hex builder? Cache the project area node grid so it is not recomputed each time + # Build hexagon graphs per height level + graphs_per_height: dict[int, HeightLevelGraph] = {} + for height_level, criteria in processed_criteria_per_height_level.items(): + criteria_for_height_level = {} + for criterion in criteria: + gdf = processed_criteria_vectors[criterion][ + processed_criteria_vectors[criterion]["relatieveHoogteligging"] == height_level + ] + criteria_for_height_level[criterion] = gdf # type: ignore + + graph, nodes_gdf = hexagon_graph_builder.build_graph( + project_area.buffer(0.01), raster_groups, criteria_for_height_level + ) + + graphs_per_height[height_level] = HeightLevelGraph(graph, nodes_gdf) + if self.debug: + self.debug_write_output_graphs(graphs_per_height) + + hexagon_graph_composer = HexagonGraphComposer( + processed_criteria_per_height_level, + graphs_per_height, + hexagon_size=hexagon_graph_builder.hexagon_size, + debug=self.debug, + ) + merged_graph = hexagon_graph_composer.compose() + + return merged_graph.graph, merged_graph.nodes_gdf + + def debug_write_output_graphs(self, graphs: dict[int, HeightLevelGraph]): + for height_level, height_level_graph in graphs.items(): + edges_gdf = convert_hexagon_edges_to_gdf(height_level_graph.graph, height_level_graph.nodes_gdf) + write_results_to_geopackage( + self.out, + height_level_graph.nodes_gdf, + f"pytest_graph_nodes_height_level_{height_level}", + overwrite=True, + ) + write_results_to_geopackage( + self.out, edges_gdf, f"pytest_graph_edges_height_level_{height_level}", overwrite=True + ) diff --git a/tests/integration/route_evaluation_test.py b/tests/integration/route_evaluation_test.py index 7c9638e..114c45d 100644 --- a/tests/integration/route_evaluation_test.py +++ b/tests/integration/route_evaluation_test.py @@ -19,7 +19,7 @@ def test_route_costs_single_input_with_project_area(self): route_evaluation_metrics = RouteEvaluationMetrics(route, path_raster, project_area=route.buffer(50)) route_evaluation_metrics.get_route_evaluation_metrics() - assert round(route_evaluation_metrics.route_relative_cost_sota) == 9969 + assert round(route_evaluation_metrics.route_relative_cost_sota) == 8998 assert route_evaluation_metrics.route_sota.length == route.length def test_route_costs_two_inputs(self): @@ -32,8 +32,8 @@ def test_route_costs_two_inputs(self): ) route_evaluation_metrics.get_route_evaluation_metrics() - assert round(route_evaluation_metrics.route_relative_cost_sota) == 9969 - assert round(route_evaluation_metrics.route_relative_cost_human) == 30991 + assert round(route_evaluation_metrics.route_relative_cost_sota) == 8998 + assert round(route_evaluation_metrics.route_relative_cost_human) == 27325 assert route_evaluation_metrics.route_sota.length == route_sota.length assert route_evaluation_metrics.route_human.length == route_human.length assert route_evaluation_metrics.route_similarity_sota == 0 @@ -78,7 +78,7 @@ def test_route_costs_of_linestring_in_single_cell(self): route_evaluation_metrics = RouteEvaluationMetrics(route, path_raster) route_evaluation_metrics.get_route_evaluation_metrics() - cost_of_single_cell = 126 # As viewed in QGIS. + cost_of_single_cell = 86 # As viewed in QGIS. assert round(route_evaluation_metrics.route_relative_cost_sota) == round(cost_of_single_cell * route.length) @@ -94,8 +94,8 @@ def test_get_number_of_nodes_edges_for_a_raster(self): route_evaluation_metrics = RouteEvaluationMetrics(route, path_raster) nodes, edges = route_evaluation_metrics.get_number_of_nodes_edges(path_raster, project_area) assert edges / nodes <= 8 - assert nodes == 2182753 - assert edges == 17435116 + assert nodes == 2182557 + assert edges == 17433560 def test_get_number_of_nodes_edges_small_array(self): pytest_array = np.array( diff --git a/tests/integration/vrt_routing_test.py b/tests/integration/vrt_routing_test.py index 630b1a5..0113e0f 100644 --- a/tests/integration/vrt_routing_test.py +++ b/tests/integration/vrt_routing_test.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. # # SPDX-License-Identifier: Apache-2.0 +import copy import geopandas as gpd import pytest @@ -40,7 +41,7 @@ def route_for_single_tiff_file( preprocessed_vectors: McdaCostSurfaceEngine, start_end_point_route, ) -> shapely.LineString: - mcda_engine = preprocessed_vectors + mcda_engine = copy.deepcopy(preprocessed_vectors) # Ensure we do not mutate the original fixture # Use a max block size that exceeds the width & height of the project area (to force using a single tiff) max_block_size = 2048 @@ -66,8 +67,8 @@ def test_vrt_results_in_same_route_as_single_tiff( max_block_size: int, debug: bool = False, ): + mcda_engine = copy.deepcopy(preprocessed_vectors) # Ensure we do not mutate the original fixture # Given the set of preprocessed vectors, preprocess the rasters given the max block size - mcda_engine = preprocessed_vectors raster_path_vrt = mcda_engine.preprocess_rasters( mcda_engine.processed_vectors, cell_size=0.5, max_block_size=max_block_size, run_in_parallel=False ) diff --git a/tests/unit/geo_utilities_test.py b/tests/unit/geo_utilities_test.py new file mode 100644 index 0000000..6ffe21a --- /dev/null +++ b/tests/unit/geo_utilities_test.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pytest +import shapely +from shapely.testing import assert_geometries_equal + +from utility_route_planner.util.geo_utilities import ( + get_angle_between_points, + extrapolate_point_to_target, + extend_linestring_both_ends, + split_polygon_by_linestrings, + get_endlines_of_multilinestring, +) + + +class TestGetAngleBetweenPoints: + @pytest.mark.parametrize( + "a,b,c,expected", + [ + (shapely.Point(1, 1), shapely.Point(1, 1), shapely.Point(0, 0), 0), + (shapely.Point(0, 10), shapely.Point(0, 1), shapely.Point(0, 0), 0), + (shapely.Point(1, 0), shapely.Point(1, 1), shapely.Point(0, 0), 45), + (shapely.Point(1, 0), shapely.Point(0, 1), shapely.Point(0, 0), 90), + (shapely.Point(1, 0), shapely.Point(-1, 0), shapely.Point(0, 0), 180), + (shapely.Point(0, 1), shapely.Point(1, 0), shapely.Point(0, 0), 270), + ], + ) + def test_some_angles(self, a, b, c, expected): + assert get_angle_between_points(a, b, c) == pytest.approx(expected, abs=0.01) + + @pytest.mark.parametrize( + "point_a, point_b, center_point, expected_exception", + [ + (shapely.Point(1, 1), shapely.Point(2, 2), shapely.Point(1, 1), ValueError), + (shapely.Point(2, 2), shapely.Point(1, 1), shapely.Point(1, 1), ValueError), + ], + ) + def test_get_angle_between_points_raises(self, point_a, point_b, center_point, expected_exception): + with pytest.raises(expected_exception): + get_angle_between_points(point_a, point_b, center_point) + + +class TestExtendLinestring: + @pytest.mark.parametrize( + "point_a, direction, distance, expected", + [ + (shapely.Point(0, 0), shapely.Point(0, 1), 100, shapely.LineString([(0, 0), (0, 100)])), + (shapely.Point(0, 0), shapely.Point(0, 1), 10, shapely.LineString([(0, 0), (0, 10)])), + (shapely.Point(0, 0), shapely.Point(0, 100), 10, shapely.LineString([(0, 0), (0, 10)])), + (shapely.Point(0, 0), shapely.Point(1, 1), 10, shapely.LineString([(0, 0), (7.07, 7.07)])), + (shapely.Point(0, 0), shapely.Point(-10, 0), 10, shapely.LineString([(0, 0), (-10, 0)])), + ], + ) + def test_extrapolate_point_to_target(self, point_a, direction, distance, expected): + result = extrapolate_point_to_target(point_a, direction, distance) + assert result.equals_exact(expected, tolerance=0.01, normalize=True) + assert result.length >= distance + assert shapely.get_num_points(result) == 2 + + @pytest.mark.parametrize( + "point_a, direction, distance", + [ + (shapely.Point(0, 0), shapely.Point(0, 0), 10), + (shapely.Point(1, 1), shapely.Point(1, 1), 10), + ], + ) + def test_extrapolate_point_to_target_raises(self, point_a, direction, distance): + with pytest.raises(ValueError): + extrapolate_point_to_target(point_a, direction, distance) + + +class TestExtendLinestringBothEnds: + @pytest.mark.parametrize( + "linestring, distance, expected", + [ + (shapely.LineString([(0, 5), (0, 10)]), 5, shapely.LineString([(0, 0), (0, 15)])), + (shapely.LineString([(0, 5), (0, 6), (0, 10)]), 5, shapely.LineString([(0, 0), (0, 6), (0, 15)])), + ( + shapely.LineString([(0, 0), (5, 5), (0, 10)]), + 5, + shapely.LineString([(-3.536, -3.536), (5, 5), (-3.536, 13.536)]), + ), + (shapely.LineString([(0, 0), (0, 5), (5, 5)]), 5, shapely.LineString([(0, -5), (0, 5), (10, 5)])), + ( + shapely.LineString([(0, 5), (0, 6), (99, 1), (0, 10), (0, 11)]), + 5, + shapely.LineString([(0, 0), (0, 6), (99, 1), (0, 10), (0, 16)]), + ), + ], + ) + def test_extend_linestring_both_ends(self, linestring, distance, expected, debug=False): + result = extend_linestring_both_ends(linestring, 5, debug) + assert result.length == linestring.length + 2 * distance + assert result.equals_exact(expected, tolerance=0.01, normalize=True) + + +class TestSplitPolygonByMultiLineString: + @pytest.mark.parametrize( + "polygon, linestrings, expected_polygons_count", + [ + (shapely.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]), [shapely.LineString([(5, -5), (5, 15)])], 2), + ( + shapely.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]), + [shapely.LineString([(5, -5), (5, 15)]), shapely.LineString([(-5, 5), (15, 5)])], + 4, + ), + ( + shapely.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]), + [ + shapely.LineString([(5, -5), (5, 15)]), + shapely.LineString([(-5, 5), (15, 5)]), + shapely.LineString([(0, 0), (10, 10)]), + ], + 6, + ), + ( + shapely.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]), + [ + shapely.LineString([(5, -5), (5, 15)]), + shapely.LineString([(-5, 5), (15, 5)]), + shapely.LineString([(0, 0), (10, 10)]), + shapely.LineString([(0, 10), (10, 0)]), + ], + 8, + ), + ( + shapely.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]), + [shapely.LineString([(15, -5), (15, 15)]), shapely.LineString([(5, -5), (5, 15)])], + 2, + ), + ( + shapely.Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]), + [ + shapely.LineString([(5, -5), (5, -1)]), + shapely.LineString([(1, 1), (5, 8)]), + shapely.LineString([(6, 9), (6, -1)]), + ], + 1, + ), + ], + ) + def test_split_polygon(self, polygon, linestrings, expected_polygons_count, debug=False): + result = split_polygon_by_linestrings(polygon, linestrings, debug) + assert len(result) == expected_polygons_count + for polygon in result: + assert polygon.is_valid + assert polygon.within(polygon) + assert polygon.area > 0 + + +class TestMultiLineStringEndparts: + def test_get_lines_star_shape(self): + linestring = shapely.MultiLineString( + [ + shapely.LineString([(5, 5), (5, 10)]), + shapely.LineString([(5, 10), (5, 5)]), # swapped version of the above, should not matter + shapely.LineString([(5, 5), (10, 0)]), + shapely.LineString([(5, 5), (0, 0)]), + shapely.LineString([(0, 5), (5, 5)]), + shapely.LineString([(5, 5), (1, 0), (0, -1)]), + ] + ) + # "hub" point should always be present first. + expected_lines = [ + shapely.LineString([(5, 5), (5, 10)]), + shapely.LineString([(5, 5), (5, 10)]), + shapely.LineString([(5, 5), (10, 0)]), + shapely.LineString([(5, 5), (0, 0)]), + shapely.LineString([(5, 5), (0, 5)]), + shapely.LineString([(1, 0), (0, -1)]), + ] + + lines = get_endlines_of_multilinestring(linestring) + assert_geometries_equal(lines, expected_lines) diff --git a/tests/unit/multilayer_network/__init__.py b/tests/unit/multilayer_network/__init__.py new file mode 100644 index 0000000..47c5400 --- /dev/null +++ b/tests/unit/multilayer_network/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/multilayer_network/conftest.py b/tests/unit/multilayer_network/conftest.py new file mode 100644 index 0000000..f1dd8f4 --- /dev/null +++ b/tests/unit/multilayer_network/conftest.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pickle + +import pytest +from networkx import MultiDiGraph + +from settings import Config + + +@pytest.fixture +def load_osm_graph_pickle(refresh_example_graph=False) -> MultiDiGraph: + # Option to refresh to example osm graph. + if refresh_example_graph: + import geopandas as gpd + from utility_route_planner.models.multilayer_network.osm_graph_downloader import OSMGraphDownloader + + project_area = ( + gpd.read_file(Config.PYTEST_PATH_GEOPACKAGE_MCDA, layer=Config.PYTEST_LAYER_NAME_PROJECT_AREA) + .iloc[0] + .geometry + ) + osm_graph_downloader = OSMGraphDownloader(project_area) + project_area_graph = osm_graph_downloader.download_graph() + + with open(Config.PYTEST_OSM_GRAPH_PICKLE, "wb") as file: + pickle.dump(project_area_graph, file) + + with open(Config.PYTEST_OSM_GRAPH_PICKLE, "rb") as file: + osm_graph = pickle.load(file) + return osm_graph diff --git a/tests/unit/multilayer_network/hexagon_graph/__init__.py b/tests/unit/multilayer_network/hexagon_graph/__init__.py new file mode 100644 index 0000000..47c5400 --- /dev/null +++ b/tests/unit/multilayer_network/hexagon_graph/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/multilayer_network/hexagon_graph/hexagon_edge_generator_test.py b/tests/unit/multilayer_network/hexagon_graph/hexagon_edge_generator_test.py new file mode 100644 index 0000000..6484a54 --- /dev/null +++ b/tests/unit/multilayer_network/hexagon_graph/hexagon_edge_generator_test.py @@ -0,0 +1,376 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 +import math + +import geopandas as gpd +import numpy as np +import polars as pl +from polars.testing import assert_frame_equal +import pytest +import shapely + +from settings import Config +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.util.write import write_results_to_geopackage + + +class TestHexagonEdgeGenerator: + @pytest.fixture() + def hexagon_size(self) -> int: + return 4 + + @pytest.fixture() + def hexagonal_grid(self) -> pl.DataFrame: + """ + Hexagonal grid generated with hexagonsize=4 + """ + return pl.DataFrame( + data=[ + [0, 19, -29162, 79691], + [1, 9, -29160, 79690], + [2, 76, -29160, 79692], + [3, 76, -29162, 79692], + [4, 126, -29161, 79693], + [5, 126, -29161, 79692], + [6, 126, -29162, 79693], + [7, 76, -29160, 79691], + [8, 86, -29161, 79691], + [9, 126, -29163, 79694], + [10, 126, -29163, 79693], + [11, 76, -29165, 79693], + [12, 19, -29164, 79692], + [13, 76, -29163, 79692], + [14, 76, -29163, 79696], + [15, 76, -29163, 79697], + [16, 76, -29163, 79695], + [17, 19, -29160, 79694], + [18, 76, -29160, 79695], + [19, 126, -29162, 79694], + [20, 126, -29160, 79693], + [21, 76, -29161, 79694], + [22, 76, -29159, 79693], + ], + schema={ + "node_id": pl.Int32, + "suitability_value": pl.Int16, + "q": pl.Int32, + "r": pl.Int32, + }, + ) + + @pytest.fixture() + def block(self): + return pl.DataFrame( + data=[ + [0, 19, -29162, 79691], + [1, 9, -29160, 79690], + [2, 76, -29160, 79692], + [3, 76, -29162, 79692], + [4, 126, -29161, 79693], + [5, 126, -29161, 79692], + [6, 126, -29162, 79693], + [7, 76, -29160, 79691], + [8, 86, -29161, 79691], + ], + schema={ + "node_id": pl.Int32, + "suitability_value": pl.Int16, + "q": pl.Int32, + "r": pl.Int32, + }, + ) + + @pytest.fixture() + def previous_edge_nodes_current_row(self) -> pl.DataFrame: + return pl.DataFrame( + data=[ + [9, 126, -29163, 79694], + [10, 126, -29163, 79693], + [11, 76, -29165, 79693], + [12, 19, -29164, 79692], + [13, 76, -29163, 79692], + ], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "q": pl.Int32, "r": pl.Int32}, + ) + + @pytest.fixture() + def relevant_edge_nodes_previous_row(self) -> pl.DataFrame: + return pl.DataFrame( + data=[ + [14, 76, -29163, 79696], + [15, 76, -29163, 79697], + [16, 76, -29163, 79695], + [17, 19, 76, -29160, 79694], + [18, 76, 76, -29160, 79695], + [19, 126, -29162, 79694], + [20, 126, -29160, 79693], + [21, 76, -29161, 79694], + [22, 76, -29159, 79693], + ], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "q": pl.Int32, "r": pl.Int32}, + ) + + def test_generate_edges_without_previous_blocks( + self, hexagonal_grid: pl.DataFrame, block: pl.DataFrame, hexagon_size: int, debug: bool = False + ): + """ + Test that verifies that all edges are present, have correct edge weights and length when generating edges + for a block without having previous edges. This means only edges within the block itself are expected. + + This test can be understood most easily by setting debug=True and inspecting the results in QGis. + """ + + empty_previous_edges = pl.DataFrame( + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "q": pl.Int32, "r": pl.Int32} + ) + nodes_to_check = pl.concat([block, empty_previous_edges]) + generator = HexagonEdgeGenerator() + result_edges = generator.generate(block, nodes_to_check) + + expected_edges = pl.DataFrame( + data=[ + [5, 6, 252], + [0, 8, 105], + [0, 3, 95], + [4, 6, 252], + [1, 7, 85], + [8, 3, 162], + [3, 5, 202], + [7, 8, 162], + [3, 6, 202], + [1, 8, 95], + [5, 4, 252], + [2, 5, 202], + [2, 4, 202], + [7, 5, 202], + [7, 2, 152], + [8, 5, 212], + ], + schema={"source_node": pl.Int32, "target_node": pl.Int32, "weight": pl.Int16}, + ) + assert_frame_equal(expected_edges, result_edges, check_row_order=False) + + hexagon_points, edges_linestrings = self.convert_nodes_and_edges_to_gdfs( + hexagonal_grid, result_edges, hexagon_size=hexagon_size + ) + assert all([length == pytest.approx(6.93, abs=0.01) for length in edges_linestrings.length]) + + if debug: + self.write_debug(hexagon_points, edges_linestrings, suffix="no_previous_nodes") + + def test_generate_edges_with_single_previous_block( + self, + hexagonal_grid: pl.DataFrame, + block: pl.DataFrame, + previous_edge_nodes_current_row: pl.DataFrame, + hexagon_size: int, + debug: bool = False, + ): + """ + Test that verifies that all edges are present, have correct edge weights and length when generating edges + for a block when having only the relevant nodes in the block to check for cross-block edges. This mimics the + situation when processing the next block on the first row of the grid. + + This test can be understood most easily by setting debug=True and inspecting the results in QGis. + """ + nodes_to_check = pl.concat([block, previous_edge_nodes_current_row]) + + generator = HexagonEdgeGenerator() + result_edges = generator.generate(block, nodes_to_check) + + expected_edges = pl.DataFrame( + data=[ + [3, 10, 202], + [7, 5, 202], + [2, 4, 202], + [3, 6, 202], + [3, 13, 152], + [2, 5, 202], + [3, 5, 202], + [5, 4, 252], + [4, 6, 252], + [7, 2, 152], + [0, 8, 105], + [1, 8, 95], + [8, 5, 212], + [6, 10, 252], + [6, 9, 252], + [0, 13, 95], + [7, 8, 162], + [5, 6, 252], + [0, 3, 95], + [8, 3, 162], + [1, 7, 85], + ], + schema={"source_node": pl.Int32, "target_node": pl.Int32, "weight": pl.Int16}, + ) + assert_frame_equal(expected_edges, result_edges, check_row_order=False) + + hexagon_points, edges_linestrings = self.convert_nodes_and_edges_to_gdfs( + hexagonal_grid, result_edges, hexagon_size=hexagon_size + ) + assert all([length == pytest.approx(6.93, abs=0.01) for length in edges_linestrings.length]) + + if debug: + self.write_debug(hexagon_points, edges_linestrings, suffix="previous_block_only") + + def test_generate_edges_with_only_previous_row( + self, + hexagonal_grid: pl.DataFrame, + block: pl.DataFrame, + relevant_edge_nodes_previous_row: pl.DataFrame, + hexagon_size: int, + debug: bool = False, + ): + """ + Test that verifies that all edges are present, have correct edge weights and length when generating edges + for a block when having only the relevant nodes in the previous row to check for cross-block edges. This mimics + the situation when entering a new row when constructing the grid. + + This test can be understood most easily by setting debug=True and inspecting the results in QGis. + """ + nodes_to_check = pl.concat([block, relevant_edge_nodes_previous_row]) + + generator = HexagonEdgeGenerator() + result_edges = generator.generate(block, nodes_to_check) + + expected_edges = pl.DataFrame( + data=[ + [2, 20, 202], + [7, 5, 202], + [2, 4, 202], + [3, 6, 202], + [4, 19, 252], + [2, 5, 202], + [3, 5, 202], + [4, 20, 252], + [5, 4, 252], + [4, 6, 252], + [6, 19, 252], + [7, 2, 152], + [0, 8, 105], + [1, 8, 95], + [8, 5, 212], + [4, 21, 202], + [7, 8, 162], + [5, 6, 252], + [0, 3, 95], + [8, 3, 162], + [1, 7, 85], + ], + schema={"source_node": pl.Int32, "target_node": pl.Int32, "weight": pl.Int16}, + ) + assert_frame_equal(expected_edges, result_edges, check_row_order=False) + + hexagon_points, edges_linestrings = self.convert_nodes_and_edges_to_gdfs( + hexagonal_grid, result_edges, hexagon_size=hexagon_size + ) + assert all([length == pytest.approx(6.93, abs=0.01) for length in edges_linestrings.length]) + + if debug: + self.write_debug(hexagon_points, edges_linestrings, suffix="previous_row_only") + + def test_generate_edges_with_previous_blocks( + self, + hexagonal_grid: pl.DataFrame, + block: pl.DataFrame, + previous_edge_nodes_current_row: pl.DataFrame, + relevant_edge_nodes_previous_row: pl.DataFrame, + hexagon_size: int, + debug: bool = False, + ): + """ + Test that verifies that all edges are present, have correct edge weights and length when generating edges + for a block when having previous edges in both the current and previous row. + + This test can be understood most easily by setting debug=True and inspecting the results in QGis. + """ + nodes_to_check = pl.concat([block, previous_edge_nodes_current_row, relevant_edge_nodes_previous_row]) + + generator = HexagonEdgeGenerator() + result_edges = generator.generate(block, nodes_to_check) + + expected_edges = pl.DataFrame( + data=[ + [2, 20, 202], + [3, 10, 202], + [7, 5, 202], + [2, 4, 202], + [3, 6, 202], + [3, 13, 152], + [4, 19, 252], + [2, 5, 202], + [3, 5, 202], + [4, 20, 252], + [5, 4, 252], + [4, 6, 252], + [6, 19, 252], + [7, 2, 152], + [0, 8, 105], + [1, 8, 95], + [8, 5, 212], + [4, 21, 202], + [6, 10, 252], + [6, 9, 252], + [0, 13, 95], + [7, 8, 162], + [5, 6, 252], + [0, 3, 95], + [8, 3, 162], + [1, 7, 85], + ], + schema={"source_node": pl.Int32, "target_node": pl.Int32, "weight": pl.Int16}, + ) + assert_frame_equal(expected_edges, result_edges, check_row_order=False) + + hexagon_points, edges_linestrings = self.convert_nodes_and_edges_to_gdfs( + hexagonal_grid, result_edges, hexagon_size=hexagon_size + ) + assert all([length == pytest.approx(6.93, abs=0.01) for length in edges_linestrings.length]) + + if debug: + self.write_debug(hexagon_points, edges_linestrings, suffix="previous_block_and_rows") + + @staticmethod + def convert_nodes_and_edges_to_gdfs( + grid: pl.DataFrame, edges: pl.DataFrame, hexagon_size: int + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + # Convert axial coordinates to cartesian coordinates + q = grid["q"] + r = grid["r"] + x = q * (-3 / 2 * hexagon_size) + y = (r + q / 2) * (math.sqrt(3) * hexagon_size) + + hexagon_points = gpd.GeoDataFrame( + data=grid.select("node_id", "suitability_value"), + geometry=gpd.points_from_xy(x, y), + columns=["node_id", "suitability_value"], + crs=Config.CRS, + ).set_index("node_id") + source_points = hexagon_points.loc[edges["source_node"], "geometry"] + target_points = hexagon_points.loc[edges["target_node"], "geometry"] + edges_linestrings = gpd.GeoDataFrame( + data=edges["weight"], + geometry=shapely.linestrings( + np.stack([source_points.get_coordinates().values, target_points.get_coordinates().values], axis=1) + ), + columns=["weight"], + crs=Config.CRS, + ) + return hexagon_points, edges_linestrings + + @staticmethod + def write_debug(hexagon_points: gpd.GeoDataFrame, edges_linestrings: gpd.GeoDataFrame, suffix: str): + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, + hexagon_points, + f"pytest_hexagon_edges_test_nodes_{suffix}", + overwrite=True, + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, + edges_linestrings, + f"pytest_hexagon_edges_test_edges_{suffix}", + overwrite=True, + ) diff --git a/tests/unit/multilayer_network/hexagon_graph/hexagon_grid_constructor_test.py b/tests/unit/multilayer_network/hexagon_graph/hexagon_grid_constructor_test.py new file mode 100644 index 0000000..cdf44bf --- /dev/null +++ b/tests/unit/multilayer_network/hexagon_graph/hexagon_grid_constructor_test.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 + +import math +import geopandas as gpd +import numpy as np +import polars as pl +from polars.testing import assert_frame_equal +import pytest +import shapely + +from settings import Config +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import ( + HexagonGridBuilder, +) + + +@pytest.fixture() +def grid_constructor() -> HexagonGridBuilder: + hexagon_size = 0.5 + block_size = 512 + return HexagonGridBuilder(hexagon_size, block_size) + + +class TestConstructHexagonalGridForBoundingBox: + @pytest.fixture() + def square_project_area(self) -> shapely.Polygon: + return shapely.Polygon( + [ + shapely.Point(0, 0), + shapely.Point(5, 0), + shapely.Point(5, 5), + shapely.Point(0, 5), + shapely.Point(0, 0), + ] + ) + + @pytest.fixture() + def triangular_project_area(self) -> shapely.Polygon: + return shapely.Polygon( + [ + shapely.Point(0, 0), + shapely.Point(4, 0), + shapely.Point(2, 4), + shapely.Point(0, 0), + ] + ) + + def test_square_project_area(self, grid_constructor: HexagonGridBuilder, square_project_area: shapely.Polygon): + x_coordinates, y_coordinates = grid_constructor.construct_hexagonal_grid_for_bounding_box(square_project_area) + + hexagon_size = grid_constructor.hexagon_size + self.check_grid_bounding_box(hexagon_size, square_project_area, x_coordinates, y_coordinates) + self.check_grid_spacing(hexagon_size, x_coordinates, y_coordinates) + + def test_triangular_project_area( + self, grid_constructor: HexagonGridBuilder, triangular_project_area: shapely.Polygon + ): + x_coordinates, y_coordinates = grid_constructor.construct_hexagonal_grid_for_bounding_box( + triangular_project_area + ) + + self.check_grid_spacing(grid_constructor.hexagon_size, x_coordinates, y_coordinates) + + hexagon_size = grid_constructor.hexagon_size + self.check_grid_bounding_box(hexagon_size, triangular_project_area, x_coordinates, y_coordinates) + self.check_grid_spacing(hexagon_size, x_coordinates, y_coordinates) + + @staticmethod + def check_grid_bounding_box( + hexagon_size: float, project_area: shapely.Polygon, x_coordinates: np.ndarray, y_coordinates: np.ndarray + ): + """ + Verifies that all coordinates are within or on the border of the project area polygon bounding box. At this stage, + the grid should be equal to the square equal to the bounding box, independent of the shape of the project area polygon. + """ + exptected_x_min, exptected_y_min, exptected_x_max, exptected_y_max = project_area.bounds + + assert pytest.approx(exptected_x_min) == x_coordinates.min() + assert exptected_x_max >= x_coordinates.max() >= (exptected_x_max - hexagon_size) + assert pytest.approx(exptected_y_min) == y_coordinates.min() + assert exptected_y_max >= y_coordinates.max() >= (exptected_y_max - hexagon_size) + + @staticmethod + def check_grid_spacing(hexagon_size: float, x_coordinates: np.ndarray, y_coordinates: np.ndarray): + # Set expected spacing between hexagon points based on known equations. + expected_horizontal_spacing = -3 / 2 * hexagon_size + expected_vertical_spacing = -math.sqrt(3) * hexagon_size + + # Verify that spacing between x-coordinates of hexagon center points equals the expected horizontal spacing + x_spacing = np.diff(x_coordinates, axis=1) + assert all(x_space == pytest.approx(expected_horizontal_spacing, abs=1e-4) for x_space in x_spacing) + + # Verify that spacing between y-coordinates of hexagon center points equals the expected vertical spacing + y_vertical_spacing = np.diff(y_coordinates, axis=0) + assert all(y_space == pytest.approx(expected_vertical_spacing, abs=1e-4) for y_space in y_vertical_spacing) + + # Verify that y-coordinates of horizontally neighbouring hexagon center points are 1/2 of the expected vertical spacing + # separated from each other. This is due to alternating offset of neighbouring hexagon points. + y_horizontal_spacing = np.diff(y_coordinates, axis=1) + expected_y_spacing = abs(expected_vertical_spacing) / 2 + assert all(y_space == pytest.approx(expected_y_spacing, abs=1e-4) for y_space in np.abs(y_horizontal_spacing)) + + # As every even column is offset by 1/2 the hexagon height, the horizontal spacing should always be negative. For + # all odd columns, the sign should be positive + signs = np.sign(y_horizontal_spacing) + assert all(sign == -1 for sign in signs[:, ::2].flatten()) + assert all(sign == 1 for sign in signs[:, 1::2].flatten()) + + +class TestAssignSuitabilityValuesToGrid: + def test_no_overlapping_points(self, grid_constructor: HexagonGridBuilder): + """ + Verify that all suitability values remain intact in case no points are overlapping. Only points in group c + should have a suitability value which equals the max node suitability value. + """ + points_on_grid = gpd.GeoDataFrame( + data=[[1, "a", 20.0], [2, "b", 30.0], [3, "c", 40.0], [4, "a", 50.0], [5, "b", 60.0], [6, "c", 70.0]], + columns=["node_id", "group", "suitability_value"], + geometry=[ + shapely.Point(0, 0), + shapely.Point(1, 1), + shapely.Point(2, 2), + shapely.Point(3, 3), + shapely.Point(4, 4), + shapely.Point(5, 5), + ], + crs=Config.CRS, + ).set_index("node_id") + + result = grid_constructor.assign_suitability_values_to_block(points_on_grid) + + expected_suitability_values = pl.DataFrame( + data=[ + [1, 20, 0.0, 0.0], + [2, 30, 1.0, 1.0], + [3, Config.MAX_NODE_SUITABILITY_VALUE, 2.0, 2.0], + [4, 50, 3.0, 3.0], + [5, 60, 4.0, 4.0], + [6, Config.MAX_NODE_SUITABILITY_VALUE, 5.0, 5.0], + ], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "x": pl.Float32, "y": pl.Float32}, + ) + # Sort result by node_id as suitability query does not take node order into account + assert_frame_equal(expected_suitability_values, result.sort("node_id")) + + def test_overlapping_points_group_a(self, grid_constructor: HexagonGridBuilder): + """ + Verify that for overlapping points in group a, the max value is used as suitability value + that point. + """ + points_on_grid = gpd.GeoDataFrame( + data=[[1, "a", 20.0], [1, "a", 30.0]], + columns=["node_id", "group", "suitability_value"], + geometry=[shapely.Point(0, 0), shapely.Point(0, 0)], + crs=Config.CRS, + ).set_index("node_id") + + result = grid_constructor.assign_suitability_values_to_block(points_on_grid) + + expected_suitability_values = pl.DataFrame( + data=[[1, 30.0, 0.0, 0.0]], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "x": pl.Float32, "y": pl.Float32}, + ) + assert_frame_equal(expected_suitability_values, result) + + def test_overlapping_points_group_b(self, grid_constructor: HexagonGridBuilder): + """ + Verify that for overlapping points in group b, values for all points are summed to + compute the suitability value + """ + points_on_grid = gpd.GeoDataFrame( + data=[[1, "b", 50.0], [1, "b", 40.0]], + columns=["node_id", "group", "suitability_value"], + geometry=[shapely.Point(0, 0), shapely.Point(0, 0)], + crs=Config.CRS, + ).set_index("node_id") + + result = grid_constructor.assign_suitability_values_to_block(points_on_grid) + + expected_suitability_values = pl.DataFrame( + data=[[1, 90, 0.0, 0.0]], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "x": pl.Float32, "y": pl.Float32}, + ) + assert_frame_equal(expected_suitability_values, result) + + def test_overlapping_points_group_c(self, grid_constructor: HexagonGridBuilder): + """ + Verify that for overlapping points in group c, the suitability value for the respective nodes is always + set to the max node suitability value. + """ + points_on_grid = gpd.GeoDataFrame( + data=[[1, "c", 100.0], [1, "b", 100.0]], + columns=["node_id", "group", "suitability_value"], + geometry=[shapely.Point(0, 0), shapely.Point(0, 0)], + crs=Config.CRS, + ).set_index("node_id") + + result = grid_constructor.assign_suitability_values_to_block(points_on_grid) + + expected_suitability_values = pl.DataFrame( + data=[[1, Config.MAX_NODE_SUITABILITY_VALUE, 0.0, 0.0]], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "x": pl.Float32, "y": pl.Float32}, + ) + assert_frame_equal(expected_suitability_values, result) + + def test_sum_overlapping_group_a_and_b(self, grid_constructor: HexagonGridBuilder): + """ + Verify that when nodes from group a and b intersect, the values are summed. Suitability values of + all nodes in these groups that do not intersect should remain intact. + """ + points_on_grid = gpd.GeoDataFrame( + data=[ + # Node 1 intersects with two groups, these values must be summed + [1, "a", 20.0], + [1, "b", 30.0], + # Node 2 & 3 do not intersect. The suitability values must remain intact + [2, "a", 40.0], + [3, "b", 50.0], + ], + columns=["node_id", "group", "suitability_value"], + geometry=[shapely.Point(0, 0), shapely.Point(0, 0), shapely.Point(1, 1), shapely.Point(2, 2)], + crs=Config.CRS, + ).set_index("node_id") + + result = grid_constructor.assign_suitability_values_to_block(points_on_grid) + + expected_suitability_values = pl.DataFrame( + data=[[1, 50, 0.0, 0.0], [2, 40, 1.0, 1.0], [3, 50, 2.0, 2.0]], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "x": pl.Float32, "y": pl.Float32}, + ) + # Sort result by node_id as suitability query does not take node order into account + assert_frame_equal(expected_suitability_values, result.sort("node_id")) + + @pytest.mark.parametrize("group", ["a", "b"]) + def test_group_a_or_b_filled_while_other_empty(self, group: str, grid_constructor: HexagonGridBuilder): + """ + In case either group a or b is filled while the other group is empty, the assigned suitability + values for each point should be equal to that of the filled group. + """ + points_on_grid = gpd.GeoDataFrame( + data=[[1, group, 20.0], [2, group, 40.0]], + columns=["node_id", "group", "suitability_value"], + geometry=[shapely.Point(0, 0), shapely.Point(1, 1)], + crs=Config.CRS, + ).set_index("node_id") + + result = grid_constructor.assign_suitability_values_to_block(points_on_grid) + + expected_suitability_values = pl.DataFrame( + data=[[1, 20, 0.0, 0.0], [2, 40, 1.0, 1.0]], + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "x": pl.Float32, "y": pl.Float32}, + ) + # Sort result by node_id as suitability query does not take node order into account + assert_frame_equal(expected_suitability_values, result.sort("node_id")) + + +class TestCartesianToAxialConversion: + def test_conversion(self, grid_constructor: HexagonGridBuilder): + """ + Verifies that all cartesian coordinates in an hexagon are converted succesfully to axial coordinates. + All coordinates should follow this format: https://www.redblobgames.com/grids/hexagons/#neighbors-axial + + Note: the coordinates are upside down compared to the examples shown in the referenced link. This is due + to numpy creating coordinates from top to bottom. + """ + + center_points = pl.DataFrame( + data=[ + # Upper neighbours of center + [22.490, 19.109], + [23.240, 19.542], + [23.990, 19.109], + # Center of hexagon + [23.240, 18.676], + # Lower neighbours of center + [22.490, 18.243], + [23.240, 17.810], + [23.990, 18.243], + ], + schema={"x": pl.Float32, "y": pl.Float32}, + ) + result = grid_constructor.convert_cartesian_coordinates_to_axial(center_points) + + expected_axial_coordinates = pl.DataFrame( + data=[ + # Upper neighbours of center + [22.490, 19.109, -30, 37], # +1, 0 + [23.240, 19.542, -31, 38], # 0, +1 + [23.990, 19.109, -32, 38], # -1, +1 + # Center of hexagon + [23.240, 18.676, -31, 37], # 0, 0 -> center point + # Lower neighbours of center + [22.490, 18.243, -30, 36], # +1, -1 + [23.240, 17.810, -31, 36], # 0, -1 + [23.990, 18.243, -32, 37], # -1, 0 + ], + schema={"x": pl.Float32, "y": pl.Float32, "q": pl.Int32, "r": pl.Int32}, + ) + assert_frame_equal(expected_axial_coordinates, result) diff --git a/tests/unit/multilayer_network/hexagon_graph/hexagon_utils_test.py b/tests/unit/multilayer_network/hexagon_graph/hexagon_utils_test.py new file mode 100644 index 0000000..82a0085 --- /dev/null +++ b/tests/unit/multilayer_network/hexagon_graph/hexagon_utils_test.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 + +import math + +import pytest + +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import get_hexagon_width_and_height + + +@pytest.mark.parametrize("size", [0.5, 1.0, 1.5, 2.0]) +def test_get_hexagon_width_and_height(size: float): + result_width, result_height = get_hexagon_width_and_height(size) + expected_width = 2 * size + expected_heigth = math.sqrt(3) * size + + assert expected_width == result_width + assert expected_heigth == result_height diff --git a/tests/unit/multilayer_network/hexagon_performance_test.py b/tests/unit/multilayer_network/hexagon_performance_test.py new file mode 100644 index 0000000..2581fd7 --- /dev/null +++ b/tests/unit/multilayer_network/hexagon_performance_test.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 + +import pytest +import shapely +import geopandas as gpd + +from utility_route_planner.models.mcda.mcda_engine import McdaCostSurfaceEngine +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_graph_builder import HexagonGraphBuilder +from settings import Config +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import HexagonGridBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import convert_hexagon_edges_to_gdf +from utility_route_planner.util.write import write_results_to_geopackage, reset_geopackage + + +@pytest.mark.skip(reason="Only used for local development of the HexagonGraphBuilder") +class TestVectorToGraph: + @pytest.fixture() + def small_project_area(self) -> shapely.Polygon: + return shapely.Polygon( + [ + shapely.Point(174951.82, 451015.49), + shapely.Point(174997.14, 451014.54), + shapely.Point(175009.01, 450981.23), + shapely.Point(174948.54, 450979.32), + shapely.Point(174951.82, 451015.49), + ] + ) + + @pytest.fixture() + def larger_project_area(self) -> shapely.Polygon: + return shapely.Polygon( + [ + shapely.Point(174932.067, 451134.757), + shapely.Point(174921.054, 451035.046), + shapely.Point(175021.659, 451031.772), + shapely.Point(175026.123, 451131.483), + shapely.Point(174932.067, 451134.757), + ] + ) + + @pytest.fixture() + def ede_project_area(self) -> shapely.Polygon: + return ( + gpd.read_file(Config.PYTEST_PATH_GEOPACKAGE_MCDA, layer=Config.PYTEST_LAYER_NAME_PROJECT_AREA) + .iloc[0] + .geometry + ) + + @pytest.fixture() + def project_area(self, larger_project_area: shapely.Polygon) -> shapely.Polygon: + return larger_project_area + + @pytest.fixture() + def vectors_for_project_areas(self, project_area: shapely.Polygon) -> McdaCostSurfaceEngine: + mcda_engine = McdaCostSurfaceEngine( + Config.RASTER_PRESET_NAME_BENCHMARK, + Config.PYTEST_PATH_GEOPACKAGE_MCDA, + project_area, + ) + mcda_engine.preprocess_vectors() + return mcda_engine + + def test_vector_to_graph( + self, vectors_for_project_areas: McdaCostSurfaceEngine, project_area: shapely.Polygon, debug: bool = True + ): + mcda_engine = vectors_for_project_areas + + raster_groups = { + criteria_key: criteria.group for criteria_key, criteria in mcda_engine.raster_preset.criteria.items() + } + grid_constructor = HexagonGridBuilder(hexagon_size=4, block_size=8) + hexagon_edge_generator = HexagonEdgeGenerator() + hexagon_graph_builder = HexagonGraphBuilder( + grid_builder=grid_constructor, edge_generator=hexagon_edge_generator + ) + graph, nodes_gdf = hexagon_graph_builder.build_graph( + mcda_engine.project_area_geometry, + raster_groups, + mcda_engine.processed_vectors, + ) + + if debug: + edges = convert_hexagon_edges_to_gdf(graph, nodes_gdf) + reset_geopackage(Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, nodes_gdf, "graph_nodes", overwrite=True + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, edges, "graph_edges", overwrite=True + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_VECTOR_GRAPH_OUTPUT, project_area, "project_area", overwrite=True + ) diff --git a/tests/unit/multilayer_network/multilayer_routing_test.py b/tests/unit/multilayer_network/multilayer_routing_test.py new file mode 100644 index 0000000..64f6e79 --- /dev/null +++ b/tests/unit/multilayer_network/multilayer_routing_test.py @@ -0,0 +1,188 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import copy + +import geopandas as gpd +import pytest +import shapely +import math +import rustworkx as rx + +from settings import Config +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_graph_builder import HexagonGraphBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import HexagonGridBuilder +from utility_route_planner.models.multilayer_network.multilayer_route_planner import MultilayerRouteEngine +from utility_route_planner.util.write import write_results_to_geopackage, reset_geopackage + + +class TestMultiLayerRouting: + hexagon_size: float = 2.5 + debug: bool = True + out = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT + + project_area = shapely.Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]).buffer(0.01) + + @pytest.fixture + def setup_grid(self): + def _setup(building_collection: tuple = ()): + buildings = gpd.GeoDataFrame( + data=building_collection, + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + grassland = ( + gpd.GeoDataFrame( + data=[ + [2, 0, self.project_area.difference(buildings.geometry.union_all())], + ], + geometry="geometry", + crs=Config.CRS, + columns=["suitability_value", "relatieveHoogteligging", "geometry"], + ) + .explode() + .reset_index(drop=True) + ) + raster_groups = {"grassland": "a", "buildings": "a"} + processed_criteria_vectors = {"grassland": grassland, "buildings": buildings} + + grid_constructor = HexagonGridBuilder(hexagon_size=self.hexagon_size, block_size=Config.HEXAGON_BLOCK_SIZE) + hexagon_edge_generator = HexagonEdgeGenerator() + hexagon_graph_builder = HexagonGraphBuilder( + grid_builder=grid_constructor, edge_generator=hexagon_edge_generator + ) + graph, nodes_gdf = hexagon_graph_builder.build_graph( + self.project_area, + raster_groups, + processed_criteria_vectors, # type: ignore + ) + + if self.debug: + reset_geopackage(self.out, truncate=False) + write_results_to_geopackage(self.out, grassland, "pytest_theory_grassland") + write_results_to_geopackage(self.out, buildings, "pytest_theory_buildings") + write_results_to_geopackage(self.out, nodes_gdf, "pytest_theory_nodes") + + gdf_nodes_copy = copy.deepcopy(nodes_gdf) + gdf_nodes_copy["geometry"] = nodes_gdf.buffer(math.sqrt(3) * self.hexagon_size / 2) # inradius + write_results_to_geopackage(self.out, gdf_nodes_copy, "pytest_theory_nodes_inradius") + + route_engine = MultilayerRouteEngine( + graph, + rx.PyGraph(), + nodes_gdf, + hexagon_size=self.hexagon_size, + write_output=self.debug, + ) + return route_engine + + return _setup + + def test_straightening_linestring_no_obstacle(self, setup_grid): + route_engine = setup_grid(()) + # test that it can create a straight line: south -> north + start_end = shapely.LineString([(10, 90), (10, 10)]) + route_engine.find_route(start_end) + assert len(route_engine.results.result_route_node_indices) == 20 + assert len(route_engine.results.result_route_straightened_node_indices) == 2 + # Due to the flat top orientation, this is almost the same as the straightened line + assert route_engine.results.result_route_linestring.length == pytest.approx(82.2, abs=0.1) + assert route_engine.results.result_route_straightened.length == pytest.approx(82.2, abs=0.1) + + # Test that it can create a straight line: east -> west + start_end = shapely.LineString([(10, 90), (90, 90)]) + route_engine.find_route(start_end) + assert len(route_engine.results.result_route_node_indices) == 22 + assert route_engine.results.result_route_linestring.length == pytest.approx(90.9, abs=0.1) + assert route_engine.results.result_route_straightened.length == pytest.approx(78.7, abs=0.1) + assert len(route_engine.results.result_route_straightened_node_indices) == 2 + + # test that it can create a straight line: diagonal + start_end = shapely.LineString([(10, 90), (90, 10)]) + route_engine.find_route(start_end) + assert len(route_engine.results.result_route_node_indices) == 30 + assert route_engine.results.result_route_linestring.length == pytest.approx(125.5, abs=0.1) + assert route_engine.results.result_route_straightened.length == pytest.approx(112.3, abs=0.1) + assert len(route_engine.results.result_route_straightened_node_indices) == 2 + + def test_straightening_linestring_small_obstacle(self, setup_grid): + route_engine = setup_grid( + ( + [30, 0, shapely.Point(35, 50).buffer(15)], # small round tower + # [30, 0, shapely.LineString([(12, 6.3), (12, 0)]).buffer(1, cap_style="flat")], # wall + ) + ) + # test that it can properly navigate half of the small tower + start_end = shapely.LineString([(10, 50), (93.7, 53.7)]) + route_engine.find_route(start_end) + assert len(route_engine.results.result_route_node_indices) == 24 + assert route_engine.results.result_route_linestring.length == pytest.approx(99.6, abs=0.1) + assert route_engine.results.result_route_straightened.length == pytest.approx(89.6, abs=0.1) + assert len(route_engine.results.result_route_straightened_node_indices) == 3 + + def test_straightening_linestring_small_obstacle_circumnavigation(self, setup_grid): + route_engine = setup_grid( + ( + [30, 0, shapely.Point(33.665, 52.258).buffer(16.5)], # small round tower + [30, 0, shapely.LineString([(33.665, 52.258), (33.665, 100)]).buffer(5, cap_style="flat")], # wall + ) + ) + + start_end = shapely.LineString([(10, 50), (44.710, 98.006)]) + route_engine.find_route(start_end) + + assert len(route_engine.results.result_route_node_indices) == 28 + assert route_engine.results.result_route_linestring.length == pytest.approx(116.9, abs=0.1) + assert route_engine.results.result_route_straightened.length == pytest.approx(107.3, abs=0.1) + assert len(route_engine.results.result_route_straightened_node_indices) == 7 + + @pytest.mark.skip(reason="Update asserts after improving route engine.") + def test_straightening_linestring_large_obstacle(self, setup_grid): + route_engine = setup_grid( + ( + [30, 0, shapely.Point(48.7, 52.2).buffer(35).intersection(self.project_area)], # big round tower + [10, 0, shapely.Point(91.022, 48.036).buffer(10)], # smaller hurdle + ) + ) + # test that it can avoid the large tower + start_end = shapely.LineString([(7.323, 51.3), (97.51, 51.3)]) + route_engine.find_route(start_end) + + assert route_engine.results.result_route_linestring.length == pytest.approx(142.9, abs=0.1) + assert route_engine.results.result_route_straightened.length == pytest.approx(130.8, abs=0.1) + assert len(route_engine.results.result_route_node_indices) == 32 + assert len(route_engine.results.result_route_straightened_node_indices) == 9 + + # self.hexagon_size = 0.5 + # route_engine = setup_grid(( + # [30, 0, shapely.Point(100, 50).buffer(35).intersection(self.project_area)], # big round tower + # ) + # ) + # route_engine.find_route(start_end) + + def test_straightening_linestring_obstacle_with_hole(self, setup_grid): + # test that it can zigzag through obstacles + pass + + def test_straightening_linestring_obstacle_with_no_data(self, setup_grid): + pass + + def test_multiple_segments_with_different_scores(self, setup_grid): + route_engine = setup_grid( + ( + [30, 0, shapely.Point(11.060, 9.058).buffer(4)], # small round tower + [29, 0, shapely.Point(25.969, 22.177).buffer(4)], # small round tower + [28, 0, shapely.Point(40.878, 35.295).buffer(4)], # small round tower + [27, 0, shapely.Point(56.787, 48.413).buffer(4)], # small round tower + ) + ) + route_engine.find_route(shapely.LineString([(10, 10), (60, 50)])) + + def test_invalid_input_route_engine(self, setup_grid): + route_engine = setup_grid(()) + start_end = shapely.LineString([(10, 1), (10, 1.2)]) + with pytest.raises(ValueError): + route_engine.find_route(start_end) + route_engine.get_source_and_target_nodes(start_end) diff --git a/tests/unit/multilayer_network/osm_graph_downloader_test.py b/tests/unit/multilayer_network/osm_graph_downloader_test.py new file mode 100644 index 0000000..c26716a --- /dev/null +++ b/tests/unit/multilayer_network/osm_graph_downloader_test.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pytest +import geopandas as gpd +import shapely + +from settings import Config +from utility_route_planner.models.multilayer_network.exceptions import NoGraphDataForProjectArea +from utility_route_planner.models.multilayer_network.osm_graph_downloader import OSMGraphDownloader + + +@pytest.mark.skip(reason="For manual testing only, requires internet connection.") +class TestOSMGraphDownloader: + @pytest.fixture + def osm_district_setup(self) -> OSMGraphDownloader: + project_area = gpd.read_file(Config.PYTEST_PATH_GEOPACKAGE_MCDA, layer=Config.PYTEST_LAYER_NAME_PROJECT_AREA) + osm_graph_downloader = OSMGraphDownloader(project_area) + + return osm_graph_downloader + + def test_download_valid_graph(self, osm_district_setup: OSMGraphDownloader): + osm_graph_io = osm_district_setup + project_area_graph = osm_graph_io.download_graph() + + assert project_area_graph.number_of_edges() > 0 + assert project_area_graph.number_of_nodes() > 0 + assert project_area_graph.graph["crs"].srs == "EPSG:28992" + + def test_invalid_project_area_geometry_raises_no_graph_for_project_area( + self, osm_district_setup: OSMGraphDownloader + ): + # Choose a point that is located in the North Sea (and therefore does not have a graph) + northsea_polygon = shapely.Point(40466, 594514).buffer(5) + osm_graph_downloader = OSMGraphDownloader(project_area_geometry=northsea_polygon) + + with pytest.raises(NoGraphDataForProjectArea): + osm_graph_downloader.download_graph() diff --git a/tests/unit/multilayer_network/osm_graph_preprocessing_test.py b/tests/unit/multilayer_network/osm_graph_preprocessing_test.py new file mode 100644 index 0000000..8e68231 --- /dev/null +++ b/tests/unit/multilayer_network/osm_graph_preprocessing_test.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pytest +import rustworkx as rx + +from settings import Config +from networkx import MultiDiGraph, MultiGraph +from pyproj import CRS +import shapely + +from utility_route_planner.util.geo_utilities import osm_graph_to_gdfs +from utility_route_planner.models.multilayer_network.osm_graph_preprocessing import ( + OSMGraphPreprocessor, +) +from utility_route_planner.models.multilayer_network.graph_datastructures import OSMEdgeInfo, OSMNodeInfo +from utility_route_planner.util.write import write_results_to_geopackage + + +class TestOSMGraphPreprocessor: + @pytest.fixture + def unprocessed_directed_graph(self): + graph = MultiDiGraph() + graph.graph["crs"] = CRS(Config.CRS) + + graph.add_node(0, osmid=0, x=0, y=0) + graph.add_node(1, osmid=1, x=0, y=1) + graph.add_node(2, osmid=2, x=0, y=-2) + + # Two-way edges between node 0 and 1 + graph.add_edge(0, 1, 0, osmid=0, geometry=shapely.LineString([(0, 0), (0, 1)])) + graph.add_edge(1, 0, 0, osmid=1, geometry=shapely.LineString([(0, 1), (0, 0)])) + + # Oneway edge from node 0 to 2 + graph.add_edge(0, 2, 0, osmid=2, geometry=shapely.LineString([(0, 0), (0, -2)])) + + return graph + + def test_graph_is_made_undirected(self, unprocessed_directed_graph: MultiDiGraph): + graph_preprocessor = OSMGraphPreprocessor(unprocessed_directed_graph) + preprocessed_graph = graph_preprocessor.preprocess_graph() + + assert isinstance(preprocessed_graph, rx.PyGraph) + assert preprocessed_graph.num_edges() == 2 + + self.check_graph_properties(preprocessed_graph, graph_preprocessor.nx_graph) + + def test_duplicate_edges_are_removed(self, unprocessed_directed_graph: MultiDiGraph): + # Add duplicate edge + unprocessed_directed_graph.add_edge(0, 1, 1, osmid=0, geometry=shapely.LineString([(0, 0), (0, 1)])) + graph_preprocessor = OSMGraphPreprocessor(unprocessed_directed_graph) + preprocessed_graph = graph_preprocessor.preprocess_graph() + + # For all edges in the preprocessed graph, the key should be 0. Keys > 0 imply duplicate edges + assert preprocessed_graph.num_edges() == 2 + + self.check_graph_properties(preprocessed_graph, graph_preprocessor.nx_graph) + + def test_osm_pickle(self, load_osm_graph_pickle: MultiGraph): + graph_preprocessor = OSMGraphPreprocessor(load_osm_graph_pickle) + preprocessed_graph = graph_preprocessor.preprocess_graph() + + self.check_graph_properties(preprocessed_graph, graph_preprocessor.nx_graph) + + def test_invalid_input(self): + with pytest.raises(TypeError): + OSMGraphPreprocessor(None).preprocess_graph() + graph = MultiDiGraph() + with pytest.raises(ValueError): + OSMGraphPreprocessor(graph).preprocess_graph() + graph.add_node(0, osmid=0, geometry=shapely.LineString([(0, 0), (0, 1)])) + with pytest.raises(ValueError): + OSMGraphPreprocessor(graph).preprocess_graph() + + def test_osm_graph_to_gdfs_conversion(self, load_osm_graph_pickle: MultiGraph): + graph_preprocessor = OSMGraphPreprocessor(load_osm_graph_pickle) + preprocessed_graph = graph_preprocessor.preprocess_graph() + + self.check_gdf_properties(preprocessed_graph) + + def test_osm_graph_to_gdfs_conversion_with_changes(self, load_osm_graph_pickle: MultiGraph): + graph_preprocessor = OSMGraphPreprocessor(load_osm_graph_pickle) + preprocessed_graph = graph_preprocessor.preprocess_graph() + + preprocessed_graph.remove_node(2) + + new_node_1 = OSMNodeInfo(osm_id=123, geometry=shapely.Point(1, 1)) + idx_1 = preprocessed_graph.add_node(new_node_1) + assert idx_1 == 2 # must be equal to the removed node id + preprocessed_graph[idx_1].node_id = idx_1 + + new_node_2 = OSMNodeInfo(osm_id=124, geometry=shapely.Point(1, 2)) + idx_2 = preprocessed_graph.add_node(new_node_2) + preprocessed_graph[idx_2].node_id = idx_2 + new_node_3 = OSMNodeInfo(osm_id=125, geometry=shapely.Point(1, 3)) + idx_3 = preprocessed_graph.add_node(new_node_3) + preprocessed_graph[idx_3].node_id = idx_3 + + preprocessed_graph.remove_edge(*preprocessed_graph.get_edge_endpoints_by_index(40)) + preprocessed_graph.remove_edge(*preprocessed_graph.get_edge_endpoints_by_index(41)) + + idx_5 = preprocessed_graph.add_edge( + 0, + 1, + OSMEdgeInfo( + osm_id=126, + geometry=shapely.LineString([preprocessed_graph[0].geometry, preprocessed_graph[1].geometry]), + ), + ) + preprocessed_graph.get_edge_data(0, 1).edge_id = idx_5 + idx_6 = preprocessed_graph.add_edge( + idx_2, + idx_3, + OSMEdgeInfo( + osm_id=127, + geometry=shapely.LineString([preprocessed_graph[idx_2].geometry, preprocessed_graph[idx_3].geometry]), + ), + ) + preprocessed_graph.get_edge_data(idx_2, idx_3).edge_id = idx_6 + + self.check_gdf_properties(preprocessed_graph) + + def test_osm_graph_to_gdfs_empty_return(self, load_osm_graph_pickle: MultiGraph): + gdf_nodes, gdf_edges = osm_graph_to_gdfs(rx.PyGraph()) + assert len(gdf_nodes) == 0 + assert len(gdf_edges) == 0 + + @staticmethod + def check_gdf_properties(rx_graph: rx.PyGraph): + gdf_nodes, gdf_edges = osm_graph_to_gdfs(rx_graph) + assert len(gdf_nodes) == rx_graph.num_nodes() + assert len(gdf_edges) == rx_graph.num_edges() + + for node in rx_graph.nodes(): + assert gdf_nodes.loc[node.node_id].osm_id == node.osm_id + assert gdf_nodes.loc[node.node_id].geometry == node.geometry + + for edge in rx_graph.edges(): + assert gdf_edges.loc[edge.edge_id].osm_id == edge.osm_id + assert gdf_edges.loc[edge.edge_id].geometry == edge.geometry + assert gdf_edges.loc[edge.edge_id].length == edge.length + + @staticmethod + def check_graph_properties(rx_graph: rx.PyGraph, nx_graph: MultiGraph): + assert nx_graph.number_of_nodes() == rx_graph.num_nodes() + assert nx_graph.number_of_edges() == rx_graph.num_edges() + + nx_nodes = nx_graph.nodes(data=True) + for node_id in rx_graph.node_indices(): + node = rx_graph[node_id] + # Check if the properties of the node are the same as the nx_graph + assert isinstance(node, OSMNodeInfo) + assert nx_nodes[node.osm_id].get("x") == node.geometry.x + assert nx_nodes[node.osm_id].get("y") == node.geometry.y + + # Check if we have the same edges as neighbours in both graphs + rx_adjacent_edges = rx_graph.adj(node_id) + nx_adjacent_edges = nx_graph.edges(node.osm_id, data=True) + assert len(rx_adjacent_edges) == len(nx_adjacent_edges) + + # Check if the edge properties are the same + nx_edge_props = set((i[2]["geometry"], round(i[2]["length"], 2), i[2]["osmid"]) for i in nx_adjacent_edges) + rx_edge_props = set( + (rx_edge.geometry, rx_edge.length, rx_edge.osm_id) for rx_edge in rx_adjacent_edges.values() + ) + assert rx_edge_props == nx_edge_props + + def test_clip_graph_to_polygon(self, load_osm_graph_pickle: MultiDiGraph, debug: bool = False): + polygon = shapely.Point(174968.78, 451010.45).buffer( + 100 + ) # Note, this needs to be within the pickled OSM graph. + + graph_preprocessor = OSMGraphPreprocessor(load_osm_graph_pickle, polygon, debug) + n_nodes_old, n_edges_old = ( + graph_preprocessor.nx_graph.number_of_nodes(), + graph_preprocessor.nx_graph.number_of_edges(), + ) + rx_graph = graph_preprocessor.preprocess_graph() + + assert rx_graph.num_nodes() == 11 < n_nodes_old + assert rx_graph.num_edges() == 10 < n_edges_old + + nodes, edges = osm_graph_to_gdfs(rx_graph) + + assert all(nodes.within(polygon)) + assert all(edges.within(polygon)) + if debug: + write_results_to_geopackage(Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, nodes, "pytest_clip_nodes") + write_results_to_geopackage(Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, edges, "pytest_clip_edges") diff --git a/tests/unit/multilayer_network/pipe_ramming_test.py b/tests/unit/multilayer_network/pipe_ramming_test.py new file mode 100644 index 0000000..4c4cc67 --- /dev/null +++ b/tests/unit/multilayer_network/pipe_ramming_test.py @@ -0,0 +1,1264 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pathlib +from enum import Enum, auto + +import pandas as pd +import pytest +import geopandas as gpd +import rustworkx as rx +import shapely + +from settings import Config +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_graph_builder import HexagonGraphBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import HexagonGridBuilder +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import ( + convert_hexagon_edges_to_gdf, +) +from utility_route_planner.models.multilayer_network.multilayer_route_planner import MultilayerRouteEngine +from utility_route_planner.util.graph_utilities import create_osm_edge_info +from utility_route_planner.models.mcda.mcda_engine import McdaCostSurfaceEngine +from utility_route_planner.models.multilayer_network.pipe_ramming import GetPotentialPipeRammingCrossings +from utility_route_planner.util.geo_utilities import osm_graph_to_gdfs +from utility_route_planner.models.multilayer_network.osm_graph_preprocessing import OSMGraphPreprocessor +from utility_route_planner.models.multilayer_network.graph_datastructures import OSMNodeInfo +from utility_route_planner.util.write import reset_geopackage, write_results_to_geopackage + + +class CrossingType(Enum): + """Helper to control settings for pipe ramming to avoid redundancy in tests.""" + + JUNCTION = auto() + SEGMENT = auto() + + +class TestPipeRamming: + debug: bool = False + hexagon_size: float = 0.5 + out: pathlib.Path = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT + + @pytest.fixture + def setup_pipe_ramming_example_polygon(self, load_osm_graph_pickle): + def _setup(project_area=None): + if project_area is None: + project_area = ( + gpd.read_file(Config.PYTEST_PATH_GEOPACKAGE_MCDA, layer=Config.PYTEST_LAYER_NAME_PROJECT_AREA) + .iloc[0] + .geometry + ) + + osm_graph_preprocessor = OSMGraphPreprocessor(load_osm_graph_pickle, project_area) + osm_graph_preprocessed = osm_graph_preprocessor.preprocess_graph() + + mcda_engine = McdaCostSurfaceEngine( + Config.RASTER_PRESET_NAME_BENCHMARK, Config.PYTEST_PATH_GEOPACKAGE_MCDA, project_area + ) + mcda_engine.preprocess_vectors() + + grid_builder = HexagonGridBuilder(hexagon_size=Config.HEXAGON_SIZE, block_size=Config.HEXAGON_BLOCK_SIZE) + hexagon_edge_generator = HexagonEdgeGenerator() + hexagon_graph_builder = HexagonGraphBuilder( + grid_builder=grid_builder, edge_generator=hexagon_edge_generator + ) + + raster_groups = { + criteria_key: criteria.group for criteria_key, criteria in mcda_engine.raster_preset.criteria.items() + } + cost_surface_graph, cost_surface_nodes = hexagon_graph_builder.build_graph( + project_area, raster_groups, mcda_engine.processed_vectors + ) + + if self.debug: + osm_nodes, osm_edges = osm_graph_to_gdfs(osm_graph_preprocessed) + reset_geopackage(self.out, truncate=False) + write_results_to_geopackage(self.out, osm_nodes, "osm_nodes") + write_results_to_geopackage(self.out, osm_edges, "osm_edges") + write_results_to_geopackage(self.out, cost_surface_nodes, "cost_surface_nodes") + + return osm_graph_preprocessed, mcda_engine, cost_surface_graph, cost_surface_nodes + + return _setup + + def test_create_street_segment_groups(self): + if self.debug: + reset_geopackage(self.out, truncate=False) + + osm_graph = rx.PyGraph() + + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(0, 0)) + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(1, 0)) + node3 = OSMNodeInfo(osm_id=3, geometry=shapely.Point(1, -1)) + node4 = OSMNodeInfo(osm_id=4, geometry=shapely.Point(1, -2)) + node5 = OSMNodeInfo(osm_id=5, geometry=shapely.Point(2, 0)) + node6 = OSMNodeInfo(osm_id=6, geometry=shapely.Point(3, 0)) + node7 = OSMNodeInfo(osm_id=7, geometry=shapely.Point(3, 1)) + node8 = OSMNodeInfo(osm_id=8, geometry=shapely.Point(4, 1)) + node9 = OSMNodeInfo(osm_id=9, geometry=shapely.Point(4, 0)) + node10 = OSMNodeInfo(osm_id=10, geometry=shapely.Point(5, 0)) + node11 = OSMNodeInfo(osm_id=11, geometry=shapely.Point(6, 1)) + node12 = OSMNodeInfo(osm_id=12, geometry=shapely.Point(6, -1)) + + node_ids = osm_graph.add_nodes_from( + [node1, node2, node3, node4, node5, node6, node7, node8, node9, node10, node11, node12] + ) + ( + node1.node_id, + node2.node_id, + node3.node_id, + node4.node_id, + node5.node_id, + node6.node_id, + node7.node_id, + node8.node_id, + node9.node_id, + node10.node_id, + node11.node_id, + node12.node_id, + ) = node_ids + + edges_to_add = [ + (node1.node_id, node2.node_id, create_osm_edge_info(100, node1, node2)), + (node2.node_id, node3.node_id, create_osm_edge_info(101, node2, node3)), + (node3.node_id, node4.node_id, create_osm_edge_info(102, node3, node4)), + (node2.node_id, node5.node_id, create_osm_edge_info(103, node2, node5)), + (node5.node_id, node6.node_id, create_osm_edge_info(104, node5, node6)), + (node6.node_id, node7.node_id, create_osm_edge_info(105, node6, node7)), + (node7.node_id, node8.node_id, create_osm_edge_info(106, node7, node8)), + (node8.node_id, node9.node_id, create_osm_edge_info(107, node8, node9)), + (node6.node_id, node9.node_id, create_osm_edge_info(108, node6, node9)), + (node9.node_id, node10.node_id, create_osm_edge_info(109, node9, node10)), + (node10.node_id, node11.node_id, create_osm_edge_info(110, node10, node11)), + (node10.node_id, node12.node_id, create_osm_edge_info(111, node10, node12)), + (node11.node_id, node12.node_id, create_osm_edge_info(112, node11, node2)), + ] + + edge_ids = osm_graph.add_edges_from(edges_to_add) + for edge, edge_id in zip(edges_to_add, edge_ids): + edge[2].edge_id = edge_id + + # Enable debug for visual debugging in QGIS. + crossings = GetPotentialPipeRammingCrossings( + osm_graph, cost_surface_graph=rx.PyGraph(), cost_surface_nodes=gpd.GeoDataFrame(), debug=self.debug + ) + crossings.create_street_segment_groups() + + edges, nodes = crossings.osm_edges, crossings.osm_nodes + + # Do a sanity check on the grouped edges and nodes. + assert len(edges) == osm_graph.num_edges() + assert len(nodes) == osm_graph.num_nodes() + + # Check that the edges are grouped correctly. + assert edges["group"].nunique() == 7 + + group_100 = edges.loc[edges["osm_id"] == 100, "group"].iloc[0] + assert (edges["group"] == group_100).sum() == 1 + + group_101 = edges.loc[edges["osm_id"] == 101, "group"].iloc[0] + group_102 = edges.loc[edges["osm_id"] == 102, "group"].iloc[0] + assert group_101 == group_102 + assert (edges["group"] == group_101).sum() == 2 + + group_103 = edges.loc[edges["osm_id"] == 103, "group"].iloc[0] + group_104 = edges.loc[edges["osm_id"] == 104, "group"].iloc[0] + assert group_103 == group_104 + assert (edges["group"] == group_103).sum() == 2 + + group_105 = edges.loc[edges["osm_id"] == 105, "group"].iloc[0] + group_106 = edges.loc[edges["osm_id"] == 106, "group"].iloc[0] + group_107 = edges.loc[edges["osm_id"] == 107, "group"].iloc[0] + assert group_105 == group_106 == group_107 + assert (edges["group"] == group_105).sum() == 3 + + group_108 = edges.loc[edges["osm_id"] == 108, "group"].iloc[0] + assert (edges["group"] == group_108).sum() == 1 + + group_109 = edges.loc[edges["osm_id"] == 109, "group"].iloc[0] + assert (edges["group"] == group_109).sum() == 1 + + group_110 = edges.loc[edges["osm_id"] == 110, "group"].iloc[0] + group_111 = edges.loc[edges["osm_id"] == 111, "group"].iloc[0] + group_112 = edges.loc[edges["osm_id"] == 112, "group"].iloc[0] + assert group_110 == group_111 == group_112 + assert (edges["group"] == group_110).sum() == 3 + + @pytest.mark.skip(reason="Only for debugging a specific junction.") + def test_single_junction(self, setup_pipe_ramming_example_polygon): + if self.debug: + reset_geopackage(self.out, truncate=False) + + node_id_to_test = 3 + project_area = shapely.Point(174967.12, 450898.60).buffer(150) + + osm_graph, _, cost_surface_graph, cost_surface_nodes = setup_pipe_ramming_example_polygon(project_area) + + max_pipe_ramming_length_m = 27 # play with value and note that crossings move + pipe_ramming = GetPotentialPipeRammingCrossings( + osm_graph, + cost_surface_graph, + cost_surface_nodes, + max_pipe_ramming_length_m=max_pipe_ramming_length_m, + debug=self.debug, + ) + pipe_ramming.create_street_segment_groups() + pipe_ramming.prepare_junction_crossings() + crossings = pipe_ramming.get_crossing_for_junction( + node_id_to_test, + pipe_ramming.junctions_of_interests.loc[node_id_to_test].osm_id, + pipe_ramming.junctions_of_interests.loc[node_id_to_test].geometry, + pipe_ramming.junctions_of_interests.loc[node_id_to_test].degree, + ) + pipe_ramming.add_crossings_to_graph(crossings) + + # Test our newly found crossing in a shortest path. + pipe_ramming.add_crossings_to_graph(crossings) + start_end = shapely.LineString([(174971.62, 450911.846), (174971.62, 450888.463)]) + multilayer_route_engine = MultilayerRouteEngine( + pipe_ramming.cost_surface_graph, + pipe_ramming.osm_graph, + pipe_ramming.cost_surface_nodes, + hexagon_size=self.hexagon_size, + prefix="pytest_junction_", + ) + multilayer_route_engine.find_route(start_end) + + assert len(crossings) == 3 + assert multilayer_route_engine.get_result_route_length() == pytest.approx(25, abs=1) + assert ( + len([i for i in crossings if i[0] and i[1] in multilayer_route_engine.results.result_route_node_indices]) + == 1 + ), "One of the new edges should be in the path." + + @pytest.mark.skip(reason="Only for debugging a specific street-segment group.") + def test_single_street_segment_group(self, setup_pipe_ramming_example_polygon): + if self.debug: + reset_geopackage(self.out, truncate=False) + + segment_group_to_cross = 48 + + project_area = shapely.Point(174974, 451093).buffer(150) + osm_graph, _, cost_surface_graph, cost_surface_nodes = setup_pipe_ramming_example_polygon(project_area) + + pipe_ramming = GetPotentialPipeRammingCrossings( + osm_graph, cost_surface_graph, cost_surface_nodes, debug=self.debug + ) + pipe_ramming.suitability_value_obstacles_threshold = 77 + pipe_ramming.create_street_segment_groups() + pipe_ramming.prepare_junction_crossings() + segments_of_interest = pipe_ramming.prepare_segment_crossings() + crossings = pipe_ramming.get_crossings_per_segment( + segment_group_to_cross, segments_of_interest.loc[segment_group_to_cross].geometry + ) + pipe_ramming.add_crossings_to_graph(crossings) + + start_end = shapely.LineString([(174927.5, 451098.452), (174932, 451089.791)]) + multilayer_route_engine = MultilayerRouteEngine( + pipe_ramming.cost_surface_graph, + pipe_ramming.osm_graph, + pipe_ramming.cost_surface_nodes, + hexagon_size=self.hexagon_size, + prefix="pytest_junction_", + ) + multilayer_route_engine.find_route(start_end) + + assert len(crossings) == 3 + assert multilayer_route_engine.get_result_route_length() == pytest.approx(12, abs=1) + assert ( + len([i for i in crossings if i[0] and i[1] in multilayer_route_engine.results.result_route_node_indices]) + == 1 + ), "One of the new edges should be in the path." + + @pytest.mark.skip(reason="Longer test for full example set, enable when big (TM) changes are made to pipe ramming.") + def test_find_all_rammings_example_set(self, setup_pipe_ramming_example_polygon): + if self.debug: + reset_geopackage(self.out, truncate=False) + + osm_graph, _, cost_surface_graph, cost_surface_nodes = setup_pipe_ramming_example_polygon() + + pipe_ramming = GetPotentialPipeRammingCrossings( + osm_graph, cost_surface_graph, cost_surface_nodes, debug=self.debug + ) + # Enable for visual checking without full debug mode which slows the test down. + pipe_ramming.plot_crossings = False + crossings = pipe_ramming.get_crossings() + assert len(crossings) > 0 + + +class TestPipeRammingTheoryExamples: + debug: bool = True + out: pathlib.Path = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT + hexagon_size = 0.5 + block_size = 32 + prefix: str = "pytest_theory_" + + @pytest.fixture + def setup_theory_examples(self): + def _setup(street, buildings, private_property, trees): + # Assign groups + raster_criteria_groups = { + "street": "a", + "buildings": "a", + "private_property": "a", + "trees": "b", + } + # Initialize dictionary input for hexagon builder + preprocessed_vectors = { + "street": street, + "buildings": buildings, + "private_property": private_property, + "trees": trees, + } + # Calculate project area, take a tiny buffer to avoid intersecting problems with geopandas. + project_area = pd.concat([street, buildings, private_property, trees]).union_all().buffer(0.01) + + if buildings.empty: + preprocessed_vectors.pop("buildings") + raster_criteria_groups.pop("buildings") + if trees.empty: + preprocessed_vectors.pop("trees") + raster_criteria_groups.pop("trees") + + grid_builder = HexagonGridBuilder(hexagon_size=self.hexagon_size, block_size=self.block_size) + hexagon_edge_generator = HexagonEdgeGenerator() + hexagon_graph_builder = HexagonGraphBuilder( + grid_builder=grid_builder, edge_generator=hexagon_edge_generator + ) + cost_surface_graph, cost_surface_nodes = hexagon_graph_builder.build_graph( + project_area, raster_criteria_groups, preprocessed_vectors + ) + + if self.debug: + reset_geopackage(self.out, truncate=False) + return hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors + + return _setup + + @pytest.mark.parametrize( + [ + "buildings", + "trees", + "n_expected_crossings", + "expected_route_length", + "expected_route_cost", + "expected_crossings_used_in_route", + "start_end", + ], + [ + # Without obstacles + ( + (), + (), + 3, + 130, + 805, + 1, + [(0.3, -6.7), (99, 39.8)], + ), + # With obstacles + ( + [ + [120, shapely.LineString([(1, -18), (99, -18)]).buffer(8, cap_style="flat")], + ], + [ + [20, shapely.Point(51.5, -7).buffer(4)], + [20, shapely.Point(58.5, -7).buffer(4)], + [20, shapely.Point(65.5, -7).buffer(4)], + ], + 2, + 128, + 840, + 2, + [(0.3, -6.7), (99, 39.8)], + ), + ], + ) + def test_theory_junction_degree_3_crossing_complex( + self, + buildings, + trees, + n_expected_crossings, + expected_route_length, + expected_route_cost, + expected_crossings_used_in_route, + start_end, + setup_theory_examples, + ): + street = ( + gpd.GeoDataFrame( + data=[ + # Street east west + [30, shapely.LineString([(0, 0), (100, 0)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(0, 7), (100, 7)]).buffer(2, cap_style="flat")], + [5, shapely.LineString([(0, -7), (100, -7)]).buffer(2, cap_style="flat")], + # Street north east + [5, shapely.LineString([(100, 50), (50, 0)]).buffer(9, cap_style="flat")], + [30, shapely.LineString([(100, 50), (50, 0)]).buffer(5, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + .dissolve(by="suitability_value") + .explode() + .reset_index() + ) + private_property = gpd.GeoDataFrame( + data=[[70, shapely.Polygon([(0, -50), (100, -50), (100, 50), (0, 50), (0, -50)])]], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + street = street.clip(private_property.iloc[0].geometry) + private_property = private_property.overlay(street, how="difference").explode(ignore_index=True) + buildings = gpd.GeoDataFrame( + data=buildings, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + trees = gpd.GeoDataFrame( + data=trees, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors = setup_theory_examples( + street, buildings, private_property, trees + ) + + # OSM graph with a junction + osm_graph = rx.PyGraph() + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(0, 0)) + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(50, 0)) # center junction node + node3 = OSMNodeInfo(osm_id=3, geometry=shapely.Point(100, 50)) + node4 = OSMNodeInfo(osm_id=4, geometry=shapely.Point(100, 0)) + node_ids = osm_graph.add_nodes_from([node1, node2, node3, node4]) + node1.node_id, node2.node_id, node3.node_id, node4.node_id = node_ids + edges_to_add = [ + (node1.node_id, node2.node_id, create_osm_edge_info(100, node1, node2)), + (node2.node_id, node3.node_id, create_osm_edge_info(101, node2, node3)), + (node2.node_id, node4.node_id, create_osm_edge_info(102, node2, node4)), + ] + + self._run_crossing( + cost_surface_graph, + cost_surface_nodes, + preprocessed_vectors, + edges_to_add, + expected_route_length, + hexagon_graph_builder, + n_expected_crossings, + osm_graph, + start_end, + CrossingType.JUNCTION, + expected_route_cost, + expected_crossings_used_in_route, + ) + + @pytest.mark.parametrize( + [ + "buildings", + "trees", + "n_expected_crossings", + "expected_route_length", + "expected_route_cost", + "expected_crossings_used_in_route", + "start_end", + ], + [ + # Without obstacles + ( + (), + (), + 3, + 106, + 681.5, + 1, + [(0.6, 6.5), (56, 49.5)], + ), + # With obstacles + ( + [ + [120, shapely.LineString([(1, -18), (99, -18)]).buffer(8, cap_style="flat")], + [120, shapely.LineString([(32, 10), (32, 46)]).buffer(8, cap_style="flat")], + ], + [[20, shapely.Point(43.6, -8.2).buffer(6)], [20, shapely.Point(58, 19).buffer(4)]], + 2, + 106, + 821.5, + 1, + [(0.6, 6.5), (56, 49.5)], + ), + ], + ) + def test_theory_junction_degree_3_crossing_simple( + self, + buildings, + trees, + n_expected_crossings, + expected_route_length, + expected_route_cost, + expected_crossings_used_in_route, + start_end, + setup_theory_examples, + ): + street = ( + gpd.GeoDataFrame( + data=[ + # Street east west + [30, shapely.LineString([(0, 0), (100, 0)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(0, 7), (100, 7)]).buffer(2, cap_style="flat")], + [5, shapely.LineString([(0, -7), (100, -7)]).buffer(2, cap_style="flat")], + # Street north + [5, shapely.LineString([(43, 50), (43, 0)]).buffer(2, cap_style="flat")], + [30, shapely.LineString([(50, 50), (50, 0)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(57, 50), (57, 0)]).buffer(2, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + .dissolve(by="suitability_value") + .explode() + .reset_index() + ) + private_property = gpd.GeoDataFrame( + data=[[70, shapely.Polygon([(0, -50), (100, -50), (100, 50), (0, 50), (0, -50)])]], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + private_property = private_property.overlay(street, how="difference").explode(ignore_index=True) + buildings = gpd.GeoDataFrame( + data=buildings, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + trees = gpd.GeoDataFrame( + data=trees, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors = setup_theory_examples( + street, buildings, private_property, trees + ) + + # OSM graph with a junction + osm_graph = rx.PyGraph() + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(0, 0)) + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(50, 0)) + node3 = OSMNodeInfo(osm_id=3, geometry=shapely.Point(100, 0)) + node4 = OSMNodeInfo(osm_id=4, geometry=shapely.Point(50, 50)) + node_ids = osm_graph.add_nodes_from([node1, node2, node3, node4]) + node1.node_id, node2.node_id, node3.node_id, node4.node_id = node_ids + edges_to_add = [ + (node1.node_id, node2.node_id, create_osm_edge_info(100, node1, node2)), + (node2.node_id, node3.node_id, create_osm_edge_info(101, node2, node3)), + (node2.node_id, node4.node_id, create_osm_edge_info(102, node2, node4)), + ] + + self._run_crossing( + cost_surface_graph, + cost_surface_nodes, + preprocessed_vectors, + edges_to_add, + expected_route_length, + hexagon_graph_builder, + n_expected_crossings, + osm_graph, + start_end, + CrossingType.JUNCTION, + expected_route_cost, + expected_crossings_used_in_route, + ) + + @pytest.mark.parametrize( + [ + "buildings", + "trees", + "n_expected_crossings", + "expected_route_length", + "expected_route_cost", + "expected_crossings_used_in_route", + "start_end", + ], + [ + # Without obstacles + ( + (), + (), + 4, + 118, + 803, + 2, + [(0.3, -6.7), (56.23, 49.68)], + ), + # With obstacles + ( + [ + # south west + [120, shapely.LineString([(1, -18), (40, -18)]).buffer(8, cap_style="flat")], + [120, shapely.LineString([(32, -18), (32, -49)]).buffer(8, cap_style="flat")], + # north east + [120, shapely.LineString([(1, 18), (40, 18)]).buffer(8, cap_style="flat")], + [120, shapely.LineString([(32, 18), (32, 49)]).buffer(8, cap_style="flat")], + # north west + [120, shapely.LineString([(68, 49), (68, 18)]).buffer(8, cap_style="flat")], + [120, shapely.LineString([(60, 18), (99, 18)]).buffer(8, cap_style="flat")], + # south east + [120, shapely.LineString([(68, -49), (68, -18)]).buffer(8, cap_style="flat")], + [120, shapely.LineString([(60, -18), (99, -18)]).buffer(8, cap_style="flat")], + ], + [ + [20, shapely.Point(42.573, 8.294).buffer(4)], + ], + 4, + 118, + 806.5, + 2, + [(0.3, -6.7), (56.23, 49.68)], + ), + ], + ) + def test_theory_junction_degree_4_crossing_simple( + self, + buildings, + trees, + n_expected_crossings, + expected_route_length, + expected_route_cost, + expected_crossings_used_in_route, + start_end, + setup_theory_examples, + ): + street = ( + gpd.GeoDataFrame( + data=[ + # Street east west + [30, shapely.LineString([(0, 0), (100, 0)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(0, 7), (100, 7)]).buffer(2, cap_style="flat")], + [5, shapely.LineString([(0, -7), (100, -7)]).buffer(2, cap_style="flat")], + # Street north south + [5, shapely.LineString([(50, -50), (50, 50)]).buffer(9, cap_style="flat")], + [30, shapely.LineString([(50, -50), (50, 50)]).buffer(5, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + .dissolve(by="suitability_value") + .explode() + .reset_index() + ) + private_property = gpd.GeoDataFrame( + data=[[70, shapely.Polygon([(0, -50), (100, -50), (100, 50), (0, 50), (0, -50)])]], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + street = street.clip(private_property.iloc[0].geometry) + private_property = private_property.overlay(street, how="difference").explode(ignore_index=True) + buildings = ( + gpd.GeoDataFrame( + data=buildings, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + .dissolve(by="suitability_value") + .explode() + .reset_index() + ) + trees = gpd.GeoDataFrame( + data=trees, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors = setup_theory_examples( + street, buildings, private_property, trees + ) + + # OSM graph with a junction + osm_graph = rx.PyGraph() + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(0, 0)) + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(50, 0)) # center junction node + node3 = OSMNodeInfo(osm_id=3, geometry=shapely.Point(50, 50)) + node4 = OSMNodeInfo(osm_id=4, geometry=shapely.Point(100, 0)) + node5 = OSMNodeInfo(osm_id=5, geometry=shapely.Point(50, -50)) + node_ids = osm_graph.add_nodes_from([node1, node2, node3, node4, node5]) + node1.node_id, node2.node_id, node3.node_id, node4.node_id, node5.node_id = node_ids + edges_to_add = [ + (node1.node_id, node2.node_id, create_osm_edge_info(100, node1, node2)), + (node2.node_id, node3.node_id, create_osm_edge_info(101, node2, node3)), + (node2.node_id, node4.node_id, create_osm_edge_info(102, node2, node4)), + (node2.node_id, node5.node_id, create_osm_edge_info(103, node2, node5)), + ] + + self._run_crossing( + cost_surface_graph, + cost_surface_nodes, + preprocessed_vectors, + edges_to_add, + expected_route_length, + hexagon_graph_builder, + n_expected_crossings, + osm_graph, + start_end, + CrossingType.JUNCTION, + expected_route_cost, + expected_crossings_used_in_route, + ) + + @pytest.mark.parametrize( + [ + "buildings", + "trees", + "n_expected_crossings", + "expected_route_length", + "expected_route_cost", + "expected_crossings_used_in_route", + "start_end", + ], + [ + # Without obstacles + ( + (), + (), + 10, + 124, + 783.5, + 1, + ((1, -3), (93, 45)), + ), + # With obstacles + ( + [[120, shapely.LineString([(2, -26), (44, -18), (63, -36)]).buffer(8, cap_style="flat")]], + [[20, shapely.Point(43, 6).buffer(4)]], + 9, + 138, + 913.5, + 2, + [(1, -3), (93, 45)], + ), + ], + ) + def test_theory_junction_degree_4_crossing_complex( + self, + buildings, + trees, + n_expected_crossings, + expected_route_length, + expected_route_cost, + expected_crossings_used_in_route, + start_end, + setup_theory_examples, + ): + street = ( + gpd.GeoDataFrame( + data=[ + # Street north + [30, shapely.LineString([(50, 50), (50, 0)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(50, 50), (50, 0)]).buffer(7, cap_style="flat")], + # Street north-east + [30, shapely.LineString([(50, 0), (90, 50)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(50, 0), (90, 50)]).buffer(7, cap_style="flat")], + # Street south-west + [30, shapely.LineString([(50, 0), (100, -50)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(50, 0), (100, -50)]).buffer(7, cap_style="flat")], + # Street west + [30, shapely.LineString([(50, 0), (0, -10)]).buffer(5, cap_style="flat")], + [5, shapely.LineString([(50, 0), (0, -10)]).buffer(7, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + .dissolve(by="suitability_value") + .explode() + .reset_index() + ) + private_property = gpd.GeoDataFrame( + data=[[70, shapely.Polygon([(0, -50), (100, -50), (100, 50), (0, 50), (0, -50)])]], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + street = street.clip(private_property.iloc[0].geometry) + private_property = private_property.overlay(street, how="difference").explode(ignore_index=True) + buildings = gpd.GeoDataFrame( + data=buildings, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + trees = gpd.GeoDataFrame( + data=trees, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors = setup_theory_examples( + street, buildings, private_property, trees + ) + + # OSM graph with a junction + osm_graph = rx.PyGraph() + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(50, 0)) # center junction node + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(50, 50)) + node3 = OSMNodeInfo(osm_id=3, geometry=shapely.Point(90, 50)) + node4 = OSMNodeInfo(osm_id=4, geometry=shapely.Point(100, -50)) + node5 = OSMNodeInfo(osm_id=5, geometry=shapely.Point(0, -10)) + node_ids = osm_graph.add_nodes_from([node1, node2, node3, node4, node5]) + node1.node_id, node2.node_id, node3.node_id, node4.node_id, node5.node_id = node_ids + edges_to_add = [ + (node1.node_id, node2.node_id, create_osm_edge_info(105, node1, node2)), + (node1.node_id, node3.node_id, create_osm_edge_info(106, node1, node3)), + (node1.node_id, node4.node_id, create_osm_edge_info(107, node1, node4)), + (node1.node_id, node5.node_id, create_osm_edge_info(108, node1, node5)), + ] + + self._run_crossing( + cost_surface_graph, + cost_surface_nodes, + preprocessed_vectors, + edges_to_add, + expected_route_length, + hexagon_graph_builder, + n_expected_crossings, + osm_graph, + start_end, + CrossingType.JUNCTION, + expected_route_cost, + expected_crossings_used_in_route, + ) + + @pytest.mark.parametrize( + [ + "buildings", + "trees", + "n_expected_crossings", + "expected_route_length", + "expected_route_cost", + "expected_crossings_used_in_route", + "start_end", + ], + [ + # Without obstacles + ( + (), + (), + 3, + 123, + 761.5, + 1, + [(1, 8), (98, -6)], + ), + # With obstacles + ( + [ + [120, shapely.LineString([(10, 15), (45, 15)]).buffer(5, cap_style="flat")], + [120, shapely.LineString([(5, -15), (45, -15)]).buffer(5, cap_style="flat")], + [120, shapely.LineString([(55, -15), (95, -15)]).buffer(5, cap_style="flat")], + ], + [[20, shapely.Point(30, -8).buffer(4)], [20, shapely.Point(65, 9).buffer(4)]], + 3, + 123, + 761.5, + 1, + [(1, 8), (98, -6)], + ), + ], + ) + def test_theory_segment_crossing_straight_street( + self, + buildings, + trees, + n_expected_crossings, + expected_route_length, + expected_route_cost, + expected_crossings_used_in_route, + start_end, + setup_theory_examples, + ): + street = gpd.GeoDataFrame( + data=[ + # Asphalt + [30, shapely.LineString([(0, 0), (100, 0)]).buffer(5, cap_style="flat")], + # Pavement north + [5, shapely.LineString([(0, 7), (100, 7)]).buffer(2, cap_style="flat")], + # Pavement south + [5, shapely.LineString([(0, -7), (100, -7)]).buffer(2, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + private_property = gpd.GeoDataFrame( + data=[ + [70, shapely.LineString([(0, 30), (100, 30)]).buffer(21, cap_style="flat")], + [70, shapely.LineString([(0, -30), (100, -30)]).buffer(21, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + buildings = gpd.GeoDataFrame( + data=buildings, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + trees = gpd.GeoDataFrame( + data=trees, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + # MCDA vectors + hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors = setup_theory_examples( + street, buildings, private_property, trees + ) + + # Create a simple OSM graph, just one street. + osm_graph = rx.PyGraph() + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(0, 0)) + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(100, 0)) + node_ids = osm_graph.add_nodes_from([node1, node2]) + node1.node_id, node2.node_id = node_ids + edges_to_add = [(node1.node_id, node2.node_id, create_osm_edge_info(100, node1, node2))] + + self._run_crossing( + cost_surface_graph, + cost_surface_nodes, + preprocessed_vectors, + edges_to_add, + expected_route_length, + hexagon_graph_builder, + n_expected_crossings, + osm_graph, + start_end, + CrossingType.SEGMENT, + expected_route_cost, + expected_crossings_used_in_route, + ) + + @pytest.mark.parametrize( + [ + "buildings", + "trees", + "n_expected_crossings", + "expected_route_length", + "expected_route_cost", + "expected_crossings_used_in_route", + "start_end", + ], + [ + # Without obstacles + ( + (), + (), + 6, + 230, + 1387.5, + 1, + [(1, 6), (158, -25)], + ), + # With obstacles + ( + [ + [ + 120, + shapely.LineString([(55.4, 33.4), (80.5, -32)]) + .buffer(11, cap_style="flat", single_sided=True) + .buffer(-1, cap_style="flat"), + ], + [ + 120, + shapely.LineString([(69.4, -47.7), (48.3, 7.1)]) + .buffer(11, cap_style="flat", single_sided=True) + .buffer(-1, cap_style="flat"), + ], + [ + 120, + shapely.LineString([(17, 8), (53.5, 38.4)]) + .buffer(11, cap_style="flat", single_sided=True) + .buffer(-1, cap_style="flat"), + ], + ], + [ + [20, shapely.Point(30, -8).buffer(4)], + [20, shapely.Point(39.804, 6.896).buffer(5)], + [20, shapely.Point(47.260, 10.421).buffer(6)], + [20, shapely.Point(87, -30.9).buffer(4)], + [20, shapely.Point(75.1, -47.6).buffer(6.5)], + ], + 4, + 246, + 1484, + 1, + [(1, 6), (158, -25)], + ), + ], + ) + def test_theory_segment_crossing_complex_street( + self, + buildings, + trees, + n_expected_crossings, + expected_route_length, + expected_route_cost, + expected_crossings_used_in_route, + start_end, + setup_theory_examples, + ): + street_linestring = shapely.LineString( + [(0, 0), (20, 0), (50, 25), (75, -40), (120, -40), (150, -20), (160, -20)] + ) + street = ( + gpd.GeoDataFrame( + data=[ + [30, street_linestring.buffer(5, cap_style="flat")], + [ + 5, + shapely.difference( + street_linestring.buffer(8, cap_style="flat"), + street_linestring.buffer(5, cap_style="flat"), + grid_size=0.01, + ), + ], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + .explode() + .reset_index(drop=True) + ) + private_property = gpd.GeoDataFrame( + data=[ + [70, shapely.LineString([(0, 0), (160, 0)]).buffer(90, cap_style="flat")], + ], + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + private_property = private_property.overlay(street, how="difference").explode(ignore_index=True) + buildings = gpd.GeoDataFrame( + data=buildings, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + trees = gpd.GeoDataFrame( + data=trees, + columns=["suitability_value", "geometry"], + crs=Config.CRS, + ) + # MCDA vectors + hexagon_graph_builder, cost_surface_graph, cost_surface_nodes, preprocessed_vectors = setup_theory_examples( + street, buildings, private_property, trees + ) + + # Create a simple OSM graph, just one street. + osm_graph = rx.PyGraph() + node1 = OSMNodeInfo(osm_id=1, geometry=shapely.Point(0, 0)) + node2 = OSMNodeInfo(osm_id=2, geometry=shapely.Point(20, 0)) + node3 = OSMNodeInfo(osm_id=3, geometry=shapely.Point(50, 25)) + node4 = OSMNodeInfo(osm_id=4, geometry=shapely.Point(75, -40)) + node5 = OSMNodeInfo(osm_id=5, geometry=shapely.Point(120, -40)) + node6 = OSMNodeInfo(osm_id=6, geometry=shapely.Point(150, -20)) + node7 = OSMNodeInfo(osm_id=7, geometry=shapely.Point(160, -20)) + node_ids = osm_graph.add_nodes_from([node1, node2, node3, node4, node5, node6, node7]) + node1.node_id, node2.node_id, node3.node_id, node4.node_id, node5.node_id, node6.node_id, node7.node_id = ( + node_ids + ) + edges_to_add = [ + (node1.node_id, node2.node_id, create_osm_edge_info(100, node1, node2)), + (node2.node_id, node3.node_id, create_osm_edge_info(101, node2, node3)), + (node3.node_id, node4.node_id, create_osm_edge_info(102, node3, node4)), + (node4.node_id, node5.node_id, create_osm_edge_info(103, node4, node5)), + (node5.node_id, node6.node_id, create_osm_edge_info(104, node5, node6)), + (node6.node_id, node7.node_id, create_osm_edge_info(105, node6, node7)), + ] + + self._run_crossing( + cost_surface_graph, + cost_surface_nodes, + preprocessed_vectors, + edges_to_add, + expected_route_length, + hexagon_graph_builder, + n_expected_crossings, + osm_graph, + start_end, + CrossingType.SEGMENT, + expected_route_cost, + expected_crossings_used_in_route, + ) + + def _run_crossing( + self, + cost_surface_graph: rx.PyGraph, + cost_surface_nodes: gpd.GeoDataFrame, + preprocessed_vectors: dict[str, gpd.GeoDataFrame], + edges_to_add: list, + expected_route_length: float, + hexagon_graph_builder: HexagonGraphBuilder, + n_expected_crossings: int, + osm_graph: rx.PyGraph, + start_end: list[tuple], + crossing_type: CrossingType, + expected_route_cost: float = 0, + expected_crossings_used_in_route: int = 0, + ): + match crossing_type: + case crossing_type.JUNCTION: + threshold_edge_length_crossing_m = 100 + case crossing_type.SEGMENT: + threshold_edge_length_crossing_m = 30 + case _: + raise ValueError("Invalid crossing type specified.") + + edge_ids = osm_graph.add_edges_from(edges_to_add) + for edge, edge_id in zip(edges_to_add, edge_ids): + edge[2].edge_id = edge_id + pipe_ramming = GetPotentialPipeRammingCrossings( + osm_graph=osm_graph, + cost_surface_graph=cost_surface_graph, + cost_surface_nodes=cost_surface_nodes, + threshold_edge_length_crossing_m=threshold_edge_length_crossing_m, + max_pipe_ramming_length_m=20, + min_pipe_ramming_length_m=3, + suitability_value_crossing_threshold=10, + suitability_value_obstacles_threshold=80, + hexagon_size=hexagon_graph_builder.hexagon_size, + debug_out=self.out, + debug=self.debug, + ) + crossings = pipe_ramming.get_crossings() + multilayer_route_engine = MultilayerRouteEngine( + pipe_ramming.cost_surface_graph, + pipe_ramming.osm_graph, + pipe_ramming.cost_surface_nodes, + hexagon_size=hexagon_graph_builder.hexagon_size, + prefix=self.prefix, + write_output=self.debug, + ) + multilayer_route_engine.find_route(shapely.LineString(start_end)) + if self.debug: + self._plot_pytest_theory( + cost_surface_graph, + cost_surface_nodes, + crossings, + pipe_ramming, + preprocessed_vectors, + ) + + self._assert_crossings(crossings, hexagon_graph_builder, pipe_ramming, n_expected_crossings) + assert multilayer_route_engine.get_result_route_length() == pytest.approx(expected_route_length, abs=1) + if expected_route_cost: + assert multilayer_route_engine.get_result_route_cost() == expected_route_cost + if expected_crossings_used_in_route: + assert ( + len( + multilayer_route_engine.results.result_route_edges[ + ~multilayer_route_engine.results.result_route_edges["origin"].isnull() + ] + ) + == expected_crossings_used_in_route + ) + + @staticmethod + def _assert_crossings( + crossings: list, + hexagon_graph_builder: HexagonGraphBuilder, + pipe_ramming: GetPotentialPipeRammingCrossings, + n_expected_crossings: int, + ): + assert len(crossings) == n_expected_crossings + # Crossings should not intersect any obstacles above the suitability_value_obstacles_threshold + assert ( + len( + [ + i + for i in crossings + if any( + pipe_ramming.cost_surface_nodes[ + pipe_ramming.cost_surface_nodes.suitability_value + > pipe_ramming.suitability_value_obstacles_threshold + ].intersects(i[2].geometry.buffer(hexagon_graph_builder.hexagon_size)) + ) + ] + ) + == 0 + ) + + def _plot_pytest_theory( + self, + cost_surface_graph: rx.PyGraph, + cost_surface_nodes: gpd.GeoDataFrame, + crossings: list, + pipe_ramming: GetPotentialPipeRammingCrossings, + preprocessed_vectors: dict, + ): + # MCDA vectors + write_results_to_geopackage(self.out, preprocessed_vectors["street"], f"{self.prefix}street", overwrite=True) + write_results_to_geopackage( + self.out, preprocessed_vectors["private_property"], f"{self.prefix}private_property", overwrite=True + ) + if "buildings" in preprocessed_vectors: + write_results_to_geopackage( + self.out, preprocessed_vectors["buildings"], f"{self.prefix}buildings", overwrite=True + ) + if "trees" in preprocessed_vectors: + write_results_to_geopackage(self.out, preprocessed_vectors["trees"], f"{self.prefix}trees", overwrite=True) + # OSM graph + write_results_to_geopackage(self.out, pipe_ramming.osm_nodes, f"{self.prefix}osm_nodes", overwrite=True) + write_results_to_geopackage(self.out, pipe_ramming.osm_edges, f"{self.prefix}osm_edges", overwrite=True) + # Cost-surface & crossings + cost_surface_edges = convert_hexagon_edges_to_gdf(cost_surface_graph, cost_surface_nodes) + write_results_to_geopackage(self.out, cost_surface_nodes, f"{self.prefix}cost_surface_nodes", overwrite=True) + write_results_to_geopackage(self.out, cost_surface_edges, f"{self.prefix}cost_surface_edges", overwrite=True) + write_results_to_geopackage( + self.out, + shapely.MultiLineString([i[2].geometry for i in crossings]), + f"{self.prefix}crossings", + overwrite=True, + ) + + +class TestPipeRammingUtils: + def test_validate_node_pairs_merges_multi_groups(self): + # Create a MultiIndex Series with more than 2 entries for one group + idx = pd.MultiIndex.from_tuples( + [ + (1, "a"), + (1, "b"), + (1, "c"), # group 1 has 3 entries + (2, "a"), + (2, "b"), # group 2 has 2 entries + ], + names=["index_right", "idx_street_side"], + ) + s = pd.Series([10, 20, 30, 40, 50], index=idx) + + result = GetPotentialPipeRammingCrossings._validate_node_pairs(s) + + # Group 1 should be split into new pairs, group 2 should remain unchanged + assert isinstance(result, pd.Series) + assert set(result.index.get_level_values(0)) >= {2, 3, 4} # Group 1 is replaced with 3 and 4 + # Check that all original nodes are present + assert set(result.values) >= {10, 20, 30, 40, 50} + + idx_expected = pd.MultiIndex.from_tuples( + [ + (2, "a"), + (2, "b"), + (3, "a"), + (3, "b"), + (4, "a"), + (4, "c"), + ], + names=["index_right", "idx_street_side"], + ) + expected = pd.Series([40, 50, 10, 20, 10, 30], index=idx_expected) + pd.testing.assert_series_equal(result, expected) + + def test_validate_node_pairs_no_multi_groups(self): + # Only groups with <=2 entries + idx = pd.MultiIndex.from_tuples( + [ + (1, "a"), + (1, "b"), + (2, "a"), + (2, "b"), + ], + names=["index_right", "idx_street_side"], + ) + s = pd.Series([10, 20, 30, 40], index=idx) + + result = GetPotentialPipeRammingCrossings._validate_node_pairs(s) + # Should return the input unchanged + pd.testing.assert_series_equal(result, s) diff --git a/utility_route_planner/models/benchmark_routes.py b/utility_route_planner/models/benchmark_routes.py new file mode 100644 index 0000000..74e6a06 --- /dev/null +++ b/utility_route_planner/models/benchmark_routes.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pathlib +from dataclasses import dataclass + +from settings import Config + + +@dataclass +class BenchmarkRoute: + path_geopackage: pathlib.Path + layer_name_project_area: str + layer_name_human_designed_route: str + raster_name_prefix: str + stops: list[list[float]] + + +@dataclass +class BenchmarkRouteCollection: + benchmark_routes = { + 1: BenchmarkRoute( + Config.PATH_GEOPACKAGE_CASE_01, + Config.LAYER_NAME_PROJECT_AREA_CASE_01, + Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_01, + "route_1_", + [], + ), + 2: BenchmarkRoute( + Config.PATH_GEOPACKAGE_CASE_02, + Config.LAYER_NAME_PROJECT_AREA_CASE_02, + Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_02, + "route_2_", + [], + ), + 3: BenchmarkRoute( + Config.PATH_GEOPACKAGE_CASE_03, + Config.LAYER_NAME_PROJECT_AREA_CASE_03, + Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_03, + "route_3_", + [], + ), + 4: BenchmarkRoute( + Config.PATH_GEOPACKAGE_CASE_04, + Config.LAYER_NAME_PROJECT_AREA_CASE_04, + Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_04, + "route_4_", + [], + ), + 5: BenchmarkRoute( + Config.PATH_GEOPACKAGE_CASE_05, + Config.LAYER_NAME_PROJECT_AREA_CASE_05, + Config.LAYER_NAME_HUMAN_DESIGNED_ROUTE_CASE_05, + "route_5_", + [[121462.8, 487153.4]], + ), + } + + def get_case(self, benchmark_id: int): + if benchmark_id not in self.benchmark_routes.keys(): + raise ValueError(f"No benchmark route found for id: {benchmark_id}") + return self.benchmark_routes[benchmark_id] + + def get_all_benchmarks(self) -> list[BenchmarkRoute]: + return list(self.benchmark_routes.values()) diff --git a/utility_route_planner/models/mcda/mcda_engine.py b/utility_route_planner/models/mcda/mcda_engine.py index 3f57741..960e53d 100644 --- a/utility_route_planner/models/mcda/mcda_engine.py +++ b/utility_route_planner/models/mcda/mcda_engine.py @@ -43,7 +43,8 @@ def __init__( raster_name_prefix: str = "", ): self.raster_preset = load_preset(preset_to_load, path_geopackage_mcda_input, project_area_geometry) - self.processed_vectors: dict = {} + self.processed_vectors: dict[str, gpd.GeoDataFrame] = {} + self.processed_criteria_per_height_level: dict[int, list[str]] = {} self.unprocessed_criteria_names: set = set() self.processed_criteria_names: set = set() self.raster_name_prefix: str = raster_name_prefix @@ -68,12 +69,17 @@ def preprocess_vectors(self): dfs_present_weight = [] for idx, criterion in enumerate(self.raster_preset.criteria): logger.info(f"Processing criteria number {idx + 1} of {self.number_of_criteria}.") - is_processed, gdf_processed, df_present_weights = self.raster_preset.criteria[ + is_processed, gdf_processed, df_present_weights, height_levels = self.raster_preset.criteria[ criterion ].preprocessing_function.execute(self.raster_preset.general, self.raster_preset.criteria[criterion]) if is_processed: self.processed_vectors[criterion] = gdf_processed dfs_present_weight.append(df_present_weights) + for height_level in height_levels: + if height_level not in self.processed_criteria_per_height_level: + self.processed_criteria_per_height_level[height_level] = [criterion] + else: + self.processed_criteria_per_height_level[height_level].append(criterion) else: if not gdf_processed.empty: raise ValueError(f"Criterion {criterion} was not processed but returned a non-empty geodataframe.") diff --git a/utility_route_planner/models/mcda/vector_preprocessing/base.py b/utility_route_planner/models/mcda/vector_preprocessing/base.py index 1c689d8..0eaa1d9 100644 --- a/utility_route_planner/models/mcda/vector_preprocessing/base.py +++ b/utility_route_planner/models/mcda/vector_preprocessing/base.py @@ -7,7 +7,6 @@ import abc import typing -import fiona import pandas import pandas as pd import shapely @@ -37,22 +36,32 @@ def criterion(self) -> str: @time_function def execute( self, general: RasterPresetGeneral, criterion: RasterPresetCriteria - ) -> tuple[bool, gpd.GeoDataFrame, pd.DataFrame]: + ) -> tuple[bool, gpd.GeoDataFrame, pd.DataFrame, list]: """Run all methods in order for a criteria returning the processed geodataframe with suitability values.""" logger.info(f"Start preprocessing: {self.criterion}.") gdf_prepared = self.prepare_input_data(general.project_area_geometry, criterion, general.path_input_geopackage) if len(gdf_prepared) == 1 and gdf_prepared[0].empty: - return False, get_empty_geodataframe(), pd.DataFrame() + return ( + False, + get_empty_geodataframe(), + pd.DataFrame(), + [], + ) # Nothing to process when there is no data available, return. gdf_processed = self.specific_preprocess(gdf_prepared, criterion) if not self.is_valid_result(gdf_processed): - return False, get_empty_geodataframe(), pd.DataFrame() + return ( + False, + get_empty_geodataframe(), + pd.DataFrame(), + [], + ) df_present_weights = self.get_statistics(criterion, gdf_processed) - + height_levels = self.get_height_levels(gdf_processed) self.write_to_file(general.prefix, gdf_processed) - return True, gdf_processed, df_present_weights + return True, gdf_processed, df_present_weights, height_levels @staticmethod def prepare_input_data( @@ -60,7 +69,7 @@ def prepare_input_data( ) -> list[gpd.GeoDataFrame]: """Check existing layers in geopackage / clip data / check if gdf is empty / filter historic BGT data""" prepared_input = [] - available_layers = fiona.listlayers(path_geopackage_mcda_input) + available_layers = gpd.list_layers(path_geopackage_mcda_input)["name"].to_list() for layer_name in criterion.layer_names: if layer_name not in available_layers: logger.warning(f"Layer name: {layer_name} is not available in geopackage, skipping.") @@ -101,6 +110,16 @@ def is_valid_result(self, gdf_processed: gpd.GeoDataFrame) -> bool: raise InvalidSuitabilityValue return True + def get_height_levels(self, processed_gdf: gpd.GeoDataFrame, default_height: int = 0) -> list: + """Get unique height levels from the processed geodataframe, if available.""" + if "relatieveHoogteligging" not in processed_gdf.columns: + logger.info(f"No height levels found for criterion: {self.criterion}. Defaulting to: {default_height}.") + processed_gdf["relatieveHoogteligging"] = default_height + return [default_height] + height_levels = processed_gdf["relatieveHoogteligging"].unique().tolist() + logger.info(f"Found height levels: {height_levels} for criterion: {self.criterion}.") + return height_levels + def write_to_file(self, prefix, gdf_validated: gpd.GeoDataFrame) -> None: """Write to the geopackage for debugging and rasterizing.""" write_results_to_geopackage(Config.PATH_GEOPACKAGE_MCDA_OUTPUT, gdf_validated, prefix + self.criterion) @@ -119,6 +138,9 @@ def get_statistics(self, criterion: RasterPresetCriteria, gdf_processed: pd.Data f"Columns to reclassify: {column} not in processed gdf columns in criterion {self.criterion}." f"Check the preprocessing function if the column is created/retained correctly." ) + # Some columns might be empty (e.g., SmallAboveGroundObstacles), ignore these. + if gdf_processed[column].isna().all(): + columns_to_reclassify.pop(columns_to_reclassify.index(column)) weights_per_m2 = gdf_processed.dissolve(by=columns_to_reclassify).area diff --git a/utility_route_planner/models/mcda/vector_preprocessing/small_above_ground_obstacles.py b/utility_route_planner/models/mcda/vector_preprocessing/small_above_ground_obstacles.py index 61bc020..750ed36 100644 --- a/utility_route_planner/models/mcda/vector_preprocessing/small_above_ground_obstacles.py +++ b/utility_route_planner/models/mcda/vector_preprocessing/small_above_ground_obstacles.py @@ -40,9 +40,6 @@ def _set_suitability_values(input_gdf: list[gpd.GeoDataFrame], weight_values: di else: bgt_others.append(gdf) - gdf_bgt_scheiding = pd.DataFrame() - gdf_remaining_obstacles = pd.DataFrame() - if bgt_scheiding: logger.info("Processing bgt scheiding.") gdf_bgt_scheiding = pd.concat(bgt_scheiding) @@ -55,6 +52,8 @@ def _set_suitability_values(input_gdf: list[gpd.GeoDataFrame], weight_values: di ) gdf_bgt_scheiding = gdf_bgt_scheiding[gdf_bgt_scheiding["bgt-type"] != "niet-bgt"] gdf_bgt_scheiding["suitability_value"] = gdf_bgt_scheiding["sv_1"] + else: + gdf_bgt_scheiding = gpd.GeoDataFrame(columns=["bgt-type", "geometry"]) if bgt_others: logger.info("Processing remaining obstacles.") @@ -72,6 +71,8 @@ def _set_suitability_values(input_gdf: list[gpd.GeoDataFrame], weight_values: di ) gdf_remaining_obstacles = gdf_remaining_obstacles[gdf_remaining_obstacles["plus-type"] != "waardeOnbekend"] gdf_remaining_obstacles["suitability_value"] = gdf_remaining_obstacles["sv_1"] + else: + gdf_remaining_obstacles = gpd.GeoDataFrame(columns=["plus-type", "function", "geometry"]) # Merge dfs gdf_merged = pd.concat([gdf_bgt_scheiding, gdf_remaining_obstacles]) diff --git a/utility_route_planner/models/mcda/vector_preprocessing/vegetation_object.py b/utility_route_planner/models/mcda/vector_preprocessing/vegetation_object.py index 3db4571..71df2d8 100644 --- a/utility_route_planner/models/mcda/vector_preprocessing/vegetation_object.py +++ b/utility_route_planner/models/mcda/vector_preprocessing/vegetation_object.py @@ -56,4 +56,9 @@ def _update_geometry_values(input_gdf: gpd.GeoDataFrame, buffer_values: dict): input_gdf["plus-type"].eq(key), input_gdf["geometry"].buffer(value), input_gdf["geometry"] ) + # Do not double-count suitability values for the same type of vegetation object when they overlap. + input_gdf = ( + input_gdf.dissolve(by=["plus-type", "suitability_value", "relatieveHoogteligging"]).explode().reset_index() + ) + return input_gdf diff --git a/utility_route_planner/models/mcda/vector_preprocessing/waterdeel.py b/utility_route_planner/models/mcda/vector_preprocessing/waterdeel.py index bcaadb0..551b7ad 100644 --- a/utility_route_planner/models/mcda/vector_preprocessing/waterdeel.py +++ b/utility_route_planner/models/mcda/vector_preprocessing/waterdeel.py @@ -58,4 +58,9 @@ def _update_geometry_values(input_gdf: gpd.GeoDataFrame, buffer_values: dict): input_gdf["class"].eq(key), input_gdf["geometry"].buffer(value), input_gdf["geometry"] ) + # Do not double-count suitability values when they overlap. + input_gdf = ( + input_gdf.dissolve(by=["class", "suitability_value", "relatieveHoogteligging"]).explode().reset_index() + ) + return input_gdf diff --git a/utility_route_planner/models/multilayer_network/__init__.py b/utility_route_planner/models/multilayer_network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utility_route_planner/models/multilayer_network/exceptions.py b/utility_route_planner/models/multilayer_network/exceptions.py new file mode 100644 index 0000000..800167f --- /dev/null +++ b/utility_route_planner/models/multilayer_network/exceptions.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 + + +class NoGraphDataForProjectArea(Exception): + pass diff --git a/utility_route_planner/models/multilayer_network/graph_datastructures.py b/utility_route_planner/models/multilayer_network/graph_datastructures.py new file mode 100644 index 0000000..e064a2e --- /dev/null +++ b/utility_route_planner/models/multilayer_network/graph_datastructures.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 +import enum +from abc import ABC +from collections import namedtuple +from dataclasses import dataclass, field +from typing import Optional + +import shapely + + +# TODO: do we want to only set the node_id on the node objects and move all other parts to dataframe(s)? +@dataclass +class NodeInfo(ABC): + node_id: int = field(init=False) + + def set_node_id(self, node_id: int): + self.node_id = node_id + + +@dataclass +class HexagonNodeInfo(NodeInfo): + weight: int + + +@dataclass +class OSMNodeInfo(NodeInfo): + osm_id: int + geometry: shapely.Point + + +@dataclass +class BaseEdgeInfo: + edge_id: int = field(init=False) + length: float = field(init=False) + geometry: shapely.LineString + + def __post_init__(self): + self.length = round(self.geometry.length, 2) + + def set_edge_id(self, edge_id: int): + self.edge_id = edge_id + + +@dataclass +class BaseWeightedEdgeInfo(BaseEdgeInfo): + weight: int + + +@dataclass +class OSMEdgeInfo(BaseEdgeInfo): + osm_id: int + + +@dataclass +class HexagonConnectionEdgeInfo(BaseWeightedEdgeInfo): + connects_height_levels: ( + bool # always True when this type of edge is used, but useful for debugging to make explicit + ) + height_level: Optional[int] = None # Only the non-main height level gets assigned explicitly. + + +class PipeRammingOrigin(enum.StrEnum): + """Helps to identify the creator of the extra edge. Refers to the process that created the edge.""" + + JUNCTION = enum.auto() + STREET_SEGMENT = enum.auto() + + +@dataclass +class PipeRammingEdgeInfo(BaseWeightedEdgeInfo): + osm_id_junction: int | None + segment_group: int + origin: PipeRammingOrigin + + +hexagon_edge_info = namedtuple("hexagon_edge_info", "edge_id weight") diff --git a/utility_route_planner/models/multilayer_network/hexagon_graph/__init__.py b/utility_route_planner/models/multilayer_network/hexagon_graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_edge_generator.py b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_edge_generator.py new file mode 100644 index 0000000..54bffbf --- /dev/null +++ b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_edge_generator.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 +import polars as pl + + +class HexagonEdgeGenerator: + def generate( + self, + block_coordinates: pl.DataFrame, + previous_row_edge_coordinates: pl.DataFrame, + ) -> pl.DataFrame: + edge_candidates = self._get_edge_candidates(block_coordinates) + + # Use lazy dataframe to allow the query to make use of Polars query optimization. + return self._get_neighbouring_edges(previous_row_edge_coordinates.lazy(), edge_candidates.lazy()) + + @staticmethod + def _get_edge_candidates( + block_coordinates: pl.DataFrame, + ) -> pl.DataFrame: + """ + For each node in the block, compute its potential edges by considering the neighbours based on the + axial coordinates. By using axial coordinates, all potential edges can be computed at once by + cross-joining an offset dataframe. + + Note: as the grid is constructed in block-wise fashion, we need to compute in four instead of three + directions which causes duplicates. Using only three directions results in missing cross-block edges. + Duplicates are handled when generating the actual edges. + + Information on the offsets can be found here: https://www.redblobgames.com/grids/hexagons/#neighbors-axial + + :param block_coordinates: all axial coordinates in a single block to compute potential neighbours for + :return: axial coordinates of all potential neighbours per node in the block + """ + + offsets = pl.DataFrame( + data=[ + # Vertical neighbour candidates (q, r+1) + [0, 1], + # Top-right neighbour candidates (q+1, r) + [-1, +1], + # Bottom-right neighbour candidates (q+1, r-1) + [-1, 0], + # Top-left neighbour candidates (q-1, r+1) + [+1, 0], + ], + schema={"dq": pl.Int8, "dr": pl.Int8}, + orient="row", + ) + + neighbour_candidates = block_coordinates.join(offsets, how="cross").select( + pl.col("node_id").alias("source_node"), + (pl.col("q") + pl.col("dq")).alias("q"), + (pl.col("r") + pl.col("dr")).alias("r"), + ) + + return neighbour_candidates + + @staticmethod + def _get_neighbouring_edges(all_nodes: pl.LazyFrame, neighbour_candidates: pl.LazyFrame) -> pl.DataFrame: + """ + For each node, determine which neighbours candidates are valid. For each valid neighbour, the suitability + value is computed by: source_node_suitability + target_node_suitability. All valid neighbours are + transformed into edges + + :param all_nodes: lazy dataframe which contains all nodes in the current block + previous nodes in the current + or previous row which could be cross-edge candidates. + :param neighbour_candidates: lazy dataframe which contains the potential neighbours per node in the current block + :return: list of tuples containing all valid edges with suitability values in the current block. + """ + neighbours = ( + # First, for join the candidates with nodes that are actually present in the graph. The neighbour + # candidate computation does not take the existence of nodes into account, which is resolved by dropping + # candidate neighbours that cannot be joined + neighbour_candidates.join( + all_nodes.select( + pl.col("node_id").alias("target_node"), + pl.col("q"), + pl.col("r"), + pl.col("suitability_value").alias("target_suitability"), + ), + on=["q", "r"], + how="inner", + ) + .join( + all_nodes.select( + pl.col("node_id"), + pl.col("suitability_value").alias("source_suitability"), + ), + left_on="source_node", + right_on="node_id", + how="inner", + ) + .with_columns((pl.col("source_suitability") + pl.col("target_suitability")).alias("weight")) + # Remove duplicate edges. Due to adding candidates for four directions (instead of three), duplicate + # edges occur in the query. By normalizing by taking the lowest and highest node id horizontally, + # duplicates can be identified and removed in the query. + .with_columns( + pl.min_horizontal("source_node", "target_node").alias("edge_low"), + pl.max_horizontal("source_node", "target_node").alias("edge_high"), + ) + .unique(subset=["edge_low", "edge_high"]) + .select("source_node", "target_node", "weight") + .collect() + ) + return neighbours diff --git a/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_graph_builder.py b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_graph_builder.py new file mode 100644 index 0000000..b45f9b9 --- /dev/null +++ b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_graph_builder.py @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import geopandas as gpd +import numpy as np +import polars as pl +import rustworkx as rx +import shapely +import structlog + +from settings import Config +from utility_route_planner.models.multilayer_network.graph_datastructures import hexagon_edge_info, HexagonNodeInfo +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_edge_generator import HexagonEdgeGenerator +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_grid_builder import ( + HexagonGridBuilder, +) +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import get_hexagon_width_and_height +from utility_route_planner.util.timer import time_function + +logger = structlog.get_logger(__name__) + + +class HexagonGraphBuilder: + """ + Class is used to construct a spatial graph in flat-top hexagonal structure given a set of spatial input + vectors. Each node and edge have an assigned suitability value that is computed based on the location + and intersecting vector. + """ + + def __init__(self, grid_builder: HexagonGridBuilder, edge_generator: HexagonEdgeGenerator): + self.hexagon_size = grid_builder.hexagon_size + self.hexagon_width, self.hexagon_height = get_hexagon_width_and_height(self.hexagon_size) + self.grid_builder = grid_builder + self.edge_generator = edge_generator + + @time_function + def build_graph( + self, + project_area: shapely.Polygon | shapely.MultiPolygon, + raster_groups: dict[str, str], + preprocessed_vectors: dict[str, gpd.GeoDataFrame], + ) -> tuple[rx.PyGraph, gpd.GeoDataFrame]: + # Left-side and bottom coordinates for all blocks in the current row. + current_row_edge_coordinates = pl.DataFrame( + schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "q": pl.Int32, "r": pl.Int32} + ) + + # Left-side and bottom coordinates for all blocks in the previous row. When finishing a row, this + # is set by the current_row_edge_coordinates dataframe. It is used to connect the top side of blocks + # in the current row to the previous row. + previous_row_edge_coordinates = self._get_empty_nodes_df() + + # Edge coordinates of the previous block in the current row. It is used to create edges from the current to + # the previous block in the current row. + previous_block_edge_coordinates = self._get_empty_nodes_df() + + x_matrix, y_matrix = self.grid_builder.construct_hexagonal_grid_for_bounding_box(project_area) + + # Before running, initialize a numpy structured array for storing all node data while constructing the graph. As + # it is based on the bounding box, -1 is used as fill value such that it can be filtered later on. Using the + # pre-initialized array is much more efficient than concatenating all node data to a list while constructing + # the graph. + n_nodes = x_matrix.shape[0] * x_matrix.shape[1] + nodes = np.full( + n_nodes, + fill_value=-1, + dtype=[("node_id", np.int32), ("suitability_value", np.int16), ("x", np.float32), ("y", np.float32)], + ) + + # Construct hexagonal graph using a sliding-window approach + graph = rx.PyGraph() + for block, last_column in self.grid_builder.construct_grid_blocks( + x_matrix, y_matrix, preprocessed_vectors, raster_groups + ): + graph, block_node_ids = self._add_nodes_to_graph(graph, block) + + # Add node id to the block dataframe for edge processing + block = block.with_columns(pl.Series("node_id", list(block_node_ids), dtype=pl.Int32)) + + # Store all block information in the total node array + nodes["node_id"][block_node_ids] = block_node_ids + nodes["suitability_value"][block_node_ids] = block["suitability_value"] + nodes["x"][block_node_ids] = block["x"] + nodes["y"][block_node_ids] = block["y"] + + # Select all attributes in the current block which are relevant for edge generation + block_edge_attributes = block.select("node_id", "suitability_value", "q", "r") + block_edge_coordinates = self.get_block_edge_coordinates(block) + current_row_edge_coordinates = pl.concat([current_row_edge_coordinates, block_edge_coordinates]) + + # Only check previous row nodes that are on top of the current block to reduce unnecessary joins. Filtering + # is performed using the min-max q-values + a buffer of 1 to include the boundaries as well. + relevant_previous_row_nodes = previous_row_edge_coordinates.filter( + pl.col("q").is_between(block_edge_attributes["q"].min() - 1, block_edge_attributes["q"].max() + 1) + ) + + # For edge determination, the edge generator must consider nodes within the block, boundary nodes from the + # previous block and boundary nodes from the previous row to make sure the graph is connected. + nodes_to_check = pl.concat( + [block_edge_attributes, previous_block_edge_coordinates, relevant_previous_row_nodes] + ) + + graph = self._add_edges_to_graph(graph, block_edge_attributes, nodes_to_check) + if last_column: + # If the final column of the current row is reached, set all edge coordinates of the current row as + # previous row edge coordinates. This way, they can be used to determine connecting upper edges in the + # next row. All other dataframes containing previous coordinates are reset + previous_row_edge_coordinates = current_row_edge_coordinates + current_row_edge_coordinates = self._get_empty_nodes_df() + previous_block_edge_coordinates = self._get_empty_nodes_df() + else: + # If the final column of the current row is not yet reached, set the previous block to the current + # block. This way, it can be connected to the next block in the current row. + previous_block_edge_coordinates = block_edge_coordinates + + nodes_gdf = self._convert_nodes_to_gdf(nodes) + return graph, nodes_gdf + + @staticmethod + def _add_nodes_to_graph(graph: rx.PyGraph, block: pl.DataFrame) -> tuple[rx.PyGraph, rx.NodeIndices]: + """ + Add nodes to the graph and set node id on the node payload. + """ + node_payloads = [HexagonNodeInfo(weight=weight) for weight in block["suitability_value"].to_list()] + block_node_ids = graph.add_nodes_from(node_payloads) + + # Assign node id to node payloads and block dataframe + [block_data_object.set_node_id(node_id) for block_data_object, node_id in zip(node_payloads, block_node_ids)] + + return graph, block_node_ids + + def _add_edges_to_graph( + self, graph: rx.PyGraph, block_edge_attributes: pl.DataFrame, nodes_to_check: pl.DataFrame + ) -> rx.PyGraph: + """ + Add edges to the graph and set edge id on the edge payload. + """ + edges = self.edge_generator.generate(block_edge_attributes, nodes_to_check) + edge_ids = graph.add_edges_from(edges.rows()) + for edge_id, (u, v, weight) in zip(edge_ids, edges.rows()): + graph.update_edge(u, v, hexagon_edge_info(edge_id, weight)) + return graph + + def get_block_edge_coordinates(self, block_coordinates: pl.DataFrame) -> pl.DataFrame: + """ + Given the coordinates of a block, get left side and bottom coordinates. + """ + min_x_coordinate = block_coordinates["x"].min() + min_y_coordinate = block_coordinates["y"].min() + + edge_coordinates = block_coordinates.filter( + (pl.col("x") == min_x_coordinate) | (abs(pl.col("y") - min_y_coordinate) <= 0.6 * self.hexagon_height) + ).select("node_id", "suitability_value", "q", "r") + + return edge_coordinates + + @staticmethod + def _get_empty_nodes_df() -> pl.DataFrame: + return pl.DataFrame(schema={"node_id": pl.Int32, "suitability_value": pl.Int16, "q": pl.Int32, "r": pl.Int32}) + + @staticmethod + def _convert_nodes_to_gdf(nodes: np.ndarray) -> gpd.GeoDataFrame: + """ + Only include filled rows for node geodataframe conversion (placeholder rows can be identified with node_id==-1) + """ + nodes = np.extract(nodes["node_id"] >= 0, nodes) + node_gdf = gpd.GeoDataFrame( + data={"node_id": nodes["node_id"], "suitability_value": nodes["suitability_value"]}, + geometry=gpd.points_from_xy(x=nodes["x"], y=nodes["y"], crs=Config.CRS), + ) + node_gdf = node_gdf.set_index("node_id") + return node_gdf diff --git a/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_graph_composer.py b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_graph_composer.py new file mode 100644 index 0000000..230d042 --- /dev/null +++ b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_graph_composer.py @@ -0,0 +1,300 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +from dataclasses import dataclass +import pathlib +import pandas as pd +import pygeoops +import shapely +import geopandas as gpd +import rustworkx as rx +import structlog + +from settings import Config +from utility_route_planner.models.multilayer_network.graph_datastructures import HexagonConnectionEdgeInfo +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import ( + convert_hexagon_edges_to_gdf, + update_edge_id, + get_hexagon_node_geometry, +) +from utility_route_planner.util.geo_utilities import ( + get_empty_geodataframe, + get_perpendicular_line, + get_endlines_of_multilinestring, +) +from utility_route_planner.util.timer import time_function +from utility_route_planner.util.write import write_results_to_geopackage + +logger = structlog.get_logger(__name__) + + +@dataclass +class HeightLevelGraph: + graph: rx.PyGraph + nodes_gdf: gpd.GeoDataFrame + + +class HexagonGraphComposer: + def __init__( + self, + processed_criteria_per_height_level: dict[int, list[str]], + processed_graphs_and_nodes_per_height_level: dict[int, HeightLevelGraph], + hexagon_size: float, + debug: bool = False, + out: pathlib.Path = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, + ): + self.processed_criteria_per_height_level = processed_criteria_per_height_level + self.processed_graphs_and_nodes_per_height_level = processed_graphs_and_nodes_per_height_level + self.hexagon_size = hexagon_size + self.gdf_main_nodes: gpd.GeoDataFrame = get_empty_geodataframe() + + self.debug = debug + self.out = out + + def compose(self) -> HeightLevelGraph: + n_height_levels = len(self.processed_graphs_and_nodes_per_height_level) + main_height_level = self.get_main_height_level() + self.gdf_main_nodes = self.processed_graphs_and_nodes_per_height_level[main_height_level].nodes_gdf + self.gdf_main_nodes["height_level"] = main_height_level + + if n_height_levels == 1: + logger.info("Only a single height level is present, no merging is required.") + else: + logger.info(f"Connecting {n_height_levels - 1} height level(s) to the main graph.") + self.merge_graphs(main_height_level) + + return self.processed_graphs_and_nodes_per_height_level[main_height_level] + + def get_main_height_level(self): + """Doublecheck the main height level is 0. This is the expected value for the BGT.""" + node_count = { + height: graph.graph.num_nodes() + for height, graph in self.processed_graphs_and_nodes_per_height_level.items() + } + main_height_level = max(node_count, key=node_count.get) + if main_height_level != 0: + logger.warning(f"Main height level is expected to be 0, but found {main_height_level} instead.") + + return main_height_level + + @time_function + def merge_graphs(self, main_height_level: int): + """ + Merge the different height level graphs into a single graph by connecting the subgraphs to the main graph. + + Note that height levels are joined directly to the main graph. Different height levels are not joined to other + height levels. + """ + for height, height_graph in self.processed_graphs_and_nodes_per_height_level.items(): + if height == main_height_level: + continue + + logger.info( + f"Height level: {height} contains {rx.number_connected_components(height_graph.graph)} subgraph(s) to connect the main graph." + ) + + height_mapping = self.add_height_graph_to_main_graph( + height_graph.graph, height_graph.nodes_gdf, main_height_level + ) + self.processed_graphs_and_nodes_per_height_level[main_height_level].nodes_gdf.loc[ + list(height_mapping.values()), "height_level" + ] = height + + # Determine which nodes to connect to each other + for component in rx.connected_components(height_graph.graph): + gdf_component_nodes = height_graph.nodes_gdf.loc[list(component)] + # Get the outer nodes (nodes to join to the main graph) of the component. + component_area = gdf_component_nodes.buffer(self.hexagon_size).union_all(grid_size=0.1) + if not isinstance(component_area, shapely.Polygon): + logger.warning("Component area is not a polygon, this is unexpected. Skipping.") + continue + + gdf_component_outer_nodes = self.filter_component_nodes(component_area, gdf_component_nodes) + # Outer component nodes are duplicated for each node to connect to in the main graph + gdf_main_nodes_to_outer_component_nodes = gdf_component_outer_nodes.sjoin( + self.gdf_main_nodes[~self.gdf_main_nodes.intersects(component_area)], + distance=self.hexagon_size * 2, + how="left", + predicate="dwithin", + ).reset_index(drop=False) + gdf_main_nodes_to_outer_component_nodes = self.validate_main_to_subgraph_pairs( + gdf_main_nodes_to_outer_component_nodes + ) + self.add_edges_between_height_levels( + gdf_main_nodes_to_outer_component_nodes, height, height_mapping, main_height_level + ) + + if self.debug: + main_height_level_graph = self.processed_graphs_and_nodes_per_height_level[main_height_level] + nodes = main_height_level_graph.nodes_gdf + edges = convert_hexagon_edges_to_gdf(main_height_level_graph.graph, nodes) + + write_results_to_geopackage(self.out, nodes, "pytest_merged_graph_nodes", overwrite=True) + write_results_to_geopackage(self.out, edges, "pytest_merged_graph_edges", overwrite=True) + + def add_height_graph_to_main_graph( + self, height_graph: rx.PyGraph, height_node_df: gpd.GeoDataFrame, main_height_level: int + ) -> dict[int, int]: + """Add the complete height graph to the main graph. Note it is still not connected at this point.""" + mapping = {} # idx_height_graph -> idx_main_graph mapping for graph merge + + # Add nodes from the subgraph to the main graph + for old_idx, node_data in enumerate(height_graph.nodes()): + # Always add as new node (even if many map to same "right" node) + new_idx = self.processed_graphs_and_nodes_per_height_level[main_height_level].graph.add_node(node_data) + self.processed_graphs_and_nodes_per_height_level[main_height_level].graph[new_idx].node_id = new_idx + mapping[old_idx] = new_idx + + # Reassign nodes to a copied height node df, as the "original" node ids are required to properly connect height + # levels in the next step + height_node_df_copy = height_node_df.copy() + height_node_df_copy.index = height_node_df_copy.index.map(mapping) + self.processed_graphs_and_nodes_per_height_level[main_height_level].nodes_gdf = gpd.GeoDataFrame( + pd.concat( + [self.processed_graphs_and_nodes_per_height_level[main_height_level].nodes_gdf, height_node_df_copy] + ) + ) + + # Add subgraph edges to the main graph + for u, v, weight in height_graph.weighted_edge_list(): + new_edge_id = self.processed_graphs_and_nodes_per_height_level[main_height_level].graph.add_edge( + mapping[u], mapping[v], weight + ) + new_edge_data = self.processed_graphs_and_nodes_per_height_level[ + main_height_level + ].graph.get_edge_data_by_index(new_edge_id) + self.processed_graphs_and_nodes_per_height_level[main_height_level].graph.update_edge_by_index( + new_edge_id, update_edge_id(new_edge_id, new_edge_data) + ) + + return mapping + + def add_edges_between_height_levels( + self, + gdf_main_nodes_to_outer_component_nodes: gpd.GeoDataFrame, + height: int, + height_mapping: dict[int, int], + main_height_level: int, + ): + """Add the edges which connect height levels between the main graph and the component/subgraph.""" + edges_to_add = [ + ( + node_pair.node_id_right, + height_mapping[node_pair.node_id_left], + HexagonConnectionEdgeInfo( + weight=node_pair.suitability_value_left + node_pair.suitability_value_right, + height_level=height, + connects_height_levels=True, + geometry=shapely.LineString( + [ + node_pair.geometry, + get_hexagon_node_geometry(self.gdf_main_nodes, node_pair.node_id_right), + ] + ), + ), + ) + for node_pair in gdf_main_nodes_to_outer_component_nodes.itertuples(index=False) + ] + edge_indices = self.processed_graphs_and_nodes_per_height_level[main_height_level].graph.add_edges_from( + edges_to_add + ) + for new_edge_id in edge_indices: + new_edge_data = self.processed_graphs_and_nodes_per_height_level[ + main_height_level + ].graph.get_edge_data_by_index(new_edge_id) + self.processed_graphs_and_nodes_per_height_level[main_height_level].graph.update_edge_by_index( + new_edge_id, update_edge_id(new_edge_id, new_edge_data) + ) + + if self.debug: + # visualize the pairs / edges to be + linestrings = gdf_main_nodes_to_outer_component_nodes.apply( + lambda x: shapely.LineString( + [ + x.geometry, + get_hexagon_node_geometry(self.gdf_main_nodes, x.node_id_right), + ] + ), + axis=1, + ) + write_results_to_geopackage(self.out, linestrings, "pytest_component_connection_lines") + + def filter_component_nodes( + self, component_area: shapely.Polygon, gdf_component: gpd.GeoDataFrame + ) -> gpd.GeoDataFrame: + """ + Filter the nodes of the component to connect so we do not connect halfway a bridge/tunnel, but only at the + start and end. + + Some thoughts and notes on improvements: + - This does not work well when a bridge/tunnel is wider than it is long due to the centerline approach. + - Validation using OSM does not work well as it is not perfectly aligned with the BGT. This is especially + apparent for smaller pedestrian or cycling tunnels. + - Expand node model so we can retrieve the BGT polygons which can be used for cleaner centerlines. This can + also be used for finding the shared boundary of BGT polygons for determining the entrypoints. E.g., finding + the road which perfectly connects through height levels. + """ + + gdf_component_outer_nodes = gdf_component[ + gdf_component.geometry.dwithin(component_area.boundary, self.hexagon_size) + ] + component_area_simplified = component_area.simplify(self.hexagon_size * 1.6) + component_area_centerline = pygeoops.centerline(component_area_simplified, extend=True) + if isinstance(component_area_centerline, shapely.LineString): + start, end = ( + shapely.get_point(component_area_centerline, 0), + shapely.get_point(component_area_centerline, 1), + ) + line_1 = get_perpendicular_line(start, end, 40) + start, end = ( + shapely.get_point(component_area_centerline, -1), + shapely.get_point(component_area_centerline, -2), + ) + line_2 = get_perpendicular_line(start, end, 40) + entrypoint_lines = shapely.MultiLineString([line_1, line_2]) + elif isinstance(component_area_centerline, shapely.MultiLineString): + lines = get_endlines_of_multilinestring(component_area_centerline) + entrypoint_lines = shapely.MultiLineString( + [get_perpendicular_line(shapely.get_point(i, 1), shapely.get_point(i, 0), 40) for i in lines] + ) + else: + entrypoint_lines = shapely.MultiLineString() + logger.warning(f"Unhandled situation: {type(component_area_centerline)}") + + if not entrypoint_lines.is_empty: + gdf_component_outer_nodes = gdf_component_outer_nodes[ + gdf_component_outer_nodes.dwithin(entrypoint_lines, distance=self.hexagon_size * 2) + ] + else: + logger.warning( + "Unable to create proper connections between height levels. All outer nodes are now connected" + ) + + if self.debug: + write_results_to_geopackage(self.out, gdf_component_outer_nodes, "pytest_component_area_outer_nodes") + write_results_to_geopackage(self.out, component_area, "pytest_component_area") + write_results_to_geopackage(self.out, component_area_simplified, "pytest_component_area_simplified") + write_results_to_geopackage(self.out, component_area_centerline, "pytest_component_area_centerline") + write_results_to_geopackage(self.out, component_area.boundary, "pytest_component_area_boundary") + write_results_to_geopackage(self.out, entrypoint_lines, "pytest_component_entrypoint_lines") + return gdf_component_outer_nodes + + def validate_main_to_subgraph_pairs(self, gdf_main_nodes_to_outer_subgraph_nodes): + """This can occur on nodes that are at the edge of the main graph.""" + if gdf_main_nodes_to_outer_subgraph_nodes["node_id_right"].isna().any(): + logger.warning("Some outer subgraph nodes could not be connected to the main graph nodes.") + na_rows = gdf_main_nodes_to_outer_subgraph_nodes[ + gdf_main_nodes_to_outer_subgraph_nodes["node_id_right"].isna() + ] + gdf_main_nodes_to_outer_subgraph_nodes.dropna(subset=["node_id_right"], inplace=True) + gdf_main_nodes_to_outer_subgraph_nodes["node_id_right"] = gdf_main_nodes_to_outer_subgraph_nodes[ + "node_id_right" + ].astype(int) + gdf_main_nodes_to_outer_subgraph_nodes["node_id_left"] = gdf_main_nodes_to_outer_subgraph_nodes[ + "node_id_left" + ].astype(int) + if self.debug: + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, na_rows, "pytest_invalid_nodes" + ) + return gdf_main_nodes_to_outer_subgraph_nodes diff --git a/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_grid_builder.py b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_grid_builder.py new file mode 100644 index 0000000..bab0f53 --- /dev/null +++ b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_grid_builder.py @@ -0,0 +1,256 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import math +from typing import Generator +from tqdm import tqdm +import geopandas as gpd +import numpy as np +import pandas as pd +import polars as pl +import shapely +import structlog + +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import get_hexagon_width_and_height +from settings import Config + +logger = structlog.get_logger(__name__) + + +class HexagonGridBuilder: + """ + Class that is used to build a spatial grid with a hexagonal structure given a set of preprocessed vectors and + raster preset. Each point has as assigned suitabililty value that is used to construct a spatial graph in the next + step. + """ + + def __init__( + self, + hexagon_size: float, + block_size: int, + ): + self.hexagon_size = hexagon_size + self.hexagon_width, self.hexagon_height = get_hexagon_width_and_height(hexagon_size) + self.block_size = block_size + + def construct_grid_blocks( + self, + x_matrix: np.ndarray, + y_matrix: np.ndarray, + preprocessed_vectors: dict[str, gpd.GeoDataFrame], + raster_groups: dict[str, str], + ) -> Generator[tuple[pl.DataFrame, bool], None, None]: + concatenated_vectors = self.concatenate_preprocessed_vectors(preprocessed_vectors, raster_groups) + + for block, final_column in self.divide_matrices_into_blocks(x_matrix, y_matrix): + hexagonal_grid_for_block = self.filter_block_to_project_area(block, concatenated_vectors) + + # A block can be empty in case it does not intersect with any vector + if not hexagonal_grid_for_block.empty: + weighted_hexagonal_block = self.assign_suitability_values_to_block(hexagonal_grid_for_block) + weighted_hexagonal_block = self.convert_cartesian_coordinates_to_axial(weighted_hexagonal_block) + + yield weighted_hexagonal_block, final_column + + def construct_hexagonal_grid_for_bounding_box(self, project_area: shapely.Polygon) -> tuple[np.ndarray, np.ndarray]: + """ + Given the bounding box of the project area, create a hexagonal grid in flat-top orientation. + + :return: GeoDataFrame where each point represents a location on the grid + """ + x_min, y_min, x_max, y_max = project_area.bounds + + # 0.75 is used to correctly set the offset of the x coordinate of the center, as each hexagon is partially covered + # by the surrounding tiles + x_coordinates = np.arange(x_min, x_max, self.hexagon_width * 0.75) + y_coordinates = np.arange(y_min, y_max, self.hexagon_height) + x_matrix, y_matrix = np.meshgrid(x_coordinates, y_coordinates) + + # Reverse order of matrices twice, to make sure y-coordinates are decreasing going south and x-coordinates are + # increasing going east. This is required since the numpy default initialization does not respect the same order + # as the coordinate system being used. + x_matrix = np.flip(x_matrix) + y_matrix = np.flip(y_matrix) + + # Every even column must be offset by half of the hexagon height to properly determine the vertical + # position of the hexagon. + y_matrix[:, ::2] += self.hexagon_height / 2 + + return x_matrix, y_matrix + + def divide_matrices_into_blocks( + self, x_matrix: np.ndarray, y_matrix: np.ndarray + ) -> Generator[tuple[gpd.GeoDataFrame, bool], None, None]: + """ + Generator which yields indexed blocks from the x- and y-matrix given the desired block size + + :param x_matrix: x_matrix to divide into blocks + :type x_matrix: np.ndarray + :param y_matrix: y_matrix to divide into blocks + :type y_matrix: np.ndarray + :return: block and indication whether the final column of the current row is reached + :rtype: Generator[tuple[gpd.GeoDataFrame, bool], None, None] + """ + # Determine number of columns and columns given the desired block size. Round up to prevent + # losing data + n_rows_blocks = math.ceil(x_matrix.shape[0] / self.block_size) + n_columns_blocks = math.ceil(y_matrix.shape[1] / self.block_size) + total_nr_of_blocks = n_rows_blocks * n_columns_blocks + logger.info(f"Total number of blocks: {n_rows_blocks * n_columns_blocks}") + + if n_rows_blocks == 1 and n_columns_blocks == 1: + grid = gpd.GeoDataFrame(geometry=gpd.points_from_xy(x_matrix.ravel(), y_matrix.ravel()), crs=Config.CRS) + grid = grid.reset_index(names="node_id") + yield grid, True + return + + row_splits = np.linspace(0, x_matrix.shape[0], n_rows_blocks + 1, dtype=int) + column_splits = np.linspace(0, y_matrix.shape[1], n_columns_blocks + 1, dtype=int) + + # Iterate over the split indexes to extract the blocks from the matrices. Each block is yielded as a polars + # dataframe. In addition, it is yielded whether the final column of the current row is reached. This is required + # for edge construction later on. + with tqdm(total=total_nr_of_blocks, desc="Constructing hexagonal suitability grid") as pbar: + for row_start, row_end in zip(row_splits[:-1], row_splits[1:]): + for column_start, column_end in zip(column_splits[:-1], column_splits[1:]): + x_block = x_matrix[row_start:row_end, column_start:column_end] + y_block = y_matrix[row_start:row_end, column_start:column_end] + + block_grid = gpd.GeoDataFrame( + geometry=gpd.points_from_xy(x_block.ravel(), y_block.ravel()), crs=Config.CRS + ) + block_grid = block_grid.reset_index(names="node_id") + + final_column = column_end == column_splits[-1] + pbar.update() + yield block_grid, final_column + + @staticmethod + def concatenate_preprocessed_vectors( + preprocessed_vectors: dict[str, gpd.GeoDataFrame], raster_groups: dict[str, str] + ): + """ + Concatenate all preprocessed vectors into a single geodataframe. + """ + for criterion, vector_gdf in preprocessed_vectors.items(): + vector_gdf["criterion"] = criterion + vector_gdf["group"] = raster_groups[criterion] + concatenated_vectors = gpd.GeoDataFrame(pd.concat(preprocessed_vectors.values()), crs=Config.CRS) + + return concatenated_vectors + + @staticmethod + def filter_block_to_project_area(bounding_box_grid: gpd.GeoDataFrame, concatenated_vectors) -> gpd.GeoDataFrame: + points_within_project_area = gpd.sjoin( + bounding_box_grid, + concatenated_vectors[["group", "suitability_value", "geometry"]], + predicate="within", + how="inner", + ).set_index("node_id") + + return points_within_project_area + + def assign_suitability_values_to_block(self, points_within_project_area: gpd.GeoDataFrame) -> pl.DataFrame: + """ + Given the group the vector of a suitability value belongs to, a specific aggregation functions is applied for overlapping + points within this group: + - group a: take max suitability value of overlapping points + - group b: sum overlapping suitability values + - group c: sum overlapping suitability values + + In case points that intersect with group a and b are overlapping, they are summed after aggregation. Finally, all points + that intersect with group c are set to the max possible suitability value. + + :return: GeoDataFrame containing all points within the project area in combination with aggregated suitability + values for every point. + """ + + polars_df = pl.from_pandas(points_within_project_area.loc[:, ["group", "suitability_value"]].reset_index()) + + # Aggregate dataframe with overlapping values for the same node id. Max for group a, sum for + # group b and c. + aggregated_suit_values_per_group_and_node = ( + polars_df.lazy() + .group_by("group", "node_id") + .agg( + pl.when(pl.first("group") == "a") + .then(pl.col("suitability_value").max()) + .otherwise(pl.col("suitability_value").sum()) + .alias("agg_suit_val"), + ) + ).collect() + + aggregated_suit_values_per_node = ( + aggregated_suit_values_per_group_and_node.lazy() + .group_by("node_id") + .agg( + has_a=(pl.col("group") == "a").any(), + has_b=(pl.col("group") == "b").any(), + has_c=(pl.col("group") == "c").any(), + summed=pl.col("agg_suit_val").sum(), + first_val=pl.col("agg_suit_val").first(), + ) + .with_columns( + # In case a node is in both group a and b but not c, use the summed total as suitability value + pl.when(pl.col("has_a") & pl.col("has_b") & ~pl.col("has_c")) + .then(pl.col("summed")) + # When a group is in node c, use the max node suitability value + .when(pl.col("has_c")) + .then(Config.MAX_NODE_SUITABILITY_VALUE) + # In all other cases there is no overlap of groups, simply pick the first value (there is always only + # 1) as suitability value + .otherwise(pl.col("first_val")) + # Make sure suitability value is always within allowed bounds + .alias("suitability_value") + .cast(pl.Int16) + .clip(Config.MIN_NODE_SUITABILITY_VALUE, Config.MAX_NODE_SUITABILITY_VALUE) + ) + .select("node_id", "suitability_value") + ).collect() + + # Rejoin coordinates to the dataframe based on node_id + coordinates = pl.from_pandas(points_within_project_area["geometry"].get_coordinates(), include_index=True) + hexagon_points = aggregated_suit_values_per_node.join(coordinates, on="node_id", how="left") + + # Remove duplicate points, as a point could have joined multiple vector which results in duplicate rows within + # the right dataframe. + hexagon_points = hexagon_points.unique(subset=["node_id"]) + hexagon_points = hexagon_points.cast({pl.Int64: pl.Int32, pl.Float64: pl.Float32}) + + return hexagon_points + + def convert_cartesian_coordinates_to_axial(self, hexagon_center_points: pl.DataFrame) -> pl.DataFrame: + """ + To efficiently determine neighbours to construct a hexagonal graph later on, convert all cartesian coordinates + to axial coordinates. + + Used algorithms as provided by: + - coordinate to hex: https://www.redblobgames.com/grids/hexagons/#pixel-to-hex + - rounding hex correctly: https://observablehq.com/@jrus/hexround (via redblobgames) + + :return: tuple containing q- and r-values as integers in numpy ndarray format + """ + x, y = hexagon_center_points["x"], hexagon_center_points["y"] + + # Convert x- and y-coordinates to axial + q = pl.Series((-2 / 3 * x) / self.hexagon_size).rename("q") + r = pl.Series((1 / 3 * x + math.sqrt(3) / 3 * y) / self.hexagon_size).rename("r") + + # Convert coordinates to integers and correct rounding errors + xgrid = q.round().cast(pl.Int32) + ygrid = r.round().cast(pl.Int32) + + q_diff = q - xgrid + r_diff = r - ygrid + + # Update x and y grid where q difference is larger than r difference + mask = q_diff.abs() > r_diff.abs() + updated_x_grid = xgrid + (q_diff + 0.5 * r_diff).round().cast(pl.Int32) + xgrid = updated_x_grid.zip_with(mask, xgrid) + + updated_y_grid = ygrid + (r_diff + 0.5 * q_diff).round().cast(pl.Int32) + ygrid = updated_y_grid.zip_with(~mask, ygrid) + + hexagon_center_points = hexagon_center_points.with_columns(xgrid, ygrid) + + return hexagon_center_points diff --git a/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_utils.py b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_utils.py new file mode 100644 index 0000000..5fbdccf --- /dev/null +++ b/utility_route_planner/models/multilayer_network/hexagon_graph/hexagon_utils.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# # +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import asdict +import math + +import geopandas as gpd +import numpy as np +import rustworkx as rx +import shapely +import structlog + +from settings import Config +from utility_route_planner.models.multilayer_network.graph_datastructures import ( + HexagonConnectionEdgeInfo, + BaseWeightedEdgeInfo, + hexagon_edge_info, +) + +logger = structlog.get_logger(__name__) + + +def get_hexagon_width_and_height(hexagon_size: float) -> tuple[float, float]: + """ + Compute hexagon width and height, given the provided size of the hexagon. In this calculation, width and height are + computed for a flat-top oriented hexagon. + + source: https://www.redblobgames.com/grids/hexagons/#basics + + :param hexagon_size: size of hexagon described by the inner circle of the hexagon that touches the edges + :return: tuple consisting of two floats that represent the width and height of the hexagon + """ + + hexagon_width = 2 * hexagon_size + hexagon_height = math.sqrt(3) * hexagon_size + + return hexagon_width, hexagon_height + + +def update_edge_id( + new_id: int, hexagon_edge: hexagon_edge_info | BaseWeightedEdgeInfo +) -> hexagon_edge_info | BaseWeightedEdgeInfo: + """ + Set the edge id as property of the edge, based on the type of edge that is encountered + - When set in the HexagonGraphBuilder: id is set as in as part of the edge data + - When set as part of piperamming or graph composing: id is set as part of the BaseWeightedEdgeInfo dataclass + """ + match hexagon_edge: + case hexagon_edge_info(): + return hexagon_edge_info(new_id, hexagon_edge[1]) + case BaseWeightedEdgeInfo(): + hexagon_edge.set_edge_id(new_id) + return hexagon_edge + case _: + raise ValueError("Encountered invalid edge type") + + +def get_hexagon_node_geometry(nodes: gpd.GeoDataFrame, node_id: int) -> shapely.Point: + return nodes.loc[node_id].geometry + + +def get_hexagon_edge_geometries_for_path( + graph: rx.PyGraph, path_node_indices: list[int], hexagon_nodes: gpd.GeoDataFrame +) -> gpd.GeoDataFrame: + """ + Given a list of node indices which represent a path on the graph, construct a dataframe to represent the path as a + list of linestrings (edges). + + :param graph: graph for which the path was calculated + :param path_node_indices: list of node indices that represent the path + :param hexagon_nodes: all nodes on the hexagon graph. This dataframe is used to determine + the edge geometries on the hexagon graph. + """ + + edges_list = [] + for source_node, target_node in zip(path_node_indices, path_node_indices[1:]): + edge_data = graph.get_edge_data(source_node, target_node) + + # As "vanilla" hexagon edges do not have dataclasses as edge attribute, the data must + # be constructed manually. For all other edges (i.e., piperamming and hexagon connection + # edges), the data can be converted from the dataclass. + if isinstance(edge_data, BaseWeightedEdgeInfo): + edge_meta_data = asdict(edge_data) + else: + edge_id = graph.edge_indices_from_endpoints(source_node, target_node)[0] + edge_linestring = shapely.LineString( + [ + get_hexagon_node_geometry(hexagon_nodes, node_id=source_node), + get_hexagon_node_geometry(hexagon_nodes, node_id=target_node), + ] + ) + edge_meta_data = dict( + edge_id=edge_id, + weight=edge_data.weight, + length=round(edge_linestring.length, 2), + connects_height_levels=False, + geometry=edge_linestring, + ) + + edges_list.append(edge_meta_data) + + hexagon_path_geometries = gpd.GeoDataFrame(data=edges_list, crs=Config.CRS) + hexagon_path_geometries.loc[:, "connects_height_levels"] = hexagon_path_geometries.loc[ + :, "connects_height_levels" + ].fillna(False) + return hexagon_path_geometries + + +def convert_hexagon_edges_to_gdf(graph: rx.PyGraph, nodes: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """ + Convert all edges in a Hexagon graph to a GeoDataframe. + + Note: when supplying large graphs, this function can take some time to complete due to the geometry creation + of all edges in the graph. + + :param graph: graph to convert edges for + :param nodes: all nodes in the graph as a geodataframe containing the source and target geometries + :return: geodataframe with all edges, edge weights and geometries from the input graph + """ + + edge_weight_map = graph.edge_index_map() + source_nodes = [source_node for source_node, _, _ in edge_weight_map.values()] + target_nodes = [target_node for _, target_node, _ in edge_weight_map.values()] + + weights = [] + connects_height_levels = [] + for _, _, edge_data in edge_weight_map.values(): + weights.append(edge_data.weight) + if isinstance(edge_data, HexagonConnectionEdgeInfo): + connects_height_levels.append(edge_data.connects_height_levels) + else: + connects_height_levels.append(False) + + source_coordinates = nodes.loc[source_nodes].get_coordinates().values + target_coordinates = nodes.loc[target_nodes].get_coordinates().values + edge_geometries = shapely.linestrings(np.stack([source_coordinates, target_coordinates], axis=1)) + + return gpd.GeoDataFrame( + { + "source_node": source_nodes, + "target_node": target_nodes, + "weight": weights, + "connects_height_levels": connects_height_levels, + "geometry": edge_geometries, + }, + crs=Config.CRS, + ) diff --git a/utility_route_planner/models/multilayer_network/multilayer_route_helpers.py b/utility_route_planner/models/multilayer_network/multilayer_route_helpers.py new file mode 100644 index 0000000..31ebc8e --- /dev/null +++ b/utility_route_planner/models/multilayer_network/multilayer_route_helpers.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import math + +import shapely + + +def _angle_between(v1: tuple[float, float], v2: tuple[float, float]) -> float: + """Deflection angle between two 2D vectors, in [0, pi].""" + dot = v1[0] * v2[0] + v1[1] * v2[1] + n1 = math.hypot(*v1) + n2 = math.hypot(*v2) + if n1 == 0 or n2 == 0: + return 0.0 + cos_a = max(-1.0, min(1.0, dot / (n1 * n2))) + return math.acos(cos_a) + + +def _point_along(origin: shapely.Point, towards: shapely.Point, distance: float) -> shapely.Point: + dx, dy = towards.x - origin.x, towards.y - origin.y + n = math.hypot(dx, dy) + if n == 0: + return origin + return shapely.Point(origin.x + dx / n * distance, origin.y + dy / n * distance) + + +def _quadratic_bezier(p0: shapely.Point, p1: shapely.Point, p2: shapely.Point, n: int) -> shapely.LineString: + pts = [] + for k in range(n): + t = k / (n - 1) + omt = 1 - t + x = omt * omt * p0.x + 2 * omt * t * p1.x + t * t * p2.x + y = omt * omt * p0.y + 2 * omt * t * p1.y + t * t * p2.y + pts.append((x, y)) + return shapely.LineString(pts) + + +def get_inradius(hexagon_size: float) -> float: + """Get the inradius of a hexagon for checking intersections with cost surface nodes during straightening.""" + return math.sqrt(3) * hexagon_size diff --git a/utility_route_planner/models/multilayer_network/multilayer_route_planner.py b/utility_route_planner/models/multilayer_network/multilayer_route_planner.py new file mode 100644 index 0000000..2ae0c18 --- /dev/null +++ b/utility_route_planner/models/multilayer_network/multilayer_route_planner.py @@ -0,0 +1,387 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +from dataclasses import dataclass, field, fields +from enum import auto, Enum +from pathlib import Path +import math + +import rustworkx as rx +import shapely +import shapely.ops +import geopandas as gpd +import structlog + +from settings import Config +from utility_route_planner.models.multilayer_network.graph_datastructures import BaseWeightedEdgeInfo, NodeInfo +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import ( + get_hexagon_edge_geometries_for_path, + get_hexagon_node_geometry, +) +from utility_route_planner.models.multilayer_network.multilayer_route_helpers import ( + _angle_between, + _point_along, + _quadratic_bezier, + get_inradius, +) +from utility_route_planner.util.geo_utilities import get_first_last_point_from_linestring, get_empty_geodataframe +from utility_route_planner.util.timer import time_function +from utility_route_planner.util.write import write_results_to_geopackage + +logger = structlog.get_logger(__name__) + + +class Algorithm(Enum): + dijkstra = auto() + astar = auto() + + +@dataclass +class MultiLayerRouteResults: + result_route_node_indices: rx.NodeIndices = field(default_factory=rx.NodeIndices) + result_route_guideline: shapely.LineString = field(default_factory=shapely.LineString) + result_route_edges: gpd.GeoDataFrame = field(default_factory=get_empty_geodataframe) + result_route_nodes: gpd.GeoDataFrame = field(default_factory=get_empty_geodataframe) + result_route_linestring: shapely.LineString = field(default_factory=shapely.LineString) + result_route_straightened: shapely.LineString = field(default_factory=shapely.LineString) + result_route_straightened_node_indices: list = field(default_factory=list) + result_route_quadratic_bezier: shapely.LineString = field(default_factory=shapely.LineString) + + def write_to_geopackage(self, out: Path, prefix: str = "") -> None: + """Write results containing a geometry to file using the dataclass field name.""" + for f in fields(self): + value = getattr(self, f.name) + if isinstance(value, gpd.GeoDataFrame | gpd.GeoSeries): + if value.empty: + continue + elif isinstance(value, shapely.geometry.base.BaseGeometry): + if value.is_empty: + continue + else: + continue + write_results_to_geopackage(out, value, f"{prefix}{f.name}") + + +class MultilayerRouteEngine: + def __init__( + self, + cost_surface_graph: rx.PyGraph, + osm_graph: rx.PyGraph, + gdf_cost_surface_nodes: gpd.GeoDataFrame, + hexagon_size: float, + # minimum_bending_radius: float = 0, + algorithm: Algorithm = Algorithm.dijkstra, + prefix: str = "", + write_output: bool = False, + out: Path = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, + ): + self.cost_surface_graph = cost_surface_graph + self.gdf_cost_surface_nodes = gdf_cost_surface_nodes + self.osm_graph = osm_graph + self.hexagon_size = hexagon_size + self.minimum_bending_radius = get_inradius(self.hexagon_size) / 2 + self.write_output = write_output + self.algorithm = algorithm + self.prefix = prefix + self.out = out + + self.results = MultiLayerRouteResults() + + def find_route(self, start_end: shapely.LineString): + source, target = self.get_source_and_target_nodes(start_end) + + straight_line = self.get_linestring(source, target) + # Offset to avoid it being exactly on top of the nodes, causes issues with distance calculations during routing. + self.results.result_route_guideline = shapely.offset_curve(straight_line, self.hexagon_size / 4) + + path_node_indices = self.find_path_node_indices(source, target) + + gdf_path_nodes = self.gdf_cost_surface_nodes.loc[path_node_indices].copy() + gdf_path_edges = get_hexagon_edge_geometries_for_path( + self.cost_surface_graph, path_node_indices, gdf_path_nodes + ) + + self.results.result_route_edges = gdf_path_edges + self.results.result_route_nodes = gdf_path_nodes + self.results.result_route_node_indices = path_node_indices + + self.results.result_route_linestring = shapely.LineString( + [get_hexagon_node_geometry(self.gdf_cost_surface_nodes, node_id=i) for i in path_node_indices] + ) + self.results.result_route_straightened, self.results.result_route_straightened_node_indices = ( + self.straighten_linestring() + ) + if self.minimum_bending_radius: + self.apply_bezier_curves(min_bend_radius=self.minimum_bending_radius) + + if self.write_output: + self.results.write_to_geopackage(self.out, self.prefix) + write_results_to_geopackage( + self.out, + self.gdf_cost_surface_nodes.loc[self.results.result_route_straightened_node_indices], + f"{self.prefix}result_route_shortcut_nodes", + ) + + def get_source_and_target_nodes(self, start_end: shapely.LineString) -> tuple[int, int]: + start, end = get_first_last_point_from_linestring(start_end) + source = self.gdf_cost_surface_nodes.iloc[self.gdf_cost_surface_nodes.distance(start).idxmin()].name + target = self.gdf_cost_surface_nodes.iloc[self.gdf_cost_surface_nodes.distance(end).idxmin()].name + if source == target: + raise ValueError("Source and target node are the same. Provide a linestring with points further apart.") + return source, target + + def get_result_route_length(self) -> float: + return self.results.result_route_edges.geometry.length.sum() + + def get_result_route_cost(self) -> float: + """ + For now, divide total route cost by 2 as the edge weight is now computed as the sum of the weights of the source + and target nodes. + """ + return self.results.result_route_edges["weight"].sum() / 2 + + def get_weight_dijkstra(self, edge: BaseWeightedEdgeInfo, modifier: float = 0.01) -> float: + """ + Weight is leading for edges (MCDA), but we want to add a small distance-based cost to prefer routes that are + closer to the straight line between start and end. + """ + weight = self.cost_surface_graph.get_edge_data_by_index(edge.edge_id).weight + node_1, node_2 = self.cost_surface_graph.get_edge_endpoints_by_index(edge.edge_id) + edge_line = self.get_linestring(node_1, node_2) + distance = edge_line.distance(self.results.result_route_guideline) * modifier + if distance > weight: + logger.warning("Unexpected situation during routing.") + return weight + distance + + def get_weight_astar(self, edge: BaseWeightedEdgeInfo) -> float: + return self.cost_surface_graph.get_edge_data_by_index(edge.edge_id).weight + + def get_estimate_astar(self, node: NodeInfo) -> float: + node_point = get_hexagon_node_geometry(self.gdf_cost_surface_nodes, node.node_id) + guideline = shapely.LineString([node_point, shapely.get_point(self.results.result_route_guideline, 1)]) + + return guideline.length + + def get_linestring(self, node_1: int, node_2: int) -> shapely.LineString: + nodes = self.gdf_cost_surface_nodes + edge_line = shapely.LineString( + [ + get_hexagon_node_geometry(nodes, node_1), + get_hexagon_node_geometry(nodes, node_2), + ] + ) + return edge_line + + @time_function + def find_path_node_indices(self, source, target): + logger.info("Starting route finding.") + match self.algorithm: + case Algorithm.dijkstra: + path_node_indices = rx.dijkstra_shortest_paths( + self.cost_surface_graph, source, target, self.get_weight_dijkstra + ) + path_node_indices = path_node_indices[target] + case Algorithm.astar: + path_node_indices = rx.astar_shortest_path( + self.cost_surface_graph, + node=source, + goal_fn=lambda x: x.node_id == target, + edge_cost_fn=self.get_weight_astar, + estimate_cost_fn=self.get_estimate_astar, + ) + case _: + raise ValueError(f"Unsupported algorithm type. Expected one of: {[a for a in Algorithm]}") + return path_node_indices + + def _get_shortcut_costs(self, line: shapely.LineString, inradius: int) -> list[float]: + # Multiply each node suitability value by 2, as edge weights are set as the sum of two node suitability values. + nearby = self.gdf_cost_surface_nodes[self.gdf_cost_surface_nodes.dwithin(line, inradius)] + costs = nearby.suitability_value.unique() + # Only look at the intersection when necessary to save resources + if len(costs) != 1: + intersected = nearby[nearby.buffer(inradius / 2).intersects(line)] + costs = intersected.suitability_value.unique() + return (costs * 2).tolist() + + @time_function + def straighten_linestring(self) -> tuple[shapely.LineString, list[int]]: + """ + The idea is to create shortcuts in the route by skipping nodes if the cost does not change. This is done by + creating a linestring from the current node to the next node in the route and checking if the suitability values + of the cost surface nodes that are within a certain distance (depending on hexagon size used) from this + linestring are all the same as the suitability value of the current node. If they are, we can skip the nodes in + between and continue from the forwarded node. If they are not, we add the last node that we could skip to the + shortcut order and continue from there. + + This is effective when the route crosses an area that is homogenous in suitability value and reduces the + zigzag effect. + + Illustrations: https://steamcdn-a.akamaihd.net/apps/valve/2009/ai_systems_of_l4d_mike_booth.pdf + - AKA collapsed path. + + This results in a "straightened" linestring. + + """ + logger.info("Starting straightening of the found route.") + # center to center distance from a neighbouring hexagon + inradius = get_inradius(self.hexagon_size) + shortcut_order: list = [self.results.result_route_node_indices[0]] + + # TODO add height + # create dataframe with: node_order, suit value, height level. use to get segments. + gdf_crossed_nodes = self.gdf_cost_surface_nodes.loc[self.results.result_route_node_indices].reset_index() + gdf_crossed_nodes["segment"] = ( + gdf_crossed_nodes["suitability_value"] != gdf_crossed_nodes["suitability_value"].shift() + ).cumsum() + + # Note segments do not encapsulate pipe rammings as the costs are not on the node. + for segment in gdf_crossed_nodes["segment"].unique(): + gdf_active_mask = gdf_crossed_nodes[gdf_crossed_nodes["segment"] == segment] + if len(gdf_active_mask) == 1: + shortcut_order.append(int(gdf_active_mask.iloc[0]["node_id"])) + continue + start_node = int(gdf_active_mask.iloc[0]["node_id"]) + forwarded_node = int(gdf_active_mask.iloc[1]["node_id"]) + end_node = int(gdf_active_mask.iloc[-1]["node_id"]) + while start_node != end_node: + # For each node in the active segment, create a line from start_node and compute shortcut costs. + # Pick the last node (most skipped) with still the same suitability costs + basic_cost = self.cost_surface_graph.get_edge_data(start_node, forwarded_node).weight + start_node_geom = get_hexagon_node_geometry(self.gdf_cost_surface_nodes, node_id=start_node) + # Create lines from start_node to all nodes in the active segment + series_forwarded = gpd.GeoSeries(shapely.shortest_line(start_node_geom, gdf_active_mask["geometry"])) + # Compute shortcut costs for each line + series_shortcut_costs = series_forwarded.apply(self._get_shortcut_costs, inradius=inradius) + + # Filter nodes where shortcut costs equal basic_cost + valid_nodes = gdf_active_mask[series_shortcut_costs.apply(lambda costs: costs == [basic_cost])] + + if valid_nodes.empty: + # No valid shortcut found for this part of the segment, move to the next node + shortcut_order.append(forwarded_node) + gdf_active_mask = gdf_active_mask[1:] + start_node = int(gdf_active_mask.iloc[0]["node_id"]) + forwarded_node = gdf_active_mask.iloc[1]["node_id"] if len(gdf_active_mask) > 1 else end_node + + else: + # Pick the last valid node (most nodes skipped) + start_node = int(valid_nodes.iloc[-1]["node_id"]) + shortcut_order.append(start_node) + gdf_active_mask = gdf_active_mask[gdf_active_mask.index > valid_nodes.iloc[-1].name] + forwarded_node = gdf_active_mask.iloc[0]["node_id"] if len(gdf_active_mask) > 0 else end_node + + shortcut_linestring = shapely.LineString( + gdf_crossed_nodes[gdf_crossed_nodes["node_id"].isin(shortcut_order)].geometry.to_list() + ) + + logger.info( + f"Input LineString: {self.results.result_route_linestring.length}. Shortcut LineString: {shortcut_linestring.length}." + ) + + return shortcut_linestring, shortcut_order + + @time_function + def apply_bezier_curves( + self, + min_bend_radius: float, + samples_per_curve: int = 30, + ) -> shapely.LineString: + """ + Replace corners in straightened route with quadratic Bezier arcs. + + At each interior vertex P_i the corner formed by legs (P_{i-1} -> P_i) and + (P_i -> P_{i+1}) is replaced by: + - a straight piece up to point A on the incoming leg, distance d back from P_i + - a quadratic Bezier with control point P_i, ending at point B on the outgoing + leg, distance d forward from P_i + The offset d is chosen so that: + 1. The minimum radius of curvature of the Bezier >= min_bend_radius. + For a symmetric quadratic Bezier with offsets d and deflection angle alpha, + r_min = d * tan(alpha / 2) -> d_min = min_bend_radius * tan(alpha / 2). + 2. The Bezier does not enter hexagon cells whose suitability_value differs + from the cells covered by the two legs being joined. + Adjacent corners share legs, so each corner can use at most half of a leg's length. + """ + logger.info("Starting route smoothing through application of bezier curves.") + # TODO split and cleanup + coords = list(self.results.result_route_straightened.coords) + if len(coords) < 3: + return self.results.result_route_straightened + + inradius = get_inradius(self.hexagon_size) + + legs = [shapely.LineString([coords[i], coords[i + 1]]) for i in range(len(coords) - 1)] + leg_lengths = [leg.length for leg in legs] + leg_costs = [self._get_shortcut_costs(leg, int(inradius)) for leg in legs] + + new_pieces: list[shapely.LineString] = [] + cursor = shapely.Point(coords[0]) + + for i in range(1, len(coords) - 1): + p_prev = shapely.Point(coords[i - 1]) + p_curr = shapely.Point(coords[i]) + p_next = shapely.Point(coords[i + 1]) + + v_in = (p_curr.x - p_prev.x, p_curr.y - p_prev.y) + v_out = (p_next.x - p_curr.x, p_next.y - p_curr.y) + alpha = _angle_between(v_in, v_out) + + # Essentially straight, no curve needed. + if alpha < 1e-3: + continue + + d_min = min_bend_radius * math.tan(alpha / 2) + d_max = 0.5 * min(leg_lengths[i - 1], leg_lengths[i]) + + if d_min > d_max: + logger.warning( + "Cannot satisfy minimum bend radius at vertex.", + vertex=i, + d_required=d_min, + d_available=d_max, + ) + # Fall back: keep the sharp corner. + new_pieces.append(shapely.LineString([cursor, p_curr])) + cursor = p_curr + continue + + allowed_costs = set(leg_costs[i - 1]) | set(leg_costs[i]) + + # Iteratively shrink d until the curve stays inside allowed cells. + d = d_max + bezier_line = None + for _ in range(10): + a = _point_along(p_curr, p_prev, d) + b = _point_along(p_curr, p_next, d) + bezier_line = _quadratic_bezier(a, p_curr, b, samples_per_curve) + if self._curve_stays_in_cells(bezier_line, allowed_costs, inradius): + break + if d <= d_min + 1e-6: + break + d = max(d_min, d * 0.5) + + assert bezier_line is not None + new_pieces.append(shapely.LineString([cursor, shapely.Point(bezier_line.coords[0])])) + new_pieces.append(bezier_line) + cursor = shapely.Point(bezier_line.coords[-1]) + + new_pieces.append(shapely.LineString([cursor, shapely.Point(coords[-1])])) + + merged = shapely.ops.linemerge(shapely.MultiLineString(new_pieces)) + if isinstance(merged, shapely.MultiLineString): + # Fallback: concatenate raw coordinates if linemerge could not produce a single line. + all_coords: list[tuple[float, float]] = [] + for piece in new_pieces: + pc = list(piece.coords) + if all_coords and all_coords[-1] == pc[0]: + all_coords.extend(pc[1:]) + else: + all_coords.extend(pc) + merged = shapely.LineString(all_coords) + + self.results.result_route_quadratic_bezier = merged + return merged + + def _curve_stays_in_cells(self, curve: shapely.LineString, allowed: set, inradius: float) -> bool: + costs = self._get_shortcut_costs(curve, int(inradius)) + return set(costs).issubset(allowed) diff --git a/utility_route_planner/models/multilayer_network/osm_graph_downloader.py b/utility_route_planner/models/multilayer_network/osm_graph_downloader.py new file mode 100644 index 0000000..25ea543 --- /dev/null +++ b/utility_route_planner/models/multilayer_network/osm_graph_downloader.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +from shapely.geometry import MultiPolygon, Polygon +import structlog +from settings import Config +import geopandas as gpd +import networkx as nx +import osmnx as ox +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_not_exception_type + +from utility_route_planner.models.multilayer_network.exceptions import NoGraphDataForProjectArea + +logger = structlog.get_logger(__name__) + + +class OSMGraphDownloader: + def __init__(self, project_area_geometry: Polygon | MultiPolygon): + self.project_area_geometry = project_area_geometry + + ox.settings.log_console = False + ox.settings.overpass_rate_limit = True + ox.settings.requests_timeout = Config.OSM_API_TIMEOUT_IN_SECONDS + ox.settings.use_cache = False + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=4, max=16), + retry=retry_if_not_exception_type(NoGraphDataForProjectArea), + ) + def download_graph(self) -> nx.MultiGraph: + logger.info("Start downloading graph from OSM") + reprojected_project_area = gpd.GeoSeries(self.project_area_geometry, crs=Config.CRS).to_crs(4326).iloc[0] + + try: + graph_wgs84 = ox.graph_from_polygon(reprojected_project_area, network_type="all", simplify=False) + except ValueError as e: + logger.error("Error while downloading graph", error=str(e)) + raise NoGraphDataForProjectArea("No graph could be created for the project area.") + + graph_rdnew = ox.project_graph(graph_wgs84, to_crs=Config.CRS) + logger.info("Successfully downloaded graph") + return graph_rdnew diff --git a/utility_route_planner/models/multilayer_network/osm_graph_preprocessing.py b/utility_route_planner/models/multilayer_network/osm_graph_preprocessing.py new file mode 100644 index 0000000..578d6db --- /dev/null +++ b/utility_route_planner/models/multilayer_network/osm_graph_preprocessing.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 + +import networkx as nx +import rustworkx as rx +import osmnx as ox +import structlog +import shapely + +from settings import Config +from utility_route_planner.models.multilayer_network.graph_datastructures import OSMEdgeInfo, OSMNodeInfo +from utility_route_planner.util.write import write_results_to_geopackage + +logger = structlog.get_logger(__name__) + + +class OSMGraphPreprocessor: + def __init__( + self, + nx_graph: nx.MultiDiGraph, + polygon_for_clip: shapely.Polygon | shapely.MultiPolygon | None = None, + debug: bool = False, + ): + self.nx_graph = nx_graph + self.polygon_for_clip = polygon_for_clip + self.debug = debug + + def preprocess_graph(self) -> rx.PyGraph: + self.validate_input() + logger.info( + f"Preprocessing NetworkX OSM graph n_nodes: {self.nx_graph.number_of_nodes()} n_edges: {self.nx_graph.number_of_edges()} to Rustworkx." + ) + self.nx_graph = ox.convert.to_undirected(self.nx_graph) + self._remove_duplicate_edges_and_add_edge_id_and_length_properties() + if self.polygon_for_clip: + self.clip_graph_to_polygon() + rx_graph = self._convert_to_rustworkx(self.nx_graph) + + return rx_graph + + def _remove_duplicate_edges_and_add_edge_id_and_length_properties(self): + """ + OSM graphs occasionally contain duplicate edges. These duplicates are removed. + In addition, a unique edge is assigned to each edge for further processing. Given the geometry of an edge, + the length is set as an edge attribute. + + :return: the number of edges, which is used as the maximum edge id to assign new edges later on + """ + edges_to_remove = [] + initial_edge_count = len(self.nx_graph.edges) + for u, v, key in self.nx_graph.edges(keys=True): + if key > 0: + edges_to_remove.append((u, v, key)) + self.nx_graph[u][v][0]["length"] = self.nx_graph[u][v][0]["geometry"].length + self.nx_graph.remove_edges_from(edges_to_remove) + logger.info(f"Removed {initial_edge_count - len(self.nx_graph.edges)} duplicate edges from the graph") + + @staticmethod + def _convert_to_rustworkx(nx_graph) -> rx.PyGraph: + rx_graph = rx.PyGraph(multigraph=False) + + nodes = list(nx_graph.nodes) + nx_rx_node_mapping = dict(zip(nodes, rx_graph.add_nodes_from(nodes))) + edges = [ + ( + nx_rx_node_mapping[u], + nx_rx_node_mapping[v], + OSMEdgeInfo(geometry=edge.get("geometry", shapely.LineString()), osm_id=edge.get("osmid", 0)), + ) + for u, v, edge in nx_graph.edges(data=True) + ] + edge_ids = rx_graph.add_edges_from(edges) + for (_, _, edge), edge_id in zip(edges, edge_ids): + edge.edge_id = edge_id + + for osm_node_id, node_index in nx_rx_node_mapping.items(): + data = nx_graph.nodes[osm_node_id] + info = OSMNodeInfo(osm_node_id, shapely.Point(data.get("x", 0), data.get("y", 0))) + info.node_id = node_index + rx_graph[node_index] = info + + return rx_graph + + def validate_input(self): + if not isinstance(self.nx_graph, nx.MultiDiGraph): + raise TypeError("Input graph must be a NetworkX MultiDiGraph.") + if self.nx_graph.number_of_edges() == 0: + raise ValueError("Graph should have edges.") + if self.nx_graph.number_of_nodes() == 0: + raise ValueError("Graph should have nodes.") + + def clip_graph_to_polygon(self) -> None: + """Clips the graph to the given polygon. Nodes and edges outside the polygon are removed.""" + nodes = ox.graph_to_gdfs(self.nx_graph, edges=False) + nodes_to_remove = nodes[~nodes.intersects(self.polygon_for_clip)] + self.nx_graph.remove_nodes_from(nodes_to_remove.index) + logger.info(f"Clipped graph to polygon, removed {len(nodes_to_remove)} nodes.") + + if self.debug: + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, self.polygon_for_clip, "pytest_polygon_for_clip" + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, nodes, "pytest_original_nodes" + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, nodes_to_remove, "pytest_nodes_to_remove" + ) diff --git a/utility_route_planner/models/multilayer_network/pipe_ramming.py b/utility_route_planner/models/multilayer_network/pipe_ramming.py new file mode 100644 index 0000000..fa01682 --- /dev/null +++ b/utility_route_planner/models/multilayer_network/pipe_ramming.py @@ -0,0 +1,722 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import pathlib + +import numpy as np +import pandas as pd +import shapely +import structlog +import rustworkx as rx +import itertools + +from tqdm import tqdm + +from settings import Config +import geopandas as gpd + +from utility_route_planner.models.multilayer_network.graph_datastructures import PipeRammingEdgeInfo, PipeRammingOrigin +from utility_route_planner.models.multilayer_network.hexagon_graph.hexagon_utils import get_hexagon_node_geometry +from utility_route_planner.util.geo_utilities import ( + osm_graph_to_gdfs, + get_empty_geodataframe, + get_angle_between_points, + extrapolate_point_to_target, + split_polygon_by_linestrings, + extend_linestring_both_ends, +) +from utility_route_planner.util.timer import time_function +from utility_route_planner.util.write import write_results_to_geopackage + +logger = structlog.get_logger(__name__) + + +class GetPotentialPipeRammingCrossings: + def __init__( + self, + osm_graph: rx.PyGraph, + cost_surface_graph: rx.PyGraph, + cost_surface_nodes: gpd.GeoDataFrame, + threshold_edge_length_crossing_m: float = Config.THRESHOLD_EDGE_LENGTH_CROSSING_M, + max_pipe_ramming_length_m: float = Config.MAX_PIPE_RAMMING_LENGTH_M, + min_pipe_ramming_length_m: float = Config.MIN_PIPE_RAMMING_LENGTH_M, + suitability_value_crossing_threshold: float = Config.SUITABILITY_VALUE_CROSSING_THRESHOLD, + suitability_value_obstacles_threshold: float = Config.SUITABILITY_VALUE_OBSTACLES_THRESHOLD, + hexagon_size: float = Config.HEXAGON_SIZE, + debug_out: pathlib.Path = Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, + debug: bool = False, + ): + self.osm_graph = osm_graph + self.osm_nodes, self.osm_edges = osm_graph_to_gdfs(osm_graph) + self.cost_surface_graph = cost_surface_graph + # TODO discuss adding the properties (BGT elements) to the hexagon nodes. That way you can force it to only include sidewalks, ignoring the suitability value. + self.cost_surface_nodes = cost_surface_nodes + self.junctions_of_interests = get_empty_geodataframe() + # Minimum length of a street segment to be considered for adding pipe ramming crossings. + self.threshold_edge_length_crossing_m = threshold_edge_length_crossing_m + # Maximum/minimum length possible of a pipe ramming crossings. + self.max_pipe_ramming_length_m = max_pipe_ramming_length_m + self.min_pipe_ramming_length_m = min_pipe_ramming_length_m + # Cost surface value below which we consider a crossing suitable. + self.suitability_value_crossing_threshold = suitability_value_crossing_threshold + # Cost surface value above which we consider unsuitable for crossing. + self.suitability_value_obstacles_threshold = suitability_value_obstacles_threshold + self.hexagon_size = hexagon_size + # Debugging options + self.debug = debug + self.out = debug_out + self.plot_crossings = False + + # Do some monkey checks on input. + if self.threshold_edge_length_crossing_m <= self.max_pipe_ramming_length_m: + raise ValueError( + "The threshold_edge_length_crossing_m should be larger than the max_pipe_ramming_length_m for segment pipe ramming." + ) + if not self.max_pipe_ramming_length_m > self.min_pipe_ramming_length_m > 0: + raise ValueError("The max_pipe_ramming_length_m should be larger than the min_pipe_ramming_length_m > 0.") + if not self.cost_surface_graph.num_nodes() and self.cost_surface_graph.num_edges(): + raise ValueError("The cost surface graph appears to be empty.") + if not self.osm_graph.num_nodes() and self.osm_graph.num_edges(): + raise ValueError("The OSM graph appears to be empty.") + + def get_crossings(self): + """ + Get the crossings for the pipe ramming process. + + We want a road crossing for each segment longer than a certain length. + For each street segment (set of edges connected by nodes which only have 2 edges) check: + - Only if crossing the road is expensive (asphalt). + - If we have enough space between houses somewhere along that edge. + - Find two cells on the cost-surface which have a low enough cost (sidewalk / berm). + - Add edges between the two cells with a cost that is lower than just crossing the road. + Start by checking junctions (nodes with more than 2 edges). + After this, check the remaining street segments and split them if they are long enough. + """ + logger.info("Start finding potential pipe ramming crossings.") + + # Group the edges into street segments between junctions (node degree > 2). + self.create_street_segment_groups() + + crossing_collection = [] + # Finds crossings (parallel to the edge!) for junctions. + self.prepare_junction_crossings() + with tqdm(total=len(self.junctions_of_interests), desc="Processing junctions") as pbar: + invalid_junctions = [] + for node_id, junction in self.junctions_of_interests.iterrows(): + crossing = self.get_crossing_for_junction(node_id, junction.osm_id, junction.geometry, junction.degree) + if len(crossing): + crossing_collection.extend(crossing) + else: + invalid_junctions.append(node_id) + pbar.update() + if invalid_junctions: + logger.warning(f"No crossings found for junction(s): {invalid_junctions}.") + + # Find crossings (perpendicular to the edge!) for larger street segments. + merged_segments_of_interest = self.prepare_segment_crossings() + with tqdm(total=len(merged_segments_of_interest), desc="Processing large street segments") as pbar: + invalid_segments = [] + for segment_group, segment in merged_segments_of_interest.iterrows(): + try: + crossing = self.get_crossings_per_segment(segment_group, segment.geometry) + if len(crossing): + crossing_collection.extend(crossing) + else: + invalid_segments.append(segment_group) + except Exception as e: + logger.error(f"An error occurred for segment group {segment_group}: {e}") + pbar.update() + if invalid_segments: + logger.warning(f"No crossings found for segment group(s): {invalid_segments}.") + + self.add_crossings_to_graph(crossing_collection) + + logger.info(f"Found and added {len(crossing_collection)} crossings.") + return crossing_collection + + def add_crossings_to_graph(self, crossing_collection: list): + """Add the crossings to the cost surface graph and set the edge ids.""" + edge_ids = self.cost_surface_graph.add_edges_from(crossing_collection) + [edge_info[2].set_edge_id(edge_id) for edge_id, edge_info in zip(edge_ids, crossing_collection)] + if self.plot_crossings: + write_results_to_geopackage( + self.out, + gpd.GeoDataFrame(data=[vars(i[2]) for i in crossing_collection]), + "pytest_crossings_added_to_graph", + ) + + def create_street_segment_groups(self): + """ + Similar function: https://osmnx.readthedocs.io/en/stable/user-reference.html#osmnx.simplification.simplify_graph + Publication: https://onlinelibrary.wiley.com/doi/10.1111/tgis.70037 + """ + # Group the edges which are connected by nodes with only 2 edges. We refer to this as a street segment. + node_degree = {i: self.osm_graph.degree(i) for i in self.osm_graph.node_indices()} + # Initialize all edges with a unique group number, then start merging adjacent edges. + self.osm_edges["group"] = pd.Series(range(len(self.osm_edges)), index=self.osm_edges.index) + + seen_nodes = set() + for edge_group_nr, (node_id, degree) in enumerate(node_degree.items(), start=len(self.osm_edges) + 1): + if degree != 2 and node_id not in seen_nodes: + continue + + # Get the complete street segment to merge. + edges_to_group = [] + nodes_to_check = [node_id] + while nodes_to_check: + for node_id_2 in nodes_to_check: + if node_degree[node_id_2] == 2 and node_id_2 not in seen_nodes: + adjacent = self.osm_graph.adj(node_id_2) + edges_to_group.extend([edge.edge_id for edge in adjacent.values()]) + nodes_to_check.extend(list(adjacent.keys())) + + nodes_to_check.remove(node_id_2) + seen_nodes.add(node_id_2) + + self.osm_edges.loc[edges_to_group, "group"] = edge_group_nr + + logger.info(f"{len(self.osm_edges)} edges were grouped into {self.osm_edges['group'].nunique()} segments.") + # Optionally, we can simplify the graph object by removing the nodes and merging the edges to 1 EdgeInfo. + + if self.debug: + # Plot the basics, OSM + cost surface + prefix = "pytest_1_" + + write_results_to_geopackage(self.out, self.osm_nodes, f"{prefix}osm_nodes") + write_results_to_geopackage(self.out, self.osm_edges, f"{prefix}osm_edges") + + write_results_to_geopackage(self.out, self.cost_surface_nodes, f"{prefix}cost_surface_nodes") + cost_surface_nodes_filtered = self.cost_surface_nodes[ + self.cost_surface_nodes["suitability_value"] < self.suitability_value_crossing_threshold + ] + write_results_to_geopackage(self.out, cost_surface_nodes_filtered, f"{prefix}cost_surface_filtered") + + def prepare_junction_crossings(self): + node_degree = {i: self.osm_graph.degree(i) for i in self.osm_graph.node_indices()} + self.osm_nodes["degree"] = pd.Series(node_degree, index=self.osm_nodes.index, dtype=int) + junctions = self.osm_nodes[self.osm_nodes["degree"] > 2] + if len(junctions) == 0: + logger.warning("No junctions found to consider for pipe ramming.") + return get_empty_geodataframe() + else: + logger.info(f"Found {len(junctions)} junctions to consider for pipe ramming.") + + # Determine the area around the junctions where we can ram pipes. + junctions["geometry"] = junctions.buffer(self.max_pipe_ramming_length_m + 1) + + self.junctions_of_interests = junctions + + if self.debug: + prefix = "pytest_2_" + write_results_to_geopackage(self.out, junctions, f"{prefix}osm_junction_areas") + + def prepare_segment_crossings(self) -> gpd.GeoDataFrame: + """ + Identify the segments which are potentially interesting for adding crossings. + + # TODO-improvement: check for shorter segments where there was no junction crossing found. + """ + # Get road crossings for only long segments. + merged_segments = self.osm_edges.dissolve(by="group") + merged_segments["length"] = merged_segments.geometry.length + merged_segments["is_suitable"] = merged_segments["length"] > self.threshold_edge_length_crossing_m * 2 + merged_segments["geometry"] = merged_segments.line_merge() + if not merged_segments.geom_type.unique() == np.array("LineString"): + logger.warning( + "Some segments are still MultiLineStrings, this is unexpected as a street should always be " + "topologically connected." + ) + logger.info( + f"Found {len(merged_segments[merged_segments['is_suitable']])} segments longer than " + f"{self.threshold_edge_length_crossing_m}m to consider for pipe ramming." + ) + + if self.debug: + prefix = "pytest_4_" + write_results_to_geopackage( + self.out, + merged_segments, + f"{prefix}merged_segments_of_interest", + ) + + return merged_segments[merged_segments["is_suitable"]] + + @time_function + def get_crossing_for_junction( + self, node_id: int, osm_id: int, junction_area: shapely.Polygon, degree: int, prefix: str = "pytest_3_" + ): + # Create rectangles which simulate potential crossings. + minx, miny, maxx, maxy = junction_area.bounds + boxes = [ + shapely.box(x, miny, min(x + self.hexagon_size, maxx), maxy) + for x in np.arange(minx, maxx, self.hexagon_size) + ] + center_outer_point = shapely.Point(self.osm_nodes.loc[node_id].geometry.x, maxy) + all_rammings = gpd.GeoDataFrame(data=boxes, columns=["geometry"], crs=Config.CRS) + all_rammings["distance_to_junction_center"] = all_rammings.distance(self.osm_nodes.loc[node_id].geometry) + + # Check for edges which are almost 180 degrees apart, create straight crossings for those. + adjacent_edges = self.osm_edges.loc[self.osm_graph.incident_edges(node_id)] + adjacent_edges = adjacent_edges.clip(junction_area) + adjacent_edges["point_a"] = adjacent_edges["geometry"].apply(lambda line: shapely.Point(line.coords[0])) + adjacent_edges["point_b"] = adjacent_edges["geometry"].apply(lambda line: shapely.Point(line.coords[1])) + adjacent_edges["point_inner"] = self.osm_graph.get_node_data(node_id).geometry + adjacent_edges["point_outer"] = adjacent_edges["geometry"].apply( + lambda line: ( + shapely.Point(line.coords[1]) + if shapely.Point(line.coords[0]).equals(self.osm_graph.get_node_data(node_id).geometry) + else shapely.Point(line.coords[0]) + ) + ) + adjacent_edges["group"] = range(len(adjacent_edges)) + # Get angle to the center point of the junction. + adjacent_edges["degree_grid"] = adjacent_edges.apply( + lambda row: get_angle_between_points( + row["point_outer"], center_outer_point, self.osm_nodes.loc[node_id].geometry + ), + axis=1, + ) + adjacent_edges["group_angle"] = adjacent_edges["degree_grid"] + # Compare angles to each other and group edges which are almost 180 degrees apart (straight lines/streets). + for idx_edge_1, idx_edge_2 in itertools.combinations(adjacent_edges.index, 2): + angle_degree = abs(adjacent_edges.loc[idx_edge_1].degree_grid - adjacent_edges.loc[idx_edge_2].degree_grid) + if 170 <= angle_degree <= 190: + # We do not cover the scenario that three or more edges are almost 180 degrees apart. + opposite1 = adjacent_edges.loc[idx_edge_1].degree_grid + 180 + if opposite1 > 360: + opposite1 -= 360 + angles_rad = np.deg2rad([adjacent_edges.loc[idx_edge_2].degree_grid, opposite1]) + mean_angle_rad = np.arctan2(np.mean(np.sin(angles_rad)), np.mean(np.cos(angles_rad))) + mean_angle_deg = np.rad2deg(mean_angle_rad) % 360 + + adjacent_edges.at[idx_edge_2, "group"] = adjacent_edges.at[idx_edge_1, "group"] + adjacent_edges.loc[idx_edge_1, "group_angle"] = mean_angle_deg + adjacent_edges.loc[idx_edge_2, "group_angle"] = mean_angle_deg + + # Extend the linestrings of the edges outwards from the junction node prior to splitting + adjacent_edges["extended"] = adjacent_edges.apply( + lambda row: extrapolate_point_to_target( + row["point_inner"], + row["point_outer"], + # long enough to cross/split the junction area polygon. + distance=self.max_pipe_ramming_length_m * 3, + ), + axis=1, + ) + + # First, split the buffered junction by the osm_edges to create the sides to connect. + street_sides = split_polygon_by_linestrings(junction_area.envelope, adjacent_edges["extended"].to_list()) + + # Second, intersect the hexagon_nodes eligible for creating crossings to each created side. + street_sides = gpd.GeoDataFrame(street_sides, columns=["geometry"], crs=Config.CRS) + cost_surface_nodes_junction = self.cost_surface_nodes.sjoin(street_sides, how="inner", predicate="intersects") + cost_surface_nodes_junction.rename(columns={"index_right": "idx_street_side"}, inplace=True) + + # Third, check number of sides created and if there are nodes in each side to connect. + if not len(street_sides) == degree: + logger.warning(f"Node {osm_id} has {degree} edges, but {len(street_sides)} polygons were created.") + if not cost_surface_nodes_junction["idx_street_side"].nunique() == degree: + logger.warning("Not all street sides have nodes to connect to.") + + unpassable_area, unpassable_area_polygon = self._get_unpassable_area(cost_surface_nodes_junction, prefix) + cost_surface_nodes_junction["distance_to_junction_center"] = cost_surface_nodes_junction.distance( + self.osm_nodes.loc[node_id].geometry + ) + + seen = set() + crossing_collection_polygons = [] + crossing_collection = [] + for idx_edge, edge in adjacent_edges.iterrows(): + if edge.group in seen: + continue + all_rammings_rotated_to_edge = all_rammings.copy() + grid_rotated = all_rammings_rotated_to_edge.rotate( + 360 - edge.group_angle, origin=self.osm_nodes.loc[node_id].geometry + ) + all_rammings_rotated_to_edge["geometry"] = grid_rotated + + # Clip rotated grid with obstacles to remove unsuitable crossings. + potential_rammings, grid_with_cost_surface = self._filter_all_rammings( + cost_surface_nodes_junction, all_rammings_rotated_to_edge, unpassable_area, prefix + ) + + closest_node_pairs = ( + grid_with_cost_surface[grid_with_cost_surface["index_right"].isin(potential_rammings.index)] + .groupby(["index_right", "idx_street_side"])["distance_to_junction_center_left"] + .idxmin() + .rename("idx_node") + ) + + # Check selected pairs which can be more than 2 if it crosses more sides. + closest_node_pairs_validated = self._validate_node_pairs(closest_node_pairs.copy()) + + closest_node_linestrings_filtered, valid_rammings = self._filter_rammings_on_execution_space( + potential_rammings, + closest_node_pairs_validated, + grid_with_cost_surface, + unpassable_area_polygon, + prefix, + ) + + # Get the one closest to the center point of the junction + best_crossings = valid_rammings.sort_values("distance_to_junction_center").drop_duplicates( + subset="combinations", keep="first" + ) + + # Used only for plotting the debug polygons + best_crossings["group"] = edge.group + crossing_collection_polygons.append(best_crossings) + + crossing_collection.extend( + self._create_crossing_selection_to_add( + crossings_to_add=best_crossings, + closest_node_linestrings_filtered=closest_node_linestrings_filtered, + closest_node_pairs=closest_node_pairs, + origin=PipeRammingOrigin.JUNCTION, + segment_group=edge.group, + node_id=node_id, + prefix=prefix, + ) + ) + + seen.add(edge.group) + + if self.debug: + write_results_to_geopackage(self.out, street_sides, f"{prefix}street_sides") + write_results_to_geopackage( + self.out, + adjacent_edges[["osm_id", "length", "group", "degree_grid", "geometry"]], + f"{prefix}junction_adjacent_edges", + ) + write_results_to_geopackage( + self.out, + adjacent_edges[["osm_id", "length", "group", "point_outer"]].set_geometry("point_outer"), + f"{prefix}junction_adjacent_outer_point", + ) + write_results_to_geopackage( + self.out, pd.concat(crossing_collection_polygons, ignore_index=True), f"{prefix}best_crossings_polygons" + ) + + return crossing_collection + + @time_function + def get_crossings_per_segment( + self, + segment_group: int, + segment_geometry: shapely.LineString, + prefix: str = "pytest_5_", + ): + """ + Create perpendicular crossings for long street segments when there are no obstacles in the way. + """ + # Determine points per segment where crossings can be added. Skip the first and last meters as this is near a + # junction and handled separately. + crossing_intervals = np.linspace( + self.max_pipe_ramming_length_m, + segment_geometry.length - self.max_pipe_ramming_length_m, + int( + (segment_geometry.length - self.max_pipe_ramming_length_m - self.max_pipe_ramming_length_m) + // self.threshold_edge_length_crossing_m + ) + + 1, + endpoint=True, + ) + crossing_points = shapely.MultiPoint([segment_geometry.interpolate(dist) for dist in crossing_intervals]) + + # Subset the cost surface nodes to those within the area of interest (buffered street). + segment_geometry_area = segment_geometry.buffer(self.max_pipe_ramming_length_m, cap_style="flat") + sides = shapely.ops.split(segment_geometry_area, segment_geometry) + if not int(shapely.get_num_geometries(sides)) == 2: + sides = shapely.ops.split(segment_geometry_area, extend_linestring_both_ends(segment_geometry, 1)) + if not int(shapely.get_num_geometries(sides)) == 2: + logger.warning(f"Splitting the segment area for {segment_group} did not result in exactly two sides.") + return [] + + gdf_street_sides = gpd.GeoDataFrame(geometry=[i for i in sides.geoms], crs=Config.CRS) + cost_surface_nodes_segment = self.cost_surface_nodes.sjoin( + gdf_street_sides, how="inner", predicate="intersects" + ) + cost_surface_nodes_segment.rename(columns={"index_right": "idx_street_side"}, inplace=True) + # Remove nodes near junctions to prevent overlapping crossings. + cost_surface_nodes_segment = cost_surface_nodes_segment.drop( + cost_surface_nodes_segment.sjoin(self.junctions_of_interests, how="inner", predicate="intersects").index + ) + + # Buffer each linestring in the larger linestring separately, rotated them 90 degrees and create rectangles. + all_ramming_rectangles = [] + for street in list(self.osm_edges[self.osm_edges["group"] == segment_group].geometry): + street_copy = street + if street.length <= self.hexagon_size: + logger.warning(f"Street segment {segment_group} appears to be very short, skipping.") + continue + if street.length < self.max_pipe_ramming_length_m * 2: + # Ensure the linestring is longer than the buffered street segment is wide. + distance_to_stretch = (((self.max_pipe_ramming_length_m * 2) - street.length) / 2) + 0.5 + street = extend_linestring_both_ends(street, distance_to_stretch) + if not street.length >= self.max_pipe_ramming_length_m * 2: + logger.warning( + "Stretching the street segment did not result in a long enough linestring for creating pipe ramming rectangles. Skipping" + ) + continue + street_rotated = shapely.affinity.rotate(street, 90, origin="centroid") + interval = np.linspace(0, street_rotated.length / 2, int((street_rotated.length / 2) // 1), endpoint=False)[ + 1: + ] + linestrings = ( + [(street_rotated.offset_curve(i)) for i in interval] + + [(street_rotated.offset_curve(-i)) for i in interval] + + [street_rotated] + ) + # Split the buffered street side with the linestrings to create potential crossing rectangles. + all_ramming_rectangles.extend( + split_polygon_by_linestrings( + street_copy.buffer(self.max_pipe_ramming_length_m, cap_style="flat"), linestrings + ) + ) + + # Assign potential crossings to the crossing points. + all_rammings = gpd.GeoDataFrame(geometry=all_ramming_rectangles, crs=Config.CRS) + + # Select the closest crossing point for each potential crossing rectangle. + all_rammings_projected = np.array([segment_geometry.project(p) for p in all_rammings.centroid.array]) + closest_crossing_idx = np.abs(all_rammings_projected[:, None] - crossing_intervals[None, :]).argmin(axis=1) + all_rammings["index_nearest_crossing"] = closest_crossing_idx + all_rammings["distance_to_crossing"] = np.abs(all_rammings_projected - crossing_intervals[closest_crossing_idx]) + + # Clip the potential crossings with obstacles and cost-surface to remove unsuitable crossings. + unpassable_area, unpassable_area_polygon = self._get_unpassable_area(cost_surface_nodes_segment, prefix) + potential_rammings, grid_with_cost_surface = self._filter_all_rammings( + cost_surface_nodes_segment, all_rammings, unpassable_area, prefix + ) + + # Will contain duplicates as multiple crossing rectangles can match to the same crossing point + grid_with_cost_surface = grid_with_cost_surface[ + grid_with_cost_surface["index_right"].isin(potential_rammings.index) + ] + grid_with_cost_surface["distance_to_street"] = grid_with_cost_surface.distance(segment_geometry) + closest_node_pairs = ( + grid_with_cost_surface.groupby(["index_right", "idx_street_side"])["distance_to_street"] + .idxmin() + .rename("idx_node") + ) + closest_node_linestrings_filtered, valid_rammings = self._filter_rammings_on_execution_space( + potential_rammings, closest_node_pairs, grid_with_cost_surface, unpassable_area_polygon, prefix + ) + # TODO note for paper, optionally, we can filter on length of the crossing here as well. As it can now occur + # that we select a suboptimal crossing when the street is very curved. + selected_rammings = valid_rammings.loc[ + valid_rammings.groupby("index_nearest_crossing")["distance_to_crossing"].idxmin() + ] + + crossings_to_add = self._create_crossing_selection_to_add( + crossings_to_add=selected_rammings, + closest_node_linestrings_filtered=closest_node_linestrings_filtered, + closest_node_pairs=closest_node_pairs, + segment_group=segment_group, + origin=PipeRammingOrigin.STREET_SEGMENT, + prefix=prefix, + ) + + if self.debug: + write_results_to_geopackage(self.out, segment_geometry, f"{prefix}segment_of_interest") + write_results_to_geopackage(self.out, gdf_street_sides, f"{prefix}street_sides") + write_results_to_geopackage(self.out, crossing_points, f"{prefix}crossing_points") + write_results_to_geopackage(self.out, cost_surface_nodes_segment, f"{prefix}cost_surface_nodes_segment") + write_results_to_geopackage(self.out, selected_rammings, f"{prefix}best_rammings") + + return crossings_to_add + + def _filter_all_rammings( + self, + cost_surface_nodes_junction: gpd.GeoDataFrame, + all_rammings: gpd.GeoDataFrame, + unpassable_area: gpd.GeoSeries, + prefix: str = "", + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + rammings_without_obstacles = all_rammings.overlay( + gpd.GeoDataFrame(geometry=unpassable_area, crs=28992), how="difference" + ) + rammings_without_obstacles = rammings_without_obstacles.explode().reset_index(drop=True) + # Filter rammings which intersect with at least 1 pair of suitable nodes on either side of the street. + rammings_intersecting_suitable_nodes = cost_surface_nodes_junction[ + cost_surface_nodes_junction["suitability_value"] <= self.suitability_value_crossing_threshold + ].sjoin(rammings_without_obstacles, predicate="intersects", how="left") + potential = rammings_intersecting_suitable_nodes.groupby(by="index_right")["idx_street_side"].nunique() + combinations = ( + rammings_intersecting_suitable_nodes.groupby(by="index_right")["idx_street_side"] + .unique() + .apply(lambda x: tuple(sorted(x))) + ) + filters = pd.DataFrame({"potential": potential, "combinations": combinations}) + potential_rammings = rammings_without_obstacles.join(filters[filters.potential > 1], how="right") + + if self.debug: + write_results_to_geopackage(self.out, all_rammings, f"{prefix}rammings_all") + write_results_to_geopackage(self.out, rammings_without_obstacles, f"{prefix}rammings_without_obstacles") + write_results_to_geopackage(self.out, potential_rammings, f"{prefix}rammings_potential") + + return potential_rammings, rammings_intersecting_suitable_nodes + + def _filter_rammings_on_execution_space( + self, + all_crossings: gpd.GeoDataFrame, + closest_node_pairs: pd.Series, + grid_with_cost_surface: gpd.GeoDataFrame, + unpassable_area_polygon: shapely.MultiPolygon | shapely.Polygon, + prefix: str = "", + ) -> tuple[gpd.GeoSeries, gpd.GeoDataFrame]: + # Create the linestrings between the selected node pairs + closest_node_pairs = closest_node_pairs.reset_index() + closest_node_pairs = closest_node_pairs.merge( + grid_with_cost_surface.loc[~grid_with_cost_surface.index.duplicated()][["geometry"]], + left_on="idx_node", + right_index=True, + how="left", + ) + closest_node_linestrings = gpd.GeoSeries( + closest_node_pairs.groupby("index_right")["geometry"].apply( + lambda geoms: shapely.LineString(geoms.tolist()) + ) + ) + if not np.all(closest_node_linestrings.apply(lambda x: shapely.get_num_points(x)).unique() == 2): + logger.warning("Some ramming linestrings do not have exactly two points, this is unexpected.") + + closest_node_linestrings_filtered = closest_node_linestrings[ + (closest_node_linestrings.length >= self.min_pipe_ramming_length_m) + & (closest_node_linestrings.length <= self.max_pipe_ramming_length_m) + ] + # Check if there is enough space in either direction for a ramming. + side_1 = closest_node_linestrings_filtered.apply( + lambda line: shapely.affinity.rotate(line, 180, origin=line.coords[0]) + ) + side_2 = closest_node_linestrings_filtered.apply( + lambda line: shapely.affinity.rotate(line, 180, origin=line.coords[1]) + ) + valid_sides = ~side_1.intersects(unpassable_area_polygon) | ~side_2.intersects(unpassable_area_polygon) + valid_crossings = all_crossings[all_crossings.index.isin(valid_sides[valid_sides].index)] + + if self.debug: + write_results_to_geopackage(self.out, side_1, f"{prefix}side_1") + write_results_to_geopackage(self.out, side_2, f"{prefix}side_2") + write_results_to_geopackage(self.out, closest_node_linestrings, f"{prefix}closest_node_linestrings") + write_results_to_geopackage( + self.out, closest_node_linestrings_filtered, f"{prefix}closest_node_linestrings_filtered" + ) + write_results_to_geopackage(self.out, valid_crossings, f"{prefix}valid_crossings") + + return closest_node_linestrings_filtered, valid_crossings + + def _get_unpassable_area( + self, cost_surface_nodes: gpd.GeoDataFrame, prefix: str = "" + ) -> tuple[gpd.GeoSeries, shapely.Polygon]: + """Determine the unpassable area for crossing.""" + unpassable_area = cost_surface_nodes[ + cost_surface_nodes["suitability_value"] >= self.suitability_value_obstacles_threshold + ].buffer(self.hexagon_size) + unpassable_area_polygon = unpassable_area.union_all() + if self.debug: + write_results_to_geopackage(self.out, unpassable_area, f"{prefix}unpassable_area") + write_results_to_geopackage(self.out, unpassable_area_polygon, f"{prefix}unpassable_area_polygon") + return unpassable_area, unpassable_area_polygon + + def _create_crossing_selection_to_add( + self, + crossings_to_add, + closest_node_linestrings_filtered, + closest_node_pairs, + segment_group, + origin: PipeRammingOrigin, + node_id=None, + prefix: str = "", + ) -> list: + crossings = [] + for index in crossings_to_add.index: + weight = rx.dijkstra_shortest_path_lengths( + self.cost_surface_graph, + closest_node_pairs[index].iloc[0], + lambda x: x.weight, + closest_node_pairs[index].iloc[1], + ) + # TODO-discuss: what is the cost of going through the cost surface this way? + crossing_weight = round(weight[closest_node_pairs[index].iloc[1]] / 3) + if crossing_weight <= 0: + logger.warning( + f"Calculated crossing weight for segment group {segment_group} is {crossing_weight}, setting to 1." + ) + crossing_weight = 1 + crossing_to_add = ( + int(closest_node_pairs[index].iloc[0]), + int(closest_node_pairs[index].iloc[1]), + PipeRammingEdgeInfo( + osm_id_junction=node_id, + segment_group=segment_group, + origin=origin, + weight=crossing_weight, + geometry=closest_node_linestrings_filtered[index], + ), + ) + crossings.append(crossing_to_add) + + if self.debug: + # We have to be a bit creative here because we cant access the geometry due to the edge-id not being set yet. + write_results_to_geopackage( + self.out, + shapely.MultiLineString( + [ + shapely.LineString( + [ + get_hexagon_node_geometry(self.cost_surface_nodes, i[0]), + get_hexagon_node_geometry(self.cost_surface_nodes, i[1]), + ] + ) + for i in crossings + ] + ), + f"{prefix}best_rammings_edge", + ) + return crossings + + @staticmethod + def _validate_node_pairs(closest_node_pairs: pd.Series): + multi_groups = closest_node_pairs.groupby(level=0).size() + multi_groups = multi_groups[multi_groups > 2].index + + if multi_groups.empty: + return closest_node_pairs + + new_index_counter = closest_node_pairs.index.get_level_values(0).max() + 1 + + def _make_pairs(group: pd.Series): + nonlocal new_index_counter + base_side = group.index.get_level_values(1)[0] + base_node = group.iloc[0] + pairs = [] + for i, node in zip(group.index.get_level_values(1), group): + if i == base_side: + continue + # create new unique index_right + idx = new_index_counter + new_index_counter += 1 + pairs.append((idx, base_side, base_node)) + pairs.append((idx, i, node)) + return pairs + + pairs = ( + closest_node_pairs.loc[closest_node_pairs.index.get_level_values(0).isin(multi_groups)] + .groupby(level=0) + .apply(_make_pairs) + .sum() # flatten list of lists + ) + + pair_series = pd.Series( + data=[v[2] for v in pairs], + index=pd.MultiIndex.from_tuples( + tuples=[(i[0], i[1]) for i in pairs], names=["index_right", "idx_street_side"] + ), + ).rename("idx_node") + + # Merge back the unprocessed pairs + unprocessed = closest_node_pairs.loc[~closest_node_pairs.index.get_level_values(0).isin(multi_groups)] + validated_pairs = pd.concat([unprocessed, pair_series]).sort_index() + + return validated_pairs diff --git a/utility_route_planner/models/smoothing.py b/utility_route_planner/models/smoothing.py new file mode 100644 index 0000000..37682b6 --- /dev/null +++ b/utility_route_planner/models/smoothing.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import shapely + + +def smooth(route: shapely.LineString) -> shapely.LineString: + # inspiration: https://www.sciencedirect.com/science/article/pii/S3050520825000211#sec3 + pass diff --git a/utility_route_planner/util/geo_utilities.py b/utility_route_planner/util/geo_utilities.py index 0ba6ee0..0b0448a 100644 --- a/utility_route_planner/util/geo_utilities.py +++ b/utility_route_planner/util/geo_utilities.py @@ -1,16 +1,20 @@ # SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. # # SPDX-License-Identifier: Apache-2.0 - +from collections import Counter from pathlib import Path +import numpy as np import rasterio +import rustworkx as rx import rasterio.mask import shapely import structlog import geopandas as gpd +from settings import Config from utility_route_planner.models.mcda.exceptions import InvalidRasterValues +from utility_route_planner.util.write import write_results_to_geopackage logger = structlog.get_logger(__name__) @@ -126,3 +130,173 @@ def load_suitability_raster_data(path_raster: Path | str, project_area: shapely. # Replace with a negative value which is ignored in LCPA. image[image == no_data] = -1 return image, transform.to_gdal() + + +def osm_graph_to_gdfs(graph: rx.PyGraph) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + if graph.num_nodes() > 0 and graph.num_edges() > 0: + data = [(node.osm_id, node.node_id, node.geometry) for node in graph.nodes()] + gdf_nodes = gpd.GeoDataFrame(data, crs=Config.CRS, columns=["osm_id", "node_id", "geometry"]) + gdf_nodes.set_index("node_id", inplace=True, drop=True) + + data = [(edge.edge_id, edge.osm_id, edge.length, edge.geometry) for edge in graph.edges()] # type: ignore + gdf_edges = gpd.GeoDataFrame(data, crs=Config.CRS, columns=["edge_id", "osm_id", "length", "geometry"]) + gdf_edges.set_index("edge_id", inplace=True, drop=True) + return gdf_nodes, gdf_edges + else: + logger.warning("There are no edges or nodes. Cannot convert to GeoDataFrames.") + return get_empty_geodataframe(), get_empty_geodataframe() + + +def get_angle_between_points(point_a: shapely.Point, point_b: shapely.Point, center_point: shapely.Point) -> float: + """Calculate the angle between two points with respect to a center point.""" + if center_point.equals(point_a) or center_point.equals(point_b): + raise ValueError("Center_point must not be equal to point_a or point_b.") + + vector_a = np.array([point_a.x - center_point.x, point_a.y - center_point.y]) + vector_b = np.array([point_b.x - center_point.x, point_b.y - center_point.y]) + cos_theta = np.dot(vector_a, vector_b) / (np.linalg.norm(vector_a) * np.linalg.norm(vector_b)) + angle_radians = np.arccos(np.clip(cos_theta, -1, 1)) + cross = np.cross(vector_a, vector_b) + angle_deg = np.degrees(angle_radians) + if cross < 0: + angle_deg = 360 - angle_deg + return float(angle_deg) + + +def extrapolate_point_to_target( + point_start: shapely.Point, direction_to_extend_to: shapely.Point, distance: float +) -> shapely.LineString: + """Extend a point to a target point for a given distance.""" + if point_start.equals(direction_to_extend_to): + raise ValueError("Point_start and point_to_extend must not be the same.") + if distance <= 0: + raise ValueError("Distance must be a positive value.") + + dx, dy = direction_to_extend_to.x - point_start.x, direction_to_extend_to.y - point_start.y + length = np.hypot(dx, dy) + dx, dy = dx / length, dy / length + new_x = point_start.x + dx * distance + new_y = point_start.y + dy * distance + return shapely.LineString([point_start, (new_x, new_y)]) + + +def extend_linestring_both_ends(line: shapely.LineString, distance: float, debug: bool = False) -> shapely.LineString: + """Extend a linestring at both ends by a given distance.""" + if distance <= 0: + raise ValueError("Distance must be a positive value.") + + coords = np.array(line.coords) + if len(coords) == 2: + center_start, center_end = np.array(line.centroid.coords)[0], np.array(line.centroid.coords)[0] + else: + center_start = coords[1] + center_end = coords[-2] + + # Start direction: from first to second point + start_vec = center_start - coords[0] + start_unit = start_vec / np.linalg.norm(start_vec) + new_start = coords[0] - start_unit * distance + + # End direction: from last-1 to last point + end_vec = center_end - coords[-1] + end_unit = end_vec / np.linalg.norm(end_vec) + new_end = coords[-1] - end_unit * distance + + if len(coords) == 2: + result = shapely.LineString([new_start, new_end]) + else: + result = shapely.LineString(np.vstack([new_start, coords[1:-1], new_end])) + + if debug: + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, line, "original_line", overwrite=True + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, result, "extended_line", overwrite=True + ) + + return result + + +def split_polygon_by_linestrings( + polygon_to_split: shapely.Polygon, linestrings_for_splitting: list[shapely.LineString], debug: bool = False +) -> list[shapely.Polygon]: + """Split a polygon by a list of linestrings into a list of polygons.""" + line_split_collection = [polygon_to_split.boundary, *linestrings_for_splitting] + merged_lines = shapely.ops.linemerge(line_split_collection) + border_lines = shapely.ops.unary_union(merged_lines) + split_polygon = [i for i in shapely.ops.polygonize(border_lines)] + if debug: + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, polygon_to_split, "polygon_to_split", overwrite=True + ) + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, + shapely.MultiLineString(linestrings_for_splitting), + "linestrings_for_splitting", + overwrite=True, + ) + + return split_polygon + + +def get_endlines_of_multilinestring(multilinestring: shapely.MultiLineString) -> list[shapely.LineString]: + """ + Get the endlines (last segments) of a multilinestring, oriented away from the shared center point. + + This function assumes a star-like topology (or set of connected lines) where there is a central + "hub" point shared by the linestrings. It returns the outermost segment of each branch, + oriented outgoing from the center. This might not work for a multilinestring with multiple "hubs" or loops. + + :param multilinestring: The input MultiLineString geometry. + :return: A list of LineString geometries representing the end segments. + """ + if not isinstance(multilinestring, shapely.MultiLineString) or len(multilinestring.geoms) <= 1: + raise ValueError("Invalid input received, expected a MultiLineString with at least 2 LineStrings") + + # Flatten all start/end points to find the "hub" (most frequent endpoint) + endpoints = [] + for line in multilinestring.geoms: + endpoints.append(line.coords[0]) + endpoints.append(line.coords[-1]) + + # The hub is the point that connects the lines (appears most frequently) + counts = Counter(endpoints) + hub_coords, _ = counts.most_common(1)[0] + hub_point = shapely.Point(hub_coords) + + result_lines = [] + for line in multilinestring.geoms: + coords = list(line.coords) + + # Determine orientation: we want to start from the hub + start_dist = shapely.Point(coords[0]).distance(hub_point) + end_dist = shapely.Point(coords[-1]).distance(hub_point) + + # Orient away from hub + if start_dist <= end_dist: + # Already starts at/near hub + oriented_coords = coords + else: + # Ends at hub, so reverse it + oriented_coords = coords[::-1] + + # Extract the last segment of the oriented line + if len(oriented_coords) >= 2: + segment = shapely.LineString([oriented_coords[-2], oriented_coords[-1]]) + result_lines.append(segment) + + return result_lines + + +def get_perpendicular_line( + origin: shapely.Point, direction_point: shapely.Point, distance: float, debug: bool = False +) -> shapely.LineString: + entrypoint_line_interpolated = extrapolate_point_to_target(origin, direction_point, 0.1) + perpendicular_line = shapely.affinity.rotate(entrypoint_line_interpolated, 90, origin=origin) + perpendicular_line_extended = extend_linestring_both_ends(perpendicular_line, distance) + if debug: + write_results_to_geopackage( + Config.PATH_GEOPACKAGE_MULTILAYER_NETWORK_OUTPUT, perpendicular_line, "pytest_entry_point_line" + ) + return perpendicular_line_extended diff --git a/utility_route_planner/util/graph_utilities.py b/utility_route_planner/util/graph_utilities.py new file mode 100644 index 0000000..3afd05c --- /dev/null +++ b/utility_route_planner/util/graph_utilities.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. +# +# SPDX-License-Identifier: Apache-2.0 +import shapely + +from utility_route_planner.models.multilayer_network.graph_datastructures import OSMNodeInfo, OSMEdgeInfo + + +def create_osm_edge_info(osm_id: int, start_node: OSMNodeInfo, end_node: OSMNodeInfo) -> OSMEdgeInfo: + return OSMEdgeInfo(osm_id=osm_id, geometry=shapely.LineString([start_node.geometry, end_node.geometry])) diff --git a/utility_route_planner/util/write.py b/utility_route_planner/util/write.py index 2c39434..00ba459 100644 --- a/utility_route_planner/util/write.py +++ b/utility_route_planner/util/write.py @@ -1,15 +1,10 @@ # SPDX-FileCopyrightText: Contributors to the utility-route-project and Alliander N.V. # # SPDX-License-Identifier: Apache-2.0 - import pathlib - -import geopandas import geopandas as gpd import shapely import structlog -import os -import fiona from settings import Config @@ -26,22 +21,21 @@ def write_to_file(geometry: gpd.GeoSeries | gpd.GeoDataFrame | shapely.Geometry, geometry.to_file(Config.PATH_RESULTS / name) -def reset_geopackage(path_geopackage: pathlib.Path, truncate=True) -> None: +def reset_geopackage(path_geopackage: pathlib.Path, truncate: bool = True) -> None: """ Clean start, delete or truncate result geopackage to write to. """ logger.info(f"Resetting geopackage {path_geopackage} for a clean start.") - if os.path.exists(path_geopackage): + if path_geopackage.exists(): if truncate: - existing_layers = [layer_name for layer_name in fiona.listlayers(path_geopackage)] + existing_layers = gpd.list_layers(path_geopackage)["name"].to_list() for layer_name in existing_layers: gdf = gpd.read_file(path_geopackage, layer=layer_name) - if gdf.empty: - return - gdf = gdf.iloc[0:0] - gdf.to_file(path_geopackage, layer=layer_name, driver="GPKG") + if not gdf.empty: + gdf = gdf.iloc[0:0] + gdf.to_file(path_geopackage, layer=layer_name, driver="GPKG") else: - os.remove(path_geopackage) + path_geopackage.unlink() else: logger.info("Geopackage does not exists, continuing as normal.") @@ -50,7 +44,7 @@ def write_results_to_geopackage( path_geopackage: pathlib.Path, item_to_write: shapely.Geometry | gpd.GeoDataFrame | gpd.GeoSeries, layer_name: str, - overwrite=False, + overwrite: bool = False, ) -> None: """ Write results to a geopackage file which is handy for debugging in QGIS and intermediate storage. @@ -62,7 +56,10 @@ def write_results_to_geopackage( """ logger.info(f"Writing features to geopackage: {layer_name}") if isinstance(item_to_write, shapely.Geometry): - item_to_write = geopandas.GeoSeries(item_to_write, crs=Config.CRS) + item_to_write = gpd.GeoSeries(item_to_write, crs=Config.CRS) + if isinstance(item_to_write, gpd.GeoSeries | gpd.GeoDataFrame): + if item_to_write.crs is None: + item_to_write.set_crs(Config.CRS, inplace=True) if overwrite: item_to_write.to_file(path_geopackage, layer=layer_name, driver="GPKG", OVERWRITE="YES") else: @@ -70,7 +67,7 @@ def write_results_to_geopackage( item_to_write.to_file(path_geopackage, layer=layer_name, driver="GPKG", mode=mode) -def _get_writing_mode_geopackage(filename, path_geopackage): +def _get_writing_mode_geopackage(filename: str, path_geopackage: pathlib.Path) -> str: """ Function for determining the mode used for writing the results to file, specific for geopackage. - We want to create a new geopackage if it does not exist -> w @@ -78,11 +75,11 @@ def _get_writing_mode_geopackage(filename, path_geopackage): - If the layer in the geopackage does not exist, create it -> w """ # if geopackage does not exist, create a new one. - if not os.path.exists(path_geopackage): + if not path_geopackage.exists(): mode = "w" else: # if layer does not exist, create a new one. Otherwise, append. - existing_layers = [layer_name for layer_name in fiona.listlayers(path_geopackage)] + existing_layers = gpd.list_layers(path_geopackage)["name"].to_list() if filename in existing_layers: mode = "a" else: