diff --git a/.gitignore b/.gitignore index 92e3c18..cd24cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ \#*\# **/.vscode .$* +.claude/ ### Byte-compiled files __pycache__ @@ -37,6 +38,7 @@ gurobi.log # earth-osm cache data/earth-osm/ +tests/data/earth-osm/ # test_snakemake_integration_testing's working directory; normally cleaned # up automatically, this is only a backstop if that's ever interrupted diff --git a/INTERFACE.yaml b/INTERFACE.yaml index 8ad8261..03794ca 100644 --- a/INTERFACE.yaml +++ b/INTERFACE.yaml @@ -7,10 +7,12 @@ pathvars: resources: default: "" description: >- - Raw OSM files are written to osm/retrieve/{country}_{feature}.json. - Clean features are written below osm/clean; generic network outputs - below osm/build (buses/lines/transformers as CSV under osm/build/csv - and GeoJSON, including substation polygons, under osm/build/geojson). + Raw OSM files are written to retrieve/{country}_{feature}.json. + Clean features are written below clean; generic network outputs + below build (buses/lines/transformers as CSV under build/csv + and GeoJSON, including substation polygons, under build/geojson). + An interactive PyDeck map of the network, and the default target + of this workflow, is written to map.html. results: default: "" description: location of module result files. diff --git a/README.md b/README.md index d50c7fc..05a965f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Currently implemented: 1. Retrieve OSM substations, lines, cables, and (optionally) circuit relations by country, either from a cached local Geofabrik PBF extract or the live Overpass API. 2. Clean the raw retrieval output, filtering voltage, frequency, construction status, and future assets, and grouping relation member ways into one line per real-world circuit. 3. Merge nearby stations and line endpoints into generic buses, AC lines, and transformers. +4. Build a self-contained interactive map of the resulting network (`map.html`), with layer toggles, voltage/text filtering, and click-through OSM links — this is the workflow's default target. ## Configuration @@ -30,15 +31,17 @@ Configuration lives in [`config/config.yaml`](./config/config.yaml), validated a Please consult the [interface file](./INTERFACE.yaml) for more information. -Raw retrieval outputs use `/osm/retrieve/{country}_{feature}.json`, one +Raw retrieval outputs use `/retrieve/{country}_{feature}.json`, one file per country and feature (`lines_way`, `cables_way`, `substations_way`, `substations_node`, `substations_relation`, `routes_relation`). Both retrieval backends write the same raw-Overpass-JSON shape, so downstream cleaning doesn't -need to know which one ran. Clean features use `/osm/clean/*.geojson`; -generic network components use `/osm/build/csv/{buses,lines,transformers}.csv` -and matching GeoJSON files under `/osm/build/geojson/`, which also +need to know which one ran. Clean features use `/clean/*.geojson`; +generic network components use `/build/csv/{buses,lines,transformers}.csv` +and matching GeoJSON files under `/build/geojson/`, which also includes `stations_polygon.geojson` (clustered station shapes) and `buses_polygon.geojson` (substation polygons scoped to the buses in the output). +An interactive map of the network is written to `/map.html`; it is +a standalone HTML file (no server required) and the workflow's default target. Country logs use `/retrieve_osm_pbf/{country}.log` or `/retrieve_osm_overpass/{country}.log`, depending on `retrieve.source`. The integration example sets these roots to `resources/grid-builder` and diff --git a/config/README.md b/config/README.md index 66acce4..e5166c9 100644 --- a/config/README.md +++ b/config/README.md @@ -6,17 +6,23 @@ mapping in the calling configuration overrides values from those regional files. `retrieve.source` picks the retrieval backend: `geofabrik` reads a cached local PBF extract (`retrieve_osm_pbf.py`), `overpass` queries the live Overpass API (`retrieve_osm_overpass.py`). Both produce the same output shape, so -`clean_osm_data` doesn't need to know which one ran. `retrieve.include_relations` -additionally retrieves `route=power`/`power=circuit` relations, so member ways -are grouped into one line per real-world circuit. `network` controls the -minimum retained AC voltage, station merge buffer radius, construction filtering, and -planned-asset cutoff date. +`clean` doesn't need to know which one ran. `network.include_relations` +decides whether the network should consider `route=power`/`power=circuit` +relations, grouping their member ways into one line per real-world circuit; +retrieval respects this too, so relations aren't fetched at all when it's off. +`network` also controls the minimum retained AC voltage, station merge buffer +radius, construction filtering, and planned-asset cutoff date. The [BE+NL example](./examples/config.BE-NL.yaml) is a small European development scope. Country files under `config/regions` are intentionally small defaults for now; community-maintained local corrections belong there rather than in workflow code. +`interactive_map` controls the size of `map.html`: `coordinate_decimals` rounds +embedded coordinates, and `simplify_geometries` sets per-geometry-type +Douglas-Peucker tolerances (in metres) for station polygons, bus polygons, and +lines, or disables simplification entirely via `simplify_geometries.enable`. + ### Personal settings and Overpass fair use Keep `config/config.yaml` as pure defaults — a test enforces that it matches the diff --git a/config/config.schema.json b/config/config.schema.json index 5354988..6b20258 100644 --- a/config/config.schema.json +++ b/config/config.schema.json @@ -31,11 +31,6 @@ "description": "Force refresh of cached data", "type": "boolean" }, - "include_relations": { - "default": true, - "description": "Additionally retrieve OSM route=power/power=circuit relations, so clean_osm_data can group their member ways into a single line matching the relation's real-world circuit", - "type": "boolean" - }, "target_date": { "anyOf": [ { @@ -99,6 +94,11 @@ "additionalProperties": false, "description": "Global assumptions for cleaning and connecting OSM grid features.", "properties": { + "include_relations": { + "default": true, + "description": "Whether the network should consider OSM route=power/power=circuit relations, grouping their member ways into a single line matching the relation's real-world circuit; retrieval respects this too, so relations aren't fetched at all when it's off", + "type": "boolean" + }, "minimum_voltage_kv": { "default": 220.0, "description": "Minimum nominal AC voltage retained from OSM, in kV", @@ -209,9 +209,50 @@ "description": "Country-specific network overrides loaded from config/regions", "type": "object" }, + "interactive_map": { + "additionalProperties": false, + "description": "Geometry simplification and coordinate rounding for build_interactive_map.py.", + "properties": { + "coordinate_decimals": { + "default": 5, + "description": "Decimal places kept for coordinates embedded in the interactive map; 5 is about 1.1m of precision at the equator, comfortably below every simplification tolerance below", + "minimum": 0, + "type": "integer" + }, + "simplify_geometries": { + "additionalProperties": false, + "description": "Douglas-Peucker simplification tolerances for the interactive map.", + "properties": { + "enable": { + "default": true, + "description": "Whether to simplify station/bus-polygon/line geometries before embedding them in the interactive map", + "type": "boolean" + }, + "stations_m": { + "default": 100.0, + "description": "Simplification tolerance for station polygon outlines, in metres", + "minimum": 0, + "type": "number" + }, + "buses_polygon_m": { + "default": 5.0, + "description": "Simplification tolerance for individual bus/substation footprint polygons, in metres", + "minimum": 0, + "type": "number" + }, + "lines_m": { + "default": 30.0, + "description": "Simplification tolerance for line geometries, in metres", + "minimum": 0, + "type": "number" + } + } + } + } + }, "crs": { "additionalProperties": false, - "description": "Coordinate reference systems used throughout clean_osm_data/build_osm_network.", + "description": "Coordinate reference systems used throughout clean/build_network.", "properties": { "geo": { "default": "EPSG:4326", diff --git a/config/config.yaml b/config/config.yaml index 640a0c2..d508946 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -7,7 +7,6 @@ countries: retrieve: source: geofabrik force_redownload: false - include_relations: true target_date: overpass_api: url: "https://overpass-api.de/api/interpreter" @@ -19,6 +18,7 @@ retrieve: website: "https://github.com/pypsa/grid-builder" network: + include_relations: true minimum_voltage_kv: 220.0 frequency_hz: AC: 50.0 @@ -29,6 +29,14 @@ network: regions: {} +interactive_map: + coordinate_decimals: 5 + simplify_geometries: + enable: true + stations_m: 100.0 + buses_polygon_m: 5.0 + lines_m: 30.0 + crs: geo: "EPSG:4326" distance: "EPSG:3035" diff --git a/config/examples/config.BE-NL.yaml b/config/examples/config.BE-NL.yaml index 0a7f58b..7c9d15d 100644 --- a/config/examples/config.BE-NL.yaml +++ b/config/examples/config.BE-NL.yaml @@ -5,9 +5,9 @@ countries: retrieve: source: geofabrik - include_relations: true network: + include_relations: true minimum_voltage_kv: 220.0 station_merge_radius_m: 500.0 remove_after: 2026-12-31 diff --git a/config/examples/config.BR.yaml b/config/examples/config.BR.yaml new file mode 100644 index 0000000..2ccc454 --- /dev/null +++ b/config/examples/config.BR.yaml @@ -0,0 +1,12 @@ +# Brazil scope for the OSM cleaning and network-building pipeline. +countries: + - BR + +retrieve: + source: overpass + +network: + include_relations: true + minimum_voltage_kv: 60.0 + station_merge_radius_m: 250.0 + remove_after: 2026-12-31 diff --git a/config/examples/config.MX.yaml b/config/examples/config.MX.yaml new file mode 100644 index 0000000..b2a2f2b --- /dev/null +++ b/config/examples/config.MX.yaml @@ -0,0 +1,12 @@ +# Mexico scope for the OSM cleaning and network-building pipeline. +countries: + - MX + +retrieve: + source: overpass + +network: + include_relations: true + minimum_voltage_kv: 100.0 + station_merge_radius_m: 150.0 + remove_after: 2026-12-31 diff --git a/config/examples/config.PH.yaml b/config/examples/config.PH.yaml new file mode 100644 index 0000000..326e3f3 --- /dev/null +++ b/config/examples/config.PH.yaml @@ -0,0 +1,12 @@ +# Philippines scope for the OSM cleaning and network-building pipeline. +countries: + - PH + +retrieve: + source: overpass + +network: + include_relations: true + minimum_voltage_kv: 120.0 + station_merge_radius_m: 500.0 + remove_after: 2026-12-31 diff --git a/config/examples/config.US.yaml b/config/examples/config.US.yaml new file mode 100644 index 0000000..4e33837 --- /dev/null +++ b/config/examples/config.US.yaml @@ -0,0 +1,12 @@ +# United States scope for the OSM cleaning and network-building pipeline. +countries: + - US + +retrieve: + source: overpass + +network: + include_relations: true + minimum_voltage_kv: 100.0 + station_merge_radius_m: 200.0 + remove_after: 2026-12-31 diff --git a/config/regions/config.BR.yaml b/config/regions/config.BR.yaml new file mode 100644 index 0000000..8fb1b13 --- /dev/null +++ b/config/regions/config.BR.yaml @@ -0,0 +1,6 @@ +# Brazil: 60 Hz grid; lower minimum voltage than the European default to +# retain its sub-transmission network. +network: + minimum_voltage_kv: 60 + frequency_hz: + AC: 60.0 diff --git a/config/regions/config.MX.yaml b/config/regions/config.MX.yaml new file mode 100644 index 0000000..c1668bb --- /dev/null +++ b/config/regions/config.MX.yaml @@ -0,0 +1,6 @@ +# Mexico: 60 Hz grid; lower minimum voltage than the European default to +# retain its sub-transmission network. +network: + minimum_voltage_kv: 100 + frequency_hz: + AC: 60.0 diff --git a/config/regions/config.PH.yaml b/config/regions/config.PH.yaml new file mode 100644 index 0000000..784b477 --- /dev/null +++ b/config/regions/config.PH.yaml @@ -0,0 +1,6 @@ +# Philippines: 60 Hz grid; lower minimum voltage than the European default +# to retain its sub-transmission network. +network: + minimum_voltage_kv: 120 + frequency_hz: + AC: 60.0 diff --git a/config/regions/config.US.yaml b/config/regions/config.US.yaml new file mode 100644 index 0000000..55a3cf4 --- /dev/null +++ b/config/regions/config.US.yaml @@ -0,0 +1,6 @@ +# United States: 60 Hz grid; lower minimum voltage than the European +# default to retain its sub-transmission network. +network: + minimum_voltage_kv: 100 + frequency_hz: + AC: 60.0 diff --git a/pixi.lock b/pixi.lock index 2d630c7..9a11ef8 100644 --- a/pixi.lock +++ b/pixi.lock @@ -202,6 +202,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.0-h04a0ce9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.5-py312h4c3975b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py312hd9148b4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/uriparser-0.9.8-hac33072_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda @@ -245,6 +246,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/branca-0.8.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/clio-tools-2026.03.30-pyhd8ed1ab_0.conda @@ -259,12 +261,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dpath-2.2.0-pyha770c72_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/earth-osm-3.0.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-4.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/folium-0.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 @@ -280,6 +284,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/humanfriendly-10.0-pyh707e725_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda @@ -304,6 +309,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nlohmann_json-abi-3.12.0-h0f90c79_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandera-0.31.1-h27fc3a9_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandera-base-0.31.1-pyhcf101f3_0.conda @@ -312,18 +318,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-11-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydeck-0.9.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydot-4.0.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.6.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda @@ -353,6 +362,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/typing_inspect-0.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.12.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2026.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yte-1.9.4-pyhd8ed1ab_0.conda @@ -383,6 +393,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-2.0.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-2.0.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/clio-tools-2026.03.30-pyhd8ed1ab_0.conda @@ -402,12 +413,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.14-py312hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dpath-2.2.0-pyha770c72_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/earth-osm-3.0.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-4.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/folium-0.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 @@ -427,6 +440,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/humanfriendly-10.0-pyh707e725_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda @@ -450,6 +464,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nlohmann_json-abi-3.12.0-h0f90c79_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandera-0.31.1-h27fc3a9_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandera-base-0.31.1-pyhcf101f3_0.conda @@ -458,12 +473,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-11-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydeck-0.9.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydot-4.0.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -472,6 +489,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-build-1.6.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.6.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.14-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-installer-1.0.1-pyh332efcf_0.conda @@ -506,6 +524,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/unearth-0.18.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.12.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2026.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yte-1.9.4-pyhd8ed1ab_0.conda @@ -666,6 +685,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlite-3.53.4-ha9f7ffe_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.10-py312ha053593_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py312h766f71e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-18.0.0-py312ha053593_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uriparser-0.9.8-h00cdb27_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/wrapt-1.17.3-py312h163523d_1.conda @@ -703,6 +723,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-2.0.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-2.0.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/clio-tools-2026.03.30-pyhd8ed1ab_0.conda @@ -722,12 +743,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.14-py312hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dpath-2.2.0-pyha770c72_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/earth-osm-3.0.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-4.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/folium-0.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 @@ -747,6 +770,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/humanfriendly-10.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda @@ -770,6 +794,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nlohmann_json-abi-3.12.0-h0f90c79_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandera-0.31.1-h27fc3a9_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandera-base-0.31.1-pyhcf101f3_0.conda @@ -777,11 +802,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-11-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydeck-0.9.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydot-4.0.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -790,6 +817,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-build-1.6.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.6.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.14-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-installer-1.0.1-pyh332efcf_0.conda @@ -824,6 +852,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/unearth-0.18.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.12.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2026.3.0-pyhd8ed1ab_0.conda @@ -974,6 +1003,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.10-py312he06e257_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py312hf90b1b7_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/unicodedata2-18.0.0-py312he06e257_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/uriparser-0.9.8-h5a68840_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_34.conda @@ -3567,6 +3597,21 @@ packages: license_family: Apache size: 859665 timestamp: 1774358032165 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py312hd9148b4_0.conda + sha256: c975070ac28fe23a5bbb2b8aeca5976b06630eb2de2dc149782f74018bf07ae8 + md5: 55fd03988b1b1bc6faabbfb5b481ecd7 + depends: + - __glibc >=2.17,<3.0.a0 + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + run_exports: {} + size: 14882 + timestamp: 1769438717830 - conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda sha256: 895bbfe9ee25c98c922799de901387d842d7c01cae45c346879865c6a907f229 md5: 0b6c506ec1f272b685240e70a29261b8 @@ -4102,6 +4147,16 @@ packages: license: ISC size: 135656 timestamp: 1776866680878 +- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 + md5: 381bd45fb7aa032691f3063aff47e3a1 + depends: + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 13589 + timestamp: 1763607964133 - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda sha256: 3f9483d62ce24ecd063f8a5a714448445dc8d9e201147c46699fc0033e824457 md5: a9167b9571f3baa9d448faa2139d1089 @@ -4350,6 +4405,17 @@ packages: license_family: BSD size: 14129 timestamp: 1740385067843 +- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e + md5: 58638f77697c4f6726753eb8be34818b + depends: + - python >=3.10 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 303705 + timestamp: 1781320269259 - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda sha256: 5603c7d0321963bb9b4030eadabc3fd7ca6103a38475b4e0ed13ed6d97c86f4e md5: 0a2014fd9860f8b1eaa0b1f3d3771a08 @@ -4408,6 +4474,16 @@ packages: license_family: MIT size: 30753 timestamp: 1756729456476 +- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-4.0.3-pyhd8ed1ab_0.conda + sha256: d5652b2891433e70ac257804adedee0525e089dea5cbebb63fece08f155f538f + md5: 0aa7737ea1fb4bd4b8f03c3558a52739 + depends: + - python >=3.11 + license: MIT + license_family: MIT + run_exports: {} + size: 84079 + timestamp: 1790215975024 - conda: https://conda.anaconda.org/conda-forge/noarch/folium-0.20.0-pyhd8ed1ab_0.conda sha256: 782fa186d7677fd3bc1ff7adb4cc3585f7d2c7177c30bcbce21f8c177135c520 md5: a6997a7dcd6673c0692c61dfeaea14ab @@ -4625,6 +4701,17 @@ packages: license_family: MIT size: 17397 timestamp: 1737618427549 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 + md5: 84a3233b709a289a4ddd7a2fd27dd988 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + run_exports: {} + size: 79757 + timestamp: 1776455344188 - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda sha256: 9ab620e6f64bb67737bd7bc1ad6f480770124e304c6710617aba7fe60b089f48 md5: fb7130c190f9b4ec91219840a05ba3ac @@ -5016,6 +5103,17 @@ packages: license_family: MIT size: 4335 timestamp: 1758194464430 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee + md5: eb52d14a901e23c39e9e7b4a1a5c015f + depends: + - python >=3.10 + - setuptools + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 40866 + timestamp: 1766261270149 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda sha256: 289861ed0c13a15d7bbb408796af4de72c2fe67e2bcb0de98f4c3fce259d7991 md5: 58335b26c38bf4a20f399384c33cbcf9 @@ -5100,6 +5198,21 @@ packages: license_family: MIT size: 25877 timestamp: 1764896838868 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda + sha256: fc0a0620a8f829c46787a9a13ddbae8b6049f2e77da353a8a2adaa01af0da7e2 + md5: c36b88db560b4e74f294380613f1a239 + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + run_exports: {} + size: 201879 + timestamp: 1786408353117 - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda sha256: 4817651a276016f3838957bfdf963386438c70761e9faec7749d411635979bae md5: edb16f14d920fb3faf17f5ce582942d6 @@ -5160,6 +5273,22 @@ packages: license_family: MIT size: 346352 timestamp: 1776728341165 +- conda: https://conda.anaconda.org/conda-forge/noarch/pydeck-0.9.3-pyhd8ed1ab_0.conda + sha256: de766dda257d65795a8011d6e03bfc02f6e0ca0ce7bb3d07fa5a1cd02b23ac2b + md5: 7c94c948b32d681345f134ae11892fee + depends: + - jinja2 >=2.10.1 + - numpy >=1.16.4 + - python >=3.10 + constrains: + - ipywidgets >=7,<8 + - ipykernel >=5.1.2 + - traitlets >=4.3.2 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 8357588 + timestamp: 1783048446690 - conda: https://conda.anaconda.org/conda-forge/noarch/pydot-4.0.1-pyhcf101f3_2.conda sha256: af7213a8ca077895e7e10c8f33d5de3436b8a26828422e8a113cc59c9277a3e2 md5: 15f6d0866b0997c5302fc230a566bc72 @@ -5271,6 +5400,18 @@ packages: license_family: APACHE size: 233310 timestamp: 1751104122689 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.6.1-pyh5ded981_0.conda + sha256: 396bf7b5a62eb3a12d20e16962acf36f9018332b2842d29548399df03b9deaa4 + md5: ae0feb6a3f297952bd4f4270fe77f530 + depends: + - python >=3.11 + - filelock >=3.15.4 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 39252 + timestamp: 1789811798811 - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda sha256: df9aa74e9e28e8d1309274648aac08ec447a92512c33f61a8de0afa9ce32ebe8 md5: 23029aae904a2ba587daba708208012f @@ -5670,6 +5811,20 @@ packages: license_family: MIT size: 103172 timestamp: 1767817860341 +- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.12.1-pyh5ded981_0.conda + sha256: 6f8adaa1df9b9de594ee58114218b83d4392b74aec44992c1e099645dd628faa + md5: ae5908ef25c51bfb5d371e3cc0ae01e9 + depends: + - python >=3.11 + - distlib >=0.3.7,<1 + - filelock >=3.24.2,<5 + - platformdirs >=3.9.1,<5 + - python-discovery >=1.6 + - python + license: MIT + run_exports: {} + size: 3993532 + timestamp: 1790323880820 - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.7.0-pyhd8ed1ab_0.conda sha256: 1ee2d8384972ecbf8630ce8a3ea9d16858358ad3e8566675295e66996d5352da md5: eb9538b8e55069434a18547f43b96059 @@ -8005,6 +8160,21 @@ packages: run_exports: {} size: 897125 timestamp: 1789911042185 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py312h766f71e_0.conda + sha256: 4d047b1d6e0f4bdd8c43e1b772665de9a10c0649a7f158df8193a3a6e7df714f + md5: e80504aa921f5ab11456f27bd9ef5d25 + depends: + - __osx >=11.0 + - cffi + - libcxx >=19 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + run_exports: {} + size: 14733 + timestamp: 1769439379176 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-18.0.0-py312ha053593_0.conda sha256: 451d39ef6f1991f573f53ba76525fe1f9b6f9a5f411b53c5d2518fc1583f6505 md5: 639c032b3599544afe156821a816895b @@ -10427,6 +10597,21 @@ packages: license: LicenseRef-MicrosoftWindowsSDK10 size: 694692 timestamp: 1756385147981 +- conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py312hf90b1b7_0.conda + sha256: 109cff6bde0af580472e8ece93323c9cf11a53c983867e411d47e212cd8efd1e + md5: 46f58bf68d690efadc0b1eb17e9f763d + depends: + - cffi + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + run_exports: {} + size: 18354 + timestamp: 1769438970742 - conda: https://conda.anaconda.org/conda-forge/win-64/unicodedata2-18.0.0-py312he06e257_0.conda sha256: 9ca3d0238b12442207b819b54e20f3ae9fbd6bb60aed54639b0cafe8f9e4bbd8 md5: 99623d0df152a82f2ef345e8add291e1 diff --git a/pixi.toml b/pixi.toml index 5c27184..f095350 100644 --- a/pixi.toml +++ b/pixi.toml @@ -32,8 +32,10 @@ osmium-tool = ">=1.19,<2" pandas = ">=2,<3" pyogrio = ">=0.10,<1" pyosmium = ">=4,<5" +pydeck = ">=0.9,<1" requests = ">=2,<3" shapely = ">=2,<3" +pre-commit = ">=4.6.2,<5" [tasks] test = {cmd = "pytest tests"} diff --git a/tests/integration/Snakefile b/tests/integration/Snakefile index 2925d03..e1f7e10 100644 --- a/tests/integration/Snakefile +++ b/tests/integration/Snakefile @@ -18,4 +18,5 @@ use rule * from grid_builder as grid_builder_* rule all: default_target: True input: - rules.grid_builder_build_osm_network.output, + rules.grid_builder_build_network.output, + rules.grid_builder_build_interactive_map.output, diff --git a/tests/integration/test_config.yaml b/tests/integration/test_config.yaml index 78dcdf5..c6624d0 100644 --- a/tests/integration/test_config.yaml +++ b/tests/integration/test_config.yaml @@ -5,5 +5,7 @@ grid_builder: retrieve: source: geofabrik force_redownload: false - include_relations: false target_date: + + network: + include_relations: false diff --git a/tests/integration_test.py b/tests/integration_test.py index 1316f42..3c9af85 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -94,7 +94,7 @@ def test_snakemake_integration_testing(module_path): shutil.copytree(workdir / "logs", log_dir, dirs_exist_ok=True) assert result.returncode == 0, run_log.read_text(encoding="utf-8") - output_dir = workdir / "resources/grid-builder/osm/retrieve" + output_dir = workdir / "resources/grid-builder/retrieve" for feature in ("substations_way", "lines_way"): payload = json.loads( (output_dir / f"benin_{feature}.json").read_text(encoding="utf-8") @@ -103,7 +103,7 @@ def test_snakemake_integration_testing(module_path): assert elements, f"No {feature} records retrieved" assert all(item["geometry"] for item in elements) - build_dir = workdir / "resources/grid-builder/osm/build" + build_dir = workdir / "resources/grid-builder/build" for component in ("buses", "lines", "transformers"): with (build_dir / "csv" / f"{component}.csv").open( encoding="utf-8" diff --git a/tests/test_config.py b/tests/test_config.py index 19558a6..05be5e2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,7 +49,7 @@ def test_generated_config_matches_repository(tmp_path): assert schema == json.loads((config_dir / "config.schema.json").read_text()) validated = validate_config(defaults) assert validated.retrieve.overpass_api.max_tries == 5 - assert validated.retrieve.include_relations is True + assert validated.network.include_relations is True assert validated.network.frequency_hz.AC == 50.0 assert validated.network.frequency_hz.DC == 0.0 assert validated.network.remove_under_construction is True diff --git a/tests/test_network_processing.py b/tests/test_network_processing.py index 3a4e2e7..e896207 100644 --- a/tests/test_network_processing.py +++ b/tests/test_network_processing.py @@ -6,8 +6,13 @@ import pandas as pd from shapely.geometry import LineString -from workflow.scripts.build_osm_network import build_osm_network -from workflow.scripts.clean_osm_data import _region_ac_hz, clean_osm_data +from workflow.scripts.build_network import build_network +from workflow.scripts.clean import ( + _apply_corrections, + _filter_by_voltage, + _region_ac_hz, + clean, +) def _write(path, elements): @@ -46,6 +51,30 @@ def test_region_ac_hz_handles_null_frequency_override(): assert _region_ac_hz("US", _NETWORK, regions_us) == "60" +def test_apply_corrections_exact_matches_whole_value_only(): + """An ``exact`` step only fires on a value equal to the pattern, unlike ``replace``. + + A freeform voltage tag containing an unrelated "m" (e.g. "amperes") + must survive untouched, where a substring "replace" would corrupt it. + """ + column = pd.Series(["m", "amperes", "medium"]) + steps = [{"exact": ["m", "33000"]}, {"exact": ["medium", "99000"]}] + result = _apply_corrections(column, steps) + assert list(result) == ["33000", "amperes", "99000"] + + +def test_filter_by_voltage_drops_oversized_garbage_without_crashing(): + """A voltage tag that cleans up into an implausibly long digit string. + + (e.g. freeform text misusing the voltage key) is dropped as noise + instead of overflowing ``astype(int)``'s fixed-width C long. + """ + df = pd.DataFrame({"voltage": ["230000", "9" * 15]}) + filtered, list_voltages = _filter_by_voltage(df, min_voltage=220000) + assert list(list_voltages) == ["230000"] + assert list(filtered["voltage"]) == ["230000"] + + def test_cleaner_removes_line_in_overlapping_substation_polygons(tmp_path): """Containment filtering remains index-safe when polygons overlap.""" square = _ring(4.0, 50.0, 4.1, 50.1) @@ -85,7 +114,7 @@ def test_cleaner_removes_line_in_overlapping_substation_polygons(tmp_path): "substations_way": [str(substations_path)], "lines_way": [str(lines_path)], } - buses, polygons, lines = clean_osm_data(inputs, _NETWORK, {}, _GEO_CRS) + buses, polygons, lines = clean(inputs, _NETWORK, {}, _GEO_CRS) assert set(buses["bus_id"]) == {"way/1", "way/2"} assert len(polygons) == 2 @@ -136,7 +165,7 @@ def _member(ref: int, lon0: float, lon1: float) -> dict: ) inputs = {"lines_way": [str(lines_path)], "routes_relation": [str(relations_path)]} - _, _, lines = clean_osm_data(inputs, _NETWORK, {}, _GEO_CRS) + _, _, lines = clean(inputs, _NETWORK, {}, _GEO_CRS) assert len(lines) == 1 assert lines.iloc[0]["line_id"] == "relation/99" @@ -188,7 +217,7 @@ def test_builder_merges_compatible_segments_through_virtual_bus(monkeypatch): crs="EPSG:4326", ) - station_seeds = build_osm_network.__globals__["_create_station_seeds"] + station_seeds = build_network.__globals__["_create_station_seeds"] captured = {} def capture_station_merge_radius(*args, **kwargs): @@ -196,22 +225,18 @@ def capture_station_merge_radius(*args, **kwargs): return station_seeds(*args, **kwargs) monkeypatch.setitem( - build_osm_network.__globals__, - "_create_station_seeds", - capture_station_merge_radius, + build_network.__globals__, "_create_station_seeds", capture_station_merge_radius ) - buses, built_lines, transformers, stations_polygon, buses_polygon = ( - build_osm_network( - substations, - substations_polygon, - lines, - False, - None, - _GEO_CRS, - _DISTANCE_CRS, - station_merge_radius_m=1, - ) + buses, built_lines, transformers, stations_polygon, buses_polygon = build_network( + substations, + substations_polygon, + lines, + False, + None, + _GEO_CRS, + _DISTANCE_CRS, + station_merge_radius_m=1, ) assert len(buses) == 2 diff --git a/workflow/Snakefile b/workflow/Snakefile index 97a0527..b8ed000 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -13,7 +13,7 @@ min_version("9.19") # Define pathvars to expose OSM retrieval outputs for downstream use. pathvars: # OSM retrieval outputs by country and feature - osm_retrieve="/osm/retrieve", + osm_retrieve="/retrieve", # Default configuration file generated from pydantic schema. @@ -30,11 +30,11 @@ with open(workflow.source_path("internal/settings.yaml"), "r") as f: include: "rules/retrieve.smk" -include: "rules/clean.smk" -include: "rules/build.smk" +include: "rules/network.smk" +include: "rules/plot.smk" rule all: default_target: True input: - rules.build_osm_network.output, + rules.build_interactive_map.output, diff --git a/workflow/envs/network.yaml b/workflow/envs/network.yaml index 6789e12..451eca8 100644 --- a/workflow/envs/network.yaml +++ b/workflow/envs/network.yaml @@ -10,5 +10,6 @@ dependencies: - pandas >=2,<3 - pyogrio >=0.10,<1 - pyproj >=3.7,<4 + - pydeck >=0.9,<1 - pyyaml >=6,<7 - shapely >=2,<3 diff --git a/workflow/internal/colors.yaml b/workflow/internal/colors.yaml new file mode 100644 index 0000000..c2dbd01 --- /dev/null +++ b/workflow/internal/colors.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Contributors to grid-builder 220000", "380000;220000"] - replace: [":", ";"] diff --git a/workflow/rules/build.smk b/workflow/rules/build.smk deleted file mode 100644 index 97e926c..0000000 --- a/workflow/rules/build.smk +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to grid-builder /build_osm_network.log", - conda: - "../envs/network.yaml" - threads: 1 - params: - station_merge_radius_m=config["network"]["station_merge_radius_m"], - remove_under_construction=config["network"]["remove_under_construction"], - remove_after=config["network"]["remove_after"], - crs=config["crs"].model_dump(mode="json"), - message: - "Building a connected generic OSM network." - script: - "../scripts/build_osm_network.py" diff --git a/workflow/rules/clean.smk b/workflow/rules/clean.smk deleted file mode 100644 index d3d24e3..0000000 --- a/workflow/rules/clean.smk +++ /dev/null @@ -1,55 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to grid-builder /osm/retrieve/{country}_lines_way.json", - country=config["countries"], - ), - cables_way=expand( - "/osm/retrieve/{country}_cables_way.json", - country=config["countries"], - ), - substations_way=expand( - "/osm/retrieve/{country}_substations_way.json", - country=config["countries"], - ), - substations_node=expand( - "/osm/retrieve/{country}_substations_node.json", - country=config["countries"], - ), - substations_relation=expand( - "/osm/retrieve/{country}_substations_relation.json", - country=config["countries"], - ), - routes_relation=( - expand( - "/osm/retrieve/{country}_routes_relation.json", - country=config["countries"], - ) - if config["retrieve"]["include_relations"] - else [] - ), - output: - substations="/osm/clean/substations.geojson", - substations_polygon="/osm/clean/substations_polygon.geojson", - lines="/osm/clean/lines.geojson", - log: - "/clean_osm_data.log", - conda: - "../envs/network.yaml" - threads: 1 - params: - network=config["network"].model_dump(mode="json"), - regions={ - code: value.model_dump(mode="json") - for code, value in config["regions"].items() - }, - crs=config["crs"].model_dump(mode="json"), - message: - "Cleaning retrieved OSM power features." - script: - "../scripts/clean_osm_data.py" diff --git a/workflow/rules/network.smk b/workflow/rules/network.smk new file mode 100644 index 0000000..3d5476b --- /dev/null +++ b/workflow/rules/network.smk @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Contributors to grid-builder /retrieve/{country}_lines_way.json", + country=config["countries"], + ), + cables_way=expand( + "/retrieve/{country}_cables_way.json", + country=config["countries"], + ), + substations_way=expand( + "/retrieve/{country}_substations_way.json", + country=config["countries"], + ), + substations_node=expand( + "/retrieve/{country}_substations_node.json", + country=config["countries"], + ), + substations_relation=expand( + "/retrieve/{country}_substations_relation.json", + country=config["countries"], + ), + routes_relation=( + expand( + "/retrieve/{country}_routes_relation.json", + country=config["countries"], + ) + if config["network"]["include_relations"] + else [] + ), + output: + substations="/clean/substations.geojson", + substations_polygon="/clean/substations_polygon.geojson", + lines="/clean/lines.geojson", + log: + "/clean.log", + conda: + "../envs/network.yaml" + threads: 1 + params: + network=config["network"].model_dump(mode="json"), + regions={ + code: value.model_dump(mode="json") + for code, value in config["regions"].items() + }, + crs=config["crs"].model_dump(mode="json"), + message: + "Cleaning retrieved OSM power features." + script: + "../scripts/clean.py" + + +rule build_network: + input: + substations=rules.clean.output.substations, + substations_polygon=rules.clean.output.substations_polygon, + lines=rules.clean.output.lines, + output: + buses="/build/csv/buses.csv", + lines="/build/csv/lines.csv", + transformers="/build/csv/transformers.csv", + buses_geojson="/build/geojson/buses.geojson", + lines_geojson="/build/geojson/lines.geojson", + transformers_geojson="/build/geojson/transformers.geojson", + stations_polygon="/build/geojson/stations_polygon.geojson", + buses_polygon="/build/geojson/buses_polygon.geojson", + log: + "/build_network.log", + conda: + "../envs/network.yaml" + threads: 1 + params: + station_merge_radius_m=config["network"]["station_merge_radius_m"], + remove_under_construction=config["network"]["remove_under_construction"], + remove_after=config["network"]["remove_after"], + crs=config["crs"].model_dump(mode="json"), + message: + "Building a connected generic OSM network." + script: + "../scripts/build_network.py" diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk new file mode 100644 index 0000000..3c06d71 --- /dev/null +++ b/workflow/rules/plot.smk @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Contributors to grid-builder /build_interactive_map.log", + conda: + "../envs/network.yaml" + threads: 1 + params: + crs=config["crs"].model_dump(mode="json"), + interactive_map=config["interactive_map"].model_dump(mode="json"), + message: + "Building an interactive OSM network map." + script: + "../scripts/build_interactive_map.py" diff --git a/workflow/rules/retrieve.smk b/workflow/rules/retrieve.smk index 05e731f..cc6144f 100644 --- a/workflow/rules/retrieve.smk +++ b/workflow/rules/retrieve.smk @@ -6,9 +6,9 @@ from pathlib import Path # Both retrieve_osm_pbf and retrieve_osm_overpass produce this same fixed # set of six files per country (routes_relation included even when -# retrieve.include_relations is off, just empty) — see either script's +# network.include_relations is off, just empty) — see either script's # module docstring for why relations aren't optional at the retrieval layer -# even though clean_osm_data only ever reads routes_relation.json when the +# even though clean only ever reads routes_relation.json when the # config flag is on. Only one of the two rules below is ever defined, since # retrieve.source picks exactly one implementation for the same output # paths — defining both unconditionally would make Snakemake's DAG @@ -22,7 +22,7 @@ _OSM_FEATURES = [ "routes_relation", ] _OSM_OUTPUTS = { - feature: f"/osm/retrieve/{{country}}_{feature}.json" + feature: f"/retrieve/{{country}}_{feature}.json" for feature in _OSM_FEATURES } @@ -38,7 +38,7 @@ if config["retrieve"]["source"] == "geofabrik": "../envs/retrieve.yaml" threads: 1 params: - include_relations=config["retrieve"]["include_relations"], + include_relations=config["network"]["include_relations"], force_redownload=config["retrieve"]["force_redownload"], data_dir=str(Path(workflow.basedir).parent / "data" / "earth-osm"), message: @@ -57,7 +57,7 @@ elif config["retrieve"]["source"] == "overpass": "../envs/retrieve.yaml" threads: 1 params: - include_relations=config["retrieve"]["include_relations"], + include_relations=config["network"]["include_relations"], overpass_api=config["retrieve"]["overpass_api"].model_dump(mode="json"), message: "Retrieve OSM power features for one country from the Overpass API." @@ -68,7 +68,7 @@ elif config["retrieve"]["source"] == "overpass": rule retrieve_osm_all: input: expand( - "/osm/retrieve/{country}_{feature}.json", + "/retrieve/{country}_{feature}.json", country=config["countries"], feature=_OSM_FEATURES, ), diff --git a/workflow/scripts/_schema.py b/workflow/scripts/_schema.py index f652c92..4877b15 100644 --- a/workflow/scripts/_schema.py +++ b/workflow/scripts/_schema.py @@ -19,6 +19,23 @@ _VALID_REGIONS: frozenset[str] = frozenset(get_all_valid_codes()) +def _validate_countries(countries: list[str]) -> None: + """Raise a clear error for any country not recognised by earth-osm's region list. + + Shared by ``ConfigSchema``'s field validator and ``load_region_configs``: + the latter resolves ``countries`` into region codes *before* the model is + ever validated, so without this same check up front, an invalid code + reaches ``earth_osm.regions.get_region_tuple`` first and surfaces as a + raw ``KeyError`` instead of this message. + """ + invalid = [c for c in countries if c not in _VALID_REGIONS] + if invalid: + raise ValueError( + f"Unknown country identifier(s): {invalid}. " + "Use an English name (e.g. 'benin') or ISO 3166-1 alpha-2 code (e.g. 'BE')." + ) + + class ConfigModel(BaseModel): """Base model with dict-like access for Snakemake compatibility.""" @@ -87,14 +104,6 @@ class RetrieveConfig(ConfigModel): "geofabrik", description="Retrieval backend for OSM data" ) force_redownload: bool = Field(False, description="Force refresh of cached data") - include_relations: bool = Field( - True, - description=( - "Additionally retrieve OSM route=power/power=circuit relations, " - "so clean_osm_data can group their member ways into a single line " - "matching the relation's real-world circuit" - ), - ) target_date: datetime | None = Field( None, description=( @@ -130,6 +139,15 @@ class NetworkConfig(ConfigModel): model_config = ConfigDict(extra="forbid") + include_relations: bool = Field( + True, + description=( + "Whether the network should consider OSM route=power/power=circuit " + "relations, grouping their member ways into a single line matching " + "the relation's real-world circuit; retrieval respects this too, " + "so relations aren't fetched at all when it's off" + ), + ) minimum_voltage_kv: float = Field( 220.0, description="Minimum nominal AC voltage retained from OSM, in kV", gt=0 ) @@ -151,21 +169,49 @@ class NetworkConfig(ConfigModel): ) -class CrsConfig(ConfigModel): - """Coordinate reference systems used throughout clean_osm_data/build_osm_network.""" +class SimplifyGeometriesConfig(ConfigModel): + """Douglas-Peucker simplification tolerances for the interactive map.""" model_config = ConfigDict(extra="forbid") - geo: str = Field( - "EPSG:4326", description="Geographic CRS used to store and exchange coordinates" + enable: bool = Field( + True, + description="Whether to simplify station/bus-polygon/line geometries before embedding them in the interactive map", ) - distance: str = Field( - "EPSG:3035", + stations_m: float = Field( + 100.0, + description="Simplification tolerance for station polygon outlines, in metres", + ge=0, + ) + buses_polygon_m: float = Field( + 5.0, + description="Simplification tolerance for individual bus/substation footprint polygons, in metres", + ge=0, + ) + lines_m: float = Field( + 30.0, + description="Simplification tolerance for line geometries, in metres", + ge=0, + ) + + +class InteractiveMapConfig(ConfigModel): + """Geometry simplification and coordinate rounding for build_interactive_map.py.""" + + model_config = ConfigDict(extra="forbid") + + coordinate_decimals: int = Field( + 5, description=( - "Equal-area/equal-distance CRS used for buffering and length " - "calculations; must suit the geographic extent in use (the " - "default, ETRS89-LAEA, covers Europe)" + "Decimal places kept for coordinates embedded in the interactive " + "map; 5 is about 1.1m of precision at the equator, comfortably " + "below every simplification tolerance below" ), + ge=0, + ) + simplify_geometries: SimplifyGeometriesConfig = Field( + default_factory=SimplifyGeometriesConfig, + description="Geometry simplification tolerances for the interactive map", ) @@ -187,6 +233,24 @@ class RegionalNetworkConfig(ConfigModel): frequency_hz: RegionalFrequencyConfig | None = Field(None) +class CrsConfig(ConfigModel): + """Coordinate reference systems used throughout clean/build_network.""" + + model_config = ConfigDict(extra="forbid") + + geo: str = Field( + "EPSG:4326", description="Geographic CRS used to store and exchange coordinates" + ) + distance: str = Field( + "EPSG:3035", + description=( + "Equal-area/equal-distance CRS used for buffering and length " + "calculations; must suit the geographic extent in use (the " + "default, ETRS89-LAEA, covers Europe)" + ), + ) + + class ConfigSchema(ConfigModel): """Top-level grid-builder config.""" @@ -202,12 +266,7 @@ class ConfigSchema(ConfigModel): @classmethod def validate_country_identifiers(cls, v: list[str]) -> list[str]: """Reject any country not recognised by earth-osm's region list.""" - invalid = [c for c in v if c not in _VALID_REGIONS] - if invalid: - raise ValueError( - f"Unknown country identifier(s): {invalid}. " - "Use an English name (e.g. 'benin') or ISO 3166-1 alpha-2 code (e.g. 'BE')." - ) + _validate_countries(v) return v retrieve: RetrieveConfig = Field( @@ -216,15 +275,19 @@ def validate_country_identifiers(cls, v: list[str]) -> list[str]: ) network: NetworkConfig = Field( default_factory=NetworkConfig, - description="Settings for clean_osm_data and build_osm_network", + description="Settings for clean and build_network", ) regions: dict[str, RegionalNetworkConfig] = Field( default_factory=dict, description="Country-specific network overrides loaded from config/regions", ) + interactive_map: InteractiveMapConfig = Field( + default_factory=InteractiveMapConfig, + description="Settings for build_interactive_map.py", + ) crs: CrsConfig = Field( default_factory=CrsConfig, - description="Coordinate reference systems used throughout clean_osm_data/build_osm_network", + description="Coordinate reference systems used throughout clean/build_network", ) @@ -251,6 +314,7 @@ def load_region_configs( raw = config.copy() supplied_regions = raw.pop("regions", {}) countries = raw.get("countries", ConfigSchema.model_fields["countries"].default) + _validate_countries(countries) yaml_reader = YAML(typ="safe") loaded_regions: dict[str, Any] = {} diff --git a/workflow/scripts/build_interactive_map.py b/workflow/scripts/build_interactive_map.py new file mode 100644 index 0000000..4fb5e97 --- /dev/null +++ b/workflow/scripts/build_interactive_map.py @@ -0,0 +1,1103 @@ +# SPDX-FileCopyrightText: Contributors to grid-builder gpd.GeoDataFrame: + """Create an HTML table from the fields actually present in a layer.""" + frame = frame.copy() + columns = [column for column in frame if column != "geometry"] + + def value_text(column: str, value: Any) -> str: + if isinstance(value, (list, tuple, dict, set)): + value = ", ".join(map(str, value)) + if pd.isna(value): + return "" + value = str(value) + if column == "osm_ids": + return "
".join( + f'{html.escape(item)}' + for item in value.split(";") + ) + return html.escape(value) + + frame["tooltip_html"] = frame.apply( + lambda row: ( + "" + + "".join( + f"" + for column in columns + ) + + "
{html.escape(column)}{value_text(column, row[column])}
" + ), + axis=1, + ) + return frame + + +def _coord(point: Any, decimals: int) -> list[float]: + """Round a 2D point to ``decimals`` places, as a plain ``[lon, lat]`` pair.""" + return [round(point[0], decimals), round(point[1], decimals)] + + +def line_colors(voltages: pd.Series) -> list[list[int]]: + """Return the PyPSA-Eur voltage palette from internal configuration.""" + bands = load_internal_yaml("colors.yaml")["lines"]["voltage"] + + def rgba(color: str) -> list[int]: + color = color.removeprefix("#") + return [int(color[index : index + 2], 16) for index in range(0, 6, 2)] + [150] + + def color(voltage: float) -> str: + for voltage_range, value in bands.items(): + minimum, maximum = voltage_range.split("-", maxsplit=1) + if ( + float(minimum) + <= voltage + <= (float(maximum) if maximum != "inf" else float("inf")) + ): + return value + raise ValueError(f"No line colour configured for {voltage} kV.") + + return [rgba(color(voltage)) for voltage in voltages] + + +def path_layer( + frame: gpd.GeoDataFrame, + name: str, + color: list[int] | str, + *, + geo_crs: str, + distance_crs: str, + coord_decimals: int, + simplify_m: float | None = None, + auto_highlight: bool = True, +) -> pdk.Layer | None: + """Render lines/transformers as a PathLayer, including MultiLineString geometries.""" + if frame.empty: + return None + if simplify_m is not None: + frame = frame.copy() + frame["geometry"] = ( + frame.geometry.to_crs(distance_crs).simplify(simplify_m).to_crs(geo_crs) + ) + data = tooltip(frame) + data["path"] = data.geometry.map( + lambda line: ( + [ + [_coord(point, coord_decimals) for point in item.coords] + for item in line.geoms + ] + if line.geom_type == "MultiLineString" + else [_coord(point, coord_decimals) for point in line.coords] + ) + ) + return pdk.Layer( + "PathLayer", + data=data.drop(columns="geometry"), + get_path="path", + get_color=color, + width_min_pixels=2, + pickable=True, + auto_highlight=auto_highlight, + parameters={"depthTest": False}, + id=name, + ) + + +def polygon_layer( + frame: gpd.GeoDataFrame, + name: str, + color: list[int], + *, + geo_crs: str, + distance_crs: str, + coord_decimals: int, + simplify_m: float | None = None, + extruded: bool = False, +) -> pdk.Layer | None: + """Render station and bus polygons, including MultiPolygon geometries.""" + if frame.empty: + return None + if simplify_m is not None: + frame = frame.copy() + frame["geometry"] = ( + frame.geometry.to_crs(distance_crs).simplify(simplify_m).to_crs(geo_crs) + ) + data = tooltip(frame) + data["polygon"] = data.geometry.map( + lambda polygon: ( + [ + [_coord(point, coord_decimals) for point in item.exterior.coords] + for item in polygon.geoms + ] + if polygon.geom_type == "MultiPolygon" + else [_coord(point, coord_decimals) for point in polygon.exterior.coords] + ) + ) + extrusion_kwargs: dict[str, Any] = {} + if extruded: + extrusion_kwargs.update( + extruded=True, + wireframe=True, + get_elevation=200, + get_line_color=[255, 255, 255], + ) + return pdk.Layer( + "PolygonLayer", + data=data.drop(columns="geometry"), + get_polygon="polygon", + get_fill_color=color, + pickable=True, + auto_highlight=True, + parameters={"depthTest": False}, + id=name, + **extrusion_kwargs, + ) + + +def build_map( + buses: gpd.GeoDataFrame, + lines: gpd.GeoDataFrame, + transformers: gpd.GeoDataFrame, + stations: gpd.GeoDataFrame, + bus_polygons: gpd.GeoDataFrame, + *, + geo_crs: str, + distance_crs: str, + stations_simplify_m: float | None, + buses_polygon_simplify_m: float | None, + lines_simplify_m: float | None, + coord_decimals: int, +) -> pdk.Deck: + """Create the generic AC map without requiring DC links or converters.""" + lines = lines.copy() + if not lines.empty: + lines["color"] = line_colors(lines["voltage_kv"]) + layers = [ + layer + for layer in ( + polygon_layer( + stations, + "Stations", + [0, 80, 255, 60], + geo_crs=geo_crs, + distance_crs=distance_crs, + coord_decimals=coord_decimals, + simplify_m=stations_simplify_m, + ), + polygon_layer( + bus_polygons, + "Bus polygons", + [255, 0, 155, 50], + geo_crs=geo_crs, + distance_crs=distance_crs, + coord_decimals=coord_decimals, + simplify_m=buses_polygon_simplify_m, + extruded=True, + ), + path_layer( + lines, + "Lines", + "color", + geo_crs=geo_crs, + distance_crs=distance_crs, + coord_decimals=coord_decimals, + simplify_m=lines_simplify_m, + auto_highlight=False, + ), + path_layer( + transformers, + "Transformers", + [255, 255, 0, 180], + geo_crs=geo_crs, + distance_crs=distance_crs, + coord_decimals=coord_decimals, + ), + ) + if layer + ] + if not buses.empty: + data = tooltip(buses) + data["position"] = data.geometry.map( + lambda point: _coord((point.x, point.y), coord_decimals) + ) + layers.append( + pdk.Layer( + "ColumnLayer", + data=data.drop(columns="geometry"), + get_position="position", + get_fill_color=[255, 0, 155, 180], + radius=20, + get_elevation=10, + pickable=True, + auto_highlight=True, + parameters={"depthTest": False}, + id="Buses", + ) + ) + geometry = pd.concat([buses.geometry, lines.geometry], ignore_index=True) + center = geometry.union_all().centroid if not geometry.empty else None + return pdk.Deck( + layers=layers, + map_style="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + initial_view_state=pdk.ViewState( + longitude=center.x if center else 0, + latitude=center.y if center else 0, + zoom=5, + pitch=25, + ), + tooltip={"html": "{tooltip_html}"}, + ) + + +def inject_controls(deck: pdk.Deck) -> str: + """Inject the release-map UI, adapted to grid-builder's available layers.""" + page = deck.to_html(as_string=True) + # PyDeck 0.9 varies the indentation before createDeck's closing delimiter, + # so inject at the final script closing tag instead of matching whitespace. + script, closing_tag = page.rsplit("\n ", maxsplit=1) + page = script + "\nwindow.deck = deckInstance;\n " + closing_tag + # This is deliberately self-contained: maps are distributed as standalone HTML. + controls = r""" + + + + + +
+
+ + +
Use & for AND, | for OR, ( ) to group
+ +
+
+ +
+ +
+
+ +
+ +
+
+""" + return page.replace( + '
', controls + '
' + ).replace("pydeck", "grid-builder OSM network") + + +def compress_html(page: str) -> str: + """Strip whitespace and compact pydeck's embedded JSON to shrink the output.""" + page = re.sub(r"", "", page, flags=re.DOTALL) + + def minify_css(match: re.Match[str]) -> str: + css = re.sub(r"\s+", " ", match.group(1)) + css = re.sub(r"\s*([{};:,])\s*", r"\1", css) + return f"" + + page = re.sub(r"", minify_css, page, flags=re.DOTALL) + + def compress_json_in_script(match: re.Match[str]) -> str: + script = match.group(0) + json_match = re.search(r"const jsonInput = (\{.*?\});", script, re.DOTALL) + if json_match: + try: + compact = json.dumps( + json.loads(json_match.group(1)), separators=(",", ":") + ) + script = script.replace(json_match.group(1), compact) + except json.JSONDecodeError: + pass + return script + + # This targets only pydeck's own auto-generated script (the one that + # starts with its jsonInput blob), never our hand-written controls + # script below it, which is left readable. + page = re.sub( + r"", + compress_json_in_script, + page, + flags=re.DOTALL, + ) + + parts = re.split(r"()", page, flags=re.DOTALL | re.IGNORECASE) + for index, part in enumerate(parts): + if not part.lower().startswith("