feat: sync source-backed OSM facility entrances
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync only explicitly mapped OpenStreetMap entrances into place_gates.
|
||||
|
||||
Entrances are extracted from the same OSM PBF used for the routing/map update.
|
||||
The importer associates a node only when it is covered by exactly one named
|
||||
target facility polygon (hospital/clinic, mall, or government facility).
|
||||
No coordinates or gate types are inferred from facility centroids.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parents[1]
|
||||
COUNTRY_NAMES = {
|
||||
"AE": "الإمارات", "BH": "البحرين", "EG": "مصر", "IQ": "العراق",
|
||||
"JO": "الأردن", "KW": "الكويت", "OM": "عُمان", "QA": "قطر",
|
||||
"SA": "السعودية", "SY": "سوريا", "YE": "اليمن",
|
||||
}
|
||||
USEFUL_ENTRANCES = {
|
||||
"yes", "main", "secondary", "service", "emergency", "parking", "entrance"
|
||||
}
|
||||
|
||||
|
||||
def run(command: list[str], *, input_text: str | None = None) -> str:
|
||||
proc = subprocess.run(
|
||||
command, cwd=APP_DIR, input=input_text, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode:
|
||||
sys.stderr.write(proc.stderr)
|
||||
raise RuntimeError(f"Command failed ({proc.returncode}): {' '.join(command)}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def psql(sql: str) -> str:
|
||||
return run([
|
||||
"docker", "compose", "exec", "-T", "db", "psql", "-X", "-v",
|
||||
"ON_ERROR_STOP=1", "-U", os.getenv("DB_USER", "mapuser"), "-d",
|
||||
os.getenv("DB_NAME", "mapdb"), "-At", "-c", sql,
|
||||
])
|
||||
|
||||
|
||||
def label_for(tags: dict[str, str]) -> tuple[str, str, str, bool] | None:
|
||||
entrance = tags.get("entrance", "").lower()
|
||||
routing_entrance = tags.get("routing:entrance", "").lower()
|
||||
barrier = tags.get("barrier", "").lower()
|
||||
if (entrance not in USEFUL_ENTRANCES
|
||||
and routing_entrance not in {"yes", "main"}
|
||||
and barrier not in {"gate", "entrance"}):
|
||||
return None
|
||||
if tags.get("access", "").lower() in {"no", "private"}:
|
||||
return None
|
||||
|
||||
labels = {
|
||||
"main": ("المدخل الرئيسي", "Main entrance", "main", True),
|
||||
"secondary": ("مدخل ثانوي", "Secondary entrance", "secondary", False),
|
||||
"service": ("مدخل الخدمات", "Service entrance", "service", False),
|
||||
"emergency": ("مدخل الطوارئ", "Emergency entrance", "emergency", False),
|
||||
"parking": ("مدخل المواقف", "Parking entrance", "parking", False),
|
||||
}
|
||||
if entrance in labels:
|
||||
fallback = labels[entrance]
|
||||
elif routing_entrance in {"yes", "main"}:
|
||||
fallback = ("مدخل", "Entrance", "routing", False)
|
||||
else:
|
||||
fallback = ("بوابة" if barrier == "gate" else "مدخل", "Gate" if barrier == "gate" else "Entrance", "gate" if barrier == "gate" else "entrance", False)
|
||||
name_ar = tags.get("name:ar") or fallback[0]
|
||||
name_en = tags.get("name:en") or tags.get("name") or fallback[1]
|
||||
return name_ar, name_en, fallback[2], fallback[3]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Import source-mapped OSM entrance nodes.")
|
||||
parser.add_argument("--pbf", required=True, help="The current merged OSM PBF used by the map updater")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Extract and report exact matches without changing the live table")
|
||||
args = parser.parse_args()
|
||||
pbf_path = Path(args.pbf)
|
||||
if not pbf_path.is_absolute():
|
||||
pbf_path = APP_DIR / pbf_path
|
||||
if not pbf_path.is_file():
|
||||
parser.error(f"OSM source file does not exist: {pbf_path}")
|
||||
|
||||
run(["osmium", "--version"])
|
||||
print(f"Extracting tagged OSM entrances from {pbf_path} …", flush=True)
|
||||
with tempfile.TemporaryDirectory(prefix="siro-osm-gates-") as temp:
|
||||
extracted = Path(temp) / "entrances.osm.pbf"
|
||||
geojson_path = Path(temp) / "entrances.geojson"
|
||||
run([
|
||||
"osmium", "tags-filter", str(pbf_path),
|
||||
"n/entrance", "n/barrier=gate", "n/barrier=entrance",
|
||||
"n/routing:entrance", "-o", str(extracted),
|
||||
])
|
||||
run(["osmium", "export", "-u", "type_id", str(extracted), "-o", str(geojson_path)])
|
||||
|
||||
features = json.loads(geojson_path.read_text(encoding="utf-8")).get("features", [])
|
||||
rows: list[tuple] = []
|
||||
tag_counts: Counter[str] = Counter()
|
||||
for feature in features:
|
||||
osm_node_id = feature.get("id", "")
|
||||
match = re.fullmatch(r"n(-?\d+)", str(osm_node_id))
|
||||
geometry = feature.get("geometry") or {}
|
||||
coordinates = geometry.get("coordinates") or []
|
||||
tags = feature.get("properties") or {}
|
||||
if not match or len(coordinates) < 2:
|
||||
continue
|
||||
classified = label_for(tags)
|
||||
if classified is None:
|
||||
continue
|
||||
lon, lat = coordinates[:2]
|
||||
rows.append((
|
||||
int(match.group(1)), float(lon), float(lat),
|
||||
tags.get("entrance"), tags.get("routing:entrance"), tags.get("barrier"),
|
||||
tags.get("access"), tags.get("name"), tags.get("name:ar"),
|
||||
tags.get("name:en"), *classified,
|
||||
json.dumps(tags, ensure_ascii=False, separators=(",", ":")),
|
||||
))
|
||||
tag_counts[tags.get("entrance") or tags.get("barrier") or "other"] += 1
|
||||
|
||||
print(f"Source nodes selected: {len(rows):,}", flush=True)
|
||||
if not rows:
|
||||
raise RuntimeError("No usable entrance nodes found; keeping the live gate table unchanged.")
|
||||
|
||||
# Stage exact WGS84 source points. This table is not read by the API.
|
||||
psql("""
|
||||
CREATE TABLE IF NOT EXISTS place_gates_osm_stage (
|
||||
osm_id bigint PRIMARY KEY, longitude double precision NOT NULL,
|
||||
latitude double precision NOT NULL, entrance text, routing_entrance text,
|
||||
barrier text, access text, name text, name_ar text, name_en text,
|
||||
gate_name_ar text NOT NULL, gate_name_en text NOT NULL,
|
||||
gate_type text NOT NULL, is_main_gate boolean NOT NULL,
|
||||
source_tags jsonb NOT NULL
|
||||
);
|
||||
TRUNCATE place_gates_osm_stage;
|
||||
""")
|
||||
with tempfile.TemporaryFile(mode="w+t", encoding="utf-8", newline="") as tsv:
|
||||
writer = csv.writer(tsv, delimiter="\t", lineterminator="\n", quoting=csv.QUOTE_MINIMAL)
|
||||
writer.writerows(rows)
|
||||
tsv.seek(0)
|
||||
run([
|
||||
"docker", "compose", "exec", "-T", "db", "psql", "-X", "-v",
|
||||
"ON_ERROR_STOP=1", "-U", os.getenv("DB_USER", "mapuser"), "-d",
|
||||
os.getenv("DB_NAME", "mapdb"), "-c",
|
||||
"COPY place_gates_osm_stage (osm_id,longitude,latitude,entrance,routing_entrance,barrier,access,name,name_ar,name_en,gate_name_ar,gate_name_en,gate_type,is_main_gate,source_tags) FROM STDIN WITH (FORMAT csv, DELIMITER E'\\t', NULL '')",
|
||||
], input_text=tsv.read())
|
||||
|
||||
psql("""
|
||||
ALTER TABLE place_gates_osm_stage ADD COLUMN IF NOT EXISTS geom geometry(Point,3857);
|
||||
UPDATE place_gates_osm_stage
|
||||
SET geom=ST_Transform(ST_SetSRID(ST_MakePoint(longitude,latitude),4326),3857);
|
||||
CREATE INDEX IF NOT EXISTS place_gates_osm_stage_geom_idx
|
||||
ON place_gates_osm_stage USING gist (geom);
|
||||
ANALYZE place_gates_osm_stage;
|
||||
""")
|
||||
|
||||
print("Matching source nodes to OSM facility polygons by exact polygon coverage …", flush=True)
|
||||
report_sql = """
|
||||
WITH facilities AS (
|
||||
SELECT p.osm_id, p.way, r.country_code,
|
||||
'regional_' || r.country_code || '_osm_polygon_' || r.source_id AS place_id
|
||||
FROM planet_osm_polygon p
|
||||
JOIN regional_place_names r
|
||||
ON r.source = 'osm_polygon' AND r.source_id = p.osm_id::text
|
||||
WHERE p.name IS NOT NULL
|
||||
AND (p.amenity IN ('hospital','clinic','townhall','courthouse')
|
||||
OR p.shop = 'mall' OR p.office = 'government' OR p.building = 'government')
|
||||
), matched AS (
|
||||
SELECT g.osm_id AS gate_osm_id, f.country_code, f.place_id
|
||||
FROM place_gates_osm_stage g
|
||||
JOIN facilities f
|
||||
ON ST_Covers(f.way, g.geom)
|
||||
), unique_matches AS (
|
||||
SELECT gate_osm_id, min(country_code) AS country_code, min(place_id) AS place_id
|
||||
FROM matched GROUP BY gate_osm_id HAVING count(DISTINCT place_id) = 1
|
||||
)
|
||||
SELECT country_code || '|' || count(*) || '|' || count(DISTINCT place_id)
|
||||
FROM unique_matches GROUP BY country_code ORDER BY country_code;
|
||||
"""
|
||||
report = psql(report_sql).strip().splitlines()
|
||||
for line in report:
|
||||
code, gates, places = line.split("|")
|
||||
print(f"{COUNTRY_NAMES.get(code, code)} ({code}): {gates} مصدرًا موثقًا ↔ {places} منشأة", flush=True)
|
||||
if not report:
|
||||
raise RuntimeError("No source nodes matched named target facilities; the live gate table is unchanged.")
|
||||
|
||||
samples = psql("""
|
||||
WITH matches AS (
|
||||
SELECT g.osm_id,g.gate_name_ar,g.gate_type,r.country_code,r.name,
|
||||
'regional_'||r.country_code||'_osm_polygon_'||r.source_id AS place_id
|
||||
FROM place_gates_osm_stage g
|
||||
JOIN planet_osm_polygon p
|
||||
ON ST_Covers(p.way,g.geom)
|
||||
JOIN regional_place_names r ON r.source='osm_polygon' AND r.source_id=p.osm_id::text
|
||||
WHERE p.name IS NOT NULL
|
||||
AND (p.amenity IN ('hospital','clinic','townhall','courthouse') OR p.shop='mall'
|
||||
OR p.office='government' OR p.building='government')
|
||||
), unique_ids AS (
|
||||
SELECT osm_id,min(place_id) AS place_id FROM matches
|
||||
GROUP BY osm_id HAVING count(DISTINCT place_id)=1
|
||||
)
|
||||
SELECT m.country_code||'|'||m.name||'|'||m.gate_name_ar||'|'||m.gate_type||'|'||m.osm_id
|
||||
FROM matches m JOIN unique_ids u ON u.osm_id=m.osm_id AND u.place_id=m.place_id
|
||||
ORDER BY m.country_code,m.name LIMIT 12;
|
||||
""").strip()
|
||||
if samples:
|
||||
print("Source-matched examples (country|facility|gate label|OSM tag type|node id):", flush=True)
|
||||
print(samples, flush=True)
|
||||
if args.dry_run:
|
||||
print("Dry run complete; live place_gates was not changed.", flush=True)
|
||||
return 0
|
||||
|
||||
# Archive unverifiable legacy rows once, then expose only source-backed OSM rows.
|
||||
sync_sql = """
|
||||
BEGIN;
|
||||
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source text;
|
||||
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source_id text;
|
||||
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS gate_type text;
|
||||
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS access text;
|
||||
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source_tags jsonb;
|
||||
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS last_seen_at timestamptz;
|
||||
CREATE TABLE IF NOT EXISTS place_gates_unverified_archive_20260929 AS
|
||||
SELECT * FROM place_gates WHERE source IS NULL AND false;
|
||||
INSERT INTO place_gates_unverified_archive_20260929
|
||||
SELECT * FROM place_gates WHERE source IS NULL;
|
||||
DELETE FROM place_gates WHERE source IS NULL OR source = 'openstreetmap';
|
||||
|
||||
WITH facilities AS (
|
||||
SELECT p.osm_id, p.way, r.country_code,
|
||||
'regional_' || r.country_code || '_osm_polygon_' || r.source_id AS place_id
|
||||
FROM planet_osm_polygon p
|
||||
JOIN regional_place_names r
|
||||
ON r.source = 'osm_polygon' AND r.source_id = p.osm_id::text
|
||||
WHERE p.name IS NOT NULL
|
||||
AND (p.amenity IN ('hospital','clinic','townhall','courthouse')
|
||||
OR p.shop = 'mall' OR p.office = 'government' OR p.building = 'government')
|
||||
), matches AS (
|
||||
SELECT g.*, f.place_id
|
||||
FROM place_gates_osm_stage g
|
||||
JOIN facilities f
|
||||
ON ST_Covers(f.way, g.geom)
|
||||
), unique_matches AS (
|
||||
SELECT osm_id, min(place_id) AS place_id FROM matches
|
||||
GROUP BY osm_id HAVING count(DISTINCT place_id) = 1
|
||||
), exact_matches AS (
|
||||
SELECT DISTINCT ON (m.osm_id) m.*
|
||||
FROM matches m JOIN unique_matches u ON u.osm_id=m.osm_id AND u.place_id=m.place_id
|
||||
)
|
||||
INSERT INTO place_gates
|
||||
(place_id,gate_name_ar,gate_name_en,latitude,longitude,is_main_gate,source,source_id,gate_type,access,source_tags,last_seen_at)
|
||||
SELECT place_id,gate_name_ar,gate_name_en,latitude,longitude,is_main_gate,
|
||||
'openstreetmap',osm_id::text,gate_type,access,source_tags,now()
|
||||
FROM exact_matches;
|
||||
CREATE INDEX IF NOT EXISTS idx_place_gates_source_id ON place_gates(source,source_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_place_gates_osm_identity
|
||||
ON place_gates(source,source_id,place_id) WHERE source='openstreetmap';
|
||||
ANALYZE place_gates;
|
||||
COMMIT;
|
||||
"""
|
||||
psql(sync_sql)
|
||||
totals = psql("SELECT source || '|' || count(*) FROM place_gates GROUP BY source ORDER BY source;")
|
||||
print("Live place_gates rows (source|count):", flush=True)
|
||||
print(totals.strip(), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # preserve the existing live table on any failed preflight/import
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user