Add bus whitelist, route exclusions, crow-fly shape filter, and KML export

- modes.yaml: bus_whitelist (A-buses + selected refs) and exclude list
  (Snälltåget 083 night train — sparse crow-fly stubs)
- prepare.py: drop degenerate crow-fly shapes below 0.05 pts/km; apply
  bus whitelist and route exclusions from modes.yaml
- export_google_mymaps.py: new script — master.gpkg -> KML for Google
  My Maps import (stops, routes, boundary, coastline layers)
- coastline.kml: reference coastline layer for the KML export
- .gitignore: also ignore generated output/*.kml
This commit is contained in:
marvin
2026-09-18 00:24:37 +02:00
parent a6715eaff5
commit a16816bf73
5 changed files with 611 additions and 4 deletions
+84 -3
View File
@@ -39,10 +39,38 @@ STATION_STYLES = {"metro", "s_tog", "light_rail", "regional"}
# within this distance. Routes have variants; without this check a stop can
# end up further from the map than the variant we chose not to draw.
STOP_LINE_MARGIN_M = 300
# Shapes sparser than this (points per km) are "crow-fly" placeholders from
# the feed (a handful of points for a several-hundred-km line) — they render
# as straight cuts across the map. Real rail/bus geometry is >= 0.5 pts/km.
MIN_SHAPE_POINTS_PER_KM = 0.05
def load_modes():
return yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
return _load_modes_yaml().get("modes", {})
def load_exclude():
"""Hard route blacklist from modes.yaml: list of (agency, ref)."""
return [(e["agency"], str(e["ref"]))
for e in _load_modes_yaml().get("exclude", [])]
def load_bus_whitelist():
"""Bus whitelist from modes.yaml: dict with categories/refs sets.
Returns None when the section is absent (= include all buses).
"""
wl = _load_modes_yaml().get("bus_whitelist")
if wl is None:
return None
return {
"categories": set(wl.get("categories") or []),
"refs": {str(r) for r in (wl.get("refs") or [])},
}
def _load_modes_yaml():
return yaml.safe_load((CONFIG / "modes.yaml").read_text())
def load_styling():
@@ -66,7 +94,7 @@ def classify(agency, route_type, modes):
return None
def load_routes(modes):
def load_routes(modes, exclude=()):
"""route_colors.csv joined with agency names and classified by style."""
agencies = pd.read_csv(GTFS_RAW / "agency.txt", dtype=str)
agency_names = dict(zip(agencies["agency_id"], agencies["agency_name"]))
@@ -77,6 +105,17 @@ def load_routes(modes):
classify(a, t, modes)
for a, t in zip(routes["agency_name"], routes["route_type"])
]
if exclude:
mask = [
(a, str(r)) in set(exclude)
for a, r in zip(routes["agency_name"], routes["route_short_name"])
]
n = sum(mask)
if n:
routes.loc[mask, "style"] = None
print(f"excluded by modes.yaml: {n} route(s) "
f"({sorted(set(zip(routes.loc[mask, 'agency_name'], routes.loc[mask, 'route_short_name'])) )})",
flush=True)
return routes
@@ -99,6 +138,15 @@ def resolve_colour(style, ref, bus_category, gtfs_colour, palette):
return p.get(ref) or p.get("default")
def shape_points_per_km(shapes_25832):
"""Point density per shape. Geometries are projected (metres)."""
def n_pts(geom):
geoms = getattr(geom, "geoms", [geom])
return sum(len(g.coords) for g in geoms)
pts = shapes_25832.geometry.map(n_pts)
return pts / (shapes_25832.geometry.length / 1000.0).clip(lower=1e-6)
def build_lines(routes, styling, area_geom, trips, st, area_stop_ids):
"""One feature per (style, ref, direction).
@@ -108,6 +156,20 @@ def build_lines(routes, styling, area_geom, trips, st, area_stop_ids):
"""
shapes = gpd.read_file(PROCESSED / "gtfs_shapes.gpkg")
# The feed contains a few "crow-fly" placeholder shapes for long-distance
# trains (e.g. Snälltåget, 6-8 points for ~700 km). Those draw as
# straight lines across the map and can shadow the proper rail-geometry
# shape in coverage comparison. Drop below a point-density floor;
# real shapes are >= 0.5 pts/km, placeholders are ~0.01 pts/km, and even
# the 0.44 km ferry 993 (a handful of points over 440 m) stays well above.
shapes = shapes.to_crs(LENGTH_CRS)
pts_km = shape_points_per_km(shapes)
degenerate = pts_km < MIN_SHAPE_POINTS_PER_KM
if degenerate.any():
print(f"dropping {int(degenerate.sum())} degenerate (crow-fly) "
f"shapes (<{MIN_SHAPE_POINTS_PER_KM:g} pts/km)", flush=True)
shapes = shapes[~degenerate].to_crs("EPSG:4326")
shape_rows = trips.dropna(subset=["shape_id"]).drop_duplicates("shape_id")
shape_direction = dict(zip(shape_rows["shape_id"], shape_rows["direction_id"]))
@@ -269,7 +331,26 @@ def main():
styling = load_styling()
area_geom = load_area()
routes = load_routes(modes)
routes = load_routes(modes, load_exclude())
bus_wl = load_bus_whitelist()
if bus_wl is not None:
patterns = styling["bus_filters"]["categories"]
is_bus = routes["style"] == "bus"
keep = routes["route_short_name"].map(
lambda r: str(r) in bus_wl["refs"]
or categorise_bus(r, patterns) in bus_wl["categories"]
)
routes.loc[is_bus & ~keep, "style"] = None
kept = sorted(
routes.loc[is_bus & keep, "route_short_name"].unique(),
key=lambda s: [int(t) if t.isdigit() else t
for t in re.split(r"(\d+)", str(s))],
)
print(f"bus whitelist: kept {len(kept)} of "
f"{int(is_bus.sum())} bus refs: {', '.join(kept)}",
flush=True)
print(f"routes classified: {routes['style'].notna().sum()} of "
f"{len(routes)} map to a style", flush=True)