Mike commited on
Commit
b443816
·
1 Parent(s): b9ac18c

fix: coastline rework

Browse files
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 src.data import load_zones_geojson, load_passages, load_stations_raw
 
 
 
 
 
 
 
 
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
- return zones, passages, stations
 
 
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 sample_shore_candidates, sample_shore_points
 
 
 
 
 
 
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
- "sample_shore_points",
 
 
 
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 (boundary of water polygons)."""
 
 
 
 
 
 
 
 
 
 
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 load_water_polygon
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(lambda lon, lat, z=None: (lon * METERS_PER_DEG_LON, lat * METERS_PER_DEG_LAT), geom)
 
 
 
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 sample_shore_points(step_m: float = 300.0) -> tuple[np.ndarray, np.ndarray]:
37
- """Walk along the water boundary in local meters and sample every `step_m`.
38
-
39
- Returns (lats, lons). Includes outer shores AND interior holes (Кронштадт).
40
- """
 
41
  if step_m <= 0:
42
  raise ValueError("step_m must be positive")
43
 
44
- water = load_water_polygon()
45
- boundary_m = _to_meters(water.boundary)
46
-
47
  lats: list[float] = []
48
  lons: list[float] = []
49
- for line in _iter_linestrings(boundary_m):
50
  L = line.length
51
- if L <= 0:
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 sample_shore_candidates(
 
 
 
 
 
 
 
 
 
 
64
  *,
65
- step_m: float = 300.0,
66
- speed_kmh: float = 40.0,
 
 
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"shore_{i:04d}" for i in range(len(lat))]
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 = set(int(i) for i in exclude_grid_indices)
85
  keep = np.array([int(i) not in excl for i in placements.grid_index], dtype=bool)
86
- if not keep.all():
87
- placements = PlacementSet(
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 = PlacementSet(
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)