feat(dispatch): OSRM road-time matrix for the VRP optimizer (Phase 2)
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) <noreply@anthropic.com>
This commit is contained in:
parent
5803fb3d0e
commit
96929bed6f
39
services/osrm/README.md
Normal file
39
services/osrm/README.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -60,6 +60,7 @@ module.exports = {
|
||||||
OMLX_URL: env('OMLX_URL', 'http://127.0.0.1:8000'),
|
OMLX_URL: env('OMLX_URL', 'http://127.0.0.1:8000'),
|
||||||
TRACCAR_URL: env('TRACCAR_URL', 'http://tracker.targointernet.com:8082'),
|
TRACCAR_URL: env('TRACCAR_URL', 'http://tracker.targointernet.com:8082'),
|
||||||
ROSTER_SOLVER_URL: env('ROSTER_SOLVER_URL', 'http://roster-solver:8090'),
|
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'),
|
JWT_SECRET: env('JWT_SECRET'),
|
||||||
FIELD_APP_URL: env('FIELD_APP_URL', 'https://msg.gigafibre.ca'),
|
FIELD_APP_URL: env('FIELD_APP_URL', 'https://msg.gigafibre.ca'),
|
||||||
EXTERNAL_URL: env('EXTERNAL_URL', 'https://msg.gigafibre.ca'),
|
EXTERNAL_URL: env('EXTERNAL_URL', 'https://msg.gigafibre.ca'),
|
||||||
|
|
|
||||||
|
|
@ -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))
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||||
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
|
// 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
|
// 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') {
|
if (path === '/roster/optimize-routes' && method === 'POST') {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' })
|
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)) }
|
try { return json(res, 200, await postSolver('/route', body)) }
|
||||||
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) }
|
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user