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
This commit is contained in:
Hamza-Ayed
2026-07-26 15:35:55 +03:00
parent e93b577ad7
commit 3a49029ee4
5 changed files with 424 additions and 7 deletions
+3
View File
@@ -32,3 +32,6 @@ apps/api/src/telemetry/*.json
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
.venv-osm/
__pycache__/
infrastructure/osm-data/*.orig
+49 -7
View File
@@ -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: graphhopper:
datareader.file: /data/master_map.osm.pbf datareader.file: /data/master_map.osm.pbf
graph.location: /data/graph-cache 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: profiles:
- name: car - name: car
# قيود الدوران الحقيقية من OSM (no_left_turn ...) مع تكلفة محدودة
# للالتفاف 180°. بدون u_turn_costs تكون التكلفة لانهائية، فيرفض المحرك
# أي عودة على نفس الطريق ويولّد التفافات ضخمة.
turn_costs:
vehicle_types: [motorcar, motor_vehicle]
u_turn_costs: 60 # ثانية — يسمح بالالتفاف عند فتحات الجزيرة الوسطية دون تفضيله
custom_model: custom_model:
distance_influence: 70 # 70 ث/كم كانت تدفع المحرك لاختيار الأقصر جغرافياً (حواري وطرق خدمة)
# على حساب الشارع الرئيسي. 20 توازن بين الزمن والمسافة.
distance_influence: 20
speed: speed:
# القاعدة العامة أولاً ثم التخصيص؛ limit_to يأخذ الأدنى دائماً.
# (قاعدة MOTORWAY/TRUNK القديمة بـ 60 كانت بلا أثر لأن العام 60 أصلاً)
- if: "true" - if: "true"
limit_to: 60 limit_to: 60
- if: "road_class == MOTORWAY || road_class == TRUNK"
limit_to: 60
- if: "road_class == PRIMARY" - if: "road_class == PRIMARY"
limit_to: 45 limit_to: 45
- if: "road_class == SECONDARY" - if: "road_class == SECONDARY"
@@ -23,12 +48,29 @@ graphhopper:
limit_to: 14 limit_to: 14
- if: "road_class == LIVING_STREET || road_class == SERVICE" - if: "road_class == LIVING_STREET || road_class == SERVICE"
limit_to: 14 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 - profile: car
routing.non_ch.max_visited_nodes: 2000000
server: server:
application_connectors: application_connectors:
- type: http - type: http
port: 8989 # 8080 داخل الحاوية: docker-compose يعرّض 8989:8080، والـ healthcheck
# و GRAPH_HOPPER_URL في الـ API يستهدفان 8080. القيمة القديمة (8989)
# كانت تخالف الثلاثة.
port: 8080
bind_host: 0.0.0.0 bind_host: 0.0.0.0
+67
View File
@@ -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
+80
View File
@@ -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
+225
View File
@@ -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()