From 3a49029ee4e91de4f28178b79e35a8b83f72f533 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 26 Jul 2026 15:35:55 +0300 Subject: [PATCH] fix(routing): allow bidirectional travel on two-way corridors - 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 --- .gitignore | 3 + infrastructure/docker/graphhopper/config.yml | 56 ++++- infrastructure/scripts/check-bidirectional.sh | 67 ++++++ infrastructure/scripts/normalize-oneway.sh | 80 +++++++ infrastructure/scripts/normalize_oneway.py | 225 ++++++++++++++++++ 5 files changed, 424 insertions(+), 7 deletions(-) create mode 100755 infrastructure/scripts/check-bidirectional.sh create mode 100755 infrastructure/scripts/normalize-oneway.sh create mode 100755 infrastructure/scripts/normalize_oneway.py diff --git a/.gitignore b/.gitignore index 332faac..8ed5f08 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ apps/api/src/telemetry/*.json .Trashes ehthumbs.db Thumbs.db +.venv-osm/ +__pycache__/ +infrastructure/osm-data/*.orig diff --git a/infrastructure/docker/graphhopper/config.yml b/infrastructure/docker/graphhopper/config.yml index add56f2..e01f92f 100644 --- a/infrastructure/docker/graphhopper/config.yml +++ b/infrastructure/docker/graphhopper/config.yml @@ -1,18 +1,43 @@ +# -------------------------------------------------------------------------- +# GraphHopper v11 — إعدادات محرك التوجيه +# +# ملاحظة جوهرية عن الاتجاهات (oneway): +# الاتجاه المسموح لكل حافة (edge) يُحسب مرة واحدة أثناء الاستيراد من وسوم OSM +# (oneway / oneway:motorcar / junction=roundabout ...). لا يمكن لأي custom_model +# هنا أن يفتح اتجاهاً مغلقاً في البيانات. لذلك أي طريق رئيسي ثنائي الاتجاه يظهر +# كأحادي الاتجاه = مشكلة بيانات، وتُعالج قبل البناء عبر: +# infrastructure/scripts/normalize-oneway.sh +# أما الالتفافات الطويلة "للعودة من نفس الطريق" فهي مشكلة إعدادات، وتُعالج هنا +# بواسطة turn_costs.u_turn_costs (كانت لانهائية = ممنوع الالتفاف نهائياً). +# -------------------------------------------------------------------------- graphhopper: datareader.file: /data/master_map.osm.pbf graph.location: /data/graph-cache - import.osm.ignored_highways: "" - graph.encoded_values: road_class,road_environment,max_speed,road_access,surface + + # كانت "" أي: لا تتجاهل شيئاً — فتُستورد الأرصفة والمسارات كحافات ويقفز عليها + # الـ snapping فيخرج مسار غريب بجانب الشارع. نعيد القيمة الافتراضية الآمنة. + import.osm.ignored_highways: "footway,cycleway,path,pedestrian,steps" + + # قائمة واحدة بلا مسافات — GraphHopper يفصل على الفاصلة فقط + graph.encoded_values: road_class,road_environment,max_speed,road_access,surface,roundabout,lanes profiles: - name: car + # قيود الدوران الحقيقية من OSM (no_left_turn ...) مع تكلفة محدودة + # للالتفاف 180°. بدون u_turn_costs تكون التكلفة لانهائية، فيرفض المحرك + # أي عودة على نفس الطريق ويولّد التفافات ضخمة. + turn_costs: + vehicle_types: [motorcar, motor_vehicle] + u_turn_costs: 60 # ثانية — يسمح بالالتفاف عند فتحات الجزيرة الوسطية دون تفضيله custom_model: - distance_influence: 70 + # 70 ث/كم كانت تدفع المحرك لاختيار الأقصر جغرافياً (حواري وطرق خدمة) + # على حساب الشارع الرئيسي. 20 توازن بين الزمن والمسافة. + distance_influence: 20 speed: + # القاعدة العامة أولاً ثم التخصيص؛ limit_to يأخذ الأدنى دائماً. + # (قاعدة MOTORWAY/TRUNK القديمة بـ 60 كانت بلا أثر لأن العام 60 أصلاً) - if: "true" limit_to: 60 - - if: "road_class == MOTORWAY || road_class == TRUNK" - limit_to: 60 - if: "road_class == PRIMARY" limit_to: 45 - if: "road_class == SECONDARY" @@ -23,12 +48,29 @@ graphhopper: limit_to: 14 - if: "road_class == LIVING_STREET || road_class == SERVICE" limit_to: 14 + priority: + # تفضيل الشرايين الرئيسية بدل قصّ الطريق عبر الأحياء + - if: "road_class == LIVING_STREET" + multiply_by: 0.5 + - if: "road_class == SERVICE" + multiply_by: 0.6 + - if: "road_class == TRACK" + multiply_by: 0.3 - profiles_ch: + # CH مُعطّل: مع turn_costs يصبح البناء edge-based ويستهلك ذاكرة كبيرة على + # ملف مدمج (الأردن+سوريا+مصر)، كما أن الـ API يرسل ch.disable في كل طلب + # يحتوي custom_model أو alternative_route أصلاً. LM يعطي السرعة المطلوبة. + profiles_ch: [] + profiles_lm: - profile: car + routing.non_ch.max_visited_nodes: 2000000 + server: application_connectors: - type: http - port: 8989 + # 8080 داخل الحاوية: docker-compose يعرّض 8989:8080، والـ healthcheck + # و GRAPH_HOPPER_URL في الـ API يستهدفان 8080. القيمة القديمة (8989) + # كانت تخالف الثلاثة. + port: 8080 bind_host: 0.0.0.0 diff --git a/infrastructure/scripts/check-bidirectional.sh b/infrastructure/scripts/check-bidirectional.sh new file mode 100755 index 0000000..c687f27 --- /dev/null +++ b/infrastructure/scripts/check-bidirectional.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# -------------------------------------------------------------------------- +# check-bidirectional.sh — فحص التوجيه في الاتجاهين على نفس المقطع +# +# المنطق: على طريق ثنائي الاتجاه سليم، مسافة A→B ومسافة B→A يجب أن تكونا +# متقاربتين. فارق كبير (> عتبة النسبة) = المحرك يجبر التفافاً في أحد +# الاتجاهين، أي أن الحافة أحادية الاتجاه في البيانات أو أن قيود الدوران +# تمنع العودة. +# +# الاستخدام: +# bash infrastructure/scripts/check-bidirectional.sh # حالات مدمجة +# bash infrastructure/scripts/check-bidirectional.sh 32.05 36.09 32.07 36.12 +# +# متغيّرات: GH_URL (افتراضي http://localhost:8989)، RATIO (افتراضي 1.5) +# -------------------------------------------------------------------------- +set -uo pipefail + +GH_URL="${GH_URL:-http://localhost:8989}" +RATIO="${RATIO:-1.5}" + +# lat1 lon1 lat2 lon2 label +CASES=( + "32.0537 36.0891 32.0721 36.1204 طريق السخنة (الزرقاء)" + "31.9539 35.9106 31.9705 35.9412 عمّان — محور رئيسي" +) +if [ "$#" -ge 4 ]; then + CASES=("$1 $2 $3 $4 مقطع مخصص") +fi + +dist() { # lat1 lon1 lat2 lon2 → متر أو FAIL + curl -fsS -X POST "$GH_URL/route" -H 'Content-Type: application/json' \ + -d "{\"points\":[[$2,$1],[$4,$3]],\"profile\":\"car\",\"instructions\":false,\"calc_points\":false}" \ + 2>/dev/null | sed -n 's/.*"distance":\([0-9.]*\).*/\1/p' | head -1 +} + +echo "════════════════════════════════════════════════════════════" +echo " فحص التوجيه ثنائي الاتجاه — $GH_URL" +echo "════════════════════════════════════════════════════════════" + +FAIL=0 +for c in "${CASES[@]}"; do + read -r a b x y label <<< "$c" + fwd=$(dist "$a" "$b" "$x" "$y") + rev=$(dist "$x" "$y" "$a" "$b") + echo "" + echo "▸ $label" + if [ -z "$fwd" ] || [ -z "$rev" ]; then + echo " ✗ لم يُعِد المحرك مساراً في أحد الاتجاهين (تحقّق أن الحاوية تعمل والنقاط داخل التغطية)" + FAIL=1 + continue + fi + printf " ذهاب : %.0f م\n عودة : %.0f م\n" "$fwd" "$rev" + verdict=$(awk -v f="$fwd" -v r="$rev" -v t="$RATIO" \ + 'BEGIN{ if(f<=0||r<=0){print "bad"; exit} m=(f>r?f/r:r/f); printf "%s %.2f", (m>t?"fail":"ok"), m }') + set -- $verdict + if [ "$1" = "ok" ]; then + echo " ✓ متقارب (نسبة ${2})" + else + echo " ✗ فارق كبير (نسبة ${2:-؟}) — اتجاه واحد مفروض أو التفاف إجباري" + echo " شغّل: bash infrastructure/scripts/normalize-oneway.sh audit" + FAIL=1 + fi +done + +echo "" +[ "$FAIL" = "0" ] && echo "✅ كل الحالات سليمة." || echo "⚠️ توجد حالات تحتاج معالجة." +exit $FAIL diff --git a/infrastructure/scripts/normalize-oneway.sh b/infrastructure/scripts/normalize-oneway.sh new file mode 100755 index 0000000..874af8c --- /dev/null +++ b/infrastructure/scripts/normalize-oneway.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# -------------------------------------------------------------------------- +# normalize-oneway.sh — تشغيل تصحيح الاتجاهات وإعادة بناء رسم GraphHopper +# +# الاستخدام: +# 1) تقرير فقط (لا يعدّل شيئاً): +# bash infrastructure/scripts/normalize-oneway.sh audit +# → infrastructure/osm-data/oneway-audit.csv +# +# 2) تعديل طرق محددة بالاسم (الأكثر أماناً): +# bash infrastructure/scripts/normalize-oneway.sh apply --names "طريق السخنة" +# +# 3) تعديل قائمة way_ids معتمدة بعد مراجعة التقرير: +# bash infrastructure/scripts/normalize-oneway.sh apply \ +# --allowlist infrastructure/osm-data/oneway-allowlist.txt +# +# 4) تعديل شامل لأصناف كاملة (خطر — يكسر الطرق المزدوجة الصحيحة): +# bash infrastructure/scripts/normalize-oneway.sh apply --force +# +# بعد apply يعيد السكربت بناء الرسم: يحذف graph-cache ويقلع map-routing. +# النسخة الأصلية من الـ PBF تبقى كما هي (master_map.osm.pbf.orig). +# -------------------------------------------------------------------------- +set -euo pipefail + +DATA_DIR="./infrastructure/osm-data" +PBF="$DATA_DIR/master_map.osm.pbf" +VENV="./.venv-osm" +PY="$VENV/bin/python" +SCRIPT="./infrastructure/scripts/normalize_oneway.py" + +MODE="${1:-audit}" +shift || true + +[ -f "$PBF" ] || { echo "❌ الملف غير موجود: $PBF"; exit 1; } + +# ── بيئة pyosmium ──────────────────────────────────────────────────────── +if [ ! -x "$PY" ]; then + echo "📦 تهيئة بيئة pyosmium..." + python3 -m venv "$VENV" + "$VENV/bin/pip" install --quiet --upgrade pip + "$VENV/bin/pip" install --quiet osmium +fi + +if [ "$MODE" = "audit" ]; then + "$PY" "$SCRIPT" audit --input "$PBF" --report "$DATA_DIR/oneway-audit.csv" "$@" + exit 0 +fi + +if [ "$MODE" != "apply" ]; then + echo "❌ الوضع غير معروف: $MODE (المتاح: audit | apply)"; exit 1 +fi + +# ── تعديل البيانات ─────────────────────────────────────────────────────── +TMP="$DATA_DIR/master_map.normalized.osm.pbf" +rm -f "$TMP" +"$PY" "$SCRIPT" apply --input "$PBF" --output "$TMP" "$@" + +if [ ! -f "$PBF.orig" ]; then + echo "💾 حفظ نسخة أصلية: $PBF.orig" + cp "$PBF" "$PBF.orig" +fi +mv "$TMP" "$PBF" + +# ── إعادة بناء الرسم ───────────────────────────────────────────────────── +# GraphHopper لا يعيد قراءة الـ PBF إن كان graph-cache موجوداً. +echo "🧹 حذف graph-cache وإعادة بناء الرسم..." +docker compose stop routing || true +rm -rf "$DATA_DIR/graph-cache" +docker compose up -d routing + +echo "⏳ انتظار جهوزية المحرك (قد يستغرق البناء عدة دقائق)..." +for i in $(seq 1 120); do + if curl -fsS http://localhost:8989/health >/dev/null 2>&1; then + echo "✅ map-routing جاهز." + exit 0 + fi + sleep 10 +done +echo "⚠️ لم يستجب المحرك بعد 20 دقيقة — راجع: docker compose logs -f routing" +exit 1 diff --git a/infrastructure/scripts/normalize_oneway.py b/infrastructure/scripts/normalize_oneway.py new file mode 100755 index 0000000..9727d2c --- /dev/null +++ b/infrastructure/scripts/normalize_oneway.py @@ -0,0 +1,225 @@ +#!/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()