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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user