- u_turn_costs: 60 (was infinite -> U-turns forbidden -> huge detours) - distance_influence 70 -> 20, de-prioritise service/living_street/track - restore safe ignored_highways (was "" -> footways imported, bad snapping) - profiles_ch -> profiles_lm (edge-based CH too heavy with turn_costs) - server port 8989 -> 8080 to match compose mapping, healthcheck and API - add normalize-oneway + check-bidirectional tooling
226 lines
8.8 KiB
Python
Executable File
226 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# --------------------------------------------------------------------------
|
|
# normalize_oneway.py — تصحيح وسوم الاتجاه الواحد قبل بناء رسم GraphHopper
|
|
#
|
|
# لماذا هذا السكربت أصلاً:
|
|
# GraphHopper يثبّت اتجاه كل حافة أثناء الاستيراد اعتماداً على وسوم OSM
|
|
# (oneway, oneway:motorcar, junction=roundabout). لا يوجد أي إعداد في
|
|
# config.yml — ولا أي custom_model — يستطيع فتح اتجاه مُغلق في البيانات.
|
|
# فإن كان طريق رئيسي ثنائي الاتجاه في الواقع (مثل طريق السخنة) موسوماً
|
|
# oneway=yes في OSM، فالحل الوحيد هو تعديل البيانات قبل البناء.
|
|
#
|
|
# وضعان:
|
|
# audit : لا يعدّل شيئاً. يُخرج CSV بكل الطرق أحادية الاتجاه المرشحة
|
|
# (way_id, highway, name, الطول التقريبي) لمراجعتها بشرياً.
|
|
# apply : يكتب ملف PBF جديداً بعد إزالة/تصحيح وسوم الاتجاه، إما
|
|
# لقائمة معتمدة (--allowlist) أو لأصناف طرق كاملة (--classes).
|
|
#
|
|
# تحذير مهم:
|
|
# الطرق المزدوجة (dual carriageway) هي جسمان منفصلان كل منهما أحادي الاتجاه
|
|
# بشكل صحيح تماماً. إزالة oneway عنها تُنتج مسارات غير قانونية (سير بعكس
|
|
# السير). لذلك الافتراضي هو --allowlist. استخدام --classes عملية شاملة
|
|
# وخطرة، وتتطلب --force، ويجب أن تسبقها مراجعة تقرير audit.
|
|
# --------------------------------------------------------------------------
|
|
import argparse
|
|
import csv
|
|
import math
|
|
import sys
|
|
|
|
try:
|
|
import osmium
|
|
except ImportError:
|
|
sys.exit(
|
|
"pyosmium غير مثبت.\n"
|
|
" python3 -m venv .venv-osm && ./.venv-osm/bin/pip install osmium\n"
|
|
"ثم شغّل السكربت عبر ./.venv-osm/bin/python"
|
|
)
|
|
|
|
ONEWAY_TRUE = {"yes", "true", "1", "-1"}
|
|
|
|
# الوسوم التي تحدد الاتجاه لسيارة. جميعها يجب أن يُزال معاً، وإلا بقي القيد.
|
|
DIRECTIONAL_TAGS = ("oneway", "oneway:motorcar", "oneway:motor_vehicle", "oneway:vehicle")
|
|
|
|
# أصناف لا تُمسّ أبداً: المسارات المنفصلة والروابط والدوارات أحادية بطبيعتها.
|
|
NEVER_TOUCH_HIGHWAY = {"motorway", "motorway_link"}
|
|
|
|
DEFAULT_CLASSES = ["trunk", "primary", "secondary", "tertiary"]
|
|
|
|
|
|
def haversine_len(nodes):
|
|
"""طول تقريبي بالأمتار من عقد الطريق (يتطلب مواقع العقد)."""
|
|
total = 0.0
|
|
prev = None
|
|
for n in nodes:
|
|
if not n.location.valid():
|
|
prev = None
|
|
continue
|
|
if prev is not None:
|
|
lat1, lon1, lat2, lon2 = map(
|
|
math.radians, (prev.lat, prev.lon, n.location.lat, n.location.lon)
|
|
)
|
|
a = (
|
|
math.sin((lat2 - lat1) / 2) ** 2
|
|
+ math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2
|
|
)
|
|
total += 2 * 6371000 * math.asin(math.sqrt(a))
|
|
prev = n.location
|
|
return total
|
|
|
|
|
|
def is_oneway(tags):
|
|
for t in DIRECTIONAL_TAGS:
|
|
if tags.get(t) in ONEWAY_TRUE:
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_protected(tags):
|
|
"""أحادي الاتجاه لسبب هيكلي صحيح — لا يُلمس."""
|
|
if tags.get("highway") in NEVER_TOUCH_HIGHWAY:
|
|
return True
|
|
if (tags.get("highway") or "").endswith("_link"):
|
|
return True
|
|
if tags.get("junction") in ("roundabout", "circular"):
|
|
return True
|
|
return False
|
|
|
|
|
|
class Auditor(osmium.SimpleHandler):
|
|
def __init__(self, classes, writer):
|
|
super().__init__()
|
|
self.classes = set(classes)
|
|
self.writer = writer
|
|
self.count = 0
|
|
self.meters = 0.0
|
|
|
|
def way(self, w):
|
|
tags = dict(w.tags)
|
|
hw = tags.get("highway")
|
|
if hw not in self.classes or not is_oneway(tags) or is_protected(tags):
|
|
return
|
|
length = haversine_len(w.nodes)
|
|
self.count += 1
|
|
self.meters += length
|
|
self.writer.writerow(
|
|
[
|
|
w.id,
|
|
hw,
|
|
tags.get("name", ""),
|
|
tags.get("name:ar", ""),
|
|
round(length),
|
|
tags.get("lanes", ""),
|
|
tags.get("oneway", ""),
|
|
]
|
|
)
|
|
|
|
|
|
class Rewriter(osmium.SimpleHandler):
|
|
"""ينسخ الملف كاملاً ويحذف وسوم الاتجاه عن الطرق المستهدفة فقط."""
|
|
|
|
def __init__(self, writer, classes, allowlist, names):
|
|
super().__init__()
|
|
self.writer = writer
|
|
self.classes = set(classes) if classes else set()
|
|
self.allowlist = allowlist or set()
|
|
self.names = {n.strip() for n in (names or []) if n.strip()}
|
|
self.changed = 0
|
|
|
|
def node(self, n):
|
|
self.writer.add_node(n)
|
|
|
|
def relation(self, r):
|
|
self.writer.add_relation(r)
|
|
|
|
def _targeted(self, w, tags):
|
|
if is_protected(tags):
|
|
return False
|
|
if w.id in self.allowlist:
|
|
return True
|
|
if self.names and (
|
|
tags.get("name") in self.names or tags.get("name:ar") in self.names
|
|
):
|
|
return True
|
|
return bool(self.classes) and tags.get("highway") in self.classes
|
|
|
|
def way(self, w):
|
|
tags = dict(w.tags)
|
|
if is_oneway(tags) and self._targeted(w, tags):
|
|
for t in DIRECTIONAL_TAGS:
|
|
tags.pop(t, None)
|
|
# أثر مقصود: علامة تسمح بتتبع ما عدّلناه لاحقاً في PostGIS/التقارير
|
|
tags["oneway"] = "no"
|
|
tags["gh:oneway_normalized"] = "yes"
|
|
self.changed += 1
|
|
self.writer.add_way(w.replace(tags=tags))
|
|
else:
|
|
self.writer.add_way(w)
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description="تصحيح وسوم الاتجاه الواحد في ملف PBF")
|
|
p.add_argument("mode", choices=["audit", "apply"])
|
|
p.add_argument("--input", required=True)
|
|
p.add_argument("--output", help="ملف PBF الناتج (مطلوب في apply)")
|
|
p.add_argument("--report", default="oneway-audit.csv")
|
|
p.add_argument(
|
|
"--classes",
|
|
default=",".join(DEFAULT_CLASSES),
|
|
help="أصناف highway المستهدفة (audit) أو المشمولة بالتعديل الشامل (apply + --force)",
|
|
)
|
|
p.add_argument("--allowlist", help="ملف نصي: way_id في كل سطر")
|
|
p.add_argument("--names", help="أسماء طرق مفصولة بفاصلة، مثل: طريق السخنة")
|
|
p.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="مطلوب لتطبيق التعديل على أصناف كاملة بدل قائمة معتمدة",
|
|
)
|
|
args = p.parse_args()
|
|
|
|
classes = [c.strip() for c in args.classes.split(",") if c.strip()]
|
|
|
|
if args.mode == "audit":
|
|
with open(args.report, "w", newline="", encoding="utf-8") as fh:
|
|
wr = csv.writer(fh)
|
|
wr.writerow(
|
|
["way_id", "highway", "name", "name_ar", "length_m", "lanes", "oneway"]
|
|
)
|
|
h = Auditor(classes, wr)
|
|
h.apply_file(args.input, locations=True)
|
|
print(f"✅ audit: {h.count} طريق أحادي الاتجاه، ~{h.meters/1000:.1f} كم")
|
|
print(f" التقرير: {args.report}")
|
|
print(" راجعه ثم مرّر way_ids المعتمدة عبر --allowlist في وضع apply.")
|
|
return
|
|
|
|
if not args.output:
|
|
sys.exit("apply يتطلب --output")
|
|
|
|
allow = set()
|
|
if args.allowlist:
|
|
with open(args.allowlist, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.split("#")[0].strip()
|
|
if line.isdigit():
|
|
allow.add(int(line))
|
|
|
|
names = args.names.split(",") if args.names else []
|
|
blanket = not allow and not names
|
|
if blanket and not args.force:
|
|
sys.exit(
|
|
"لا allowlist ولا names → هذا تعديل شامل على أصناف كاملة.\n"
|
|
"يكسر الطرق المزدوجة (dual carriageway) الموسومة بشكل صحيح.\n"
|
|
"أعد التشغيل مع --force إن كان هذا مقصوداً بعد مراجعة تقرير audit."
|
|
)
|
|
|
|
writer = osmium.SimpleWriter(args.output)
|
|
try:
|
|
h = Rewriter(writer, classes if blanket else [], allow, names)
|
|
h.apply_file(args.input, locations=False)
|
|
finally:
|
|
writer.close()
|
|
print(f"✅ apply: عُدّل {h.changed} طريق → {args.output}")
|
|
print(" الخطوة التالية: احذف graph-cache وأعد بناء GraphHopper.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|