mapstyle 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mapstyle-0.3.0/src/mapstyle.egg-info → mapstyle-0.4.0}/PKG-INFO +5 -8
- {mapstyle-0.3.0 → mapstyle-0.4.0}/README.md +3 -3
- {mapstyle-0.3.0 → mapstyle-0.4.0}/pyproject.toml +3 -4
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/__init__.py +1 -1
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/map.py +56 -28
- {mapstyle-0.3.0 → mapstyle-0.4.0/src/mapstyle.egg-info}/PKG-INFO +5 -8
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/SOURCES.txt +0 -1
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/requires.txt +1 -5
- {mapstyle-0.3.0 → mapstyle-0.4.0}/tests/test_map.py +54 -92
- mapstyle-0.3.0/src/mapstyle/node_levels.py +0 -526
- {mapstyle-0.3.0 → mapstyle-0.4.0}/LICENSE +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/setup.cfg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/dashboard.js +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/bicycle.svg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/bus.svg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/crossing.svg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/parking_p.svg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/traffic_light.svg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/train.svg +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/layers.js +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/levels.py +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/patterns.py +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/planner.html +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/style.py +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/layers.yaml +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/modes.yaml +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/osm_carto.yaml +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/paths.yaml +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/themes/google.yaml +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/themes/grey.yaml +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/dependency_links.txt +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/entry_points.txt +0 -0
- {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mapstyle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A whole OpenStreetMap map in one offline HTML page: roads for every travel mode over a full base map, from a duckOSM database
|
|
5
5
|
Author: Kaveh Khoshkhah
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,18 +18,15 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: roadstyle>=0.
|
|
21
|
+
Requires-Dist: roadstyle>=0.14.0
|
|
22
22
|
Requires-Dist: duckdb
|
|
23
23
|
Requires-Dist: geopandas
|
|
24
24
|
Requires-Dist: pyyaml
|
|
25
25
|
Requires-Dist: pillow
|
|
26
26
|
Provides-Extra: tiles
|
|
27
27
|
Requires-Dist: roadstyle[tiles]; extra == "tiles"
|
|
28
|
-
Provides-Extra: solver
|
|
29
|
-
Requires-Dist: ortools>=9.10; extra == "solver"
|
|
30
28
|
Provides-Extra: dev
|
|
31
29
|
Requires-Dist: pytest; extra == "dev"
|
|
32
|
-
Requires-Dist: ortools>=9.10; extra == "dev"
|
|
33
30
|
Provides-Extra: docs
|
|
34
31
|
Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
|
|
35
32
|
Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
|
|
@@ -98,9 +95,9 @@ ms.render_map("monaco.duckdb", dashboard=True).save("dashboard.html") # a dashbo
|
|
|
98
95
|
- **A full base map from the same database**: the sea, land use with openstreetmap.org's textures,
|
|
99
96
|
water, buildings, railways, parking, transit, traffic lights and crossings. No tile server, no API key.
|
|
100
97
|
- **Roads drawn right at junctions**: two-way roads as two lanes, bridges and overpasses over, tunnels
|
|
101
|
-
under, crossings over their street. The drawing order of the roads is
|
|
102
|
-
|
|
103
|
-
roads at different levels shows no ring.
|
|
98
|
+
under, crossings over their street. The drawing order of the roads is [roadstyle's](https://github.com/Khoshkhah/roadstyle):
|
|
99
|
+
a casing number and a fill number for every road, read from the database when `duckosm levels` stored them, else computed
|
|
100
|
+
while the page is made, so a joint of roads at different levels shows no ring.
|
|
104
101
|
- **A route planner demo**: drag a start and an end and see duckOSM's route on the map (routing
|
|
105
102
|
itself is duckOSM's job).
|
|
106
103
|
- **A dashboard**: filter by mode, road class and layer, colour by mode, inspect what you click.
|
|
@@ -60,9 +60,9 @@ ms.render_map("monaco.duckdb", dashboard=True).save("dashboard.html") # a dashbo
|
|
|
60
60
|
- **A full base map from the same database**: the sea, land use with openstreetmap.org's textures,
|
|
61
61
|
water, buildings, railways, parking, transit, traffic lights and crossings. No tile server, no API key.
|
|
62
62
|
- **Roads drawn right at junctions**: two-way roads as two lanes, bridges and overpasses over, tunnels
|
|
63
|
-
under, crossings over their street. The drawing order of the roads is
|
|
64
|
-
|
|
65
|
-
roads at different levels shows no ring.
|
|
63
|
+
under, crossings over their street. The drawing order of the roads is [roadstyle's](https://github.com/Khoshkhah/roadstyle):
|
|
64
|
+
a casing number and a fill number for every road, read from the database when `duckosm levels` stored them, else computed
|
|
65
|
+
while the page is made, so a joint of roads at different levels shows no ring.
|
|
66
66
|
- **A route planner demo**: drag a start and an end and see duckOSM's route on the map (routing
|
|
67
67
|
itself is duckOSM's job).
|
|
68
68
|
- **A dashboard**: filter by mode, road class and layer, colour by mode, inspect what you click.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mapstyle"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "A whole OpenStreetMap map in one offline HTML page: roads for every travel mode over a full base map, from a duckOSM database"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
|
|
24
24
|
dependencies = [
|
|
25
|
-
"roadstyle>=0.
|
|
25
|
+
"roadstyle>=0.14.0", # the page and the drawing order (compute_levels / load_levels, band_col, directed_col, cap_col)
|
|
26
26
|
"duckdb", # reads the duckOSM db
|
|
27
27
|
"geopandas",
|
|
28
28
|
"pyyaml", # styles/*.yaml
|
|
@@ -31,8 +31,7 @@ dependencies = [
|
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
33
|
tiles = ["roadstyle[tiles]"] # --tiles / render_map(tiles=True)
|
|
34
|
-
|
|
35
|
-
dev = ["pytest", "ortools>=9.10"]
|
|
34
|
+
dev = ["pytest"]
|
|
36
35
|
docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5,<10", "mkdocs-llmstxt>=0.5,<0.6"]
|
|
37
36
|
|
|
38
37
|
[project.urls]
|
|
@@ -374,8 +374,43 @@ def planner_data(db, roads):
|
|
|
374
374
|
return data
|
|
375
375
|
|
|
376
376
|
|
|
377
|
+
def _band(roads):
|
|
378
|
+
"""The band of every road: below (-1 ...), on (0) or over (1 ...) the ground (docs/design/stored_levels.md). It is complete, because roadstyle's ``band_col``
|
|
379
|
+
replaces the level from the tags: the OSM ``layer`` if that is a number, else 1 for a bridge, -1 for a tunnel, else 0; except for a path with a
|
|
380
|
+
``walk_type``: a sidewalk is -1 (under its street) and a crossing 1 (over it). The same as duckOSM's (``duckosm levels``)."""
|
|
381
|
+
import numpy as np
|
|
382
|
+
import pandas as pd
|
|
383
|
+
|
|
384
|
+
layer = pd.to_numeric(roads["layer"], errors="coerce").fillna(0).astype(int).to_numpy()
|
|
385
|
+
|
|
386
|
+
def yes(col):
|
|
387
|
+
return (roads[col].notna() & ~roads[col].astype(str).isin(["", "no", "None", "nan"])).to_numpy()
|
|
388
|
+
tags = np.where(layer != 0, layer, np.where(yes("bridge"), 1, np.where(yes("tunnel"), -1, 0)))
|
|
389
|
+
path = (roads["highway"].isin(PATH_CLASSES) & roads["walk_type"].isin(["sidewalk", "crossing"])).to_numpy()
|
|
390
|
+
return np.where(path, roads["walk_type"].map({"crossing": 1, "sidewalk": -1}).fillna(0).astype(int).to_numpy(), tags).astype(int)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def stored_levels(db, roads):
|
|
394
|
+
"""``roads`` with the four columns of ``visualization.edge_levels`` (``casing_start``, ``casing_level``, ``casing_end``, ``fill_level``), or None when the file has no such
|
|
395
|
+
table. The numbers must be those computed for these roads with ``duckosm levels``' defaults: if they are not (the file was rebuilt, other options), a ``ValueError`` says what
|
|
396
|
+
differs and that ``duckosm levels`` must be run again. Nothing is recomputed (docs/design/stored_levels.md)."""
|
|
397
|
+
import duckdb
|
|
398
|
+
import roadstyle as rs
|
|
399
|
+
|
|
400
|
+
con = duckdb.connect(str(db), read_only=True)
|
|
401
|
+
try:
|
|
402
|
+
if not con.execute("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'visualization' AND table_name = 'edge_levels'").fetchone()[0]:
|
|
403
|
+
return None
|
|
404
|
+
try:
|
|
405
|
+
return rs.load_levels(con, roads, band_col="band", order="class")
|
|
406
|
+
except ValueError as e:
|
|
407
|
+
raise ValueError(f"{e}; run `duckosm levels {db}` again") from None
|
|
408
|
+
finally:
|
|
409
|
+
con.close()
|
|
410
|
+
|
|
411
|
+
|
|
377
412
|
def render_map(db, mode=None, layers=True, planner=False, dashboard=False, interaction=None,
|
|
378
|
-
paths=PATHS, theme="osm",
|
|
413
|
+
paths=PATHS, theme="osm", pieces=False, **kwargs):
|
|
379
414
|
"""``rs.render_edges`` of the db's roads (all modes' edges) in the ``mode``'s style, over the
|
|
380
415
|
``features.*`` base map, with mapstyle's rs* functions (``rsSetModes``, ``rsSetKinds``,
|
|
381
416
|
``rsSetInteraction``: layers.js). ``layers``: True = every styles/layers.yaml layer, a list of
|
|
@@ -386,15 +421,16 @@ def render_map(db, mode=None, layers=True, planner=False, dashboard=False, inter
|
|
|
386
421
|
kind and interaction filters (docs/design/dashboard.md). ``mode``: which network stands out
|
|
387
422
|
(all, driving, walking, cycling); default all (the planner too: in the walking look a road's two directions are one line, so only one can be clicked).
|
|
388
423
|
``theme``: the whole map's colours, ``osm`` (default) or a styles/themes/*.yaml
|
|
389
|
-
(docs/design/themes.md).
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
approach, cutting a road with a level into pieces (docs/design/levels_plan.md); not the default, and not with ``order``.
|
|
424
|
+
(docs/design/themes.md). The drawing order of the roads is roadstyle's (docs/design/stored_levels.md): every road has a casing number and a fill number, so that connected
|
|
425
|
+
roads merge cleanly and a road that passes over another is drawn over it. They are read from ``visualization.edge_levels`` when the file has them (``duckosm levels``),
|
|
426
|
+
else roadstyle computes them while it renders. ``pieces``: the other approach, cutting a road with a level into pieces (docs/design/levels_plan.md); not the default.
|
|
393
427
|
The base map is ``blank``: the db's own layers are the map (the sea is ``features.ocean``).
|
|
394
428
|
``kwargs`` go to roadstyle
|
|
395
429
|
(``basemap``, ``tiles``, ``arrows``, ...)."""
|
|
396
430
|
import roadstyle as rs
|
|
397
431
|
|
|
432
|
+
if "order" in kwargs:
|
|
433
|
+
raise ValueError("render_map(order=...) is removed: the drawing order is roadstyle's (docs/design/stored_levels.md)")
|
|
398
434
|
mode = mode or "all"
|
|
399
435
|
if planner and kwargs.get("tiles"):
|
|
400
436
|
raise ValueError("planner=True can't use tiles=True: the planner snaps to the roads in the page")
|
|
@@ -436,32 +472,24 @@ def render_map(db, mode=None, layers=True, planner=False, dashboard=False, inter
|
|
|
436
472
|
"Road class": {}, "Modes": {"color_by": "modes", "colors": load_style("modes")["mode_colors"]}})
|
|
437
473
|
html += f"<script>{(_HERE / 'dashboard.js').read_text()}</script>"
|
|
438
474
|
render = rs.render_report
|
|
439
|
-
|
|
440
|
-
# it, casings included (roadstyle's band_col; roadstyle/docs/design/draw_order_per_edge.md).
|
|
441
|
-
# Paths only: duckOSM also marks a car road "sidewalk" when you walk along its own sidewalk,
|
|
442
|
-
# and that road must stay with the other streets (708 road edges in Monaco).
|
|
443
|
-
path = roads["highway"].isin(PATH_CLASSES)
|
|
444
|
-
roads["_band"] = roads["walk_type"].map({"crossing": 1, "sidewalk": -1}).where(path)
|
|
445
|
-
kw["band_col"], kw["cap_col"] = "_band", "_cap"
|
|
446
|
-
roads["_cap"] = False
|
|
475
|
+
roads["band"] = _band(roads)
|
|
447
476
|
if pieces:
|
|
448
|
-
# a road with a level (a layer tag, a tunnel) is ground except where it really passes over or under a road: cut
|
|
449
|
-
#
|
|
477
|
+
# a road with a level (a layer tag, a tunnel) is ground except where it really passes over or under a road: cut into pieces for drawing, square-ended
|
|
478
|
+
# (docs/design/levels_plan.md; roadstyle's cap_col). The band of a piece is ``_band``; a crossing over its street, a sidewalk under it (paths only)
|
|
479
|
+
path = roads["highway"].isin(PATH_CLASSES)
|
|
480
|
+
roads["_band"] = roads["walk_type"].map({"crossing": 1, "sidewalk": -1}).where(path)
|
|
481
|
+
kw["band_col"], kw["cap_col"] = "_band", "_cap"
|
|
482
|
+
roads["_cap"] = False
|
|
450
483
|
from mapstyle.levels import with_pieces
|
|
451
484
|
roads = with_pieces(roads, *load_pieces(db, roads))
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
for band, pos in ((1, 1), (-1, -1)):
|
|
461
|
-
m_ = roads["_band"] == band
|
|
462
|
-
roads.loc[m_, ["_cl", "_fl"]] = pos
|
|
463
|
-
kw["casing_level_col"], kw["fill_level_col"] = "_cl", "_fl"
|
|
464
|
-
kw.pop("cap_col")
|
|
485
|
+
else:
|
|
486
|
+
# the drawing order: the numbers stored in the file, else roadstyle computes them from the band (docs/design/stored_levels.md)
|
|
487
|
+
stored = stored_levels(db, roads)
|
|
488
|
+
if stored is None:
|
|
489
|
+
kw["band_col"] = "band"
|
|
490
|
+
else:
|
|
491
|
+
roads = stored
|
|
492
|
+
kw.update(casing_level_col="casing_level", fill_level_col="fill_level", casing_start_col="casing_start", casing_end_col="casing_end")
|
|
465
493
|
# duckOSM stores every path, and a one-way street's walking-only reverse, as a reverse edge too:
|
|
466
494
|
# roadstyle draws a pair as two lanes only when both edges are directed (directed_col,
|
|
467
495
|
# twin_ends.md)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mapstyle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A whole OpenStreetMap map in one offline HTML page: roads for every travel mode over a full base map, from a duckOSM database
|
|
5
5
|
Author: Kaveh Khoshkhah
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,18 +18,15 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: roadstyle>=0.
|
|
21
|
+
Requires-Dist: roadstyle>=0.14.0
|
|
22
22
|
Requires-Dist: duckdb
|
|
23
23
|
Requires-Dist: geopandas
|
|
24
24
|
Requires-Dist: pyyaml
|
|
25
25
|
Requires-Dist: pillow
|
|
26
26
|
Provides-Extra: tiles
|
|
27
27
|
Requires-Dist: roadstyle[tiles]; extra == "tiles"
|
|
28
|
-
Provides-Extra: solver
|
|
29
|
-
Requires-Dist: ortools>=9.10; extra == "solver"
|
|
30
28
|
Provides-Extra: dev
|
|
31
29
|
Requires-Dist: pytest; extra == "dev"
|
|
32
|
-
Requires-Dist: ortools>=9.10; extra == "dev"
|
|
33
30
|
Provides-Extra: docs
|
|
34
31
|
Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
|
|
35
32
|
Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
|
|
@@ -98,9 +95,9 @@ ms.render_map("monaco.duckdb", dashboard=True).save("dashboard.html") # a dashbo
|
|
|
98
95
|
- **A full base map from the same database**: the sea, land use with openstreetmap.org's textures,
|
|
99
96
|
water, buildings, railways, parking, transit, traffic lights and crossings. No tile server, no API key.
|
|
100
97
|
- **Roads drawn right at junctions**: two-way roads as two lanes, bridges and overpasses over, tunnels
|
|
101
|
-
under, crossings over their street. The drawing order of the roads is
|
|
102
|
-
|
|
103
|
-
roads at different levels shows no ring.
|
|
98
|
+
under, crossings over their street. The drawing order of the roads is [roadstyle's](https://github.com/Khoshkhah/roadstyle):
|
|
99
|
+
a casing number and a fill number for every road, read from the database when `duckosm levels` stored them, else computed
|
|
100
|
+
while the page is made, so a joint of roads at different levels shows no ring.
|
|
104
101
|
- **A route planner demo**: drag a start and an end and see duckOSM's route on the map (routing
|
|
105
102
|
itself is duckOSM's job).
|
|
106
103
|
- **A dashboard**: filter by mode, road class and layer, colour by mode, inspect what you click.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
roadstyle>=0.
|
|
1
|
+
roadstyle>=0.14.0
|
|
2
2
|
duckdb
|
|
3
3
|
geopandas
|
|
4
4
|
pyyaml
|
|
@@ -6,15 +6,11 @@ pillow
|
|
|
6
6
|
|
|
7
7
|
[dev]
|
|
8
8
|
pytest
|
|
9
|
-
ortools>=9.10
|
|
10
9
|
|
|
11
10
|
[docs]
|
|
12
11
|
mkdocs<2,>=1.6
|
|
13
12
|
mkdocs-material<10,>=9.5
|
|
14
13
|
mkdocs-llmstxt<0.6,>=0.5
|
|
15
14
|
|
|
16
|
-
[solver]
|
|
17
|
-
ortools>=9.10
|
|
18
|
-
|
|
19
15
|
[tiles]
|
|
20
16
|
roadstyle[tiles]
|
|
@@ -145,101 +145,63 @@ def test_roads_with_a_level_are_cut_into_pieces(monaco, monkeypatch):
|
|
|
145
145
|
assert whole[eid] - 1e-9 <= rows.geometry.length.sum() <= whole[eid] + extra + 1e-9
|
|
146
146
|
off = {}
|
|
147
147
|
monkeypatch.setattr(rs, "render_edges", lambda g, **kw: off.update(g=g) or SimpleNamespace(_tpl="</body>"))
|
|
148
|
-
render_map(monaco, layers=False,
|
|
149
|
-
assert len(off["g"]) == n and not off["g"]
|
|
148
|
+
render_map(monaco, layers=False, pieces=False)
|
|
149
|
+
assert len(off["g"]) == n and "_cap" not in off["g"]
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def
|
|
153
|
-
"""
|
|
154
|
-
|
|
152
|
+
def test_the_band_is_complete(monaco):
|
|
153
|
+
"""docs/design/stored_levels.md: roadstyle's band_col replaces the level from the tags, so the band has a value for every road: the layer, else a bridge 1 and a tunnel -1;
|
|
154
|
+
a path's sidewalk -1 and crossing 1."""
|
|
155
|
+
from mapstyle.map import _band
|
|
156
|
+
roads = load_roads(monaco)
|
|
157
|
+
band = _band(roads)
|
|
158
|
+
assert len(band) == len(roads) and set(band) >= {-1, 0, 1}
|
|
159
|
+
tunnel = roads["tunnel"].notna() & ~roads["tunnel"].astype(str).isin(["", "no"]) & roads["layer"].isna() & ~roads["walk_type"].isin(["sidewalk", "crossing"])
|
|
160
|
+
assert tunnel.any() and (band[tunnel.to_numpy()] == -1).all() # a tunnel with no layer tag is below the ground
|
|
161
|
+
path = roads["highway"].isin(["footway", "path", "cycleway", "steps", "pedestrian", "bridleway", "corridor"])
|
|
162
|
+
assert (band[(path & (roads["walk_type"] == "crossing")).to_numpy()] == 1).all() and (band[(path & (roads["walk_type"] == "sidewalk")).to_numpy()] == -1).all()
|
|
155
163
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
c = duckdb.connect(str(path))
|
|
160
|
-
c.execute("INSTALL spatial; LOAD spatial;")
|
|
161
|
-
node = {}
|
|
162
|
-
for m in MODES:
|
|
163
|
-
c.execute(f"CREATE SCHEMA {m}")
|
|
164
|
-
c.execute(f"CREATE TABLE {m}.edges (edge_id BIGINT, source BIGINT, target BIGINT, osm_id BIGINT, highway VARCHAR, name VARCHAR, "
|
|
165
|
-
"bridge VARCHAR, tunnel VARCHAR, layer VARCHAR, oneway BOOLEAN, geometry GEOMETRY)")
|
|
166
|
-
for i, (mode, hw, nm, pts, lay, br, tu) in enumerate(edges, 1):
|
|
167
|
-
nid = lambda q: node.setdefault((round(q[0], 3), round(q[1], 3)), len(node) + 1) # noqa: E731
|
|
168
|
-
wkt = "LINESTRING(" + ",".join("%.8f %.8f" % ll(*q) for q in pts) + ")"
|
|
169
|
-
c.execute(f"INSERT INTO {mode}.edges VALUES (?,?,?,?,?,?,?,?,?,false,ST_GeomFromText(?))",
|
|
170
|
-
[1000 + i, nid(pts[0]), nid(pts[-1]), 500 + i, hw, nm, "yes" if br else None, "yes" if tu else None,
|
|
171
|
-
str(lay) if lay else None, wkt])
|
|
172
|
-
c.close()
|
|
173
|
-
return path
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _levels_of(db):
|
|
177
|
-
from mapstyle.node_levels import compute
|
|
178
|
-
lv = compute(db)
|
|
179
|
-
return lv, (lambda e: lv.intervals.get(str(e), (0, 0)))
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def test_a_raised_path_over_a_street_joined_to_ground_paths(tmp_path):
|
|
183
|
-
"""The scene of docs/design/node_levels.md: a path with a layer tag joins two ground paths and passes over a street. The
|
|
184
|
-
intervals of roads that share a node intersect; the upper road's casing comes after the street's fill."""
|
|
185
|
-
R, P = "residential", "pedestrian"
|
|
186
|
-
db = _tiny_db(tmp_path / "ramp.duckdb", [
|
|
187
|
-
("walking", P, "ground", [(-40, 0), (0, 0)], 0, 0, 0), ("walking", P, "ground", [(0, 0), (-20, -28)], 0, 0, 0),
|
|
188
|
-
("walking", P, "raised", [(0, 0), (36, 14.4)], 1, 0, 0), ("driving", R, "street", [(17, -30), (17, 40)], 0, 0, 0)])
|
|
189
|
-
lv, iv = _levels_of(db)
|
|
190
|
-
ground1, ground2, raised, street = (iv(1001 + i) for i in range(4))
|
|
191
|
-
assert lv.info["overpass_pairs"] == 1 and lv.info["given_up"] == 0
|
|
192
|
-
assert raised[0] > street[1] # over the street, its casing after the street's fill
|
|
193
|
-
for ground in (ground1, ground2): # the ground paths meet the raised road at a node
|
|
194
|
-
assert ground[0] <= raised[1] and raised[0] <= ground[1]
|
|
195
|
-
assert not lv.cuts
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def test_a_tunnel_under_a_street_and_no_overpass_without_a_crossing(tmp_path):
|
|
199
|
-
R = "residential"
|
|
200
|
-
db = _tiny_db(tmp_path / "tunnel.duckdb", [
|
|
201
|
-
("driving", R, "road", [(-30, 0), (-12, 0)], 0, 0, 0), ("driving", R, "tunnel", [(-12, 0), (12, 0)], -1, 0, 1),
|
|
202
|
-
("driving", R, "road", [(12, 0), (30, 0)], 0, 0, 0), ("driving", R, "street", [(0, -40), (0, 40)], 0, 0, 0)])
|
|
203
|
-
lv, iv = _levels_of(db)
|
|
204
|
-
mouth1, tunnel, mouth2, street = (iv(1001 + i) for i in range(4))
|
|
205
|
-
assert lv.info["overpass_pairs"] == 1 and street[0] > tunnel[1]
|
|
206
|
-
assert mouth1[0] <= tunnel[1] and tunnel[0] <= mouth1[1] and mouth2[0] <= tunnel[1] and tunnel[0] <= mouth2[1]
|
|
207
|
-
only = _tiny_db(tmp_path / "flat.duckdb", [("driving", R, "a", [(-30, 0), (0, 0)], 0, 0, 0), ("driving", R, "b", [(0, 0), (30, 0)], 1, 0, 0)])
|
|
208
|
-
lv, iv = _levels_of(only)
|
|
209
|
-
assert lv.info["overpass_pairs"] == 0 and not lv.intervals # nothing crosses: every road stays at 0
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def test_node_levels_without_a_solver_and_on_monaco(monaco):
|
|
213
|
-
"""The heuristic runs without OR-Tools; on Monaco every overpass pair is satisfied after the conflict fixes."""
|
|
214
|
-
from mapstyle.node_levels import compute
|
|
215
|
-
h = compute(monaco, solver=False)
|
|
216
|
-
assert h.info["solver"] is False and h.info["overpass_pairs"] > 100
|
|
217
|
-
s = compute(monaco)
|
|
218
|
-
assert s.info["unsatisfied"] == 0 and s.info["given_up"] >= s.info["dropped"] and s.info["levels"] <= 12
|
|
219
|
-
assert s.info["given_up"] <= 8 # 3 in the sample; a few for another build of it
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def test_the_default_page_draws_the_order_of_the_roads(monaco, monkeypatch):
|
|
223
|
-
"""render_map gives roadstyle ``casing_level_col`` / ``fill_level_col``: even positions from the intervals, a crossing at 1 and a
|
|
224
|
-
mapped sidewalk at -1; a road cut into pieces keeps its row and appends the other pieces with ``_piece``."""
|
|
225
|
-
seen = {}
|
|
164
|
+
|
|
165
|
+
def _seen(monkeypatch, db, **kw):
|
|
166
|
+
"""The arguments and the roads that render_map gives roadstyle's render_edges."""
|
|
226
167
|
from types import SimpleNamespace
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
assert
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
168
|
+
seen = {}
|
|
169
|
+
monkeypatch.setattr(rs, "render_edges", lambda g, **k: seen.update(k, g=g) or SimpleNamespace(_tpl="</body>"))
|
|
170
|
+
render_map(db, layers=False, **kw)
|
|
171
|
+
return seen
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_without_stored_levels_roadstyle_computes_the_order(monaco, monkeypatch):
|
|
175
|
+
"""docs/design/stored_levels.md: a file without visualization.edge_levels: roadstyle computes the numbers from the complete band; every road is drawn whole."""
|
|
176
|
+
seen = _seen(monkeypatch, monaco)
|
|
177
|
+
assert seen["band_col"] == "band" and "casing_level_col" not in seen and "cap_col" not in seen
|
|
178
|
+
assert len(seen["g"]) == len(load_roads(monaco)) and "band" in seen["g"] and not seen["g"]["band"].isna().any()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_stored_levels_are_read_and_a_stale_table_is_an_error(monaco, tmp_path, monkeypatch):
|
|
182
|
+
"""With the table (duckosm levels) the page is drawn with its numbers; a table computed with other options is an error that says to run duckosm levels again."""
|
|
183
|
+
import shutil
|
|
184
|
+
|
|
185
|
+
if not DUCKOSM_EXE.exists():
|
|
186
|
+
pytest.skip("needs duckOSM's duckosm")
|
|
187
|
+
db = tmp_path / "stored.duckdb"
|
|
188
|
+
shutil.copy(monaco, db)
|
|
189
|
+
done = subprocess.run([DUCKOSM_EXE, "levels", str(db)], capture_output=True, text=True)
|
|
190
|
+
if done.returncode:
|
|
191
|
+
pytest.skip(f"duckosm levels is not available (duckOSM 0.2.0 with roadstyle 0.13.1 is needed): {done.stderr[-200:]}")
|
|
192
|
+
seen = _seen(monkeypatch, db)
|
|
193
|
+
assert [seen[k] for k in ("casing_level_col", "fill_level_col", "casing_start_col", "casing_end_col")] == ["casing_level", "fill_level", "casing_start", "casing_end"]
|
|
194
|
+
assert "band_col" not in seen and (seen["g"]["casing_level"] <= seen["g"]["fill_level"]).all()
|
|
195
|
+
assert len(seen["g"]) == len(load_roads(db))
|
|
196
|
+
assert _seen(monkeypatch, db, tiles=True)["casing_level_col"] == "casing_level" # also with vector tiles
|
|
197
|
+
subprocess.run([DUCKOSM_EXE, "levels", str(db), "--no-min-positions"], check=True, capture_output=True)
|
|
198
|
+
with pytest.raises(ValueError, match="duckosm levels"):
|
|
199
|
+
render_map(db, layers=False) # the file's numbers were computed with other options: never recomputed silently
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_the_argument_order_is_removed(monaco):
|
|
203
|
+
with pytest.raises(ValueError, match="removed"):
|
|
204
|
+
render_map(monaco, layers=False, order=True)
|
|
243
205
|
|
|
244
206
|
|
|
245
207
|
def test_pieces_of_one_road():
|
|
@@ -466,7 +428,7 @@ def test_grey_theme_is_muted_except_the_water_and_green_hints():
|
|
|
466
428
|
|
|
467
429
|
|
|
468
430
|
def test_theme_osm_is_todays_page_and_an_unknown_theme_names_the_choices(monaco):
|
|
469
|
-
assert render_map(monaco, layers=False
|
|
431
|
+
assert render_map(monaco, layers=False).html == render_map(monaco, layers=False, theme="osm").html
|
|
470
432
|
with pytest.raises(ValueError, match="unknown theme 'nope'; choose from \\('osm', 'google', 'grey'\\)"):
|
|
471
433
|
render_map(monaco, theme="nope")
|
|
472
434
|
html = render_map(monaco, theme="grey").html
|
|
@@ -1,526 +0,0 @@
|
|
|
1
|
-
"""The order in which each road's casing and fill are drawn (docs/design/node_levels.md).
|
|
2
|
-
|
|
3
|
-
Every road (both directions of a segment together) gets an interval ``[a, b]`` of integers: ``a`` is the position in the
|
|
4
|
-
drawing order where its casing is drawn and ``b`` where its fill is drawn; at each position all casings come before all fills.
|
|
5
|
-
|
|
6
|
-
* roads that share a node have intersecting intervals (so they merge cleanly);
|
|
7
|
-
* an overpass (two roads whose lines cross, no node in common, different level tags) has disjoint intervals, the upper one
|
|
8
|
-
later, so the upper road is drawn over the lower one with its own casing; an overpass that cannot be satisfied is given up.
|
|
9
|
-
|
|
10
|
-
Found with OR-Tools CP-SAT (``pip install mapstyle[solver]``); without it a heuristic is used. Only the roads within a few hops
|
|
11
|
-
of an overpass are in the problem; every other road is at position 0 (``[0, 0]``).
|
|
12
|
-
|
|
13
|
-
Two conflict-driven fixes, applied only where a conflict is left (docs/design/node_levels.md): a pair whose crossing is within
|
|
14
|
-
``EPS_NODE_M`` of an end node of either road is not an overpass; otherwise the road that changes level along itself is cut
|
|
15
|
-
at its crossing.
|
|
16
|
-
"""
|
|
17
|
-
import logging
|
|
18
|
-
from collections import defaultdict
|
|
19
|
-
|
|
20
|
-
log = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
LO, HI = -20, 20 # the ends of an interval are integers in [LO, HI]
|
|
23
|
-
EPS_NODE_M = 0.3 # a crossing this close to an end node of either road is a junction missing a node, not an overpass
|
|
24
|
-
HOPS = 4 # roads this many nodes away from an overpass road are in the problem; the others are [0, 0]
|
|
25
|
-
M_PER_DEG = 95000.0 # rough: only used to compare distances along one road
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def have_solver():
|
|
29
|
-
try:
|
|
30
|
-
import ortools # noqa: F401
|
|
31
|
-
return True
|
|
32
|
-
except ImportError:
|
|
33
|
-
return False
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# ---- the data -------------------------------------------------------------------------------------------------------
|
|
37
|
-
def load(con, union):
|
|
38
|
-
"""``(roads, lk, pairs)``. ``roads``: ``{road id: (s, t, level, length_m)}``, a road being the directed edges with the same
|
|
39
|
-
two end nodes and the same line; ``lk``: ``{edge id: road id}`` for the edges of a road with more than one; ``pairs``: the
|
|
40
|
-
overpass pairs ``(upper, lower)`` as road ids. ``con`` has the spatial extension loaded."""
|
|
41
|
-
t = lambda c: f"({c} IS NOT NULL AND lower(CAST({c} AS VARCHAR)) NOT IN ('', 'no', 'false', '0'))" # noqa: E731
|
|
42
|
-
o = "ORDER BY mode IS NULL, mode, pmode"
|
|
43
|
-
con.execute(f"""CREATE TEMP TABLE nl_e0 AS SELECT edge_id AS eid, first(source {o}) AS s, first(target {o}) AS t,
|
|
44
|
-
first(layer {o}) AS layer, first(bridge {o}) AS bridge, first(tunnel {o}) AS tunnel, first(geometry {o}) AS g
|
|
45
|
-
FROM ({union}) GROUP BY edge_id""")
|
|
46
|
-
con.execute(f"""CREATE TEMP TABLE nl_lv AS SELECT eid, s, t, g,
|
|
47
|
-
COALESCE(NULLIF(TRY_CAST(layer AS INTEGER), 0), CASE WHEN {t('bridge')} THEN 1 WHEN {t('tunnel')} THEN -1 ELSE 0 END) AS l
|
|
48
|
-
FROM nl_e0""")
|
|
49
|
-
# candidate pairs through a grid (a spatial join over a whole county takes minutes), then the exact test
|
|
50
|
-
cs = 0.001
|
|
51
|
-
con.execute(f"""CREATE TEMP TABLE nl_bb AS SELECT eid, l, CAST(floor(ST_XMin(g)/{cs}) AS BIGINT) cx0, CAST(floor(ST_XMax(g)/{cs}) AS BIGINT) cx1,
|
|
52
|
-
CAST(floor(ST_YMin(g)/{cs}) AS BIGINT) cy0, CAST(floor(ST_YMax(g)/{cs}) AS BIGINT) cy1 FROM nl_lv""")
|
|
53
|
-
con.execute("CREATE TEMP TABLE nl_cx AS SELECT eid, l, unnest(range(cx0, cx1 + 1)) i, cy0, cy1 FROM nl_bb")
|
|
54
|
-
con.execute("CREATE TEMP TABLE nl_cells AS SELECT eid, l, i, unnest(range(cy0, cy1 + 1)) j FROM nl_cx")
|
|
55
|
-
con.execute("""CREATE TEMP TABLE nl_cand AS SELECT DISTINCT a.eid ea, o.eid eo FROM nl_cells a JOIN nl_cells o
|
|
56
|
-
ON a.i = o.i AND a.j = o.j AND a.eid <> o.eid AND a.l <> 0 AND a.l <> o.l AND (o.l = 0 OR a.eid < o.eid)""")
|
|
57
|
-
raw = con.execute("""SELECT CASE WHEN a.l > o.l THEN a.eid ELSE o.eid END, CASE WHEN a.l > o.l THEN o.eid ELSE a.eid END
|
|
58
|
-
FROM nl_cand c JOIN nl_lv a ON a.eid = c.ea JOIN nl_lv o ON o.eid = c.eo
|
|
59
|
-
WHERE a.s NOT IN (o.s, o.t) AND a.t NOT IN (o.s, o.t) AND ST_Crosses(a.g, o.g)""").fetchall()
|
|
60
|
-
# roads: the two directions of a segment (same end nodes, same line reversed); the road id is the smallest edge id
|
|
61
|
-
grp = con.execute("""SELECT a.eid, min(o.eid) FROM nl_lv a JOIN nl_lv o ON least(a.s, a.t) = least(o.s, o.t)
|
|
62
|
-
AND greatest(a.s, a.t) = greatest(o.s, o.t) AND ST_Equals(a.g, o.g) GROUP BY a.eid""").fetchall()
|
|
63
|
-
lk = {str(a): str(b) for a, b in grp if a != b}
|
|
64
|
-
need = {str(x) for pr in raw for x in pr}
|
|
65
|
-
need |= {lk.get(x, x) for x in need}
|
|
66
|
-
roads = {}
|
|
67
|
-
ids = ", ".join(sorted(need)) or "0"
|
|
68
|
-
for e, s, t_, l, ln in con.execute(f"SELECT eid, s, t, l, ST_Length(g) FROM nl_lv WHERE eid IN ({ids})").fetchall():
|
|
69
|
-
roads[str(e)] = (s, t_, int(l), ln * M_PER_DEG)
|
|
70
|
-
pairs = sorted({(lk.get(str(u), str(u)), lk.get(str(l), str(l))) for u, l in raw} - {(x, x) for x in roads})
|
|
71
|
-
return roads, lk, pairs
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _neighbourhood(con, seeds, hops, lk):
|
|
75
|
-
"""Roads (as representative edge ids) within ``hops`` nodes of the roads ``seeds``: ``{road: (s, t, level, length_m)}``."""
|
|
76
|
-
rows = {}
|
|
77
|
-
frontier = set()
|
|
78
|
-
for e in seeds:
|
|
79
|
-
r = con.execute(f"SELECT eid, s, t, l, ST_Length(g) FROM nl_lv WHERE eid = {e}").fetchone()
|
|
80
|
-
rows[str(r[0])] = (r[1], r[2], int(r[3]), r[4] * M_PER_DEG)
|
|
81
|
-
frontier |= {r[1], r[2]}
|
|
82
|
-
seen = set(frontier)
|
|
83
|
-
for _ in range(hops):
|
|
84
|
-
if not frontier:
|
|
85
|
-
break
|
|
86
|
-
ns = ", ".join(str(n) for n in frontier)
|
|
87
|
-
new = set()
|
|
88
|
-
for e, s, t, l, ln in con.execute(f"SELECT eid, s, t, l, ST_Length(g) FROM nl_lv WHERE s IN ({ns}) OR t IN ({ns})").fetchall():
|
|
89
|
-
if str(e) not in lk: # the other direction of a road is not a road of its own
|
|
90
|
-
rows[str(e)] = (s, t, int(l), ln * M_PER_DEG)
|
|
91
|
-
new |= {s, t}
|
|
92
|
-
frontier = new - seen
|
|
93
|
-
seen |= new
|
|
94
|
-
return rows
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def join_pairs(items):
|
|
98
|
-
at = defaultdict(list)
|
|
99
|
-
for e, (s, t, *_) in items.items():
|
|
100
|
-
at[s].append(e)
|
|
101
|
-
if t != s:
|
|
102
|
-
at[t].append(e)
|
|
103
|
-
out = set()
|
|
104
|
-
for es in at.values():
|
|
105
|
-
for i in range(len(es)):
|
|
106
|
-
for j in range(i + 1, len(es)):
|
|
107
|
-
out.add((es[i], es[j]) if es[i] < es[j] else (es[j], es[i]))
|
|
108
|
-
return sorted(out)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# ---- the models -----------------------------------------------------------------------------------------------------
|
|
112
|
-
def _model(items, pairs, fixed, lits=False):
|
|
113
|
-
from ortools.sat.python import cp_model
|
|
114
|
-
m = cp_model.CpModel()
|
|
115
|
-
a = {e: m.NewIntVar(LO, HI, "") for e in items}
|
|
116
|
-
b = {e: m.NewIntVar(LO, HI, "") for e in items}
|
|
117
|
-
for e in items:
|
|
118
|
-
m.Add(a[e] <= b[e])
|
|
119
|
-
for e, (lo, hi) in fixed.items():
|
|
120
|
-
m.Add(a[e] == lo)
|
|
121
|
-
m.Add(b[e] == hi)
|
|
122
|
-
for x, y in join_pairs(items):
|
|
123
|
-
m.Add(a[x] <= b[y])
|
|
124
|
-
m.Add(a[y] <= b[x])
|
|
125
|
-
return m, a, b
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _solve(m, limit, workers=8):
|
|
129
|
-
from ortools.sat.python import cp_model
|
|
130
|
-
sv = cp_model.CpSolver()
|
|
131
|
-
sv.parameters.max_time_in_seconds = limit
|
|
132
|
-
sv.parameters.num_workers = workers
|
|
133
|
-
return sv, sv.Solve(m), cp_model
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def solve_penalty(items, pairs, boundary, limit=60.0, hint=None):
|
|
137
|
-
"""Minimise the number of overpass pairs given up. ``boundary``: roads that touch a road outside the problem must contain 0.
|
|
138
|
-
Returns ``({road: (a, b)}, [pairs given up], status)``."""
|
|
139
|
-
m, a, b = _model(items, pairs, {})
|
|
140
|
-
for e in boundary:
|
|
141
|
-
m.Add(a[e] <= 0)
|
|
142
|
-
m.Add(b[e] >= 0)
|
|
143
|
-
v = []
|
|
144
|
-
for i, (u, l) in enumerate(pairs):
|
|
145
|
-
q = m.NewBoolVar("")
|
|
146
|
-
v.append(q)
|
|
147
|
-
m.Add(b[l] + 1 <= a[u]).OnlyEnforceIf(q.Not())
|
|
148
|
-
if hint:
|
|
149
|
-
for e, (lo, hi) in hint.items():
|
|
150
|
-
m.AddHint(a[e], max(LO, min(HI, lo)))
|
|
151
|
-
m.AddHint(b[e], max(LO, min(HI, hi)))
|
|
152
|
-
m.Minimize(sum(v))
|
|
153
|
-
sv, st, cp = _solve(m, limit)
|
|
154
|
-
if st not in (cp.OPTIMAL, cp.FEASIBLE):
|
|
155
|
-
return None, [], sv.StatusName(st)
|
|
156
|
-
out = {e: (sv.Value(a[e]), sv.Value(b[e])) for e in items}
|
|
157
|
-
return out, [pairs[i] for i, q in enumerate(v) if sv.Value(q)], sv.StatusName(st)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def solve_hard(items, pairs, boundary, limit=60.0, rounds=20):
|
|
161
|
-
"""The overpass pairs as hard constraints. Infeasible: the conflicting sets of pairs (unsatisfiable cores, through assumptions),
|
|
162
|
-
one pair released per round until it is feasible. Returns ``(status of the first solve, [cores], [released pairs])``."""
|
|
163
|
-
released, cores, first = [], [], None
|
|
164
|
-
for _ in range(rounds):
|
|
165
|
-
m, a, b = _model(items, pairs, {})
|
|
166
|
-
for e in boundary:
|
|
167
|
-
m.Add(a[e] <= 0)
|
|
168
|
-
m.Add(b[e] >= 0)
|
|
169
|
-
lits, names = [], []
|
|
170
|
-
gone = set(released)
|
|
171
|
-
for u, l in pairs:
|
|
172
|
-
if (u, l) in gone:
|
|
173
|
-
continue
|
|
174
|
-
q = m.NewBoolVar("")
|
|
175
|
-
m.Add(b[l] + 1 <= a[u]).OnlyEnforceIf(q)
|
|
176
|
-
lits.append(q)
|
|
177
|
-
names.append((u, l))
|
|
178
|
-
m.AddAssumptions(lits)
|
|
179
|
-
sv, st, cp = _solve(m, limit)
|
|
180
|
-
first = first or sv.StatusName(st)
|
|
181
|
-
if st in (cp.OPTIMAL, cp.FEASIBLE):
|
|
182
|
-
return first, cores, released
|
|
183
|
-
idx = {lit.Index(): i for i, lit in enumerate(lits)}
|
|
184
|
-
core = [names[idx[c]] for c in sv.SufficientAssumptionsForInfeasibility() if c in idx]
|
|
185
|
-
if not core:
|
|
186
|
-
return first, cores, released
|
|
187
|
-
cores.append(core)
|
|
188
|
-
released.append(core[0])
|
|
189
|
-
return first, cores, released
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def solve_fixed(items, pairs, fixed, boundary, limit=60.0):
|
|
193
|
-
"""A feasible assignment, hard pairs; roads in ``fixed`` keep their interval; the others as short as possible."""
|
|
194
|
-
m, a, b = _model(items, pairs, fixed)
|
|
195
|
-
for e in boundary:
|
|
196
|
-
m.Add(a[e] <= 0)
|
|
197
|
-
m.Add(b[e] >= 0)
|
|
198
|
-
for u, l in pairs:
|
|
199
|
-
m.Add(b[l] + 1 <= a[u])
|
|
200
|
-
m.Minimize(sum(b[e] - a[e] for e in items if e not in fixed))
|
|
201
|
-
sv, st, cp = _solve(m, limit)
|
|
202
|
-
if st not in (cp.OPTIMAL, cp.FEASIBLE):
|
|
203
|
-
return None
|
|
204
|
-
return {e: (sv.Value(a[e]), sv.Value(b[e])) for e in items}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def compact(items, pairs, boundary, given_up, limit=60.0):
|
|
208
|
-
"""Keep at most ``given_up`` overpasses given up and make the intervals as short as possible (fewer distinct numbers)."""
|
|
209
|
-
m, a, b = _model(items, pairs, {})
|
|
210
|
-
for e in boundary:
|
|
211
|
-
m.Add(a[e] <= 0)
|
|
212
|
-
m.Add(b[e] >= 0)
|
|
213
|
-
v = []
|
|
214
|
-
for u, l in pairs:
|
|
215
|
-
q = m.NewBoolVar("")
|
|
216
|
-
v.append(q)
|
|
217
|
-
m.Add(b[l] + 1 <= a[u]).OnlyEnforceIf(q.Not())
|
|
218
|
-
m.Add(sum(v) <= given_up)
|
|
219
|
-
m.Minimize(sum(b[e] - a[e] for e in items))
|
|
220
|
-
sv, st, cp = _solve(m, limit)
|
|
221
|
-
if st not in (cp.OPTIMAL, cp.FEASIBLE):
|
|
222
|
-
return None, []
|
|
223
|
-
return ({e: (sv.Value(a[e]), sv.Value(b[e])) for e in items}, [pairs[i] for i, q in enumerate(v) if sv.Value(q)])
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def heuristic(items, pairs):
|
|
227
|
-
"""Without a solver: a level for each node (all start at 0; a raised road is lifted, any other road sunk, until every pair that
|
|
228
|
-
is not in a cycle is satisfied); a road's interval is the lowest to the highest level of its two nodes."""
|
|
229
|
-
sccs = _scc(pairs, items)
|
|
230
|
-
keep = [(u, l) for u, l in pairs if not any(sccs.get(nl) == sccs.get(nu) for nu in items[u][:2] for nl in items[l][:2])]
|
|
231
|
-
p = defaultdict(int)
|
|
232
|
-
for _ in range(100):
|
|
233
|
-
moved = False
|
|
234
|
-
for u, l in keep:
|
|
235
|
-
for nu in items[u][:2]:
|
|
236
|
-
for nl in items[l][:2]:
|
|
237
|
-
if p[nu] > p[nl]:
|
|
238
|
-
continue
|
|
239
|
-
if items[u][2] > 0:
|
|
240
|
-
p[nu] = p[nl] + 1
|
|
241
|
-
else:
|
|
242
|
-
p[nl] = p[nu] - 1
|
|
243
|
-
moved = True
|
|
244
|
-
if not moved:
|
|
245
|
-
break
|
|
246
|
-
out = {e: (max(LO, min(HI, min(p[s], p[t]))), max(LO, min(HI, max(p[s], p[t])))) for e, (s, t, *_ ) in items.items()}
|
|
247
|
-
return out, [pr for pr in pairs if pr not in set(keep)]
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def _scc(pairs, items):
|
|
251
|
-
g = defaultdict(list)
|
|
252
|
-
for u, l in pairs:
|
|
253
|
-
for nu in items[u][:2]:
|
|
254
|
-
for nl in items[l][:2]:
|
|
255
|
-
g[nl].append(nu)
|
|
256
|
-
nodes = set(g) | {x for vs in g.values() for x in vs}
|
|
257
|
-
index, low, on, stack, comp, c = {}, {}, set(), [], {}, [0]
|
|
258
|
-
for root in nodes:
|
|
259
|
-
if root in index:
|
|
260
|
-
continue
|
|
261
|
-
work = [(root, iter(g.get(root, ())))]
|
|
262
|
-
index[root] = low[root] = len(index)
|
|
263
|
-
stack.append(root)
|
|
264
|
-
on.add(root)
|
|
265
|
-
while work:
|
|
266
|
-
v, it = work[-1]
|
|
267
|
-
for w in it:
|
|
268
|
-
if w not in index:
|
|
269
|
-
index[w] = low[w] = len(index)
|
|
270
|
-
stack.append(w)
|
|
271
|
-
on.add(w)
|
|
272
|
-
work.append((w, iter(g.get(w, ()))))
|
|
273
|
-
break
|
|
274
|
-
if w in on:
|
|
275
|
-
low[v] = min(low[v], index[w])
|
|
276
|
-
else:
|
|
277
|
-
work.pop()
|
|
278
|
-
if work:
|
|
279
|
-
low[work[-1][0]] = min(low[work[-1][0]], low[v])
|
|
280
|
-
if low[v] == index[v]:
|
|
281
|
-
while True:
|
|
282
|
-
w = stack.pop()
|
|
283
|
-
on.discard(w)
|
|
284
|
-
comp[w] = c[0]
|
|
285
|
-
if w == v:
|
|
286
|
-
break
|
|
287
|
-
c[0] += 1
|
|
288
|
-
return comp
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
# ---- the whole computation ------------------------------------------------------------------------------------------
|
|
292
|
-
class Levels:
|
|
293
|
-
"""``intervals``: ``{edge_id: (casing, fill)}`` for every directed edge that is not ``(0, 0)`` (anything missing is ``(0, 0)``);
|
|
294
|
-
``cuts``: ``{road id: {"bounds": [m...], "intervals": [(casing, fill)...], "edges": [edge ids]}}``, the roads to draw in pieces
|
|
295
|
-
(piece i runs from ``bounds[i]`` to ``bounds[i + 1]`` metres along the road's line); ``info``: counts and timing."""
|
|
296
|
-
|
|
297
|
-
def __init__(self, intervals, cuts, info):
|
|
298
|
-
self.intervals, self.cuts, self.info = intervals, cuts, info
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def _near_node(geom, u, l, eps=EPS_NODE_M):
|
|
302
|
-
from shapely.geometry import Point
|
|
303
|
-
x = geom[u].intersection(geom[l])
|
|
304
|
-
pts = [] if x.is_empty else (list(x.geoms) if hasattr(x, "geoms") else [x])
|
|
305
|
-
return any(min(Point(g.coords[0]).distance(q), Point(g.coords[-1]).distance(q)) * M_PER_DEG < eps
|
|
306
|
-
for g in (geom[u], geom[l]) for q in pts)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def _cut_positions(line, uppers, geom, clear=6.0):
|
|
310
|
-
"""Where to cut ``line`` so that one piece (the one crossing the upper roads) is separate from the node ends: just before the
|
|
311
|
-
first crossing and just after the last, never into a node and never past a crossing, as far as ``clear`` metres."""
|
|
312
|
-
ts = []
|
|
313
|
-
for u in uppers:
|
|
314
|
-
x = line.intersection(geom[u])
|
|
315
|
-
pts = [] if x.is_empty else (list(x.geoms) if hasattr(x, "geoms") else [x])
|
|
316
|
-
ts += [line.project(q) * M_PER_DEG for q in pts]
|
|
317
|
-
length = line.length * M_PER_DEG
|
|
318
|
-
cuts = []
|
|
319
|
-
if ts:
|
|
320
|
-
t_min, t_max = min(ts), max(ts)
|
|
321
|
-
if t_min - 0.3 > 0.3:
|
|
322
|
-
cuts.append(t_min - min(clear, t_min - 0.3))
|
|
323
|
-
if length - t_max - 0.3 > 0.3:
|
|
324
|
-
cuts.append(t_max + min(clear, length - t_max - 0.3))
|
|
325
|
-
return cuts, length
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _compress(values):
|
|
329
|
-
"""Order-preserving integers: negative values to -1, -2 ..., positive to 1, 2 ..., zero stays zero."""
|
|
330
|
-
vs = sorted(set(values) | {0})
|
|
331
|
-
neg = [v for v in vs if v < 0]
|
|
332
|
-
pos = [v for v in vs if v > 0]
|
|
333
|
-
m = {0: 0}
|
|
334
|
-
m.update({v: -(len(neg) - i) for i, v in enumerate(neg)})
|
|
335
|
-
m.update({v: i + 1 for i, v in enumerate(pos)})
|
|
336
|
-
return m
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def compute(db, hops=HOPS, limit=60.0, solver=None):
|
|
340
|
-
"""The drawing order of the roads of the duckOSM db ``db``: a ``Levels``. ``solver``: use OR-Tools (default: if it is installed)."""
|
|
341
|
-
import time
|
|
342
|
-
|
|
343
|
-
import duckdb
|
|
344
|
-
from shapely import wkb
|
|
345
|
-
from shapely.ops import substring
|
|
346
|
-
|
|
347
|
-
from mapstyle.map import _roads_union
|
|
348
|
-
t0 = time.time()
|
|
349
|
-
use = have_solver() if solver is None else solver
|
|
350
|
-
con = duckdb.connect(str(db), read_only=True)
|
|
351
|
-
try:
|
|
352
|
-
union = _roads_union(con, db)
|
|
353
|
-
roads, lk, pairs = load(con, union)
|
|
354
|
-
info = {"overpass_pairs": len(pairs), "solver": use, "given_up": 0, "dropped": 0, "cut": 0}
|
|
355
|
-
if not pairs:
|
|
356
|
-
info["seconds"] = round(time.time() - t0, 1)
|
|
357
|
-
return Levels({}, {}, info)
|
|
358
|
-
seeds = {x for pr in pairs for x in pr}
|
|
359
|
-
items = _neighbourhood(con, seeds, hops, lk)
|
|
360
|
-
items.update({r: roads[r] for r in seeds if r in roads})
|
|
361
|
-
# roads at the edge of the problem touch roads outside it (at 0): they must contain 0
|
|
362
|
-
nodes = {n for s, t, *_ in items.values() for n in (s, t)}
|
|
363
|
-
ns = ", ".join(str(n) for n in nodes)
|
|
364
|
-
outside = set()
|
|
365
|
-
for e, s, t in con.execute(f"SELECT eid, s, t FROM nl_lv WHERE s IN ({ns}) OR t IN ({ns})").fetchall():
|
|
366
|
-
if str(e) not in items and str(e) not in lk:
|
|
367
|
-
outside |= {s, t}
|
|
368
|
-
boundary = [r for r, (s, t, *_ ) in items.items() if s in outside or t in outside]
|
|
369
|
-
info.update(roads_in_problem=len(items), boundary=len(boundary))
|
|
370
|
-
base_h, dropped_h = heuristic(items, pairs)
|
|
371
|
-
if not use:
|
|
372
|
-
iv, given = base_h, dropped_h
|
|
373
|
-
info["given_up"] = len(given)
|
|
374
|
-
res, cuts, dropped = iv, {}, []
|
|
375
|
-
else:
|
|
376
|
-
out0, given, status = solve_penalty(items, pairs, boundary, limit, hint=base_h)
|
|
377
|
-
if out0 is None:
|
|
378
|
-
raise RuntimeError(f"node levels: the solver returned {status}")
|
|
379
|
-
info["status"] = status
|
|
380
|
-
base, _ = compact(items, pairs, boundary, len(given), limit)
|
|
381
|
-
base = base or out0
|
|
382
|
-
res, cuts, dropped = base, {}, []
|
|
383
|
-
if given:
|
|
384
|
-
res, cuts, dropped, pairs = _fix_conflicts(con, items, pairs, boundary, base, limit, wkb, substring)
|
|
385
|
-
info["given_up"] = len(given)
|
|
386
|
-
info["dropped"], info["cut"] = len(dropped), len(cuts)
|
|
387
|
-
finally:
|
|
388
|
-
con.close()
|
|
389
|
-
# numbers: order-preserving, small
|
|
390
|
-
vals = [v for ab in res.values() for v in ab] + [v for c in cuts.values() for ab in c["intervals"] for v in ab]
|
|
391
|
-
cm = _compress(vals)
|
|
392
|
-
members = defaultdict(list)
|
|
393
|
-
for e, r in lk.items():
|
|
394
|
-
members[r].append(e)
|
|
395
|
-
intervals = {}
|
|
396
|
-
for r, (a, b) in res.items():
|
|
397
|
-
a, b = cm[a], cm[b]
|
|
398
|
-
if r in cuts or (a, b) == (0, 0):
|
|
399
|
-
continue
|
|
400
|
-
for e in [r] + members.get(r, []):
|
|
401
|
-
intervals[e] = (a, b)
|
|
402
|
-
for r, c in cuts.items():
|
|
403
|
-
c["intervals"] = [(cm[a], cm[b]) for a, b in c["intervals"]]
|
|
404
|
-
c["edges"] = [r] + members.get(r, [])
|
|
405
|
-
info["unsatisfied"] = len(given) if not use else 0 # after the conflict fixes the solver's drawing order is feasible
|
|
406
|
-
info["levels"] = len({v for ab in intervals.values() for v in ab} | {v for c in cuts.values() for ab in c["intervals"] for v in ab})
|
|
407
|
-
info["seconds"] = round(time.time() - t0, 1)
|
|
408
|
-
log.info("node levels: %s", info)
|
|
409
|
-
return Levels(intervals, cuts, info)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def _fix_conflicts(con, items, pairs, boundary, base, limit, wkb, substring):
|
|
413
|
-
"""The conflicts that are left (docs/design/node_levels.md): drop a pair whose crossing is next to an end node; otherwise cut the
|
|
414
|
-
road that changes level along itself. Every road outside the conflicts keeps its interval in ``base``."""
|
|
415
|
-
ids = ", ".join(sorted({x for pr in pairs for x in pr}))
|
|
416
|
-
geom = {str(e): wkb.loads(bytes(g)) for e, g in con.execute(f"SELECT eid, ST_AsWKB(g) FROM nl_lv WHERE eid IN ({ids})").fetchall()}
|
|
417
|
-
items, pairs = dict(items), list(pairs)
|
|
418
|
-
dropped, cut, affected, new_node = [], {}, set(), [-1]
|
|
419
|
-
for rnd in range(5):
|
|
420
|
-
status, cores, released = solve_hard(items, pairs, boundary, limit)
|
|
421
|
-
if not cores:
|
|
422
|
-
break
|
|
423
|
-
todo = {}
|
|
424
|
-
for core in cores:
|
|
425
|
-
affected |= {x.split("#")[0] for pr in core for x in pr}
|
|
426
|
-
near = [pr for pr in core if _near_node(geom, *pr)]
|
|
427
|
-
if near:
|
|
428
|
-
for pr in near:
|
|
429
|
-
if pr in pairs:
|
|
430
|
-
pairs.remove(pr)
|
|
431
|
-
dropped.append(pr)
|
|
432
|
-
continue
|
|
433
|
-
lowers = {}
|
|
434
|
-
for u, l in core:
|
|
435
|
-
lowers.setdefault(l, []).append(u)
|
|
436
|
-
|
|
437
|
-
def vertical(e):
|
|
438
|
-
ns = set(items[e][:2])
|
|
439
|
-
return any(abs(items[o][2] - items[e][2]) >= 2 for o in items if o != e and ns & set(items[o][:2]))
|
|
440
|
-
l = ([x for x in lowers if vertical(x)] or list(lowers))[0]
|
|
441
|
-
todo.setdefault(l, [])
|
|
442
|
-
todo[l] += [u for u in lowers[l] if u not in todo[l]]
|
|
443
|
-
for l, ups in todo.items():
|
|
444
|
-
root = l.split("#")[0]
|
|
445
|
-
if root in cut:
|
|
446
|
-
continue
|
|
447
|
-
cuts_m, length = _cut_positions(geom[l], ups, geom)
|
|
448
|
-
if not cuts_m:
|
|
449
|
-
continue
|
|
450
|
-
s_, t_, lv, ln = items[l]
|
|
451
|
-
nodes = [s_] + [new_node.append(new_node[-1] - 1) or new_node[-1] for _ in cuts_m] + [t_]
|
|
452
|
-
bounds = [0.0] + cuts_m + [length]
|
|
453
|
-
names = []
|
|
454
|
-
for i in range(len(bounds) - 1):
|
|
455
|
-
pid = f"{l}#{i}"
|
|
456
|
-
geom[pid] = substring(geom[l], bounds[i] / M_PER_DEG, bounds[i + 1] / M_PER_DEG)
|
|
457
|
-
items[pid] = (nodes[i], nodes[i + 1], lv, bounds[i + 1] - bounds[i])
|
|
458
|
-
names.append(pid)
|
|
459
|
-
del items[l]
|
|
460
|
-
newp = []
|
|
461
|
-
for u2, x2 in pairs:
|
|
462
|
-
if x2 == l:
|
|
463
|
-
newp += [(u2, pid) for pid in names if geom[u2].crosses(geom[pid])]
|
|
464
|
-
elif u2 == l:
|
|
465
|
-
newp += [(pid, x2) for pid in names if geom[x2].crosses(geom[pid])]
|
|
466
|
-
else:
|
|
467
|
-
newp.append((u2, x2))
|
|
468
|
-
pairs = sorted(set(newp))
|
|
469
|
-
cut[l] = {"bounds": bounds, "pieces": names}
|
|
470
|
-
free = set(affected) | {p for c in cut.values() for p in c["pieces"]}
|
|
471
|
-
for r in list(free):
|
|
472
|
-
ns = set(items[r][:2]) if r in items else set()
|
|
473
|
-
free |= {o for o in items if ns & set(items[o][:2])}
|
|
474
|
-
fixed = {e: base[e] for e in items if e not in free and e in base}
|
|
475
|
-
final = solve_fixed(items, pairs, fixed, boundary, limit) or solve_fixed(items, pairs, {}, boundary, limit)
|
|
476
|
-
if final is None:
|
|
477
|
-
raise RuntimeError("node levels: no feasible drawing order after the conflict fixes")
|
|
478
|
-
cuts_out = {r: {"bounds": c["bounds"], "intervals": [final[p] for p in c["pieces"]]} for r, c in cut.items()}
|
|
479
|
-
res = {e: iv for e, iv in final.items() if "#" not in e}
|
|
480
|
-
return res, cuts_out, dropped, pairs
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def with_cuts(roads, levels, scale=2):
|
|
484
|
-
"""The roads GeoDataFrame with ``_cl`` / ``_fl`` (the drawing-order positions, ``scale`` times the interval numbers so that the odd
|
|
485
|
-
positions between them stay free) and the roads that must be drawn in pieces split: the row of a directed edge of a cut road
|
|
486
|
-
becomes its first piece (so the row index the planner's graphs point to is unchanged), the other pieces are appended with
|
|
487
|
-
``_piece`` set. ``levels``: a ``Levels``."""
|
|
488
|
-
import geopandas as gpd
|
|
489
|
-
import pandas as pd
|
|
490
|
-
from shapely.ops import substring
|
|
491
|
-
|
|
492
|
-
roads = roads.copy()
|
|
493
|
-
iv = levels.intervals
|
|
494
|
-
roads["_cl"] = [scale * iv.get(e, (0, 0))[0] for e in roads["edge_id"]]
|
|
495
|
-
roads["_fl"] = [scale * iv.get(e, (0, 0))[1] for e in roads["edge_id"]]
|
|
496
|
-
roads["_piece"] = False
|
|
497
|
-
extra = []
|
|
498
|
-
for rid, c in levels.cuts.items():
|
|
499
|
-
bounds = c["bounds"]
|
|
500
|
-
total = bounds[-1]
|
|
501
|
-
rep = None
|
|
502
|
-
for e in c["edges"]:
|
|
503
|
-
idx = roads.index[roads["edge_id"] == e]
|
|
504
|
-
if len(idx) == 0:
|
|
505
|
-
continue
|
|
506
|
-
i = idx[0]
|
|
507
|
-
g = roads.at[i, "geometry"]
|
|
508
|
-
if rep is None:
|
|
509
|
-
rep = g
|
|
510
|
-
# the piece bounds run along the road's own line; a twin's line runs the other way
|
|
511
|
-
same = ((g.coords[0][0] - rep.coords[0][0]) ** 2 + (g.coords[0][1] - rep.coords[0][1]) ** 2
|
|
512
|
-
<= (g.coords[0][0] - rep.coords[-1][0]) ** 2 + (g.coords[0][1] - rep.coords[-1][1]) ** 2)
|
|
513
|
-
for n, (a, b) in enumerate(c["intervals"]):
|
|
514
|
-
lo, hi = (bounds[n], bounds[n + 1]) if same else (total - bounds[n + 1], total - bounds[n])
|
|
515
|
-
line = substring(g, lo / M_PER_DEG, hi / M_PER_DEG)
|
|
516
|
-
if n == 0:
|
|
517
|
-
roads.at[i, "geometry"], roads.at[i, "_cl"], roads.at[i, "_fl"] = line, scale * a, scale * b
|
|
518
|
-
else:
|
|
519
|
-
r = roads.loc[i].copy()
|
|
520
|
-
r["geometry"], r["_cl"], r["_fl"], r["_piece"] = line, scale * a, scale * b, True
|
|
521
|
-
if "k" in r.index:
|
|
522
|
-
r["k"] = float("nan")
|
|
523
|
-
extra.append(r)
|
|
524
|
-
if extra:
|
|
525
|
-
roads = gpd.GeoDataFrame(pd.concat([roads, pd.DataFrame(extra)], ignore_index=True), geometry="geometry", crs=roads.crs)
|
|
526
|
-
return roads
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|