From 96929bed6fb57cbb36565d2b7b6822e7dd4ae94e Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 13:48:43 -0400 Subject: [PATCH] feat(dispatch): OSRM road-time matrix for the VRP optimizer (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the haversine straight-line estimate with real road travel times from a self-hosted OSRM (free/offline — Mapbox Matrix is billable). - hub: osrmMatrix() calls OSRM /table for the job+home coords (node order matches route_solver: jobs then homes), returns a minutes matrix. Handles PARTIAL coords — OSRM for geolocated nodes, 0/neutral for the rest (matches the solver's own fallback), so a few tech homes without coords don't disable OSRM for the geolocated majority. /roster/ optimize-routes injects body.matrix; graceful fallback to haversine if OSRM is unreachable or too few points. - config: OSRM_URL (default http://osrm:5000). - services/osrm/README.md: reproducible setup (Québec extract, MLD pipeline, osrm-routed on erpnext_erpnext network). Deployed + verified: OSRM /table hit with the full coord set, no fallback; optimizer routes on real road times. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/osrm/README.md | 39 ++++++++++++++++++++++++++++++++ services/targo-hub/lib/config.js | 1 + services/targo-hub/lib/roster.js | 33 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 services/osrm/README.md diff --git a/services/osrm/README.md b/services/osrm/README.md new file mode 100644 index 0000000..762c52a --- /dev/null +++ b/services/osrm/README.md @@ -0,0 +1,39 @@ +# OSRM — moteur de routes (matrice de temps de route réels) + +Fournit à l'optimiseur VRP (`roster-solver`) une **matrice de temps de route** réels +(réseau routier OSM), au lieu du haversine à vol d'oiseau. Le hub (`targo-hub`, +`lib/roster.js` → `osrmMatrix`) appelle `OSRM /table` puis passe la matrice à +`roster-solver /route`. Auto-hébergé → **gratuit, offline, sans coût par appel** +(contrairement à Mapbox Matrix). + +- Image : `osrm/osrm-backend` (pipeline **MLD**) +- Conteneur : `osrm` sur le réseau `erpnext_erpnext`, port interne `5000`, `--restart unless-stopped` +- Données : extrait **Québec** de Geofabrik, préprocessé dans `/opt/osrm/` (host) +- Config hub : `OSRM_URL` (défaut `http://osrm:5000`) + +## (Re)déploiement / mise à jour de la carte + +```sh +mkdir -p /opt/osrm && cd /opt/osrm +curl -sL -o quebec-latest.osm.pbf https://download.geofabrik.de/north-america/canada/quebec-latest.osm.pbf +docker pull osrm/osrm-backend +docker run --rm -v /opt/osrm:/data osrm/osrm-backend osrm-extract -p /opt/car.lua /data/quebec-latest.osm.pbf +docker run --rm -v /opt/osrm:/data osrm/osrm-backend osrm-partition /data/quebec-latest.osrm +docker run --rm -v /opt/osrm:/data osrm/osrm-backend osrm-customize /data/quebec-latest.osrm +docker rm -f osrm 2>/dev/null || true +docker run -d --name osrm --network erpnext_erpnext --restart unless-stopped \ + -v /opt/osrm:/data osrm/osrm-backend \ + osrm-routed --algorithm mld --max-table-size 2000 /data/quebec-latest.osrm +``` + +Préproc ~10 min pour le Québec (pic RAM ~4,4 Go). Rafraîchir la carte = relancer le bloc +(re-télécharge le pbf + re-préprocesse). Vérifier : + +```sh +docker exec targo-hub node -e 'require("http").get({host:"osrm",port:5000,path:"/table/v1/driving/-74.10,45.05;-74.35,45.12?annotations=duration"},r=>{let d="";r.on("data",c=>d+=c);r.on("end",()=>console.log(d))})' +# → {"code":"Ok","durations":[[0,2160],[2160,0]], ...} (secondes) +``` + +## Phase suivante (optionnel) +- Recalculer la carte périodiquement (cron mensuel) — le réseau routier évolue peu. +- Si on sort du Québec : extrait plus large (Canada) — plus de RAM/disk au préproc. diff --git a/services/targo-hub/lib/config.js b/services/targo-hub/lib/config.js index 8f90bdb..c24ac6c 100644 --- a/services/targo-hub/lib/config.js +++ b/services/targo-hub/lib/config.js @@ -60,6 +60,7 @@ module.exports = { OMLX_URL: env('OMLX_URL', 'http://127.0.0.1:8000'), TRACCAR_URL: env('TRACCAR_URL', 'http://tracker.targointernet.com:8082'), ROSTER_SOLVER_URL: env('ROSTER_SOLVER_URL', 'http://roster-solver:8090'), + OSRM_URL: env('OSRM_URL', 'http://osrm:5000'), // moteur de routes OSM (matrice temps de route réels) pour l'optimiseur VRP JWT_SECRET: env('JWT_SECRET'), FIELD_APP_URL: env('FIELD_APP_URL', 'https://msg.gigafibre.ca'), EXTERNAL_URL: env('EXTERNAL_URL', 'https://msg.gigafibre.ca'), diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 282f955..030ad30 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -84,6 +84,35 @@ function postSolver (path, body) { }) } +const OSRM_URL = cfg.OSRM_URL || 'http://osrm:5000' +// Matrice de TEMPS DE ROUTE (minutes) via OSRM /table, dans l'ordre attendu par route_solver : jobs d'abord, PUIS domiciles des véhicules. +// null si un nœud n'a pas de coordonnées → le solveur retombe proprement sur le haversine (pas de dépendance dure à OSRM). +async function osrmMatrix (jobs, vehicles) { + const ok = (a, b) => a != null && b != null && isFinite(+a) && isFinite(+b) && (Math.abs(+a) > 0.01 || Math.abs(+b) > 0.01) + // Ordre des nœuds = celui de route_solver : jobs d'abord, PUIS domiciles des véhicules. null = sans coordonnées. + const nodes = [] + for (const j of jobs) nodes.push(ok(j.lat, j.lon) ? [+j.lon, +j.lat] : null) + for (const v of vehicles) nodes.push(ok(v.home_lat, v.home_lon) ? [+v.home_lon, +v.home_lat] : null) + const N = nodes.length + const withIdx = []; for (let i = 0; i < N; i++) if (nodes[i]) withIdx.push(i) + if (withIdx.length < 2) return null // pas assez de points géolocalisés → le solveur retombe sur le haversine + const coords = withIdx.map(i => nodes[i]) + const path = '/table/v1/driving/' + coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';') + '?annotations=duration' + const u = new URL(OSRM_URL + path) + const raw = await new Promise((resolve, reject) => { + const rq = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET', timeout: 20000 }, (r) => { + let d = ''; r.on('data', c => { d += c }); r.on('end', () => resolve(d)) + }) + rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end() + }) + const j = JSON.parse(raw) + if (j.code !== 'Ok' || !Array.isArray(j.durations)) throw new Error('osrm code ' + (j.code || '?')) + // Matrice NxN complète en MINUTES : temps de route OSRM pour les paires géolocalisées, 0 (neutre) pour tout nœud sans coords. + const M = Array.from({ length: N }, () => new Array(N).fill(0)) + for (let a = 0; a < withIdx.length; a++) for (let b = 0; b < withIdx.length; b++) { const s = j.durations[a][b]; M[withIdx[a]][withIdx[b]] = (s == null ? 0 : Math.round(s / 60 * 10) / 10) } + return M +} + const sleep = (ms) => new Promise(r => setTimeout(r, ms)) // Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur // les lignes chaudes (Dispatch Technician maj en continu par le GPS/dispatch) un @@ -915,6 +944,10 @@ async function handle (req, res, method, path, url) { if (path === '/roster/optimize-routes' && method === 'POST') { const body = await parseBody(req) if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' }) + if (!body.matrix) { // temps de ROUTE réels via OSRM si dispo ; sinon le solveur retombe sur le haversine + try { const m = await osrmMatrix(body.jobs, body.vehicles); if (m) { body.matrix = m; body.matrix_source = 'osrm' } } + catch (e) { log('osrm matrix skip: ' + (e && e.message)) } + } try { return json(res, 200, await postSolver('/route', body)) } catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) } }