Mike commited on
Commit ·
b443816
1
Parent(s): b9ac18c
fix: coastline rework
Browse files- data/kronshtadt_cuts.geojson +38 -0
- pages/1_map.py +42 -4
- src/data.py +101 -1
- src/optimization/__init__.py +11 -2
- src/optimization/candidates.py +131 -45
data/kronshtadt_cuts.geojson
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "FeatureCollection",
|
| 3 |
+
"features": [
|
| 4 |
+
{
|
| 5 |
+
"type": "Feature",
|
| 6 |
+
"properties": {"name": "north-east ear cut"},
|
| 7 |
+
"geometry": {
|
| 8 |
+
"type": "LineString",
|
| 9 |
+
"coordinates": [
|
| 10 |
+
[29.761036841326387, 60.021661076922044],
|
| 11 |
+
[29.762484232397355, 60.01903326732494]
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"type": "Feature",
|
| 17 |
+
"properties": {"name": "south-west ear cut"},
|
| 18 |
+
"geometry": {
|
| 19 |
+
"type": "LineString",
|
| 20 |
+
"coordinates": [
|
| 21 |
+
[29.686663466388694, 59.993833135687055],
|
| 22 |
+
[29.70662355596363, 59.989413501298884]
|
| 23 |
+
]
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"type": "Feature",
|
| 28 |
+
"properties": {"name": "first northern port"},
|
| 29 |
+
"geometry": {
|
| 30 |
+
"type": "LineString",
|
| 31 |
+
"coordinates": [
|
| 32 |
+
[29.743139525632785, 60.02137813492695],
|
| 33 |
+
[29.746746668132545, 60.02145744067599]
|
| 34 |
+
]
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
}
|
pages/1_map.py
CHANGED
|
@@ -3,7 +3,15 @@
|
|
| 3 |
import streamlit as st
|
| 4 |
import pydeck as pdk
|
| 5 |
|
| 6 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from src.config import get_config_value, MAPBOX_TOKEN
|
| 8 |
|
| 9 |
st.set_page_config(page_title="Карта", layout="wide")
|
|
@@ -15,10 +23,12 @@ def get_data():
|
|
| 15 |
zones = load_zones_geojson()
|
| 16 |
passages = load_passages()
|
| 17 |
stations = load_stations_raw()
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
-
zones, passages, stations = get_data()
|
| 22 |
station_charset = '"' + "".join(sorted({ch for s in stations for ch in s["name"]})) + '"'
|
| 23 |
|
| 24 |
zones_colored = {
|
|
@@ -55,6 +65,28 @@ zone_layer = pdk.Layer(
|
|
| 55 |
pickable=True,
|
| 56 |
)
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
passage_layer = pdk.Layer(
|
| 59 |
"ScatterplotLayer",
|
| 60 |
data=passages_data,
|
|
@@ -91,7 +123,7 @@ map_style = get_config_value("map_style")
|
|
| 91 |
|
| 92 |
st.pydeck_chart(
|
| 93 |
pdk.Deck(
|
| 94 |
-
layers=[zone_layer, passage_layer, station_layer, station_labels],
|
| 95 |
initial_view_state=view,
|
| 96 |
tooltip={"text": "{name}"},
|
| 97 |
map_style=map_style,
|
|
@@ -99,3 +131,9 @@ st.pydeck_chart(
|
|
| 99 |
),
|
| 100 |
height=800,
|
| 101 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import streamlit as st
|
| 4 |
import pydeck as pdk
|
| 5 |
|
| 6 |
+
from shapely.geometry import mapping
|
| 7 |
+
|
| 8 |
+
from src.data import (
|
| 9 |
+
load_kronshtadt_outline,
|
| 10 |
+
load_passages,
|
| 11 |
+
load_shoreline,
|
| 12 |
+
load_stations_raw,
|
| 13 |
+
load_zones_geojson,
|
| 14 |
+
)
|
| 15 |
from src.config import get_config_value, MAPBOX_TOKEN
|
| 16 |
|
| 17 |
st.set_page_config(page_title="Карта", layout="wide")
|
|
|
|
| 23 |
zones = load_zones_geojson()
|
| 24 |
passages = load_passages()
|
| 25 |
stations = load_stations_raw()
|
| 26 |
+
mainland_geom = mapping(load_shoreline())
|
| 27 |
+
kron_geom = mapping(load_kronshtadt_outline())
|
| 28 |
+
return zones, passages, stations, mainland_geom, kron_geom
|
| 29 |
|
| 30 |
|
| 31 |
+
zones, passages, stations, mainland_geom, kron_geom = get_data()
|
| 32 |
station_charset = '"' + "".join(sorted({ch for s in stations for ch in s["name"]})) + '"'
|
| 33 |
|
| 34 |
zones_colored = {
|
|
|
|
| 65 |
pickable=True,
|
| 66 |
)
|
| 67 |
|
| 68 |
+
mainland_layer = pdk.Layer(
|
| 69 |
+
"GeoJsonLayer",
|
| 70 |
+
data={"type": "Feature", "geometry": mainland_geom, "properties": {"name": "Берег материка"}},
|
| 71 |
+
stroked=True,
|
| 72 |
+
filled=False,
|
| 73 |
+
get_line_color=[230, 30, 60, 80], # magenta-red, translucent
|
| 74 |
+
get_line_width=70,
|
| 75 |
+
line_width_min_pixels=2,
|
| 76 |
+
pickable=True,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
kronshtadt_layer = pdk.Layer(
|
| 80 |
+
"GeoJsonLayer",
|
| 81 |
+
data={"type": "Feature", "geometry": kron_geom, "properties": {"name": "Берег Кронштадта"}},
|
| 82 |
+
stroked=True,
|
| 83 |
+
filled=False,
|
| 84 |
+
get_line_color=[20, 200, 230, 80], # cyan, translucent
|
| 85 |
+
get_line_width=70,
|
| 86 |
+
line_width_min_pixels=2,
|
| 87 |
+
pickable=True,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
passage_layer = pdk.Layer(
|
| 91 |
"ScatterplotLayer",
|
| 92 |
data=passages_data,
|
|
|
|
| 123 |
|
| 124 |
st.pydeck_chart(
|
| 125 |
pdk.Deck(
|
| 126 |
+
layers=[zone_layer, mainland_layer, kronshtadt_layer, passage_layer, station_layer, station_labels],
|
| 127 |
initial_view_state=view,
|
| 128 |
tooltip={"text": "{name}"},
|
| 129 |
map_style=map_style,
|
|
|
|
| 131 |
),
|
| 132 |
height=800,
|
| 133 |
)
|
| 134 |
+
|
| 135 |
+
st.caption(
|
| 136 |
+
"🟥 Берег материка (`shoreline.geojson`) — источник кандидатов «материк». "
|
| 137 |
+
"🟦 Контур Кронштадта (включая прилегающие сегменты КЗС) — источник кандидатов «остров». "
|
| 138 |
+
"Тёмные обводы зон — полные границы акватории (туда станции уже не сэмплируются)."
|
| 139 |
+
)
|
src/data.py
CHANGED
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
from shapely import contains_xy
|
| 10 |
-
from shapely.geometry import shape
|
| 11 |
from shapely.ops import unary_union
|
| 12 |
|
| 13 |
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
|
@@ -89,6 +89,106 @@ def load_zone_polygons() -> list[tuple[str, any]]:
|
|
| 89 |
]
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
@lru_cache(maxsize=1)
|
| 93 |
def _north_zone_union():
|
| 94 |
return unary_union([p for name, p in load_zone_polygons() if name == "north"])
|
|
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
from shapely import contains_xy
|
| 10 |
+
from shapely.geometry import LineString, MultiLineString, Point, box, shape
|
| 11 |
from shapely.ops import unary_union
|
| 12 |
|
| 13 |
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
|
|
|
| 89 |
]
|
| 90 |
|
| 91 |
|
| 92 |
+
# Bounding box around Kronshtadt island (incl. КЗС causeway segments alongside it).
|
| 93 |
+
# Used to extract the island outline from zone outer rings.
|
| 94 |
+
KRONSHTADT_BBOX = (29.62, 59.97, 29.83, 60.04) # (lon_min, lat_min, lon_max, lat_max)
|
| 95 |
+
|
| 96 |
+
# Minimum length (in degrees) for a Kronshtadt outline segment to be kept.
|
| 97 |
+
# Drops tiny artifact runs (~21 pts near С-2 entry).
|
| 98 |
+
_KRONSHTADT_MIN_PART_LEN_DEG = 0.005
|
| 99 |
+
|
| 100 |
+
# Tolerance for detecting whether a fragment touches an "ear tip" (endpoint of
|
| 101 |
+
# the original outer-ring run inside the bbox). ~10m at this latitude.
|
| 102 |
+
_KRONSHTADT_EAR_TOL_DEG = 1e-4
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@lru_cache(maxsize=1)
|
| 106 |
+
def load_kronshtadt_cuts():
|
| 107 |
+
"""User-provided line segments that trim КЗС "ears" from the Kronshtadt outline."""
|
| 108 |
+
with open(DATA_DIR / "kronshtadt_cuts.geojson", encoding="utf-8") as f:
|
| 109 |
+
return json.load(f)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _extract_runs_in_bbox(bbox) -> list[LineString]:
|
| 113 |
+
runs: list[LineString] = []
|
| 114 |
+
for _zone_name, polygon in load_zone_polygons():
|
| 115 |
+
coords = list(polygon.exterior.coords)
|
| 116 |
+
cur: list[tuple[float, float]] = []
|
| 117 |
+
for x, y in coords:
|
| 118 |
+
if bbox.covers(Point(x, y)):
|
| 119 |
+
cur.append((x, y))
|
| 120 |
+
else:
|
| 121 |
+
if len(cur) >= 2:
|
| 122 |
+
runs.append(LineString(cur))
|
| 123 |
+
cur = []
|
| 124 |
+
if len(cur) >= 2:
|
| 125 |
+
runs.append(LineString(cur))
|
| 126 |
+
return runs
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@lru_cache(maxsize=1)
|
| 130 |
+
def load_kronshtadt_outline():
|
| 131 |
+
"""Outline of Kronshtadt as a MultiLineString.
|
| 132 |
+
|
| 133 |
+
Pipeline:
|
| 134 |
+
1. Take north + south zone outer rings restricted to KRONSHTADT_BBOX.
|
| 135 |
+
2. Split each run at intersections with cuts from `kronshtadt_cuts.geojson`.
|
| 136 |
+
3. Drop fragments that touch an original run endpoint — these are the КЗС
|
| 137 |
+
causeway "ears" approaching С-1 / С-2.
|
| 138 |
+
4. Drop fragments whose both endpoints lie on the SAME cut — these are
|
| 139 |
+
inlets (ports) where the contour wraps in and out across one cut line.
|
| 140 |
+
5. Drop fragments shorter than `_KRONSHTADT_MIN_PART_LEN_DEG`.
|
| 141 |
+
"""
|
| 142 |
+
from shapely.ops import split, unary_union
|
| 143 |
+
|
| 144 |
+
bbox = box(*KRONSHTADT_BBOX)
|
| 145 |
+
raw_runs = _extract_runs_in_bbox(bbox)
|
| 146 |
+
if not raw_runs:
|
| 147 |
+
raise RuntimeError("Kronshtadt outline extraction returned no segments")
|
| 148 |
+
|
| 149 |
+
ear_tips = []
|
| 150 |
+
for run in raw_runs:
|
| 151 |
+
ear_tips.append(Point(run.coords[0]))
|
| 152 |
+
ear_tips.append(Point(run.coords[-1]))
|
| 153 |
+
|
| 154 |
+
cuts_geojson = load_kronshtadt_cuts()
|
| 155 |
+
cut_lines = [shape(f["geometry"]) for f in cuts_geojson["features"]]
|
| 156 |
+
cuts_union = unary_union(cut_lines) if cut_lines else None
|
| 157 |
+
|
| 158 |
+
def closest_cut_idx(pt: Point) -> int | None:
|
| 159 |
+
if not cut_lines:
|
| 160 |
+
return None
|
| 161 |
+
dists = [pt.distance(c) for c in cut_lines]
|
| 162 |
+
return int(min(range(len(cut_lines)), key=lambda i: dists[i]))
|
| 163 |
+
|
| 164 |
+
kept: list[LineString] = []
|
| 165 |
+
for run in raw_runs:
|
| 166 |
+
pieces = [run]
|
| 167 |
+
if cuts_union is not None and run.intersects(cuts_union):
|
| 168 |
+
split_result = split(run, cuts_union)
|
| 169 |
+
pieces = [g for g in split_result.geoms if isinstance(g, LineString)]
|
| 170 |
+
for piece in pieces:
|
| 171 |
+
if piece.length < _KRONSHTADT_MIN_PART_LEN_DEG:
|
| 172 |
+
continue
|
| 173 |
+
if any(piece.distance(t) < _KRONSHTADT_EAR_TOL_DEG for t in ear_tips):
|
| 174 |
+
continue
|
| 175 |
+
# Detect inlet: both ends lie on the same cut LineString (within tol).
|
| 176 |
+
if cut_lines:
|
| 177 |
+
a = Point(piece.coords[0])
|
| 178 |
+
b = Point(piece.coords[-1])
|
| 179 |
+
ia, ib = closest_cut_idx(a), closest_cut_idx(b)
|
| 180 |
+
if ia is not None and ia == ib:
|
| 181 |
+
da = a.distance(cut_lines[ia])
|
| 182 |
+
db = b.distance(cut_lines[ib])
|
| 183 |
+
if da < _KRONSHTADT_EAR_TOL_DEG and db < _KRONSHTADT_EAR_TOL_DEG:
|
| 184 |
+
continue
|
| 185 |
+
kept.append(piece)
|
| 186 |
+
|
| 187 |
+
if not kept:
|
| 188 |
+
raise RuntimeError("Kronshtadt outline: nothing left after cuts")
|
| 189 |
+
return MultiLineString(kept)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
@lru_cache(maxsize=1)
|
| 193 |
def _north_zone_union():
|
| 194 |
return unary_union([p for name, p in load_zone_polygons() if name == "north"])
|
src/optimization/__init__.py
CHANGED
|
@@ -10,7 +10,13 @@ Public API:
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from .algorithms import Solution, greedy, greedy_then_swap, local_swap
|
| 13 |
-
from .candidates import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from .objective import (
|
| 15 |
SeparableObjective,
|
| 16 |
expected_failure,
|
|
@@ -29,7 +35,10 @@ __all__ = [
|
|
| 29 |
"from_stations",
|
| 30 |
"attach_travel_times",
|
| 31 |
"sample_shore_candidates",
|
| 32 |
-
"
|
|
|
|
|
|
|
|
|
|
| 33 |
"Problem",
|
| 34 |
"Solution",
|
| 35 |
"greedy",
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from .algorithms import Solution, greedy, greedy_then_swap, local_swap
|
| 13 |
+
from .candidates import (
|
| 14 |
+
sample_kronshtadt_candidates,
|
| 15 |
+
sample_kronshtadt_points,
|
| 16 |
+
sample_mainland_candidates,
|
| 17 |
+
sample_mainland_points,
|
| 18 |
+
sample_shore_candidates,
|
| 19 |
+
)
|
| 20 |
from .objective import (
|
| 21 |
SeparableObjective,
|
| 22 |
expected_failure,
|
|
|
|
| 35 |
"from_stations",
|
| 36 |
"attach_travel_times",
|
| 37 |
"sample_shore_candidates",
|
| 38 |
+
"sample_mainland_candidates",
|
| 39 |
+
"sample_mainland_points",
|
| 40 |
+
"sample_kronshtadt_candidates",
|
| 41 |
+
"sample_kronshtadt_points",
|
| 42 |
"Problem",
|
| 43 |
"Solution",
|
| 44 |
"greedy",
|
src/optimization/candidates.py
CHANGED
|
@@ -1,4 +1,14 @@
|
|
| 1 |
-
"""Sample candidate placements along the shore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from typing import Sequence
|
| 4 |
|
|
@@ -7,13 +17,16 @@ from scipy.sparse import csr_matrix
|
|
| 7 |
from shapely.geometry import LineString, MultiLineString
|
| 8 |
from shapely.ops import transform
|
| 9 |
|
| 10 |
-
from ..data import
|
| 11 |
from ..grid import METERS_PER_DEG_LAT, METERS_PER_DEG_LON
|
| 12 |
from .placement import PlacementSet, attach_travel_times
|
| 13 |
|
| 14 |
|
| 15 |
def _to_meters(geom):
|
| 16 |
-
return transform(
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def _from_meters(x: float, y: float) -> tuple[float, float]:
|
|
@@ -33,22 +46,21 @@ def _iter_linestrings(geom) -> list[LineString]:
|
|
| 33 |
raise TypeError(f"unsupported boundary geometry: {type(geom).__name__}")
|
| 34 |
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
| 41 |
if step_m <= 0:
|
| 42 |
raise ValueError("step_m must be positive")
|
| 43 |
|
| 44 |
-
|
| 45 |
-
boundary_m = _to_meters(water.boundary)
|
| 46 |
-
|
| 47 |
lats: list[float] = []
|
| 48 |
lons: list[float] = []
|
| 49 |
-
for line in _iter_linestrings(
|
| 50 |
L = line.length
|
| 51 |
-
if L <
|
| 52 |
continue
|
| 53 |
n_steps = max(1, int(np.floor(L / step_m)))
|
| 54 |
for k in range(n_steps):
|
|
@@ -56,54 +68,128 @@ def sample_shore_points(step_m: float = 300.0) -> tuple[np.ndarray, np.ndarray]:
|
|
| 56 |
la, lo = _from_meters(pt.x, pt.y)
|
| 57 |
lats.append(la)
|
| 58 |
lons.append(lo)
|
| 59 |
-
|
| 60 |
return np.asarray(lats, dtype=np.float64), np.asarray(lons, dtype=np.float64)
|
| 61 |
|
| 62 |
|
| 63 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
*,
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
graph: csr_matrix,
|
| 68 |
grid_lats: np.ndarray,
|
| 69 |
grid_lons: np.ndarray,
|
| 70 |
-
exclude_grid_indices: Sequence[int]
|
| 71 |
) -> PlacementSet:
|
| 72 |
-
"""Sample shore candidates and attach precomputed travel times."""
|
| 73 |
-
lat, lon = sample_shore_points(step_m=step_m)
|
| 74 |
speed = np.full(len(lat), float(speed_kmh), dtype=np.float64)
|
| 75 |
-
labels = [f"
|
| 76 |
-
|
| 77 |
placements = attach_travel_times(
|
| 78 |
lat=lat, lon=lon, speed_kmh=speed, labels=labels,
|
| 79 |
graph=graph, grid_lats=grid_lats, grid_lons=grid_lons,
|
| 80 |
)
|
| 81 |
-
|
| 82 |
-
# Drop candidates that snap onto a cell already occupied by an existing station
|
| 83 |
if len(exclude_grid_indices):
|
| 84 |
-
excl =
|
| 85 |
keep = np.array([int(i) not in excl for i in placements.grid_index], dtype=bool)
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
lat=placements.lat[keep],
|
| 89 |
-
lon=placements.lon[keep],
|
| 90 |
-
speed_kmh=placements.speed_kmh[keep],
|
| 91 |
-
grid_index=placements.grid_index[keep],
|
| 92 |
-
travel_times=placements.travel_times[keep],
|
| 93 |
-
labels=[lbl for lbl, k in zip(placements.labels, keep) if k],
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
# Deduplicate candidates that snapped to the same grid cell — keep first
|
| 97 |
_, first_idx = np.unique(placements.grid_index, return_index=True)
|
| 98 |
first_idx = np.sort(first_idx)
|
| 99 |
if len(first_idx) != placements.K:
|
| 100 |
-
placements =
|
| 101 |
-
lat=placements.lat[first_idx],
|
| 102 |
-
lon=placements.lon[first_idx],
|
| 103 |
-
speed_kmh=placements.speed_kmh[first_idx],
|
| 104 |
-
grid_index=placements.grid_index[first_idx],
|
| 105 |
-
travel_times=placements.travel_times[first_idx],
|
| 106 |
-
labels=[placements.labels[i] for i in first_idx],
|
| 107 |
-
)
|
| 108 |
-
|
| 109 |
return placements
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sample candidate placements along the mainland shore and the Kronshtadt outline.
|
| 2 |
+
|
| 3 |
+
Two physical sources:
|
| 4 |
+
- Mainland coast — `load_shoreline()` (curated north + south mainland LineStrings).
|
| 5 |
+
- Kronshtadt outline — `load_kronshtadt_outline()` (extracted from zone outer rings
|
| 6 |
+
inside the Kronshtadt bbox; includes adjacent КЗС causeway segments).
|
| 7 |
+
|
| 8 |
+
`sample_shore_candidates` returns the union of both. Other water-boundary points
|
| 9 |
+
(small islands, the dam outside Kronshtadt) are intentionally excluded — placing a
|
| 10 |
+
rescue station there is not physically meaningful.
|
| 11 |
+
"""
|
| 12 |
|
| 13 |
from typing import Sequence
|
| 14 |
|
|
|
|
| 17 |
from shapely.geometry import LineString, MultiLineString
|
| 18 |
from shapely.ops import transform
|
| 19 |
|
| 20 |
+
from ..data import load_kronshtadt_outline, load_shoreline
|
| 21 |
from ..grid import METERS_PER_DEG_LAT, METERS_PER_DEG_LON
|
| 22 |
from .placement import PlacementSet, attach_travel_times
|
| 23 |
|
| 24 |
|
| 25 |
def _to_meters(geom):
|
| 26 |
+
return transform(
|
| 27 |
+
lambda lon, lat, z=None: (lon * METERS_PER_DEG_LON, lat * METERS_PER_DEG_LAT),
|
| 28 |
+
geom,
|
| 29 |
+
)
|
| 30 |
|
| 31 |
|
| 32 |
def _from_meters(x: float, y: float) -> tuple[float, float]:
|
|
|
|
| 46 |
raise TypeError(f"unsupported boundary geometry: {type(geom).__name__}")
|
| 47 |
|
| 48 |
|
| 49 |
+
def _sample_along(
|
| 50 |
+
geom,
|
| 51 |
+
step_m: float,
|
| 52 |
+
min_segment_m: float = 200.0,
|
| 53 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 54 |
+
"""Walk every linestring in `geom` (lon/lat) and emit a point every step_m meters."""
|
| 55 |
if step_m <= 0:
|
| 56 |
raise ValueError("step_m must be positive")
|
| 57 |
|
| 58 |
+
geom_m = _to_meters(geom)
|
|
|
|
|
|
|
| 59 |
lats: list[float] = []
|
| 60 |
lons: list[float] = []
|
| 61 |
+
for line in _iter_linestrings(geom_m):
|
| 62 |
L = line.length
|
| 63 |
+
if L < min_segment_m:
|
| 64 |
continue
|
| 65 |
n_steps = max(1, int(np.floor(L / step_m)))
|
| 66 |
for k in range(n_steps):
|
|
|
|
| 68 |
la, lo = _from_meters(pt.x, pt.y)
|
| 69 |
lats.append(la)
|
| 70 |
lons.append(lo)
|
|
|
|
| 71 |
return np.asarray(lats, dtype=np.float64), np.asarray(lons, dtype=np.float64)
|
| 72 |
|
| 73 |
|
| 74 |
+
def sample_mainland_points(step_m: float = 300.0) -> tuple[np.ndarray, np.ndarray]:
|
| 75 |
+
"""Sample the mainland (`shoreline.geojson`) at constant arclength."""
|
| 76 |
+
return _sample_along(load_shoreline(), step_m=step_m)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def sample_kronshtadt_points(step_m: float = 300.0) -> tuple[np.ndarray, np.ndarray]:
|
| 80 |
+
"""Sample the Kronshtadt outline (extracted from zone outer rings)."""
|
| 81 |
+
return _sample_along(load_kronshtadt_outline(), step_m=step_m)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _build(
|
| 85 |
*,
|
| 86 |
+
lat: np.ndarray,
|
| 87 |
+
lon: np.ndarray,
|
| 88 |
+
speed_kmh: float,
|
| 89 |
+
label_prefix: str,
|
| 90 |
graph: csr_matrix,
|
| 91 |
grid_lats: np.ndarray,
|
| 92 |
grid_lons: np.ndarray,
|
| 93 |
+
exclude_grid_indices: Sequence[int],
|
| 94 |
) -> PlacementSet:
|
|
|
|
|
|
|
| 95 |
speed = np.full(len(lat), float(speed_kmh), dtype=np.float64)
|
| 96 |
+
labels = [f"{label_prefix}_{i:04d}" for i in range(len(lat))]
|
|
|
|
| 97 |
placements = attach_travel_times(
|
| 98 |
lat=lat, lon=lon, speed_kmh=speed, labels=labels,
|
| 99 |
graph=graph, grid_lats=grid_lats, grid_lons=grid_lons,
|
| 100 |
)
|
|
|
|
|
|
|
| 101 |
if len(exclude_grid_indices):
|
| 102 |
+
excl = {int(i) for i in exclude_grid_indices}
|
| 103 |
keep = np.array([int(i) not in excl for i in placements.grid_index], dtype=bool)
|
| 104 |
+
placements = _select(placements, keep)
|
| 105 |
+
# Dedupe candidates that snapped to the same grid cell — keep first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
_, first_idx = np.unique(placements.grid_index, return_index=True)
|
| 107 |
first_idx = np.sort(first_idx)
|
| 108 |
if len(first_idx) != placements.K:
|
| 109 |
+
placements = _select_by_index(placements, first_idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return placements
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _select(p: PlacementSet, mask: np.ndarray) -> PlacementSet:
|
| 114 |
+
return PlacementSet(
|
| 115 |
+
lat=p.lat[mask], lon=p.lon[mask], speed_kmh=p.speed_kmh[mask],
|
| 116 |
+
grid_index=p.grid_index[mask], travel_times=p.travel_times[mask],
|
| 117 |
+
labels=[lbl for lbl, k in zip(p.labels, mask) if k],
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _select_by_index(p: PlacementSet, idx: np.ndarray) -> PlacementSet:
|
| 122 |
+
return PlacementSet(
|
| 123 |
+
lat=p.lat[idx], lon=p.lon[idx], speed_kmh=p.speed_kmh[idx],
|
| 124 |
+
grid_index=p.grid_index[idx], travel_times=p.travel_times[idx],
|
| 125 |
+
labels=[p.labels[int(i)] for i in idx],
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _concat(a: PlacementSet, b: PlacementSet) -> PlacementSet:
|
| 130 |
+
return PlacementSet(
|
| 131 |
+
lat=np.concatenate([a.lat, b.lat]),
|
| 132 |
+
lon=np.concatenate([a.lon, b.lon]),
|
| 133 |
+
speed_kmh=np.concatenate([a.speed_kmh, b.speed_kmh]),
|
| 134 |
+
grid_index=np.concatenate([a.grid_index, b.grid_index]),
|
| 135 |
+
travel_times=np.concatenate([a.travel_times, b.travel_times], axis=0),
|
| 136 |
+
labels=list(a.labels) + list(b.labels),
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def sample_mainland_candidates(
|
| 141 |
+
*,
|
| 142 |
+
step_m: float = 300.0,
|
| 143 |
+
speed_kmh: float = 40.0,
|
| 144 |
+
graph: csr_matrix,
|
| 145 |
+
grid_lats: np.ndarray,
|
| 146 |
+
grid_lons: np.ndarray,
|
| 147 |
+
exclude_grid_indices: Sequence[int] = (),
|
| 148 |
+
) -> PlacementSet:
|
| 149 |
+
lat, lon = sample_mainland_points(step_m=step_m)
|
| 150 |
+
return _build(
|
| 151 |
+
lat=lat, lon=lon, speed_kmh=speed_kmh, label_prefix="main",
|
| 152 |
+
graph=graph, grid_lats=grid_lats, grid_lons=grid_lons,
|
| 153 |
+
exclude_grid_indices=exclude_grid_indices,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def sample_kronshtadt_candidates(
|
| 158 |
+
*,
|
| 159 |
+
step_m: float = 300.0,
|
| 160 |
+
speed_kmh: float = 40.0,
|
| 161 |
+
graph: csr_matrix,
|
| 162 |
+
grid_lats: np.ndarray,
|
| 163 |
+
grid_lons: np.ndarray,
|
| 164 |
+
exclude_grid_indices: Sequence[int] = (),
|
| 165 |
+
) -> PlacementSet:
|
| 166 |
+
lat, lon = sample_kronshtadt_points(step_m=step_m)
|
| 167 |
+
return _build(
|
| 168 |
+
lat=lat, lon=lon, speed_kmh=speed_kmh, label_prefix="kron",
|
| 169 |
+
graph=graph, grid_lats=grid_lats, grid_lons=grid_lons,
|
| 170 |
+
exclude_grid_indices=exclude_grid_indices,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def sample_shore_candidates(
|
| 175 |
+
*,
|
| 176 |
+
step_m: float = 300.0,
|
| 177 |
+
speed_kmh: float = 40.0,
|
| 178 |
+
graph: csr_matrix,
|
| 179 |
+
grid_lats: np.ndarray,
|
| 180 |
+
grid_lons: np.ndarray,
|
| 181 |
+
exclude_grid_indices: Sequence[int] = (),
|
| 182 |
+
) -> PlacementSet:
|
| 183 |
+
"""Mainland coast + Kronshtadt outline, both at the same `step_m`."""
|
| 184 |
+
mainland = sample_mainland_candidates(
|
| 185 |
+
step_m=step_m, speed_kmh=speed_kmh,
|
| 186 |
+
graph=graph, grid_lats=grid_lats, grid_lons=grid_lons,
|
| 187 |
+
exclude_grid_indices=exclude_grid_indices,
|
| 188 |
+
)
|
| 189 |
+
kron = sample_kronshtadt_candidates(
|
| 190 |
+
step_m=step_m, speed_kmh=speed_kmh,
|
| 191 |
+
graph=graph, grid_lats=grid_lats, grid_lons=grid_lons,
|
| 192 |
+
exclude_grid_indices=tuple(int(i) for i in exclude_grid_indices)
|
| 193 |
+
+ tuple(int(i) for i in mainland.grid_index),
|
| 194 |
+
)
|
| 195 |
+
return _concat(mainland, kron)
|