diff --git a/CHANGELOG.md b/CHANGELOG.md index 2038a7d..ec807ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Versionado: una entrada por **entrega académica** del semestre (no SemVer estri ## [Unreleased] +### Added — H5-fix + H5-eval-95: fixture v3 (Ruta B) y cierre de CP-01a-95 (2026-06-02) +- **H5-fix-1** — `tools/generate_osrm_fixture.py` extendido con `--modo {basesxincidentes, cartesiano}` y `--n-objetivo`. El modo `cartesiano` (nuevo) genera una grilla 8×8 de anclas sobre el bbox `(-71.45, -30.10, -71.15, -29.85)` + jitter amplio (`0.01°` ≈ 1.1 km) en ambos extremos: cubre todo el bbox con rutas largas inter-comuna, frente al modo `basesxincidentes` (default, fixture v2) anclado al clúster urbano. El fixture marca `version: "3"` y `modo`. +- **H5-fix-2** — Nuevo fixture committeado [`core-python/tests/fixtures/osrm_oracle_v3.json`](core-python/tests/fixtures/osrm_oracle_v3.json): 300 pares contra OSRM 5.27.1 Docker (`tools/build_osrm_oracle.sh`). El v2 (`osrm_oracle.json`) se mantiene intacto para preservar la línea histórica del experimento. +- **H5-fix-3** — `tools/bootstrap_cp01a.py` y `tools/analyze_outliers.py` hechos robustos a `NoRutaDisponibleError`: los pares que OSRM rutea (snapeando anclas oceánicas a la costa) pero el grafo propio no conecta (componentes desconectados) se cuentan como **miss** definitivo / outlier de conectividad, no se descartan (evita survivorship bias). El render de ambos scripts se parametrizó por N. Re-corridos sobre v3: [bootstrap-cp01a-v3.md](docs/quality/bootstrap-cp01a-v3.md) + [outliers-cp01a-v3.md](docs/quality/outliers-cp01a-v3.md) (18 clasificados + 13 irruteables / 300). +- **H5-eval-95** — **CP-01a-95 ✅ CUMPLIDO** sobre el fixture v3 (A* operativo, snap-to-node, B=1000, semilla 2026): fracción dentro de ±30 % = **0.897**, **IC95 inferior 0.860 ≥ 0.75**, **P(fracción ≥ 0.75) = 100 % ≥ 0.95**. El margen estrecho de v2 (78/100, IC95 inf 69) era un artefacto del sesgo a rutas urbanas cortas (ADR-0011 §V/L#3): con rutas largas el error de snap se diluye. Nuevo test `test_routing_vs_osrm.py::test_cp01a_95_fixture_v3` (`slow`, fuera del CI rápido). + +### Changed — H5-fix / H5-eval-95 +- [ADR-0016](docs/architecture/decisions/0016-camino-95-cp01a.md) promovido a `accepted` con §Resultado (eval-95) y tabla de tareas Ruta A+B completas. +- [ADR-0011](docs/architecture/decisions/0011-reformulacion-criterio-it01.md) §V/L#5 marcado `RESUELTO` (margen estrecho era sesgo de muestra, no del A*). +- `docs/quality/trazabilidad.md`: RF-03 + sección IT-01 con el cierre CP-01a-95. + ### Added — H5-cal-3: snap-to-edge + recalibración CP-01c' + ADR-0021 (2026-05-28) - Nuevo módulo [`domain/routing/geometria.py`](core-python/src/sentinel_dispatch/domain/routing/geometria.py): `proyectar_en_polilinea(punto, polilinea)` proyecta un punto (lat, lon) sobre la polilínea de una arista en un plano métrico local equirectangular, devolviendo el punto más cercano, la distancia y la fracción recorrida. 13 UT en [`test_geometria.py`](core-python/tests/unit/domain/routing/test_geometria.py). - Nuevo módulo experimental [`domain/routing/a_estrella_snap_edge.py`](core-python/src/sentinel_dispatch/domain/routing/a_estrella_snap_edge.py): A* con **nodos virtuales** origen (`-1`) y destino (`-2`) inyectados sobre las aristas más cercanas vía decorador `_GrafoConPuntosVirtuales`; reusa `a_estrella_calibrado` como motor. Elimina la inflación de ruta del snap-to-node. Tipo `PosicionEnArista` y protocolo `GrafoVialConSnapEdge` agregados en `domain/routing/tipos.py` y `grafo_vial.py`. 23 UT. diff --git a/core-python/tests/fixtures/osrm_oracle_v3.json b/core-python/tests/fixtures/osrm_oracle_v3.json new file mode 100644 index 0000000..b29cba7 --- /dev/null +++ b/core-python/tests/fixtures/osrm_oracle_v3.json @@ -0,0 +1,3935 @@ +{ + "version": "3", + "modo": "cartesiano", + "generated_at": "2026-06-02T22:13:09-0400", + "bbox": [ + -71.45, + -30.1, + -71.15, + -29.85 + ], + "osrm": { + "base_url": "http://localhost:5000", + "profile": "car", + "algorithm": "mld", + "endpoint": "/route/v1/driving", + "seed": 2026, + "descartes": { + "red": 0, + "sin_ruta": 0, + "distancia_corta": 2 + } + }, + "jitter": { + "radio_grados": 0.01, + "radio_metros_aprox": 1110.0, + "distribucion": "uniform", + "aplicado_sobre": "ambos extremos (grilla cartesiana sobre el bbox)", + "generador": "random.Random(seed).uniform(-radio_grados, +radio_grados) por componente lat y lon, independiente, en origen y destino", + "grid_lado": 8 + }, + "distancia_minima_m": 200.0, + "n_objetivo": 300, + "pares": [ + { + "id": 0, + "origen": { + "lat": -30.108271, + "lon": -71.237097 + }, + "destino": { + "lat": -29.965171, + "lon": -71.313261 + }, + "duration_s": 1011.6, + "distance_m": 18422.9 + }, + { + "id": 1, + "origen": { + "lat": -29.961809, + "lon": -71.273518 + }, + "destino": { + "lat": -29.858818, + "lon": -71.152012 + }, + "duration_s": 2047.5, + "distance_m": 22960.4 + }, + { + "id": 2, + "origen": { + "lat": -30.096709, + "lon": -71.146431 + }, + "destino": { + "lat": -29.845903, + "lon": -71.188855 + }, + "duration_s": 4320.4, + "distance_m": 37751.3 + }, + { + "id": 3, + "origen": { + "lat": -30.023393, + "lon": -71.327638 + }, + "destino": { + "lat": -29.965591, + "lon": -71.147939 + }, + "duration_s": 4494.3, + "distance_m": 39233.7 + }, + { + "id": 4, + "origen": { + "lat": -29.99386, + "lon": -71.414168 + }, + "destino": { + "lat": -29.96228, + "lon": -71.201901 + }, + "duration_s": 2876.8, + "distance_m": 31507.5 + }, + { + "id": 5, + "origen": { + "lat": -30.030175, + "lon": -71.227072 + }, + "destino": { + "lat": -29.95691, + "lon": -71.457978 + }, + "duration_s": 2510.0, + "distance_m": 26545.2 + }, + { + "id": 6, + "origen": { + "lat": -30.059473, + "lon": -71.288043 + }, + "destino": { + "lat": -30.001966, + "lon": -71.407819 + }, + "duration_s": 2260.5, + "distance_m": 27312.1 + }, + { + "id": 7, + "origen": { + "lat": -30.036397, + "lon": -71.191493 + }, + "destino": { + "lat": -30.026267, + "lon": -71.40529 + }, + "duration_s": 3002.2, + "distance_m": 29734.5 + }, + { + "id": 8, + "origen": { + "lat": -30.1035, + "lon": -71.18927 + }, + "destino": { + "lat": -29.997738, + "lon": -71.273445 + }, + "duration_s": 1195.4, + "distance_m": 14772.6 + }, + { + "id": 9, + "origen": { + "lat": -29.984273, + "lon": -71.400026 + }, + "destino": { + "lat": -29.880518, + "lon": -71.242761 + }, + "duration_s": 1950.9, + "distance_m": 23591.6 + }, + { + "id": 10, + "origen": { + "lat": -29.922156, + "lon": -71.150345 + }, + "destino": { + "lat": -30.030856, + "lon": -71.279284 + }, + "duration_s": 2251.8, + "distance_m": 27638.5 + }, + { + "id": 11, + "origen": { + "lat": -29.921619, + "lon": -71.318775 + }, + "destino": { + "lat": -29.915374, + "lon": -71.449376 + }, + "duration_s": 1076.4, + "distance_m": 10075.4 + }, + { + "id": 12, + "origen": { + "lat": -30.002492, + "lon": -71.359997 + }, + "destino": { + "lat": -30.067115, + "lon": -71.365562 + }, + "duration_s": 726.3, + "distance_m": 10975.9 + }, + { + "id": 13, + "origen": { + "lat": -29.886598, + "lon": -71.230571 + }, + "destino": { + "lat": -29.882376, + "lon": -71.283362 + }, + "duration_s": 514.3, + "distance_m": 5153.7 + }, + { + "id": 14, + "origen": { + "lat": -29.881868, + "lon": -71.144739 + }, + "destino": { + "lat": -30.104027, + "lon": -71.32595 + }, + "duration_s": 3934.3, + "distance_m": 41404.0 + }, + { + "id": 15, + "origen": { + "lat": -29.988743, + "lon": -71.198201 + }, + "destino": { + "lat": -29.965715, + "lon": -71.451295 + }, + "duration_s": 2606.6, + "distance_m": 27811.8 + }, + { + "id": 16, + "origen": { + "lat": -29.878912, + "lon": -71.413804 + }, + "destino": { + "lat": -29.852675, + "lon": -71.272206 + }, + "duration_s": 1718.6, + "distance_m": 21291.2 + }, + { + "id": 17, + "origen": { + "lat": -29.891457, + "lon": -71.372344 + }, + "destino": { + "lat": -29.986954, + "lon": -71.441456 + }, + "duration_s": 1393.7, + "distance_m": 14545.7 + }, + { + "id": 18, + "origen": { + "lat": -30.05981, + "lon": -71.287831 + }, + "destino": { + "lat": -29.921639, + "lon": -71.196553 + }, + "duration_s": 1961.1, + "distance_m": 25614.6 + }, + { + "id": 19, + "origen": { + "lat": -29.987085, + "lon": -71.354915 + }, + "destino": { + "lat": -30.025397, + "lon": -71.449713 + }, + "duration_s": 882.9, + "distance_m": 9128.5 + }, + { + "id": 20, + "origen": { + "lat": -30.033361, + "lon": -71.360457 + }, + "destino": { + "lat": -30.094546, + "lon": -71.27312 + }, + "duration_s": 3033.1, + "distance_m": 31861.6 + }, + { + "id": 21, + "origen": { + "lat": -29.894606, + "lon": -71.409944 + }, + "destino": { + "lat": -30.031722, + "lon": -71.358158 + }, + "duration_s": 1483.2, + "distance_m": 12156.8 + }, + { + "id": 22, + "origen": { + "lat": -29.889334, + "lon": -71.401479 + }, + "destino": { + "lat": -29.92083, + "lon": -71.151181 + }, + "duration_s": 2217.0, + "distance_m": 27583.1 + }, + { + "id": 23, + "origen": { + "lat": -30.020318, + "lon": -71.459687 + }, + "destino": { + "lat": -30.074022, + "lon": -71.190234 + }, + "duration_s": 2675.6, + "distance_m": 29296.9 + }, + { + "id": 24, + "origen": { + "lat": -29.857944, + "lon": -71.321735 + }, + "destino": { + "lat": -29.882955, + "lon": -71.198988 + }, + "duration_s": 889.5, + "distance_m": 8534.9 + }, + { + "id": 25, + "origen": { + "lat": -29.950685, + "lon": -71.397795 + }, + "destino": { + "lat": -30.028368, + "lon": -71.371482 + }, + "duration_s": 1106.2, + "distance_m": 17486.2 + }, + { + "id": 26, + "origen": { + "lat": -29.858518, + "lon": -71.184227 + }, + "destino": { + "lat": -29.928243, + "lon": -71.244544 + }, + "duration_s": 1127.6, + "distance_m": 10689.1 + }, + { + "id": 27, + "origen": { + "lat": -30.065855, + "lon": -71.280227 + }, + "destino": { + "lat": -29.891748, + "lon": -71.24037 + }, + "duration_s": 1999.0, + "distance_m": 24029.0 + }, + { + "id": 28, + "origen": { + "lat": -30.021502, + "lon": -71.4158 + }, + "destino": { + "lat": -29.885778, + "lon": -71.447077 + }, + "duration_s": 1546.7, + "distance_m": 13452.0 + }, + { + "id": 29, + "origen": { + "lat": -29.962871, + "lon": -71.458375 + }, + "destino": { + "lat": -30.036528, + "lon": -71.237254 + }, + "duration_s": 2678.5, + "distance_m": 26938.2 + }, + { + "id": 30, + "origen": { + "lat": -30.061583, + "lon": -71.444522 + }, + "destino": { + "lat": -29.854665, + "lon": -71.200468 + }, + "duration_s": 2589.7, + "distance_m": 37094.6 + }, + { + "id": 31, + "origen": { + "lat": -29.916884, + "lon": -71.142232 + }, + "destino": { + "lat": -30.025762, + "lon": -71.244772 + }, + "duration_s": 2210.1, + "distance_m": 27247.7 + }, + { + "id": 32, + "origen": { + "lat": -29.948936, + "lon": -71.318526 + }, + "destino": { + "lat": -29.954163, + "lon": -71.192991 + }, + "duration_s": 1617.3, + "distance_m": 19175.0 + }, + { + "id": 33, + "origen": { + "lat": -29.857599, + "lon": -71.410967 + }, + "destino": { + "lat": -30.036145, + "lon": -71.189864 + }, + "duration_s": 2370.4, + "distance_m": 22343.8 + }, + { + "id": 34, + "origen": { + "lat": -29.888362, + "lon": -71.361424 + }, + "destino": { + "lat": -29.952705, + "lon": -71.366868 + }, + "duration_s": 568.1, + "distance_m": 5025.5 + }, + { + "id": 35, + "origen": { + "lat": -30.065225, + "lon": -71.451917 + }, + "destino": { + "lat": -30.064675, + "lon": -71.186828 + }, + "duration_s": 2474.7, + "distance_m": 36311.7 + }, + { + "id": 36, + "origen": { + "lat": -29.994181, + "lon": -71.330767 + }, + "destino": { + "lat": -30.090425, + "lon": -71.194821 + }, + "duration_s": 1685.3, + "distance_m": 23650.4 + }, + { + "id": 37, + "origen": { + "lat": -30.059769, + "lon": -71.356461 + }, + "destino": { + "lat": -29.913887, + "lon": -71.363278 + }, + "duration_s": 1139.2, + "distance_m": 17508.5 + }, + { + "id": 38, + "origen": { + "lat": -30.0997, + "lon": -71.399496 + }, + "destino": { + "lat": -29.91856, + "lon": -71.45996 + }, + "duration_s": 1153.8, + "distance_m": 15779.0 + }, + { + "id": 39, + "origen": { + "lat": -29.915036, + "lon": -71.227188 + }, + "destino": { + "lat": -30.103388, + "lon": -71.35575 + }, + "duration_s": 3122.3, + "distance_m": 31905.1 + }, + { + "id": 40, + "origen": { + "lat": -29.853238, + "lon": -71.445482 + }, + "destino": { + "lat": -30.071955, + "lon": -71.374187 + }, + "duration_s": 1259.7, + "distance_m": 18728.5 + }, + { + "id": 41, + "origen": { + "lat": -29.956576, + "lon": -71.374244 + }, + "destino": { + "lat": -29.984357, + "lon": -71.199334 + }, + "duration_s": 2123.0, + "distance_m": 23232.3 + }, + { + "id": 42, + "origen": { + "lat": -29.892542, + "lon": -71.450687 + }, + "destino": { + "lat": -29.99883, + "lon": -71.197525 + }, + "duration_s": 2371.4, + "distance_m": 22528.2 + }, + { + "id": 43, + "origen": { + "lat": -30.099827, + "lon": -71.33068 + }, + "destino": { + "lat": -29.919005, + "lon": -71.370378 + }, + "duration_s": 2829.7, + "distance_m": 28468.3 + }, + { + "id": 44, + "origen": { + "lat": -30.036427, + "lon": -71.28164 + }, + "destino": { + "lat": -29.857284, + "lon": -71.363337 + }, + "duration_s": 2101.2, + "distance_m": 26224.0 + }, + { + "id": 45, + "origen": { + "lat": -29.958307, + "lon": -71.274821 + }, + "destino": { + "lat": -30.026366, + "lon": -71.356216 + }, + "duration_s": 1534.4, + "distance_m": 16255.1 + }, + { + "id": 46, + "origen": { + "lat": -29.995817, + "lon": -71.199686 + }, + "destino": { + "lat": -30.001182, + "lon": -71.369674 + }, + "duration_s": 2081.6, + "distance_m": 22457.1 + }, + { + "id": 47, + "origen": { + "lat": -29.987913, + "lon": -71.142793 + }, + "destino": { + "lat": -29.841051, + "lon": -71.270105 + }, + "duration_s": 4150.8, + "distance_m": 37057.8 + }, + { + "id": 48, + "origen": { + "lat": -29.948668, + "lon": -71.283379 + }, + "destino": { + "lat": -30.031769, + "lon": -71.226775 + }, + "duration_s": 1232.9, + "distance_m": 13600.0 + }, + { + "id": 49, + "origen": { + "lat": -29.914089, + "lon": -71.360432 + }, + "destino": { + "lat": -30.028795, + "lon": -71.459319 + }, + "duration_s": 1370.9, + "distance_m": 14348.0 + }, + { + "id": 50, + "origen": { + "lat": -29.87711, + "lon": -71.151412 + }, + "destino": { + "lat": -29.895053, + "lon": -71.372361 + }, + "duration_s": 2318.4, + "distance_m": 28628.1 + }, + { + "id": 51, + "origen": { + "lat": -29.998529, + "lon": -71.316548 + }, + "destino": { + "lat": -30.060729, + "lon": -71.411695 + }, + "duration_s": 1157.2, + "distance_m": 17182.2 + }, + { + "id": 52, + "origen": { + "lat": -29.924745, + "lon": -71.185675 + }, + "destino": { + "lat": -30.107854, + "lon": -71.372315 + }, + "duration_s": 2180.3, + "distance_m": 33438.4 + }, + { + "id": 53, + "origen": { + "lat": -30.100525, + "lon": -71.270135 + }, + "destino": { + "lat": -29.913086, + "lon": -71.156963 + }, + "duration_s": 3236.1, + "distance_m": 37390.0 + }, + { + "id": 54, + "origen": { + "lat": -29.851291, + "lon": -71.369019 + }, + "destino": { + "lat": -30.022856, + "lon": -71.368349 + }, + "duration_s": 2000.1, + "distance_m": 24543.8 + }, + { + "id": 55, + "origen": { + "lat": -30.063485, + "lon": -71.361387 + }, + "destino": { + "lat": -29.993358, + "lon": -71.323928 + }, + "duration_s": 896.6, + "distance_m": 14857.3 + }, + { + "id": 56, + "origen": { + "lat": -29.847872, + "lon": -71.199264 + }, + "destino": { + "lat": -29.848104, + "lon": -71.232113 + }, + "duration_s": 166.4, + "distance_m": 1112.1 + }, + { + "id": 57, + "origen": { + "lat": -30.023171, + "lon": -71.401306 + }, + "destino": { + "lat": -29.857953, + "lon": -71.277804 + }, + "duration_s": 2174.9, + "distance_m": 25876.7 + }, + { + "id": 58, + "origen": { + "lat": -30.05743, + "lon": -71.278592 + }, + "destino": { + "lat": -29.929767, + "lon": -71.414216 + }, + "duration_s": 1953.4, + "distance_m": 23031.1 + }, + { + "id": 59, + "origen": { + "lat": -29.876676, + "lon": -71.400358 + }, + "destino": { + "lat": -29.882543, + "lon": -71.154158 + }, + "duration_s": 2248.9, + "distance_m": 27344.5 + }, + { + "id": 60, + "origen": { + "lat": -29.955695, + "lon": -71.184249 + }, + "destino": { + "lat": -29.986416, + "lon": -71.147593 + }, + "duration_s": 4146.8, + "distance_m": 35681.3 + }, + { + "id": 61, + "origen": { + "lat": -29.851462, + "lon": -71.372908 + }, + "destino": { + "lat": -29.921863, + "lon": -71.416005 + }, + "duration_s": 1591.9, + "distance_m": 20038.9 + }, + { + "id": 62, + "origen": { + "lat": -30.02752, + "lon": -71.192116 + }, + "destino": { + "lat": -30.054463, + "lon": -71.287863 + }, + "duration_s": 1670.8, + "distance_m": 13239.0 + }, + { + "id": 63, + "origen": { + "lat": -29.875819, + "lon": -71.278177 + }, + "destino": { + "lat": -29.857953, + "lon": -71.281884 + }, + "duration_s": 199.4, + "distance_m": 1460.8 + }, + { + "id": 64, + "origen": { + "lat": -29.984284, + "lon": -71.444795 + }, + "destino": { + "lat": -29.881742, + "lon": -71.446262 + }, + "duration_s": 1670.4, + "distance_m": 13704.4 + }, + { + "id": 65, + "origen": { + "lat": -30.024691, + "lon": -71.241187 + }, + "destino": { + "lat": -29.948141, + "lon": -71.283938 + }, + "duration_s": 1010.4, + "distance_m": 11353.9 + }, + { + "id": 66, + "origen": { + "lat": -29.858945, + "lon": -71.143078 + }, + "destino": { + "lat": -29.879133, + "lon": -71.284593 + }, + "duration_s": 1608.6, + "distance_m": 16027.1 + }, + { + "id": 67, + "origen": { + "lat": -29.997796, + "lon": -71.37249 + }, + "destino": { + "lat": -29.990359, + "lon": -71.271747 + }, + "duration_s": 1242.1, + "distance_m": 14773.5 + }, + { + "id": 68, + "origen": { + "lat": -30.02644, + "lon": -71.372036 + }, + "destino": { + "lat": -29.843702, + "lon": -71.368973 + }, + "duration_s": 1669.6, + "distance_m": 25387.0 + }, + { + "id": 69, + "origen": { + "lat": -29.954316, + "lon": -71.280117 + }, + "destino": { + "lat": -29.951969, + "lon": -71.442802 + }, + "duration_s": 1423.9, + "distance_m": 16794.8 + }, + { + "id": 70, + "origen": { + "lat": -29.858555, + "lon": -71.232194 + }, + "destino": { + "lat": -29.885923, + "lon": -71.245256 + }, + "duration_s": 389.3, + "distance_m": 3494.1 + }, + { + "id": 71, + "origen": { + "lat": -29.957033, + "lon": -71.146406 + }, + "destino": { + "lat": -29.882451, + "lon": -71.235074 + }, + "duration_s": 4170.4, + "distance_m": 34524.7 + }, + { + "id": 72, + "origen": { + "lat": -30.102797, + "lon": -71.279252 + }, + "destino": { + "lat": -30.026508, + "lon": -71.314155 + }, + "duration_s": 2977.1, + "distance_m": 31641.8 + }, + { + "id": 73, + "origen": { + "lat": -29.954195, + "lon": -71.31472 + }, + "destino": { + "lat": -29.840311, + "lon": -71.360885 + }, + "duration_s": 1184.8, + "distance_m": 15223.2 + }, + { + "id": 74, + "origen": { + "lat": -30.031622, + "lon": -71.367891 + }, + "destino": { + "lat": -30.100572, + "lon": -71.156569 + }, + "duration_s": 4163.5, + "distance_m": 36715.9 + }, + { + "id": 75, + "origen": { + "lat": -30.000547, + "lon": -71.357668 + }, + "destino": { + "lat": -30.01954, + "lon": -71.40715 + }, + "duration_s": 895.2, + "distance_m": 7648.2 + }, + { + "id": 76, + "origen": { + "lat": -30.027953, + "lon": -71.414823 + }, + "destino": { + "lat": -30.103396, + "lon": -71.152705 + }, + "duration_s": 4426.0, + "distance_m": 38257.7 + }, + { + "id": 77, + "origen": { + "lat": -29.856743, + "lon": -71.144456 + }, + "destino": { + "lat": -29.962963, + "lon": -71.245111 + }, + "duration_s": 2103.8, + "distance_m": 21860.8 + }, + { + "id": 78, + "origen": { + "lat": -29.883997, + "lon": -71.367475 + }, + "destino": { + "lat": -29.99123, + "lon": -71.240436 + }, + "duration_s": 1422.9, + "distance_m": 14077.5 + }, + { + "id": 79, + "origen": { + "lat": -30.097413, + "lon": -71.365476 + }, + "destino": { + "lat": -29.948184, + "lon": -71.190725 + }, + "duration_s": 2361.8, + "distance_m": 34904.7 + }, + { + "id": 80, + "origen": { + "lat": -30.021707, + "lon": -71.287853 + }, + "destino": { + "lat": -29.992345, + "lon": -71.399934 + }, + "duration_s": 1770.8, + "distance_m": 21505.1 + }, + { + "id": 81, + "origen": { + "lat": -29.962793, + "lon": -71.319298 + }, + "destino": { + "lat": -30.103718, + "lon": -71.454133 + }, + "duration_s": 1073.9, + "distance_m": 17053.0 + }, + { + "id": 82, + "origen": { + "lat": -29.998915, + "lon": -71.282627 + }, + "destino": { + "lat": -29.857699, + "lon": -71.146003 + }, + "duration_s": 2183.1, + "distance_m": 24889.5 + }, + { + "id": 83, + "origen": { + "lat": -30.034444, + "lon": -71.412444 + }, + "destino": { + "lat": -29.964095, + "lon": -71.448513 + }, + "duration_s": 1407.4, + "distance_m": 19484.5 + }, + { + "id": 84, + "origen": { + "lat": -29.992569, + "lon": -71.239481 + }, + "destino": { + "lat": -29.988378, + "lon": -71.363614 + }, + "duration_s": 1469.6, + "distance_m": 15316.2 + }, + { + "id": 85, + "origen": { + "lat": -30.029139, + "lon": -71.453615 + }, + "destino": { + "lat": -30.095715, + "lon": -71.440229 + }, + "duration_s": 1077.4, + "distance_m": 12348.5 + }, + { + "id": 86, + "origen": { + "lat": -30.109719, + "lon": -71.459996 + }, + "destino": { + "lat": -29.991244, + "lon": -71.184214 + }, + "duration_s": 3008.5, + "distance_m": 36096.7 + }, + { + "id": 87, + "origen": { + "lat": -30.035547, + "lon": -71.196046 + }, + "destino": { + "lat": -30.069044, + "lon": -71.364557 + }, + "duration_s": 2894.7, + "distance_m": 33591.4 + }, + { + "id": 88, + "origen": { + "lat": -30.032363, + "lon": -71.184965 + }, + "destino": { + "lat": -30.057077, + "lon": -71.243218 + }, + "duration_s": 1502.7, + "distance_m": 11599.9 + }, + { + "id": 89, + "origen": { + "lat": -30.060311, + "lon": -71.198377 + }, + "destino": { + "lat": -30.028761, + "lon": -71.146611 + }, + "duration_s": 2267.7, + "distance_m": 17149.5 + }, + { + "id": 90, + "origen": { + "lat": -29.984926, + "lon": -71.361753 + }, + "destino": { + "lat": -30.07231, + "lon": -71.147167 + }, + "duration_s": 3601.3, + "distance_m": 31803.1 + }, + { + "id": 91, + "origen": { + "lat": -29.917127, + "lon": -71.280262 + }, + "destino": { + "lat": -29.987697, + "lon": -71.233523 + }, + "duration_s": 1288.4, + "distance_m": 13836.9 + }, + { + "id": 92, + "origen": { + "lat": -29.841461, + "lon": -71.322681 + }, + "destino": { + "lat": -29.925338, + "lon": -71.311492 + }, + "duration_s": 1502.5, + "distance_m": 20101.3 + }, + { + "id": 93, + "origen": { + "lat": -29.852902, + "lon": -71.447737 + }, + "destino": { + "lat": -29.99191, + "lon": -71.314091 + }, + "duration_s": 951.0, + "distance_m": 8438.5 + }, + { + "id": 94, + "origen": { + "lat": -30.096359, + "lon": -71.358969 + }, + "destino": { + "lat": -30.068314, + "lon": -71.193205 + }, + "duration_s": 2296.3, + "distance_m": 33844.2 + }, + { + "id": 95, + "origen": { + "lat": -29.999093, + "lon": -71.190634 + }, + "destino": { + "lat": -30.038184, + "lon": -71.241634 + }, + "duration_s": 2138.0, + "distance_m": 18836.8 + }, + { + "id": 96, + "origen": { + "lat": -30.096052, + "lon": -71.155657 + }, + "destino": { + "lat": -30.103475, + "lon": -71.194259 + }, + "duration_s": 3019.3, + "distance_m": 25539.4 + }, + { + "id": 97, + "origen": { + "lat": -29.921209, + "lon": -71.36891 + }, + "destino": { + "lat": -29.993098, + "lon": -71.202481 + }, + "duration_s": 1913.4, + "distance_m": 19791.4 + }, + { + "id": 98, + "origen": { + "lat": -29.886543, + "lon": -71.26915 + }, + "destino": { + "lat": -29.923663, + "lon": -71.153482 + }, + "duration_s": 1366.6, + "distance_m": 13965.3 + }, + { + "id": 99, + "origen": { + "lat": -29.856782, + "lon": -71.322751 + }, + "destino": { + "lat": -30.062197, + "lon": -71.185958 + }, + "duration_s": 3249.4, + "distance_m": 33238.9 + }, + { + "id": 100, + "origen": { + "lat": -29.99868, + "lon": -71.192336 + }, + "destino": { + "lat": -29.964071, + "lon": -71.409677 + }, + "duration_s": 2823.1, + "distance_m": 26942.9 + }, + { + "id": 101, + "origen": { + "lat": -29.843096, + "lon": -71.182891 + }, + "destino": { + "lat": -29.888642, + "lon": -71.452173 + }, + "duration_s": 2074.7, + "distance_m": 23575.7 + }, + { + "id": 102, + "origen": { + "lat": -29.88118, + "lon": -71.355164 + }, + "destino": { + "lat": -30.090458, + "lon": -71.411364 + }, + "duration_s": 1320.8, + "distance_m": 19214.0 + }, + { + "id": 103, + "origen": { + "lat": -29.966683, + "lon": -71.28578 + }, + "destino": { + "lat": -30.001522, + "lon": -71.284916 + }, + "duration_s": 518.4, + "distance_m": 6562.3 + }, + { + "id": 104, + "origen": { + "lat": -29.95708, + "lon": -71.313951 + }, + "destino": { + "lat": -29.846292, + "lon": -71.268807 + }, + "duration_s": 1266.5, + "distance_m": 16220.7 + }, + { + "id": 105, + "origen": { + "lat": -29.844612, + "lon": -71.151417 + }, + "destino": { + "lat": -29.847484, + "lon": -71.278268 + }, + "duration_s": 2260.4, + "distance_m": 25579.5 + }, + { + "id": 106, + "origen": { + "lat": -29.953088, + "lon": -71.373049 + }, + "destino": { + "lat": -30.057315, + "lon": -71.149328 + }, + "duration_s": 3421.1, + "distance_m": 29819.4 + }, + { + "id": 107, + "origen": { + "lat": -30.036185, + "lon": -71.281762 + }, + "destino": { + "lat": -29.878984, + "lon": -71.409236 + }, + "duration_s": 1352.0, + "distance_m": 15680.1 + }, + { + "id": 108, + "origen": { + "lat": -30.057106, + "lon": -71.276133 + }, + "destino": { + "lat": -30.025766, + "lon": -71.271349 + }, + "duration_s": 526.5, + "distance_m": 6025.4 + }, + { + "id": 109, + "origen": { + "lat": -30.03568, + "lon": -71.367678 + }, + "destino": { + "lat": -29.858605, + "lon": -71.318473 + }, + "duration_s": 1884.4, + "distance_m": 28379.2 + }, + { + "id": 110, + "origen": { + "lat": -29.89119, + "lon": -71.408111 + }, + "destino": { + "lat": -30.030677, + "lon": -71.283278 + }, + "duration_s": 1499.1, + "distance_m": 15564.2 + }, + { + "id": 111, + "origen": { + "lat": -29.922651, + "lon": -71.36281 + }, + "destino": { + "lat": -30.07305, + "lon": -71.150323 + }, + "duration_s": 3693.5, + "distance_m": 31570.0 + }, + { + "id": 112, + "origen": { + "lat": -30.090861, + "lon": -71.313386 + }, + "destino": { + "lat": -30.054938, + "lon": -71.404134 + }, + "duration_s": 2995.0, + "distance_m": 34560.5 + }, + { + "id": 113, + "origen": { + "lat": -30.067115, + "lon": -71.441388 + }, + "destino": { + "lat": -29.9549, + "lon": -71.458814 + }, + "duration_s": 1334.5, + "distance_m": 17550.2 + }, + { + "id": 114, + "origen": { + "lat": -30.032729, + "lon": -71.156459 + }, + "destino": { + "lat": -30.067234, + "lon": -71.145529 + }, + "duration_s": 813.8, + "distance_m": 5652.7 + }, + { + "id": 115, + "origen": { + "lat": -29.967012, + "lon": -71.458918 + }, + "destino": { + "lat": -29.850906, + "lon": -71.187604 + }, + "duration_s": 2800.9, + "distance_m": 30194.4 + }, + { + "id": 116, + "origen": { + "lat": -30.032648, + "lon": -71.274534 + }, + "destino": { + "lat": -29.92719, + "lon": -71.227345 + }, + "duration_s": 1451.7, + "distance_m": 18053.2 + }, + { + "id": 117, + "origen": { + "lat": -29.845872, + "lon": -71.4483 + }, + "destino": { + "lat": -29.878043, + "lon": -71.278885 + }, + "duration_s": 1524.7, + "distance_m": 19107.3 + }, + { + "id": 118, + "origen": { + "lat": -29.895475, + "lon": -71.159236 + }, + "destino": { + "lat": -30.02665, + "lon": -71.451574 + }, + "duration_s": 2741.4, + "distance_m": 33331.5 + }, + { + "id": 119, + "origen": { + "lat": -30.099839, + "lon": -71.363754 + }, + "destino": { + "lat": -30.028298, + "lon": -71.272738 + }, + "duration_s": 1626.6, + "distance_m": 26571.6 + }, + { + "id": 120, + "origen": { + "lat": -29.994867, + "lon": -71.402871 + }, + "destino": { + "lat": -30.021465, + "lon": -71.15725 + }, + "duration_s": 3190.5, + "distance_m": 29718.5 + }, + { + "id": 121, + "origen": { + "lat": -29.966095, + "lon": -71.370824 + }, + "destino": { + "lat": -29.983724, + "lon": -71.31417 + }, + "duration_s": 830.4, + "distance_m": 8724.4 + }, + { + "id": 122, + "origen": { + "lat": -29.953236, + "lon": -71.14768 + }, + "destino": { + "lat": -29.995582, + "lon": -71.153715 + }, + "duration_s": 1224.7, + "distance_m": 8483.5 + }, + { + "id": 123, + "origen": { + "lat": -30.108563, + "lon": -71.276786 + }, + "destino": { + "lat": -29.988156, + "lon": -71.323666 + }, + "duration_s": 2608.6, + "distance_m": 26885.0 + }, + { + "id": 124, + "origen": { + "lat": -29.895132, + "lon": -71.272624 + }, + "destino": { + "lat": -29.886432, + "lon": -71.155331 + }, + "duration_s": 1413.8, + "distance_m": 15383.1 + }, + { + "id": 125, + "origen": { + "lat": -29.895151, + "lon": -71.3603 + }, + "destino": { + "lat": -29.912567, + "lon": -71.229434 + }, + "duration_s": 1465.9, + "distance_m": 16792.6 + }, + { + "id": 126, + "origen": { + "lat": -30.001574, + "lon": -71.321939 + }, + "destino": { + "lat": -30.023019, + "lon": -71.158137 + }, + "duration_s": 2828.3, + "distance_m": 25783.3 + }, + { + "id": 127, + "origen": { + "lat": -30.094443, + "lon": -71.238992 + }, + "destino": { + "lat": -30.058097, + "lon": -71.187641 + }, + "duration_s": 872.5, + "distance_m": 9337.0 + }, + { + "id": 128, + "origen": { + "lat": -29.994928, + "lon": -71.403536 + }, + "destino": { + "lat": -29.850929, + "lon": -71.455357 + }, + "duration_s": 1552.3, + "distance_m": 14116.1 + }, + { + "id": 129, + "origen": { + "lat": -29.88284, + "lon": -71.452935 + }, + "destino": { + "lat": -29.842194, + "lon": -71.316108 + }, + "duration_s": 1680.0, + "distance_m": 20295.1 + }, + { + "id": 130, + "origen": { + "lat": -30.061948, + "lon": -71.191694 + }, + "destino": { + "lat": -29.965535, + "lon": -71.154013 + }, + "duration_s": 3989.1, + "distance_m": 32633.7 + }, + { + "id": 131, + "origen": { + "lat": -29.999994, + "lon": -71.326322 + }, + "destino": { + "lat": -29.953437, + "lon": -71.152828 + }, + "duration_s": 2102.7, + "distance_m": 26489.8 + }, + { + "id": 132, + "origen": { + "lat": -30.031671, + "lon": -71.14291 + }, + "destino": { + "lat": -30.068285, + "lon": -71.230885 + }, + "duration_s": 2291.1, + "distance_m": 18127.4 + }, + { + "id": 133, + "origen": { + "lat": -29.987466, + "lon": -71.158143 + }, + "destino": { + "lat": -29.95683, + "lon": -71.413058 + }, + "duration_s": 3417.7, + "distance_m": 31074.6 + }, + { + "id": 134, + "origen": { + "lat": -29.985064, + "lon": -71.400802 + }, + "destino": { + "lat": -30.108486, + "lon": -71.268886 + }, + "duration_s": 3162.6, + "distance_m": 32362.1 + }, + { + "id": 135, + "origen": { + "lat": -29.848188, + "lon": -71.326079 + }, + "destino": { + "lat": -30.01961, + "lon": -71.187419 + }, + "duration_s": 2666.8, + "distance_m": 29138.5 + }, + { + "id": 136, + "origen": { + "lat": -30.067211, + "lon": -71.235983 + }, + "destino": { + "lat": -29.847024, + "lon": -71.242512 + }, + "duration_s": 2190.7, + "distance_m": 30451.0 + }, + { + "id": 137, + "origen": { + "lat": -29.958036, + "lon": -71.362035 + }, + "destino": { + "lat": -29.914043, + "lon": -71.415686 + }, + "duration_s": 575.8, + "distance_m": 3253.1 + }, + { + "id": 138, + "origen": { + "lat": -29.921768, + "lon": -71.440326 + }, + "destino": { + "lat": -30.021402, + "lon": -71.149137 + }, + "duration_s": 3176.3, + "distance_m": 28965.9 + }, + { + "id": 139, + "origen": { + "lat": -29.881022, + "lon": -71.286507 + }, + "destino": { + "lat": -29.996055, + "lon": -71.27854 + }, + "duration_s": 1526.5, + "distance_m": 24056.2 + }, + { + "id": 140, + "origen": { + "lat": -30.071496, + "lon": -71.159338 + }, + "destino": { + "lat": -30.09798, + "lon": -71.354761 + }, + "duration_s": 4331.9, + "distance_m": 43303.7 + }, + { + "id": 141, + "origen": { + "lat": -29.849845, + "lon": -71.416721 + }, + "destino": { + "lat": -29.882242, + "lon": -71.286363 + }, + "duration_s": 1444.8, + "distance_m": 18573.2 + }, + { + "id": 142, + "origen": { + "lat": -30.072704, + "lon": -71.225889 + }, + "destino": { + "lat": -29.99526, + "lon": -71.27013 + }, + "duration_s": 998.0, + "distance_m": 10956.9 + }, + { + "id": 143, + "origen": { + "lat": -30.072782, + "lon": -71.327667 + }, + "destino": { + "lat": -30.033123, + "lon": -71.231154 + }, + "duration_s": 1975.5, + "distance_m": 14927.4 + }, + { + "id": 144, + "origen": { + "lat": -29.989303, + "lon": -71.281643 + }, + "destino": { + "lat": -29.856281, + "lon": -71.322235 + }, + "duration_s": 1410.0, + "distance_m": 18789.3 + }, + { + "id": 145, + "origen": { + "lat": -29.955715, + "lon": -71.231624 + }, + "destino": { + "lat": -30.0382, + "lon": -71.154675 + }, + "duration_s": 2341.3, + "distance_m": 20747.4 + }, + { + "id": 146, + "origen": { + "lat": -29.988551, + "lon": -71.193497 + }, + "destino": { + "lat": -29.882252, + "lon": -71.280243 + }, + "duration_s": 1959.8, + "distance_m": 20360.1 + }, + { + "id": 147, + "origen": { + "lat": -29.914073, + "lon": -71.159426 + }, + "destino": { + "lat": -29.949695, + "lon": -71.285685 + }, + "duration_s": 1555.8, + "distance_m": 21563.6 + }, + { + "id": 148, + "origen": { + "lat": -30.092261, + "lon": -71.272827 + }, + "destino": { + "lat": -29.851332, + "lon": -71.269904 + }, + "duration_s": 3072.1, + "distance_m": 36054.1 + }, + { + "id": 149, + "origen": { + "lat": -29.955503, + "lon": -71.372041 + }, + "destino": { + "lat": -29.889905, + "lon": -71.184333 + }, + "duration_s": 2038.9, + "distance_m": 24175.0 + }, + { + "id": 150, + "origen": { + "lat": -29.919999, + "lon": -71.281976 + }, + "destino": { + "lat": -30.108731, + "lon": -71.367693 + }, + "duration_s": 1569.1, + "distance_m": 24409.4 + }, + { + "id": 151, + "origen": { + "lat": -30.065696, + "lon": -71.45353 + }, + "destino": { + "lat": -29.965276, + "lon": -71.315066 + }, + "duration_s": 1069.6, + "distance_m": 19016.1 + }, + { + "id": 152, + "origen": { + "lat": -30.106833, + "lon": -71.1955 + }, + "destino": { + "lat": -30.024212, + "lon": -71.372603 + }, + "duration_s": 2108.3, + "distance_m": 38062.6 + }, + { + "id": 153, + "origen": { + "lat": -29.99126, + "lon": -71.407624 + }, + "destino": { + "lat": -29.844343, + "lon": -71.183598 + }, + "duration_s": 2814.4, + "distance_m": 30288.2 + }, + { + "id": 154, + "origen": { + "lat": -30.037909, + "lon": -71.362956 + }, + "destino": { + "lat": -29.886803, + "lon": -71.448225 + }, + "duration_s": 1458.1, + "distance_m": 12838.6 + }, + { + "id": 155, + "origen": { + "lat": -30.05871, + "lon": -71.366072 + }, + "destino": { + "lat": -29.957416, + "lon": -71.200056 + }, + "duration_s": 2472.8, + "distance_m": 34963.2 + }, + { + "id": 156, + "origen": { + "lat": -29.923366, + "lon": -71.227549 + }, + "destino": { + "lat": -29.893422, + "lon": -71.145329 + }, + "duration_s": 1155.7, + "distance_m": 14105.5 + }, + { + "id": 157, + "origen": { + "lat": -29.850778, + "lon": -71.157834 + }, + "destino": { + "lat": -30.067212, + "lon": -71.229292 + }, + "duration_s": 2937.7, + "distance_m": 35767.7 + }, + { + "id": 158, + "origen": { + "lat": -29.963476, + "lon": -71.244815 + }, + "destino": { + "lat": -29.892723, + "lon": -71.368331 + }, + "duration_s": 1167.0, + "distance_m": 13787.5 + }, + { + "id": 159, + "origen": { + "lat": -30.104142, + "lon": -71.40632 + }, + "destino": { + "lat": -29.959998, + "lon": -71.311973 + }, + "duration_s": 1155.3, + "distance_m": 18334.4 + }, + { + "id": 160, + "origen": { + "lat": -30.030077, + "lon": -71.235373 + }, + "destino": { + "lat": -29.878073, + "lon": -71.445322 + }, + "duration_s": 1861.8, + "distance_m": 18014.0 + }, + { + "id": 161, + "origen": { + "lat": -29.919675, + "lon": -71.275856 + }, + "destino": { + "lat": -29.998081, + "lon": -71.455336 + }, + "duration_s": 1634.8, + "distance_m": 19626.8 + }, + { + "id": 162, + "origen": { + "lat": -29.993917, + "lon": -71.227565 + }, + "destino": { + "lat": -29.930425, + "lon": -71.450422 + }, + "duration_s": 2247.0, + "distance_m": 22809.6 + }, + { + "id": 163, + "origen": { + "lat": -30.098916, + "lon": -71.143339 + }, + "destino": { + "lat": -30.020648, + "lon": -71.449769 + }, + "duration_s": 4317.8, + "distance_m": 38872.6 + }, + { + "id": 164, + "origen": { + "lat": -30.102919, + "lon": -71.404185 + }, + "destino": { + "lat": -30.06117, + "lon": -71.287996 + }, + "duration_s": 2190.7, + "distance_m": 32556.3 + }, + { + "id": 165, + "origen": { + "lat": -30.092516, + "lon": -71.320339 + }, + "destino": { + "lat": -29.850286, + "lon": -71.31954 + }, + "duration_s": 3272.6, + "distance_m": 36983.7 + }, + { + "id": 166, + "origen": { + "lat": -29.913053, + "lon": -71.233217 + }, + "destino": { + "lat": -29.852494, + "lon": -71.459013 + }, + "duration_s": 2056.6, + "distance_m": 21221.2 + }, + { + "id": 167, + "origen": { + "lat": -29.847892, + "lon": -71.285883 + }, + "destino": { + "lat": -29.853371, + "lon": -71.356608 + }, + "duration_s": 679.0, + "distance_m": 7330.1 + }, + { + "id": 168, + "origen": { + "lat": -30.108067, + "lon": -71.445049 + }, + "destino": { + "lat": -29.994631, + "lon": -71.236726 + }, + "duration_s": 1981.0, + "distance_m": 26477.1 + }, + { + "id": 169, + "origen": { + "lat": -30.035938, + "lon": -71.152198 + }, + "destino": { + "lat": -29.924174, + "lon": -71.313955 + }, + "duration_s": 2860.3, + "distance_m": 25997.4 + }, + { + "id": 170, + "origen": { + "lat": -29.993804, + "lon": -71.330672 + }, + "destino": { + "lat": -29.94948, + "lon": -71.359739 + }, + "duration_s": 752.9, + "distance_m": 7416.4 + }, + { + "id": 171, + "origen": { + "lat": -29.842542, + "lon": -71.409363 + }, + "destino": { + "lat": -30.060618, + "lon": -71.443488 + }, + "duration_s": 1040.6, + "distance_m": 14085.1 + }, + { + "id": 172, + "origen": { + "lat": -29.949905, + "lon": -71.227745 + }, + "destino": { + "lat": -29.954068, + "lon": -71.330633 + }, + "duration_s": 1254.0, + "distance_m": 14432.3 + }, + { + "id": 173, + "origen": { + "lat": -29.984514, + "lon": -71.145903 + }, + "destino": { + "lat": -29.843058, + "lon": -71.242234 + }, + "duration_s": 4049.1, + "distance_m": 36039.2 + }, + { + "id": 174, + "origen": { + "lat": -30.099903, + "lon": -71.324687 + }, + "destino": { + "lat": -30.032993, + "lon": -71.40422 + }, + "duration_s": 3178.5, + "distance_m": 35786.0 + }, + { + "id": 175, + "origen": { + "lat": -29.926915, + "lon": -71.445573 + }, + "destino": { + "lat": -30.026888, + "lon": -71.281301 + }, + "duration_s": 1544.3, + "distance_m": 17934.7 + }, + { + "id": 176, + "origen": { + "lat": -29.914454, + "lon": -71.146644 + }, + "destino": { + "lat": -30.060126, + "lon": -71.326286 + }, + "duration_s": 3129.2, + "distance_m": 36191.0 + }, + { + "id": 177, + "origen": { + "lat": -29.954917, + "lon": -71.443053 + }, + "destino": { + "lat": -29.99436, + "lon": -71.283486 + }, + "duration_s": 2056.7, + "distance_m": 24422.9 + }, + { + "id": 178, + "origen": { + "lat": -29.888175, + "lon": -71.403573 + }, + "destino": { + "lat": -30.064947, + "lon": -71.186128 + }, + "duration_s": 1935.2, + "distance_m": 22614.4 + }, + { + "id": 179, + "origen": { + "lat": -29.988181, + "lon": -71.185401 + }, + "destino": { + "lat": -29.89022, + "lon": -71.445465 + }, + "duration_s": 2500.5, + "distance_m": 23698.5 + }, + { + "id": 180, + "origen": { + "lat": -29.848848, + "lon": -71.272732 + }, + "destino": { + "lat": -29.965347, + "lon": -71.274514 + }, + "duration_s": 1177.1, + "distance_m": 16297.6 + }, + { + "id": 181, + "origen": { + "lat": -30.028566, + "lon": -71.191513 + }, + "destino": { + "lat": -30.102306, + "lon": -71.368528 + }, + "duration_s": 2918.9, + "distance_m": 34366.3 + }, + { + "id": 182, + "origen": { + "lat": -29.892615, + "lon": -71.324541 + }, + "destino": { + "lat": -30.022165, + "lon": -71.158075 + }, + "duration_s": 2660.0, + "distance_m": 24503.0 + }, + { + "id": 183, + "origen": { + "lat": -29.986222, + "lon": -71.327395 + }, + "destino": { + "lat": -29.882759, + "lon": -71.143028 + }, + "duration_s": 2283.0, + "distance_m": 27472.7 + }, + { + "id": 184, + "origen": { + "lat": -30.055722, + "lon": -71.235946 + }, + "destino": { + "lat": -30.068281, + "lon": -71.183556 + }, + "duration_s": 2020.6, + "distance_m": 15451.7 + }, + { + "id": 185, + "origen": { + "lat": -30.108649, + "lon": -71.143456 + }, + "destino": { + "lat": -29.918403, + "lon": -71.232689 + }, + "duration_s": 3527.7, + "distance_m": 30770.2 + }, + { + "id": 186, + "origen": { + "lat": -30.094065, + "lon": -71.145842 + }, + "destino": { + "lat": -30.056401, + "lon": -71.454826 + }, + "duration_s": 4035.5, + "distance_m": 38945.0 + }, + { + "id": 187, + "origen": { + "lat": -30.068937, + "lon": -71.35716 + }, + "destino": { + "lat": -30.069316, + "lon": -71.226967 + }, + "duration_s": 2095.9, + "distance_m": 33275.5 + }, + { + "id": 188, + "origen": { + "lat": -30.030282, + "lon": -71.320558 + }, + "destino": { + "lat": -29.842508, + "lon": -71.194236 + }, + "duration_s": 2577.4, + "distance_m": 28434.1 + }, + { + "id": 189, + "origen": { + "lat": -30.034765, + "lon": -71.282009 + }, + "destino": { + "lat": -29.886276, + "lon": -71.442318 + }, + "duration_s": 1399.8, + "distance_m": 15438.5 + }, + { + "id": 190, + "origen": { + "lat": -30.093155, + "lon": -71.366287 + }, + "destino": { + "lat": -30.06992, + "lon": -71.287488 + }, + "duration_s": 2384.8, + "distance_m": 33922.5 + }, + { + "id": 191, + "origen": { + "lat": -30.071456, + "lon": -71.278128 + }, + "destino": { + "lat": -29.996316, + "lon": -71.148987 + }, + "duration_s": 4014.7, + "distance_m": 35931.5 + }, + { + "id": 192, + "origen": { + "lat": -30.02943, + "lon": -71.229873 + }, + "destino": { + "lat": -29.983856, + "lon": -71.282648 + }, + "duration_s": 968.8, + "distance_m": 10415.4 + }, + { + "id": 193, + "origen": { + "lat": -29.878341, + "lon": -71.228536 + }, + "destino": { + "lat": -29.924417, + "lon": -71.18979 + }, + "duration_s": 945.9, + "distance_m": 10119.2 + }, + { + "id": 194, + "origen": { + "lat": -29.840047, + "lon": -71.278365 + }, + "destino": { + "lat": -30.060646, + "lon": -71.315343 + }, + "duration_s": 2217.9, + "distance_m": 28435.2 + }, + { + "id": 195, + "origen": { + "lat": -30.030527, + "lon": -71.36818 + }, + "destino": { + "lat": -29.918071, + "lon": -71.404363 + }, + "duration_s": 1284.2, + "distance_m": 11910.2 + }, + { + "id": 196, + "origen": { + "lat": -29.876819, + "lon": -71.142166 + }, + "destino": { + "lat": -30.107955, + "lon": -71.244027 + }, + "duration_s": 2928.0, + "distance_m": 37711.2 + }, + { + "id": 197, + "origen": { + "lat": -30.072545, + "lon": -71.192486 + }, + "destino": { + "lat": -30.071589, + "lon": -71.283672 + }, + "duration_s": 1247.9, + "distance_m": 8850.3 + }, + { + "id": 198, + "origen": { + "lat": -29.953741, + "lon": -71.149957 + }, + "destino": { + "lat": -29.915395, + "lon": -71.452612 + }, + "duration_s": 2407.4, + "distance_m": 29338.3 + }, + { + "id": 199, + "origen": { + "lat": -29.922632, + "lon": -71.354865 + }, + "destino": { + "lat": -29.92914, + "lon": -71.403755 + }, + "duration_s": 463.1, + "distance_m": 3086.6 + }, + { + "id": 200, + "origen": { + "lat": -29.880133, + "lon": -71.15737 + }, + "destino": { + "lat": -30.094342, + "lon": -71.413364 + }, + "duration_s": 2858.2, + "distance_m": 40359.0 + }, + { + "id": 201, + "origen": { + "lat": -29.894597, + "lon": -71.239818 + }, + "destino": { + "lat": -30.103628, + "lon": -71.273907 + }, + "duration_s": 2838.7, + "distance_m": 29838.5 + }, + { + "id": 202, + "origen": { + "lat": -30.104923, + "lon": -71.359186 + }, + "destino": { + "lat": -30.070311, + "lon": -71.325956 + }, + "duration_s": 1596.0, + "distance_m": 17336.5 + }, + { + "id": 203, + "origen": { + "lat": -29.990744, + "lon": -71.368525 + }, + "destino": { + "lat": -29.887511, + "lon": -71.451183 + }, + "duration_s": 869.6, + "distance_m": 8319.2 + }, + { + "id": 204, + "origen": { + "lat": -30.023263, + "lon": -71.287292 + }, + "destino": { + "lat": -30.033326, + "lon": -71.23751 + }, + "duration_s": 766.9, + "distance_m": 5902.7 + }, + { + "id": 205, + "origen": { + "lat": -30.093713, + "lon": -71.230727 + }, + "destino": { + "lat": -29.88758, + "lon": -71.272415 + }, + "duration_s": 2044.0, + "distance_m": 31586.2 + }, + { + "id": 206, + "origen": { + "lat": -30.070869, + "lon": -71.457244 + }, + "destino": { + "lat": -30.108907, + "lon": -71.273233 + }, + "duration_s": 3126.3, + "distance_m": 38999.8 + }, + { + "id": 207, + "origen": { + "lat": -30.036333, + "lon": -71.27887 + }, + "destino": { + "lat": -29.927017, + "lon": -71.140502 + }, + "duration_s": 2296.7, + "distance_m": 29685.5 + }, + { + "id": 208, + "origen": { + "lat": -29.959973, + "lon": -71.409056 + }, + "destino": { + "lat": -30.06291, + "lon": -71.231886 + }, + "duration_s": 2181.2, + "distance_m": 25307.0 + }, + { + "id": 209, + "origen": { + "lat": -29.922567, + "lon": -71.40026 + }, + "destino": { + "lat": -29.927442, + "lon": -71.312395 + }, + "duration_s": 454.9, + "distance_m": 3194.4 + }, + { + "id": 210, + "origen": { + "lat": -29.953087, + "lon": -71.226263 + }, + "destino": { + "lat": -29.997893, + "lon": -71.410219 + }, + "duration_s": 1992.3, + "distance_m": 23851.3 + }, + { + "id": 211, + "origen": { + "lat": -30.10861, + "lon": -71.228036 + }, + "destino": { + "lat": -29.983254, + "lon": -71.459141 + }, + "duration_s": 2203.5, + "distance_m": 31340.8 + }, + { + "id": 212, + "origen": { + "lat": -30.022295, + "lon": -71.402236 + }, + "destino": { + "lat": -29.919762, + "lon": -71.404156 + }, + "duration_s": 1546.7, + "distance_m": 13452.0 + }, + { + "id": 213, + "origen": { + "lat": -30.095977, + "lon": -71.239683 + }, + "destino": { + "lat": -30.103845, + "lon": -71.322813 + }, + "duration_s": 1785.9, + "distance_m": 15345.5 + }, + { + "id": 214, + "origen": { + "lat": -29.955042, + "lon": -71.149307 + }, + "destino": { + "lat": -29.893663, + "lon": -71.364111 + }, + "duration_s": 4273.3, + "distance_m": 36565.4 + }, + { + "id": 215, + "origen": { + "lat": -29.879045, + "lon": -71.244222 + }, + "destino": { + "lat": -30.10963, + "lon": -71.231283 + }, + "duration_s": 1966.4, + "distance_m": 30551.0 + }, + { + "id": 216, + "origen": { + "lat": -29.962595, + "lon": -71.278885 + }, + "destino": { + "lat": -29.9848, + "lon": -71.192646 + }, + "duration_s": 1377.7, + "distance_m": 11757.0 + }, + { + "id": 217, + "origen": { + "lat": -29.845338, + "lon": -71.27897 + }, + "destino": { + "lat": -29.995543, + "lon": -71.441523 + }, + "duration_s": 2158.7, + "distance_m": 28081.0 + }, + { + "id": 218, + "origen": { + "lat": -29.949061, + "lon": -71.235544 + }, + "destino": { + "lat": -29.929913, + "lon": -71.326445 + }, + "duration_s": 1284.4, + "distance_m": 15708.3 + }, + { + "id": 219, + "origen": { + "lat": -29.88637, + "lon": -71.234344 + }, + "destino": { + "lat": -30.028962, + "lon": -71.447947 + }, + "duration_s": 1986.6, + "distance_m": 25750.8 + }, + { + "id": 220, + "origen": { + "lat": -29.966631, + "lon": -71.318402 + }, + "destino": { + "lat": -29.983994, + "lon": -71.197866 + }, + "duration_s": 1524.1, + "distance_m": 15912.4 + }, + { + "id": 221, + "origen": { + "lat": -29.925698, + "lon": -71.142878 + }, + "destino": { + "lat": -30.107088, + "lon": -71.287055 + }, + "duration_s": 3432.8, + "distance_m": 39847.6 + }, + { + "id": 222, + "origen": { + "lat": -30.108166, + "lon": -71.19541 + }, + "destino": { + "lat": -29.840908, + "lon": -71.357109 + }, + "duration_s": 2053.0, + "distance_m": 32767.9 + }, + { + "id": 223, + "origen": { + "lat": -30.020306, + "lon": -71.445313 + }, + "destino": { + "lat": -29.916901, + "lon": -71.190386 + }, + "duration_s": 2413.9, + "distance_m": 28115.8 + }, + { + "id": 224, + "origen": { + "lat": -29.923642, + "lon": -71.357927 + }, + "destino": { + "lat": -30.093053, + "lon": -71.457205 + }, + "duration_s": 1320.8, + "distance_m": 19214.0 + }, + { + "id": 225, + "origen": { + "lat": -29.962757, + "lon": -71.158597 + }, + "destino": { + "lat": -29.890942, + "lon": -71.269885 + }, + "duration_s": 3891.6, + "distance_m": 32839.0 + }, + { + "id": 226, + "origen": { + "lat": -29.883117, + "lon": -71.23081 + }, + "destino": { + "lat": -29.842968, + "lon": -71.281709 + }, + "duration_s": 705.6, + "distance_m": 7297.1 + }, + { + "id": 227, + "origen": { + "lat": -30.092428, + "lon": -71.447086 + }, + "destino": { + "lat": -29.887554, + "lon": -71.187191 + }, + "duration_s": 2396.0, + "distance_m": 35852.2 + }, + { + "id": 228, + "origen": { + "lat": -30.066937, + "lon": -71.195663 + }, + "destino": { + "lat": -29.850038, + "lon": -71.400083 + }, + "duration_s": 1916.2, + "distance_m": 22557.2 + }, + { + "id": 229, + "origen": { + "lat": -29.87845, + "lon": -71.288446 + }, + "destino": { + "lat": -30.092305, + "lon": -71.202483 + }, + "duration_s": 2067.2, + "distance_m": 31648.0 + }, + { + "id": 230, + "origen": { + "lat": -30.098816, + "lon": -71.32387 + }, + "destino": { + "lat": -29.878442, + "lon": -71.314128 + }, + "duration_s": 3215.0, + "distance_m": 36263.6 + }, + { + "id": 231, + "origen": { + "lat": -29.848276, + "lon": -71.195273 + }, + "destino": { + "lat": -29.964897, + "lon": -71.269777 + }, + "duration_s": 1855.3, + "distance_m": 19050.7 + }, + { + "id": 232, + "origen": { + "lat": -30.023057, + "lon": -71.314732 + }, + "destino": { + "lat": -30.070987, + "lon": -71.443005 + }, + "duration_s": 1396.7, + "distance_m": 19461.3 + }, + { + "id": 233, + "origen": { + "lat": -29.853064, + "lon": -71.245551 + }, + "destino": { + "lat": -29.920991, + "lon": -71.183857 + }, + "duration_s": 1137.7, + "distance_m": 14362.7 + }, + { + "id": 234, + "origen": { + "lat": -29.886084, + "lon": -71.402856 + }, + "destino": { + "lat": -30.103041, + "lon": -71.312171 + }, + "duration_s": 2804.3, + "distance_m": 28225.2 + }, + { + "id": 235, + "origen": { + "lat": -29.960627, + "lon": -71.150507 + }, + "destino": { + "lat": -29.953878, + "lon": -71.410495 + }, + "duration_s": 4275.5, + "distance_m": 37006.2 + }, + { + "id": 236, + "origen": { + "lat": -30.07412, + "lon": -71.228302 + }, + "destino": { + "lat": -29.928358, + "lon": -71.441206 + }, + "duration_s": 2250.1, + "distance_m": 27076.6 + }, + { + "id": 237, + "origen": { + "lat": -29.996435, + "lon": -71.442943 + }, + "destino": { + "lat": -29.84731, + "lon": -71.32624 + }, + "duration_s": 2310.7, + "distance_m": 26766.9 + }, + { + "id": 238, + "origen": { + "lat": -29.930384, + "lon": -71.197261 + }, + "destino": { + "lat": -29.925224, + "lon": -71.363096 + }, + "duration_s": 1636.1, + "distance_m": 21030.0 + }, + { + "id": 239, + "origen": { + "lat": -29.985444, + "lon": -71.367354 + }, + "destino": { + "lat": -30.02515, + "lon": -71.319813 + }, + "duration_s": 862.9, + "distance_m": 8757.7 + }, + { + "id": 240, + "origen": { + "lat": -30.036114, + "lon": -71.458531 + }, + "destino": { + "lat": -29.960077, + "lon": -71.279698 + }, + "duration_s": 1643.3, + "distance_m": 16963.6 + }, + { + "id": 241, + "origen": { + "lat": -29.883552, + "lon": -71.200314 + }, + "destino": { + "lat": -30.062713, + "lon": -71.453438 + }, + "duration_s": 2051.7, + "distance_m": 28750.2 + }, + { + "id": 242, + "origen": { + "lat": -30.068302, + "lon": -71.364807 + }, + "destino": { + "lat": -29.850919, + "lon": -71.410436 + }, + "duration_s": 1251.0, + "distance_m": 18387.1 + }, + { + "id": 243, + "origen": { + "lat": -30.065241, + "lon": -71.155892 + }, + "destino": { + "lat": -29.885902, + "lon": -71.155289 + }, + "duration_s": 4495.4, + "distance_m": 41629.5 + }, + { + "id": 244, + "origen": { + "lat": -30.022425, + "lon": -71.412236 + }, + "destino": { + "lat": -29.988843, + "lon": -71.237233 + }, + "duration_s": 2181.3, + "distance_m": 23048.6 + }, + { + "id": 245, + "origen": { + "lat": -29.88873, + "lon": -71.154039 + }, + "destino": { + "lat": -29.877905, + "lon": -71.195698 + }, + "duration_s": 1123.0, + "distance_m": 10682.0 + }, + { + "id": 246, + "origen": { + "lat": -30.09421, + "lon": -71.355466 + }, + "destino": { + "lat": -29.999277, + "lon": -71.368935 + }, + "duration_s": 505.7, + "distance_m": 10183.2 + }, + { + "id": 247, + "origen": { + "lat": -30.036496, + "lon": -71.321039 + }, + "destino": { + "lat": -30.101887, + "lon": -71.229996 + }, + "duration_s": 1282.3, + "distance_m": 11715.9 + }, + { + "id": 248, + "origen": { + "lat": -29.885556, + "lon": -71.198418 + }, + "destino": { + "lat": -30.038455, + "lon": -71.142062 + }, + "duration_s": 3527.3, + "distance_m": 32993.7 + }, + { + "id": 249, + "origen": { + "lat": -30.035896, + "lon": -71.277684 + }, + "destino": { + "lat": -29.930816, + "lon": -71.196978 + }, + "duration_s": 1755.2, + "distance_m": 21160.2 + }, + { + "id": 250, + "origen": { + "lat": -29.995733, + "lon": -71.36057 + }, + "destino": { + "lat": -29.959111, + "lon": -71.323785 + }, + "duration_s": 563.7, + "distance_m": 6478.3 + }, + { + "id": 251, + "origen": { + "lat": -30.072524, + "lon": -71.327655 + }, + "destino": { + "lat": -30.10608, + "lon": -71.312047 + }, + "duration_s": 334.7, + "distance_m": 2251.4 + }, + { + "id": 252, + "origen": { + "lat": -29.848126, + "lon": -71.153787 + }, + "destino": { + "lat": -29.882341, + "lon": -71.319626 + }, + "duration_s": 1524.8, + "distance_m": 15608.0 + }, + { + "id": 253, + "origen": { + "lat": -29.92979, + "lon": -71.285147 + }, + "destino": { + "lat": -29.848058, + "lon": -71.202451 + }, + "duration_s": 1494.1, + "distance_m": 14429.2 + }, + { + "id": 254, + "origen": { + "lat": -29.98854, + "lon": -71.19165 + }, + "destino": { + "lat": -30.063281, + "lon": -71.145126 + }, + "duration_s": 3623.7, + "distance_m": 28748.0 + }, + { + "id": 255, + "origen": { + "lat": -30.096201, + "lon": -71.287326 + }, + "destino": { + "lat": -29.846977, + "lon": -71.399667 + }, + "duration_s": 2334.1, + "distance_m": 25112.2 + }, + { + "id": 256, + "origen": { + "lat": -30.104471, + "lon": -71.284194 + }, + "destino": { + "lat": -29.960339, + "lon": -71.158143 + }, + "duration_s": 4756.0, + "distance_m": 41001.4 + }, + { + "id": 257, + "origen": { + "lat": -30.00142, + "lon": -71.364445 + }, + "destino": { + "lat": -29.965461, + "lon": -71.144868 + }, + "duration_s": 4203.4, + "distance_m": 37821.0 + }, + { + "id": 258, + "origen": { + "lat": -29.879058, + "lon": -71.233632 + }, + "destino": { + "lat": -29.849205, + "lon": -71.15018 + }, + "duration_s": 1268.9, + "distance_m": 12525.2 + }, + { + "id": 259, + "origen": { + "lat": -29.989374, + "lon": -71.20124 + }, + "destino": { + "lat": -29.8859, + "lon": -71.194547 + }, + "duration_s": 2273.0, + "distance_m": 23252.8 + }, + { + "id": 260, + "origen": { + "lat": -29.987214, + "lon": -71.153986 + }, + "destino": { + "lat": -29.992211, + "lon": -71.244994 + }, + "duration_s": 3151.4, + "distance_m": 23665.7 + }, + { + "id": 261, + "origen": { + "lat": -29.915932, + "lon": -71.226026 + }, + "destino": { + "lat": -29.887674, + "lon": -71.325102 + }, + "duration_s": 898.1, + "distance_m": 8617.6 + }, + { + "id": 262, + "origen": { + "lat": -29.8765, + "lon": -71.154471 + }, + "destino": { + "lat": -29.958039, + "lon": -71.270968 + }, + "duration_s": 1802.9, + "distance_m": 22161.0 + }, + { + "id": 263, + "origen": { + "lat": -29.893707, + "lon": -71.398856 + }, + "destino": { + "lat": -29.960757, + "lon": -71.285965 + }, + "duration_s": 953.7, + "distance_m": 8766.1 + }, + { + "id": 264, + "origen": { + "lat": -30.028746, + "lon": -71.140484 + }, + "destino": { + "lat": -29.962428, + "lon": -71.362882 + }, + "duration_s": 3482.0, + "distance_m": 29093.8 + }, + { + "id": 265, + "origen": { + "lat": -29.87651, + "lon": -71.331014 + }, + "destino": { + "lat": -30.001608, + "lon": -71.186072 + }, + "duration_s": 2844.7, + "distance_m": 30032.8 + }, + { + "id": 266, + "origen": { + "lat": -29.912815, + "lon": -71.23276 + }, + "destino": { + "lat": -29.955184, + "lon": -71.234919 + }, + "duration_s": 1413.5, + "distance_m": 12226.2 + }, + { + "id": 267, + "origen": { + "lat": -29.843546, + "lon": -71.140379 + }, + "destino": { + "lat": -30.020464, + "lon": -71.283059 + }, + "duration_s": 2942.9, + "distance_m": 36108.4 + }, + { + "id": 268, + "origen": { + "lat": -29.921448, + "lon": -71.365596 + }, + "destino": { + "lat": -29.882541, + "lon": -71.268877 + }, + "duration_s": 1527.7, + "distance_m": 18747.2 + }, + { + "id": 269, + "origen": { + "lat": -29.998088, + "lon": -71.237568 + }, + "destino": { + "lat": -30.02673, + "lon": -71.327875 + }, + "duration_s": 2190.5, + "distance_m": 22088.8 + }, + { + "id": 270, + "origen": { + "lat": -29.857034, + "lon": -71.183866 + }, + "destino": { + "lat": -29.925702, + "lon": -71.445846 + }, + "duration_s": 2394.5, + "distance_m": 26800.0 + }, + { + "id": 271, + "origen": { + "lat": -30.026692, + "lon": -71.280401 + }, + "destino": { + "lat": -30.027287, + "lon": -71.456098 + }, + "duration_s": 1858.8, + "distance_m": 22087.3 + }, + { + "id": 272, + "origen": { + "lat": -29.992939, + "lon": -71.318038 + }, + "destino": { + "lat": -29.848747, + "lon": -71.322709 + }, + "duration_s": 1664.5, + "distance_m": 20909.2 + }, + { + "id": 273, + "origen": { + "lat": -29.880043, + "lon": -71.270555 + }, + "destino": { + "lat": -29.959145, + "lon": -71.327976 + }, + "duration_s": 1040.5, + "distance_m": 14870.5 + }, + { + "id": 274, + "origen": { + "lat": -30.058022, + "lon": -71.186943 + }, + "destino": { + "lat": -29.855572, + "lon": -71.442317 + }, + "duration_s": 2993.4, + "distance_m": 26813.2 + }, + { + "id": 275, + "origen": { + "lat": -30.037202, + "lon": -71.191532 + }, + "destino": { + "lat": -29.993628, + "lon": -71.398277 + }, + "duration_s": 2861.1, + "distance_m": 28751.1 + }, + { + "id": 276, + "origen": { + "lat": -30.094004, + "lon": -71.449962 + }, + "destino": { + "lat": -30.028994, + "lon": -71.270882 + }, + "duration_s": 1998.6, + "distance_m": 29142.9 + }, + { + "id": 277, + "origen": { + "lat": -30.001935, + "lon": -71.146679 + }, + "destino": { + "lat": -30.097319, + "lon": -71.440135 + }, + "duration_s": 4393.9, + "distance_m": 45357.0 + }, + { + "id": 278, + "origen": { + "lat": -29.849709, + "lon": -71.186166 + }, + "destino": { + "lat": -29.985559, + "lon": -71.24404 + }, + "duration_s": 1867.8, + "distance_m": 18365.3 + }, + { + "id": 279, + "origen": { + "lat": -29.849104, + "lon": -71.142494 + }, + "destino": { + "lat": -29.954587, + "lon": -71.31676 + }, + "duration_s": 2474.0, + "distance_m": 31487.9 + }, + { + "id": 280, + "origen": { + "lat": -29.950224, + "lon": -71.363868 + }, + "destino": { + "lat": -29.851704, + "lon": -71.284364 + }, + "duration_s": 1764.9, + "distance_m": 21328.1 + }, + { + "id": 281, + "origen": { + "lat": -29.961635, + "lon": -71.14792 + }, + "destino": { + "lat": -30.019639, + "lon": -71.36857 + }, + "duration_s": 4651.2, + "distance_m": 40322.3 + }, + { + "id": 282, + "origen": { + "lat": -29.92539, + "lon": -71.321257 + }, + "destino": { + "lat": -29.992245, + "lon": -71.320144 + }, + "duration_s": 845.6, + "distance_m": 9148.5 + }, + { + "id": 283, + "origen": { + "lat": -29.924451, + "lon": -71.200515 + }, + "destino": { + "lat": -30.055466, + "lon": -71.190693 + }, + "duration_s": 2265.6, + "distance_m": 27982.0 + }, + { + "id": 284, + "origen": { + "lat": -29.853487, + "lon": -71.280371 + }, + "destino": { + "lat": -30.058416, + "lon": -71.279603 + }, + "duration_s": 2038.2, + "distance_m": 29210.8 + }, + { + "id": 285, + "origen": { + "lat": -29.924881, + "lon": -71.452234 + }, + "destino": { + "lat": -30.106763, + "lon": -71.245706 + }, + "duration_s": 2042.2, + "distance_m": 27171.5 + }, + { + "id": 286, + "origen": { + "lat": -29.89069, + "lon": -71.45933 + }, + "destino": { + "lat": -29.849699, + "lon": -71.155694 + }, + "duration_s": 2544.5, + "distance_m": 29116.7 + }, + { + "id": 287, + "origen": { + "lat": -30.074138, + "lon": -71.281764 + }, + "destino": { + "lat": -30.068296, + "lon": -71.190288 + }, + "duration_s": 1331.5, + "distance_m": 9471.2 + }, + { + "id": 288, + "origen": { + "lat": -29.895473, + "lon": -71.240234 + }, + "destino": { + "lat": -29.998785, + "lon": -71.369405 + }, + "duration_s": 1488.9, + "distance_m": 18930.1 + }, + { + "id": 289, + "origen": { + "lat": -29.918875, + "lon": -71.234699 + }, + "destino": { + "lat": -29.879096, + "lon": -71.35838 + }, + "duration_s": 1250.5, + "distance_m": 16614.0 + }, + { + "id": 290, + "origen": { + "lat": -30.071006, + "lon": -71.271663 + }, + "destino": { + "lat": -29.915777, + "lon": -71.357328 + }, + "duration_s": 1780.7, + "distance_m": 21278.5 + }, + { + "id": 291, + "origen": { + "lat": -29.85755, + "lon": -71.271312 + }, + "destino": { + "lat": -29.955612, + "lon": -71.372889 + }, + "duration_s": 1776.2, + "distance_m": 21447.9 + }, + { + "id": 292, + "origen": { + "lat": -29.927822, + "lon": -71.37099 + }, + "destino": { + "lat": -30.034854, + "lon": -71.284728 + }, + "duration_s": 1660.3, + "distance_m": 16699.4 + }, + { + "id": 293, + "origen": { + "lat": -30.072515, + "lon": -71.277328 + }, + "destino": { + "lat": -30.103804, + "lon": -71.153503 + }, + "duration_s": 3131.7, + "distance_m": 23387.9 + }, + { + "id": 294, + "origen": { + "lat": -29.951752, + "lon": -71.322407 + }, + "destino": { + "lat": -29.929634, + "lon": -71.373759 + }, + "duration_s": 513.6, + "distance_m": 4415.2 + }, + { + "id": 295, + "origen": { + "lat": -30.030964, + "lon": -71.449281 + }, + "destino": { + "lat": -29.859746, + "lon": -71.275776 + }, + "duration_s": 2320.3, + "distance_m": 26800.9 + }, + { + "id": 296, + "origen": { + "lat": -29.952011, + "lon": -71.28682 + }, + "destino": { + "lat": -30.07273, + "lon": -71.414389 + }, + "duration_s": 1376.4, + "distance_m": 21439.8 + }, + { + "id": 297, + "origen": { + "lat": -29.920153, + "lon": -71.227916 + }, + "destino": { + "lat": -29.96545, + "lon": -71.415579 + }, + "duration_s": 2040.6, + "distance_m": 25449.0 + }, + { + "id": 298, + "origen": { + "lat": -29.85823, + "lon": -71.410751 + }, + "destino": { + "lat": -29.997053, + "lon": -71.455765 + }, + "duration_s": 1338.9, + "distance_m": 14221.5 + }, + { + "id": 299, + "origen": { + "lat": -30.05619, + "lon": -71.447517 + }, + "destino": { + "lat": -29.982874, + "lon": -71.149177 + }, + "duration_s": 4463.3, + "distance_m": 47603.6 + } + ] +} diff --git a/core-python/tests/integration/test_routing_vs_osrm.py b/core-python/tests/integration/test_routing_vs_osrm.py index a0452c0..7ec7091 100644 --- a/core-python/tests/integration/test_routing_vs_osrm.py +++ b/core-python/tests/integration/test_routing_vs_osrm.py @@ -22,6 +22,7 @@ import json import logging +import random import statistics from pathlib import Path @@ -34,6 +35,7 @@ ) from sentinel_dispatch.domain.routing.a_estrella import a_estrella from sentinel_dispatch.domain.routing.a_estrella_snap_edge import a_estrella_snap_edge +from sentinel_dispatch.domain.routing.tipos import NoRutaDisponibleError _log = logging.getLogger(__name__) @@ -66,6 +68,13 @@ """ FIXTURE_PATH: Path = Path(__file__).resolve().parents[1] / "fixtures" / "osrm_oracle.json" +FIXTURE_V3_PATH: Path = Path(__file__).resolve().parents[1] / "fixtures" / "osrm_oracle_v3.json" + +# CP-01a-95 (ADR-0016, eval-95): criterio sobre la *fracción* dentro de +# tolerancia (independiente de N) y su estabilidad bootstrap. +MINIMO_FRACCION_CP01A95: float = 0.75 +N_BOOTSTRAP_CP01A95: int = 1000 +SEED_BOOTSTRAP_CP01A95: int = 2026 pytestmark = [pytest.mark.integration, pytest.mark.slow] @@ -297,3 +306,104 @@ def test_cp01c_snap_to_edge( f"{TOLERANCIA_DURACION_CP01C}, mínimo exigido: {MINIMO_DENTRO_CP01C}. " f"Distribución observada: {_resumen_distribucion(errores_duracion)}" ) + + +@pytest.fixture(scope="module") +def fixture_osrm_v3() -> dict[str, object]: + """Carga el fixture OSRM oracle v3 (cartesiano, N≥300) committeado.""" + if not FIXTURE_V3_PATH.exists(): + pytest.skip( + f"Fixture OSRM v3 ausente: {FIXTURE_V3_PATH}. Regenerar con: " + "tools/build_osrm_oracle.sh && uv run python " + "tools/generate_osrm_fixture.py --modo cartesiano --n-objetivo 300 " + "--output core-python/tests/fixtures/osrm_oracle_v3.json" + ) + with FIXTURE_V3_PATH.open("r", encoding="utf-8") as f: + data: dict[str, object] = json.load(f) + return data + + +def test_cp01a_95_fixture_v3( + fixture_osrm_v3: dict[str, object], + adapter: OsmnxGrafoVial, +) -> None: + """CP-01a-95 (ADR-0016, eval-95): la fracción dentro de ±30 % es ≥ 0.75 con + confianza estadística, medida sobre el fixture v3 (cartesiano, N≥300). + + Criterio (ADR-0016 §Validación final), sobre la *fracción* para ser + independiente de N: + + - ``IC95_inferior(fracción dentro de ±30 %) ≥ 0.75`` + - ``P(fracción ≥ 0.75) ≥ 0.95`` + + Bootstrap no paramétrico (B=1000, semilla 2026) sobre los errores + relativos de `distance`, replicando `tools/bootstrap_cp01a.py`. Los pares + que OSRM rutea pero el grafo propio no conecta (anclas oceánicas del modo + cartesiano, componentes desconectados) se cuentan como **miss** definitivo + (err → ∞): descartarlos sesgaría la fracción al alza (survivorship bias). + + Usa el A* **operativo** (snap-to-node, sin calibrar): CP-01a-95 mide la + fidelidad de `distance`, distinta de CP-01c' (`duration`, ADR-0021). + """ + pares = fixture_osrm_v3["pares"] + assert isinstance(pares, list) + assert len(pares) >= 300, f"fixture v3 debe tener ≥300 pares, tiene {len(pares)}" + + errores: list[float] = [] + irruteables = 0 + for par in pares: + assert isinstance(par, dict) + origen_coord = par["origen"] + destino_coord = par["destino"] + assert isinstance(origen_coord, dict) + assert isinstance(destino_coord, dict) + d_osrm = float(par["distance_m"]) + if d_osrm <= 0.0: + continue + + nodo_origen = adapter.nodo_mas_cercano( + float(origen_coord["lat"]), float(origen_coord["lon"]) + ) + nodo_destino = adapter.nodo_mas_cercano( + float(destino_coord["lat"]), float(destino_coord["lon"]) + ) + try: + _, ruta = a_estrella( + adapter, nodo_origen, nodo_destino, factor_hora=1.0, factor_sirena=1.0 + ) + except NoRutaDisponibleError: + irruteables += 1 + errores.append(float("inf")) + continue + d_propio = _distancia_de_ruta(adapter, ruta) + errores.append(abs(d_propio - d_osrm) / d_osrm) + + n = len(errores) + minimo = round(MINIMO_FRACCION_CP01A95 * n) + conteo_real = sum(1 for e in errores if e <= TOLERANCIA_DISTANCIA) + + rng = random.Random(SEED_BOOTSTRAP_CP01A95) # noqa: S311 — bootstrap reproducible, no cripto + conteos = sorted( + sum(1 for e in rng.choices(errores, k=n) if e <= TOLERANCIA_DISTANCIA) + for _ in range(N_BOOTSTRAP_CP01A95) + ) + ic95_inferior = conteos[max(0, round(0.025 * N_BOOTSTRAP_CP01A95) - 1)] + p_cumplen = sum(1 for c in conteos if c >= minimo) / N_BOOTSTRAP_CP01A95 + + _log.info( + "CP-01a-95 v3: conteo=%d/%d (irruteables=%d) minimo=%d(0.75) " + "IC95_inf=%d (%.3f) P(frac≥0.75)=%.3f", + conteo_real, + n, + irruteables, + minimo, + ic95_inferior, + ic95_inferior / n, + p_cumplen, + ) + + assert ic95_inferior >= minimo, ( + f"CP-01a-95 falla (IC95): IC95_inferior={ic95_inferior}/{n} " + f"({ic95_inferior / n:.3f}) < mínimo {minimo}/{n} (0.75)." + ) + assert p_cumplen >= 0.95, f"CP-01a-95 falla (P): P(fracción≥0.75)={p_cumplen:.3f} < 0.95." diff --git a/docs/architecture/decisions/0011-reformulacion-criterio-it01.md b/docs/architecture/decisions/0011-reformulacion-criterio-it01.md index 9c8c6d8..d181af7 100644 --- a/docs/architecture/decisions/0011-reformulacion-criterio-it01.md +++ b/docs/architecture/decisions/0011-reformulacion-criterio-it01.md @@ -133,6 +133,7 @@ Esta sección registra explícitamente las debilidades del experimento y de la d 5. **CP-01a se cumple por margen estrecho.** 78/100 contra mínimo 75/100 — margen de 3 pares. Si la próxima regeneración del fixture cae a 74/100, el test rompe sin que el algoritmo haya cambiado. Mitigación: el fixture está committeado al repo (`tests/fixtures/osrm_oracle.json`) precisamente para que la regeneración sea explícita y revisable, no automática. - **Estado (PR #11)**: cuantificado por bootstrap no paramétrico (B=1000 réplicas, semilla=2026, ver [`docs/quality/bootstrap-cp01a.md`](../../quality/bootstrap-cp01a.md)). Resultado: mediana 78, IC95 **[69, 86]**, P(conteo≥75)=**78.9%**. **El IC95 inferior (69) está por debajo del mínimo (75)**, por lo tanto el margen *no es defendible al 95% de confianza*; sin embargo, el 78.9% de las réplicas bootstrap mantienen CP-01a, lo que sí es una cota inferior cuantitativa razonable para la defensa. La limitación sigue existiendo: cuantificarla es honesto, no resolverla — resolverla requiere fixture v3 (V/L#3) con más pares para estrechar el IC. - **Plan al 95% (PR #12)**: [ADR-0016](0016-camino-95-cp01a.md) formaliza el camino al 95% como **Ruta A** (calibración + turn penalties + snap-to-edge, mueve mediana ↑) + **Ruta B** (fixture v3 con N≥300, aprieta IC95). Criterio CP-01a-95 assertable: `IC95 inferior ≥ 75` y `P(conteo≥75) ≥ 0.95`. Tareas descompuestas H4-cal-1/2/eval, H5-cal-3, H5-fix-1/2/3, H5-eval-95. + - **RESUELTO (2026-06-02, H5-eval-95)**: sobre el fixture v3 cartesiano (N=300, [ADR-0016](0016-camino-95-cp01a.md) §Resultado), la fracción dentro de ±30 % es **0.897** con **IC95 inferior 0.860 ≥ 0.75** y **P(fracción ≥ 0.75) = 100 % ≥ 0.95** — CP-01a-95 cumplido. El margen estrecho de v2 (78/100, IC95 inferior 69) era un **artefacto del sesgo de muestra de V/L#3** (jitter pequeño → rutas urbanas cortas donde el snap domina el error relativo), no una limitación del A\*: con rutas largas inter-comuna el mismo snap se diluye. La limitación queda **resuelta**, no solo cuantificada. Ver [`docs/quality/bootstrap-cp01a-v3.md`](../../quality/bootstrap-cp01a-v3.md). 6. **Las cinco fuentes de divergencia no se aislan experimentalmente.** Para aislarlas habría que re-correr OSRM con perfiles modificados (`turn_penalty=0`, `speed_reduction=1.0`, etc.) y comparar fixture-vs-fixture. Esa validación es "Decisión a futuro" pto 1; sin ella, las atribuciones del clasificador son consistentes con la hipótesis pero no la prueban. - **Estado (PR #11)**: no abordado en este lote. Corrección planificada en lote B (requiere Docker activo y regeneración de fixture sobre `car.lua` modificado). diff --git a/docs/architecture/decisions/0016-camino-95-cp01a.md b/docs/architecture/decisions/0016-camino-95-cp01a.md index e12821a..30db56a 100644 --- a/docs/architecture/decisions/0016-camino-95-cp01a.md +++ b/docs/architecture/decisions/0016-camino-95-cp01a.md @@ -1,14 +1,51 @@ --- adr: 0016 title: Camino al 95% de confianza sobre CP-01a — calibración (Ruta A) + fixture v3 (Ruta B) -status: proposed +status: accepted date: 2026-05-19 +resuelto: 2026-06-02 deciders: Benjamín López tags: [adr, dominio, routing, it01, osrm, srs, validacion, bootstrap, calibracion] --- # ADR 0016 — Camino al 95% de confianza sobre CP-01a +## Resultado — eval-95 (2026-06-02): CP-01a-95 ✅ CUMPLIDO + +Ejecutadas Ruta A (calibración + snap-to-edge, [ADR-0021](0021-cp01c-snap-to-edge-criterio-realista.md)) +y Ruta B (fixture v3 cartesiano N=300), la verificación final **se cumple con +holgura**, medida sobre `tests/fixtures/osrm_oracle_v3.json` con el A\* +**operativo** (snap-to-node, sin calibrar) y B=1000, semilla 2026: + +| Métrica (fracción dentro de ±30 %) | Valor v3 | Umbral | ¿Cumple? | +|---|---|---|:--:| +| Conteo real | 269/300 = **0.897** | — | — | +| IC95 inferior | 258/300 = **0.860** | ≥ 0.75 | ✅ | +| P(fracción ≥ 0.75) | **100.0 %** | ≥ 0.95 | ✅ | + +**Hallazgo central**: el criterio se evalúa sobre la **fracción** (no el conteo +absoluto), para ser independiente de N (§Validación final). El A\* operativo +alcanza **89.7 %** de fidelidad de `distance` sobre una muestra diversa, +bbox-wide, mucho mejor que el 78/100 de v2. La causa es que el modo cartesiano +genera **rutas largas inter-comuna**, donde el error de snap (~150 m fijo) es una +fracción pequeña del total; v2 estaba **sesgado a rutas urbanas cortas** +(exactamente el [ADR-0011](0011-reformulacion-criterio-it01.md) §V/L#3), donde +ese mismo snap dominaba el error relativo. La predicción de este ADR de que +"Ruta B sola probablemente no alcanza" (basada en suponer mediana 0.78) quedó +**superada**: la mediana real sobre v3 es 0.897. + +**Tratamiento conservador**: 13/300 pares que OSRM rutea (snapeando anclas +oceánicas a la costa) pero el grafo propio no conecta (componentes +desconectados) se cuentan como **miss** (no se descartan: sería survivorship +bias). Aun así el criterio pasa. La distribución de outliers se desglosa en +[outliers-cp01a-v3.md](../quality/outliers-cp01a-v3.md) (18 clasificados + 13 +irruteables) y el bootstrap completo en +[bootstrap-cp01a-v3.md](../quality/bootstrap-cp01a-v3.md). + +Automatizado en `tests/integration/test_routing_vs_osrm.py::test_cp01a_95_fixture_v3` +(`slow`, fuera del CI rápido). Con esto **ADR-0011 §V/L#5 queda resuelto** (el +margen estrecho de v2 era un artefacto del sesgo de muestra, no del A\*). + ## Contexto [ADR-0011](0011-reformulacion-criterio-it01.md) §Verdad/Limitaciones #5 reconoce que CP-01a se cumple por **margen estrecho**: 78/100 contra mínimo 75/100. PR #11 cuantificó esa observación con un **bootstrap no paramétrico** (B=1000, semilla=2026, ver [`docs/quality/bootstrap-cp01a.md`](../../quality/bootstrap-cp01a.md)): @@ -107,11 +144,11 @@ Generar un fixture nuevo `tests/fixtures/osrm_oracle_v3.json` con mayor diversid ### Validación final — H5-eval-95 -8. **H5-eval-95**: ejecutar `uv run --project core-python python tools/bootstrap_cp01a.py --fixture tests/fixtures/osrm_oracle_v3.json` tras aplicar Ruta A + Ruta B. Assertear: - - `IC95 inferior ≥ 75` - - `P(conteo ≥ 75) ≥ 0.95` +8. **H5-eval-95** ✅ (2026-06-02): ejecutar `uv run --project core-python python tools/bootstrap_cp01a.py --fixture core-python/tests/fixtures/osrm_oracle_v3.json --minimo 225` (225 = ⌈0.75·300⌉) sobre el fixture v3. El criterio se asserta sobre la **fracción** (no el conteo absoluto) para ser independiente de N: + - `IC95_inferior(fracción) ≥ 0.75` → **0.860** ✅ + - `P(fracción ≥ 0.75) ≥ 0.95` → **100.0 %** ✅ - Si ambas se cumplen, marcar ADR-0016 como `accepted`, marcar ADR-0011 §V/L#5 como `resuelto`, actualizar matriz de trazabilidad RF-03 y, opcionalmente, agregar test de integración `test_bootstrap_cp01a_95.py` que automatice el chequeo en CI. + Ambas se cumplen → ADR-0016 `accepted`, ADR-0011 §V/L#5 `resuelto`, trazabilidad RF-03 actualizada. Automatizado en `test_routing_vs_osrm.py::test_cp01a_95_fixture_v3` (`slow`). Ver §Resultado arriba. ## Alternativas consideradas @@ -157,21 +194,21 @@ Generar un fixture nuevo `tests/fixtures/osrm_oracle_v3.json` con mayor diversid ### Neutras -- ADR queda en `status: proposed` hasta que H5-eval-95 se ejecute. Las tareas individuales pueden completarse y marcarse antes (cada tarea actualiza este ADR con su estado). -- Este ADR **complementa** ADR-0013 (no lo supersede). ADR-0013 fijó el criterio CP-01c (duration ±15% en ≥85/100), **recalibrado a CP-01c' (±30%/≥75) por [ADR-0021](0021-cp01c-snap-to-edge-criterio-realista.md)** tras medir snap-to-edge (objetivo histórico inalcanzable por brecha estructural vs `car.lua`); ADR-0016 agrega el criterio CP-01a-95, que es distinto e independiente. La Ruta A (calibración + snap-to-edge) está completa y medida; la Ruta B (fixture v3 N≥300) sigue pendiente en H5-fix. +- ADR `accepted` desde 2026-06-02 (H5-eval-95 ejecutado, CP-01a-95 cumplido — ver §Resultado). Cada tarea individual se marcó a medida que se completó. +- Este ADR **complementa** ADR-0013 (no lo supersede). ADR-0013 fijó el criterio CP-01c (duration ±15% en ≥85/100), **recalibrado a CP-01c' (±30%/≥75) por [ADR-0021](0021-cp01c-snap-to-edge-criterio-realista.md)** tras medir snap-to-edge (objetivo histórico inalcanzable por brecha estructural vs `car.lua`); ADR-0016 agrega el criterio CP-01a-95, que es distinto e independiente. Tanto la Ruta A (calibración + snap-to-edge) como la Ruta B (fixture v3 cartesiano N=300) están completas y medidas. ## Tareas explícitas y trazabilidad -| Tarea | Ruta | Hito | Esfuerzo | Bloqueante CP-01a-95 | -|---|---|---|---:|:---:| -| H4-cal-1 — `factor_calibracion=0.85` | A | H4 | 2-3 h | Sí | -| H4-cal-2 — turn penalties en A* | A | H4 | 3-4 h | Sí | -| H4-cal-eval — verificar CP-01c | A | H4 | 1 h | Sí | -| H5-cal-3 — snap-to-edge | A | H5 | 6-8 h | Sí (ADR-0011 §V/L#5 lo exige) | -| H5-fix-1 — flags en `generate_osrm_fixture.py` | B | H5 | 1 h | Sí | -| H5-fix-2 — regenerar v3 (N≥300) | B | H5 | 1 h (con Docker) | Sí | -| H5-fix-3 — bootstrap/outliers sobre v3 | B | H5 | 0.5 h | Sí | -| H5-eval-95 — verificación final + accept ADR | A+B | H5 | 0.5 h | — | +| Tarea | Ruta | Hito | Bloqueante CP-01a-95 | Estado | +|---|---|---|:---:|:---:| +| H4-cal-1 — `factor_calibracion=0.85` | A | H4 | Sí | ✅ | +| H4-cal-2 — turn penalties en A* | A | H4 | Sí | ✅ | +| H4-cal-eval — verificar CP-01c | A | H4 | Sí | ✅ | +| H5-cal-3 — snap-to-edge | A | H5 | Sí (ADR-0011 §V/L#5 lo exige) | ✅ (ADR-0021) | +| H5-fix-1 — flags en `generate_osrm_fixture.py` | B | H5 | Sí | ✅ | +| H5-fix-2 — regenerar v3 (N≥300) | B | H5 | Sí | ✅ (300 pares) | +| H5-fix-3 — bootstrap/outliers sobre v3 | B | H5 | Sí | ✅ | +| H5-eval-95 — verificación final + accept ADR | A+B | H5 | — | ✅ (CP-01a-95 cumplido) | **Total estimado**: 15-19 h distribuidas entre H4 y H5. diff --git a/docs/quality/bootstrap-cp01a-v3.md b/docs/quality/bootstrap-cp01a-v3.md new file mode 100644 index 0000000..3bd9a1b --- /dev/null +++ b/docs/quality/bootstrap-cp01a-v3.md @@ -0,0 +1,35 @@ +# Bootstrap CP-01a — estabilidad estadística del conteo 269/300 + +Cuantifica la estabilidad estadística del margen 269/300 (mínimo 225/300, ADR-0011 §V/L#5) mediante un bootstrap no paramétrico sobre los 300 errores relativos de un fixture OSRM oracle. + +## Metodología + +- **Muestra original**: 300 pares (idéntico al test IT-01). +- **Estadístico**: `dentro_de_tolerancia(err_rel ≤ 0.30)` → conteo entero en `[0, 300]`. +- **Bootstrap**: 1000 réplicas, muestreo con reemplazo, tamaño igual al original (300). +- **Semilla**: `random.Random(2026)` — el resultado es determinista y reproducible. +- **Criterio CP-01a**: ≥ 225 de 300 pares dentro de tolerancia. + +## Resultados + +| métrica | valor | +|---|---:| +| Conteo real (sin bootstrap) | **269 / 300** | +| Mediana bootstrap | 269.0 | +| Media bootstrap | 268.91 | +| Desviación estándar | 5.35 | +| IC95 (p2.5, p97.5) | **[258, 279]** | +| Rango (min, max) | [253, 284] | +| % réplicas con conteo ≥ 225 | **100.0%** | + +## Interpretación + +**IC95 inferior = 258 ≥ 225 → el margen 269/300 es defendible matemáticamente.** El 95% inferior de las réplicas bootstrap mantiene el cumplimiento de CP-01a. + +Adicionalmente, el **100.0%** de las 1000 réplicas bootstrap obtuvieron un conteo ≥ 225, lo que es una estimación directa de la probabilidad de que una repetición del experimento (con el mismo proceso generador del jitter y el mismo grafo) cumpla CP-01a. + +## Limitaciones del bootstrap + +El bootstrap no paramétrico asume que los 300 pares del fixture son intercambiables y representativos del proceso generador subyacente. Esta hipótesis es razonable porque el jitter es uniforme y la semilla determinista (ADR-0011 §Cómo se generan los pares), pero **no captura sesgos sistemáticos** como el documentado en V/L#3 (sesgo hacia rutas urbanas cortas por radio de jitter pequeño). El IC95 mide variabilidad muestral dada esa distribución, no validez externa frente a una distribución diferente de orígenes/destinos. + +Regenerar con `uv run --project core-python python tools/bootstrap_cp01a.py --n-bootstrap 1000 --seed 2026`. diff --git a/docs/quality/outliers-cp01a-v3.csv b/docs/quality/outliers-cp01a-v3.csv new file mode 100644 index 0000000..3acf583 --- /dev/null +++ b/docs/quality/outliers-cp01a-v3.csv @@ -0,0 +1,19 @@ +par_id,d_propio_m,d_osrm_m,err_rel,n_aristas,n_giros,pct_via_filtrada,pct_aristas_cortas,causa,notas +122,34722.7,8483.5,3.0930,141,15,0.0000,0.0071,residual,no atribuible a snap/turn/via/simplify; residuo combinado +202,42672.3,17336.5,1.4614,87,12,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +114,0.0,5652.7,1.0000,0,0,0.0000,0.0000,snap_endpoints,d_propio=0 m << d_OSRM=5653 m (ratio 0.00); snap-to-node colapsó endpoint(s) +184,3104.5,15451.7,0.7991,13,2,0.0000,0.0000,snap_endpoints,d_propio=3105 m << d_OSRM=15452 m (ratio 0.20); snap-to-node colapsó endpoint(s) +195,21043.6,11910.2,0.7669,87,23,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +103,11565.9,6562.3,0.7625,39,8,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +95,7255.2,18836.8,0.6148,30,9,0.0000,0.0000,snap_endpoints,d_propio=7255 m << d_OSRM=18837 m (ratio 0.39); snap-to-node colapsó endpoint(s) +71,13921.4,34524.7,0.5968,73,7,0.0000,0.0000,snap_endpoints,d_propio=13921 m << d_OSRM=34525 m (ratio 0.40); snap-to-node colapsó endpoint(s) +25,7370.0,17486.2,0.5785,24,9,0.0000,0.0000,snap_endpoints,d_propio=7370 m << d_OSRM=17486 m (ratio 0.42); snap-to-node colapsó endpoint(s) +266,5646.3,12226.2,0.5382,80,10,0.0000,0.0000,snap_endpoints,d_propio=5646 m << d_OSRM=12226 m (ratio 0.46); snap-to-node colapsó endpoint(s) +225,16260.4,32839.0,0.5048,72,10,0.0000,0.0000,snap_endpoints,d_propio=16260 m << d_OSRM=32839 m (ratio 0.50); snap-to-node colapsó endpoint(s) +287,13156.0,9471.2,0.3890,30,7,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +34,3266.0,5025.5,0.3501,44,13,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +68,34090.4,25387.0,0.3428,127,15,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +63,965.4,1460.8,0.3391,10,0,0.1000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +105,17390.2,25579.5,0.3202,69,14,0.0000,0.0145,residual,no atribuible a snap/turn/via/simplify; residuo combinado +139,16648.0,24056.2,0.3080,78,4,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado +137,2274.7,3253.1,0.3008,25,7,0.0000,0.0000,residual,no atribuible a snap/turn/via/simplify; residuo combinado diff --git a/docs/quality/outliers-cp01a-v3.md b/docs/quality/outliers-cp01a-v3.md new file mode 100644 index 0000000..ae8e190 --- /dev/null +++ b/docs/quality/outliers-cp01a-v3.md @@ -0,0 +1,39 @@ +# Outliers CP-01a — clasificación por causa probable + +Generado por `tools/analyze_outliers.py` sobre `core-python/tests/fixtures/osrm_oracle_v3.json` (tolerancia: 30%). Las heurísticas y umbrales se documentan en el módulo. Esta tabla se referencia desde ADR-0011 §Diagnóstico. + +**Total outliers**: 31 / 300 (18 clasificados por causa + 13 irruteables en el grafo propio — componente desconectado, típico de anclas oceánicas del fixture cartesiano v3; son outliers de conectividad, no de divergencia de distancia). + +## Resumen por causa + +| Causa | Conteo | % de outliers | +|---|---:|---:| +| `residual` | 11 | 61% | +| `snap_endpoints` | 7 | 39% | + +## Detalle por par + +| id | d_propio (m) | d_OSRM (m) | err_rel | giros | %vía filtrada | %aristas <5 m | causa | nota | +|---:|---:|---:|---:|---:|---:|---:|---|---| +| 122 | 34723 | 8484 | 3.093 | 15 | 0% | 1% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 202 | 42672 | 17336 | 1.461 | 12 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 114 | 0 | 5653 | 1.000 | 0 | 0% | 0% | `snap_endpoints` | d_propio=0 m << d_OSRM=5653 m (ratio 0.00); snap-to-node colapsó endpoint(s) | +| 184 | 3105 | 15452 | 0.799 | 2 | 0% | 0% | `snap_endpoints` | d_propio=3105 m << d_OSRM=15452 m (ratio 0.20); snap-to-node colapsó endpoint(s) | +| 195 | 21044 | 11910 | 0.767 | 23 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 103 | 11566 | 6562 | 0.762 | 8 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 95 | 7255 | 18837 | 0.615 | 9 | 0% | 0% | `snap_endpoints` | d_propio=7255 m << d_OSRM=18837 m (ratio 0.39); snap-to-node colapsó endpoint(s) | +| 71 | 13921 | 34525 | 0.597 | 7 | 0% | 0% | `snap_endpoints` | d_propio=13921 m << d_OSRM=34525 m (ratio 0.40); snap-to-node colapsó endpoint(s) | +| 25 | 7370 | 17486 | 0.579 | 9 | 0% | 0% | `snap_endpoints` | d_propio=7370 m << d_OSRM=17486 m (ratio 0.42); snap-to-node colapsó endpoint(s) | +| 266 | 5646 | 12226 | 0.538 | 10 | 0% | 0% | `snap_endpoints` | d_propio=5646 m << d_OSRM=12226 m (ratio 0.46); snap-to-node colapsó endpoint(s) | +| 225 | 16260 | 32839 | 0.505 | 10 | 0% | 0% | `snap_endpoints` | d_propio=16260 m << d_OSRM=32839 m (ratio 0.50); snap-to-node colapsó endpoint(s) | +| 287 | 13156 | 9471 | 0.389 | 7 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 34 | 3266 | 5026 | 0.350 | 13 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 68 | 34090 | 25387 | 0.343 | 15 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 63 | 965 | 1461 | 0.339 | 0 | 10% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 105 | 17390 | 25580 | 0.320 | 14 | 0% | 1% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 139 | 16648 | 24056 | 0.308 | 4 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | +| 137 | 2275 | 3253 | 0.301 | 7 | 0% | 0% | `residual` | no atribuible a snap/turn/via/simplify; residuo combinado | + +## Interpretación + +La causa dominante es `residual` (11/18). Cada causa coincide con una de las cinco fuentes de divergencia enumeradas en ADR-0011 §Diagnóstico, por lo que la divergencia de los 18 outliers respecto a la tolerancia CP-01a queda atribuida empíricamente y no como hipótesis. diff --git a/docs/quality/trazabilidad.md b/docs/quality/trazabilidad.md index 0de5b30..6a1770b 100644 --- a/docs/quality/trazabilidad.md +++ b/docs/quality/trazabilidad.md @@ -31,7 +31,7 @@ La matriz cubre los **doce Requisitos Funcionales** (RF-01..RF-12), las **diez R |---|---|---|---|---|---| | **RF-01** Validación de coordenadas IV Región en tiempo real | `domain/incidente/` + `interfaces/api` | `validar_coordenadas_iv_region(lat, lon)` ([validacion.py](../../core-python/src/sentinel_dispatch/domain/incidente/validacion.py)) expuesta vía `POST /v1/incidentes/validar-coordenadas` ([main.py](../../core-python/src/sentinel_dispatch/interfaces/api/main.py)); adapter `OsmnxGrafoVial.nodo_mas_cercano` delega como segunda barrera (ADR-0012) | [CP-09](../SRS.md#213-casos-de-prueba) | Coordenadas fuera de `lat ∈ [−30.5, −29.5] ∧ lon ∈ [−71.7, −70.5]` rechazadas con HTTP 422 antes de A*; sin log de despacho generado | ✅ (vía [test_validacion_coordenadas.py](../../core-python/tests/unit/domain/incidente/test_validacion_coordenadas.py) + [test_api_validacion_coordenadas.py](../../core-python/tests/integration/test_api_validacion_coordenadas.py)) | | **RF-02** Árbol de triaje MPDS-subset (Alpha..Echo) | `domain/triaje/` → [`arbol.py`](../../core-python/src/sentinel_dispatch/domain/triaje/arbol.py), [`tipos.py`](../../core-python/src/sentinel_dispatch/domain/triaje/tipos.py) | `clasificar_mpds(respuesta)` aplica las 9 reglas del SRS sec. 2.6-A en orden estricto | `test_regla_1_*` .. `test_regla_9_*` (9 tests) + `test_clasificacion_dataset[I-01..I-12]` (12 tests) | Cada respuesta válida produce la categoría MPDS esperada por la regla disparada; 12/12 incidentes del dataset clasificados correctamente | ✅ | -| **RF-03** Grafo OSM + ruteo A* con pesos calibrados | `domain/routing/` + `adapters/grafo_osmnx.py` | `a_estrella(grafo, origen, destino, factor_hora, factor_sirena)` ([a_estrella.py](../../core-python/src/sentinel_dispatch/domain/routing/a_estrella.py)) | [CP-01a](../SRS.md#213-casos-de-prueba) (paridad de distancia A* vs OSRM ±30%, ADR-0011) · CP-02 (factor_hora) · CP-03 (factor_sirena) | A* propio recorre rutas con `\|Δ_distance\|/d_OSRM ≤ 0.30` en ≥ 75/100 pares del fixture OSRM (78/100 actual); divergencia en duration reportada vía log | ✅ H2 (CP-01a/b vía [test_routing_vs_osrm.py](../../core-python/tests/integration/test_routing_vs_osrm.py)) | +| **RF-03** Grafo OSM + ruteo A* con pesos calibrados | `domain/routing/` + `adapters/grafo_osmnx.py` | `a_estrella(grafo, origen, destino, factor_hora, factor_sirena)` ([a_estrella.py](../../core-python/src/sentinel_dispatch/domain/routing/a_estrella.py)) | [CP-01a](../SRS.md#213-casos-de-prueba) (paridad de distancia A* vs OSRM ±30%, ADR-0011) · CP-02 (factor_hora) · CP-03 (factor_sirena) | A* propio recorre rutas con `\|Δ_distance\|/d_OSRM ≤ 0.30` en ≥ 75/100 pares del fixture OSRM (v2: 78/100). **CP-01a-95 cumplido** sobre fixture v3 (N=300, cartesiano): fracción dentro de ±30 % = 0.897, IC95 inferior 0.860 ≥ 0.75, P(≥0.75)=100 % ([ADR-0016](../architecture/decisions/0016-camino-95-cp01a.md) §Resultado, eval-95 2026-06-02); divergencia en duration reportada vía log | ✅ H2 (CP-01a/b) + ✅ H5 (CP-01a-95) vía [test_routing_vs_osrm.py](../../core-python/tests/integration/test_routing_vs_osrm.py) (`test_cp01a_95_fixture_v3`) | | **RF-04** Función de costo multiobjetivo `α·T_viaje + β·Penalización_Idoneidad` | `domain/dispatch/` → [`funcion_costo.py`](../../core-python/src/sentinel_dispatch/domain/dispatch/funcion_costo.py) | `costo(unidad, incidente, t_viaje_s) → CostoDespacho` con `α=1.0`, `β=600s` y `TABLA_PENALIZACION_IDONEIDAD` exhaustiva (10 entradas); decisión arquitectónica en [ADR-0014](../architecture/decisions/0014-funcion-costo-dispatch.md) | [CP-04](../SRS.md#213-casos-de-prueba) Charlie+Básica · [CP-05](../SRS.md#213-casos-de-prueba) Echo+Básica | Echo/Delta + Básica → `math.inf`; Charlie + Básica → `1.0` (=600s); Avanzada lejana gana a Básica cercana en Charlie (CP-04); excepciones `UnidadInelegibleError` (RN-04, Taller) y `TViajeInvalidoError` (NaN/negativo) | ✅ H3 fase 1 (función de costo) — argmin pendiente | | **RF-05** Selección óptima por `argmin_u Costo(u, i)` | `domain/dispatch/` → [`seleccion.py`](../../core-python/src/sentinel_dispatch/domain/dispatch/seleccion.py) | `seleccionar_unidad(unidades, incidente, tiempos_viaje)` → `ResultadoSeleccion` con `elegida`, `costo_elegida`, `candidatos` ordenados por `(costo, id)` (desempate CP-11) | CP-04 · CP-11 (empate lexicográfico) | Unidad seleccionada minimiza el costo; empate finito se desempata por `unidad.id` lex asc; Taller excluido silenciosamente; ``elegida=None`` si todas son inf (saturación de idoneidad, manejada por application) | ✅ H3 fase 2 | | **RF-06** Log inmutable JSON de cada despacho confirmado | `adapters/repositorio_jsonl.py` + `ports/repositorio_eventos.py` ([ADR-0018](../architecture/decisions/0018-schema-evento-log.md)) | `JsonlRepositorioEventos.append(EventoLog)` sobre JSONL append-only (ADR-0007). Activable desde `sentinel run-dataset --log-eventos PATH`. Payload bit-exacto con schema RT-02 vía `serializar_resultado_despacho` | [CP-08](../SRS.md#213-casos-de-prueba) intento de edición (spike) | Spike CP-08 (`test_repositorio_jsonl_append_only.py::TestSpikeCP08`): edición externa detectable via `ValidationError`/`EventoDuplicadoError` al reabrir; el adapter no expone API de update/delete (RN-03/RN-07 estructural) | ✅ | @@ -115,7 +115,9 @@ Suite `core-python/tests/unit/domain/routing/` con **20 tests verdes** distribui | **Regla de Negocio** | 3 | `test_distancia_snap_exacto_es_cero`, `test_distancia_snap_dentro_de_500m_es_aceptable_para_rn09`, `test_distancia_snap_mayor_a_500m_activa_alerta_rn09` | | **Total** | **11** | suite `core-python/tests/unit/adapters/test_grafo_osmnx_snap.py` | -Validación IT-01 con OSRM oracle (CP-01a/b, ADR-0011): [test_routing_vs_osrm.py](../../core-python/tests/integration/test_routing_vs_osrm.py) — assert ≥ 75/100 pares con `|Δ_distance|/d_OSRM ≤ 0.30` (resultado: 78/100). Reporta también la distribución de divergencia en `duration`. +Validación IT-01 con OSRM oracle (CP-01a/b, ADR-0011): [test_routing_vs_osrm.py](../../core-python/tests/integration/test_routing_vs_osrm.py) — assert ≥ 75/100 pares con `|Δ_distance|/d_OSRM ≤ 0.30` (resultado v2: 78/100). Reporta también la distribución de divergencia en `duration`. + +**CP-01a-95 cumplido (H5-eval-95, 2026-06-02, [ADR-0016](../architecture/decisions/0016-camino-95-cp01a.md) `accepted`)** — sobre el fixture v3 cartesiano (N=300, `osrm_oracle_v3.json`), el A* operativo alcanza fracción dentro de ±30 % = **0.897** con **IC95 inferior 0.860 ≥ 0.75** y **P(fracción ≥ 0.75) = 100 % ≥ 0.95** (bootstrap B=1000, semilla 2026; 13 pares irruteables por anclas oceánicas contados como miss). El margen estrecho de v2 era artefacto del sesgo a rutas cortas (ADR-0011 §V/L#3, ahora `resuelto`); con rutas largas inter-comuna la fidelidad de `distance` sube. Bootstrap en [bootstrap-cp01a-v3.md](bootstrap-cp01a-v3.md), outliers en [outliers-cp01a-v3.md](outliers-cp01a-v3.md), test `test_cp01a_95_fixture_v3` (`slow`). **Blindaje defensa (2026-05-19)** — descomposición empírica de los 22 outliers vía [tools/analyze_outliers.py](../../tools/analyze_outliers.py): 55% `snap_endpoints` + 14% `snap_corto` (68% snap-to-node) + 14% `via_filtrada` (filtrado `car.lua`) + 18% `residual`. Tabla detallada en [outliers-cp01a.md](outliers-cp01a.md) y CSV en [outliers-cp01a.csv](outliers-cp01a.csv). Documentación del jitter (`radio=0.0013°`, distribución uniforme, seed=2026, generador `random.Random(seed).uniform`) ahora vive explícitamente en el header del fixture (v2). [ADR-0013](../architecture/decisions/0013-cp01c-criterio-calibrado.md) fijó *a priori* el criterio post-calibración esperable: **CP-01c — duration ±15% en ≥ 85/100** tras `factor_calibracion=0.85` + turn penalties. Medido en H5-cal-3 (2026-05-28), ese objetivo resultó inalcanzable (brecha estructural vs el modelo `car.lua` de OSRM); [ADR-0021](../architecture/decisions/0021-cp01c-snap-to-edge-criterio-realista.md) lo recalibró a **CP-01c' — duration ±30% en ≥ 75/100** (logrado 78/100 con snap-to-edge a `factor_calibracion=0.80`) y promovió ADR-0013 a `accepted`. diff --git a/tools/analyze_outliers.py b/tools/analyze_outliers.py index bb65969..b84459a 100644 --- a/tools/analyze_outliers.py +++ b/tools/analyze_outliers.py @@ -57,6 +57,7 @@ cargar_grafo_iv_region, ) from sentinel_dispatch.domain.routing.a_estrella import a_estrella +from sentinel_dispatch.domain.routing.tipos import NoRutaDisponibleError # --------------------------------------------------------------------------- # Configuración @@ -300,13 +301,20 @@ def cargar_pares(path: Path) -> list[dict[str, Any]]: def detectar_outliers( adapter: OsmnxGrafoVial, pares: Iterable[dict[str, Any]] -) -> list[OutlierRaw]: +) -> tuple[list[OutlierRaw], int]: """Detecta los outliers y precomputa sus features (sin clasificar). Separa el paso caro (A* + features) del paso barato (clasificación), de modo que la grilla de sensibilidad reuse los mismos features. + + Devuelve ``(raws, n_irruteables)``. Los pares que OSRM rutea pero nuestro + grafo no puede conectar (componentes desconectados — anclas oceánicas del + fixture cartesiano v3) se cuentan aparte como ``n_irruteables``: son + outliers de conectividad, no de divergencia de distancia, y no tienen + ruta de la cual extraer features. """ raws: list[OutlierRaw] = [] + n_irruteables = 0 for par in pares: par_id = int(par["id"]) nodo_origen = adapter.nodo_mas_cercano( @@ -315,9 +323,13 @@ def detectar_outliers( nodo_destino = adapter.nodo_mas_cercano( float(par["destino"]["lat"]), float(par["destino"]["lon"]) ) - _, ruta = a_estrella( - adapter, nodo_origen, nodo_destino, factor_hora=1.0, factor_sirena=1.0 - ) + try: + _, ruta = a_estrella( + adapter, nodo_origen, nodo_destino, factor_hora=1.0, factor_sirena=1.0 + ) + except NoRutaDisponibleError: + n_irruteables += 1 + continue features = calcular_features(adapter, ruta) d_osrm = float(par["distance_m"]) if d_osrm <= 0.0: @@ -334,7 +346,7 @@ def detectar_outliers( features=features, ) ) - return raws + return raws, n_irruteables def clasificar_outliers( @@ -406,7 +418,13 @@ def analisis_sensibilidad( # --------------------------------------------------------------------------- -def render_markdown(outliers: list[Outlier]) -> str: +def render_markdown( + outliers: list[Outlier], + *, + n_total: int = 100, + n_irruteables: int = 0, + fixture_nombre: str = "core-python/tests/fixtures/osrm_oracle.json", +) -> str: conteo = Counter(o.causa for o in outliers) total = len(outliers) lineas: list[str] = [] @@ -414,13 +432,19 @@ def render_markdown(outliers: list[Outlier]) -> str: lineas.append("") lineas.append( "Generado por `tools/analyze_outliers.py` sobre " - "`core-python/tests/fixtures/osrm_oracle.json` " + f"`{fixture_nombre}` " f"(tolerancia: {TOLERANCIA_DISTANCIA:.0%}). " "Las heurísticas y umbrales se documentan en el módulo. " "Esta tabla se referencia desde ADR-0011 §Diagnóstico." ) lineas.append("") - lineas.append(f"**Total outliers**: {total} / 100") + lineas.append( + f"**Total outliers**: {total + n_irruteables} / {n_total} " + f"({total} clasificados por causa + {n_irruteables} irruteables en el " + "grafo propio — componente desconectado, típico de anclas oceánicas " + "del fixture cartesiano v3; son outliers de conectividad, no de " + "divergencia de distancia)." + ) lineas.append("") lineas.append("## Resumen por causa") lineas.append("") @@ -626,12 +650,25 @@ def main() -> int: pares = cargar_pares(args.fixture) logging.info("Analizando %d pares…", len(pares)) - raws = detectar_outliers(adapter, pares) + raws, n_irruteables = detectar_outliers(adapter, pares) outliers = clasificar_outliers(raws) - logging.info("Outliers detectados: %d / %d", len(outliers), len(pares)) + logging.info( + "Outliers: %d clasificados + %d irruteables / %d pares", + len(outliers), + n_irruteables, + len(pares), + ) args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(render_markdown(outliers), encoding="utf-8") + args.output.write_text( + render_markdown( + outliers, + n_total=len(pares), + n_irruteables=n_irruteables, + fixture_nombre=args.fixture.as_posix(), + ), + encoding="utf-8", + ) write_csv(outliers, args.csv) logging.info("Markdown → %s", args.output) logging.info("CSV → %s", args.csv) diff --git a/tools/bootstrap_cp01a.py b/tools/bootstrap_cp01a.py index 8e4e907..df462c3 100644 --- a/tools/bootstrap_cp01a.py +++ b/tools/bootstrap_cp01a.py @@ -51,6 +51,7 @@ cargar_grafo_iv_region, ) from sentinel_dispatch.domain.routing.a_estrella import a_estrella +from sentinel_dispatch.domain.routing.tipos import NoRutaDisponibleError # --------------------------------------------------------------------------- # Configuración @@ -98,9 +99,19 @@ def calcular_errores_distancia( destino = par["destino"] nodo_o = adapter.nodo_mas_cercano(float(origen["lat"]), float(origen["lon"])) nodo_d = adapter.nodo_mas_cercano(float(destino["lat"]), float(destino["lon"])) - _, ruta = a_estrella( - adapter, nodo_o, nodo_d, factor_hora=1.0, factor_sirena=1.0 - ) + try: + _, ruta = a_estrella( + adapter, nodo_o, nodo_d, factor_hora=1.0, factor_sirena=1.0 + ) + except NoRutaDisponibleError: + # Par que OSRM rutea (snapeando a la costa) pero nuestro grafo no + # puede conectar — componentes desconectados, típico de anclas + # oceánicas del modo cartesiano (fixture v3). Se cuenta como miss + # definitivo (err → ∞ > tolerancia), NO se descarta: descartarlo + # sesgaría la fracción al alza (survivorship bias). En el fixture + # v2 (urbano, conexo) esta rama nunca se activa. + errores.append(float("inf")) + continue d_propio = _distancia_de_ruta(adapter, ruta) d_osrm = float(par["distance_m"]) if d_osrm <= 0.0: @@ -214,12 +225,13 @@ def render_markdown(res: ResultadoBootstrap) -> str: "del fixture disponible)." ) lineas = [ - "# Bootstrap CP-01a — estabilidad estadística del conteo 78/100", + f"# Bootstrap CP-01a — estabilidad estadística del conteo " + f"{res.conteo_real}/{res.n_muestra}", "", - "Cuantifica la afirmación del ADR-0011 §V/L#5 *CP-01a se cumple por " - "margen estrecho (78/100 vs mínimo 75/100)* mediante un bootstrap " - "no paramétrico sobre los 100 errores relativos del fixture " - "`tests/fixtures/osrm_oracle.json`.", + f"Cuantifica la estabilidad estadística del margen {res.conteo_real}/" + f"{res.n_muestra} (mínimo {res.minimo}/{res.n_muestra}, ADR-0011 §V/L#5) " + f"mediante un bootstrap no paramétrico sobre los {res.n_muestra} errores " + "relativos de un fixture OSRM oracle.", "", "## Metodología", "", @@ -257,7 +269,7 @@ def render_markdown(res: ResultadoBootstrap) -> str: "", "## Limitaciones del bootstrap", "", - "El bootstrap no paramétrico asume que los 100 pares del fixture " + f"El bootstrap no paramétrico asume que los {res.n_muestra} pares del fixture " "son intercambiables y representativos del proceso generador " "subyacente. Esta hipótesis es razonable porque el jitter es " "uniforme y la semilla determinista (ADR-0011 §Cómo se generan " diff --git a/tools/generate_osrm_fixture.py b/tools/generate_osrm_fixture.py index 3e1b01c..c2208b9 100644 --- a/tools/generate_osrm_fixture.py +++ b/tools/generate_osrm_fixture.py @@ -49,13 +49,20 @@ SEED: int = 2026 PARES_OBJETIVO: int = 100 -JITTERS_POR_INCIDENTE: int = ( - 10 # 10 bases × 12 incidentes × 10 jitters = 1200 candidatos -) +JITTERS_POR_INCIDENTE: int = 10 # 10 bases × 12 incidentes × 10 jitters = 1200 candidatos JITTER_GRADOS: float = 0.0013 # ~150 m al sur de Coquimbo (1° lat ≈ 111 km) DISTANCIA_MINIMA_M: float = 200.0 TIMEOUT_S: float = 10.0 +# Modo cartesiano (Ruta B, ADR-0016): grilla regular sobre el bbox + jitter +# amplio en ambos extremos. Cubre todo el bbox (no solo el clúster urbano de +# bases/incidentes), expone rutas largas inter-comuna que aprietan el IC95 y +# mitiga el sesgo de jitter pequeño de ADR-0011 §V/L#3. +MODO_BASESXINCIDENTES: str = "basesxincidentes" +MODO_CARTESIANO: str = "cartesiano" +JITTER_GRADOS_AMPLIO: float = 0.01 # ~1.1 km (1° lat ≈ 111 km) +GRID_LADO_CARTESIANO: int = 8 # 8×8 = 64 anclas → 64×63 = 4032 pares candidatos + ROOT: Path = Path(__file__).resolve().parents[1] UNIDADES_PATH: Path = ROOT / "data" / "dataset" / "unidades.json" INCIDENTES_PATH: Path = ROOT / "data" / "dataset" / "incidentes.json" @@ -105,6 +112,46 @@ def generar_candidatos( return candidatos +def generar_candidatos_cartesiano( + rng: random.Random, + *, + grid_lado: int = GRID_LADO_CARTESIANO, + jitter_grados: float = JITTER_GRADOS_AMPLIO, +) -> list[tuple[tuple[float, float], tuple[float, float]]]: + """Producto cartesiano de una grilla regular sobre el bbox + jitter amplio. + + Construye ``grid_lado × grid_lado`` anclas equiespaciadas en el bbox + ``(-71.45, -30.10, -71.15, -29.85)``, forma todos los pares ordenados + origen≠destino (``grid_lado² · (grid_lado² − 1)`` candidatos) y aplica + jitter uniforme ``±jitter_grados`` (~1.1 km a 0.01°) a ambos extremos, + independiente por componente lat/lon. A diferencia de + :func:`generar_candidatos` —anclada al clúster urbano de bases e + incidentes—, cubre todo el bbox: incluye rutas largas inter-comuna que + aprietan el IC95 (Ruta B del ADR-0016) y reduce el sesgo de jitter + pequeño señalado en ADR-0011 §V/L#3. Las anclas oceánicas del oeste del + bbox se descartan en :func:`generar_fixture` por ``sin_ruta``. + """ + lats = [BBOX_BOTTOM + (BBOX_TOP - BBOX_BOTTOM) * i / (grid_lado - 1) for i in range(grid_lado)] + lons = [BBOX_LEFT + (BBOX_RIGHT - BBOX_LEFT) * j / (grid_lado - 1) for j in range(grid_lado)] + anclas = [(lat, lon) for lat in lats for lon in lons] + candidatos: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for origen in anclas: + for destino in anclas: + if origen == destino: + continue + jittered_o = ( + origen[0] + rng.uniform(-jitter_grados, jitter_grados), + origen[1] + rng.uniform(-jitter_grados, jitter_grados), + ) + jittered_d = ( + destino[0] + rng.uniform(-jitter_grados, jitter_grados), + destino[1] + rng.uniform(-jitter_grados, jitter_grados), + ) + candidatos.append((jittered_o, jittered_d)) + rng.shuffle(candidatos) + return candidatos + + class MotivoDescarte: """Etiquetas de descarte para contar separadamente cada causa.""" @@ -147,11 +194,43 @@ def consultar_osrm( return float(ruta["duration"]), float(ruta["distance"]) -def generar_fixture() -> dict[str, Any]: - bases = cargar_bases() - incidentes = cargar_incidentes() +def generar_fixture( + *, + modo: str = MODO_BASESXINCIDENTES, + n_objetivo: int = PARES_OBJETIVO, +) -> dict[str, Any]: rng = random.Random(SEED) - candidatos = generar_candidatos(bases, incidentes, rng) + if modo == MODO_CARTESIANO: + candidatos = generar_candidatos_cartesiano(rng) + version = "3" + jitter_meta: dict[str, Any] = { + "radio_grados": JITTER_GRADOS_AMPLIO, + "radio_metros_aprox": round(JITTER_GRADOS_AMPLIO * 111_000.0, 1), + "distribucion": "uniform", + "aplicado_sobre": "ambos extremos (grilla cartesiana sobre el bbox)", + "generador": ( + "random.Random(seed).uniform(-radio_grados, +radio_grados) por " + "componente lat y lon, independiente, en origen y destino" + ), + "grid_lado": GRID_LADO_CARTESIANO, + } + else: + bases = cargar_bases() + incidentes = cargar_incidentes() + candidatos = generar_candidatos(bases, incidentes, rng) + version = "2" + jitter_meta = { + "radio_grados": JITTER_GRADOS, + "radio_metros_aprox": round(JITTER_GRADOS * 111_000.0, 1), + "distribucion": "uniform", + "aplicado_sobre": "destino (incidente); origen sin jitter", + "generador": ( + "random.Random(seed).uniform(-radio_grados, +radio_grados) por " + "componente lat y lon, independiente" + ), + "jitters_por_incidente": JITTERS_POR_INCIDENTE, + } + pares: list[dict[str, Any]] = [] descartes: dict[str, int] = { MotivoDescarte.RED: 0, @@ -169,7 +248,7 @@ def generar_fixture() -> dict[str, Any]: raise SystemExit(f"OSRM no responde en {OSRM_BASE_URL}: {exc}") from exc for origen, destino in candidatos: - if len(pares) >= PARES_OBJETIVO: + if len(pares) >= n_objetivo: break resultado = consultar_osrm(cliente, origen, destino) @@ -198,17 +277,19 @@ def generar_fixture() -> dict[str, Any]: # mantiene el mismo patrón si en el futuro se apunta al demo público. time.sleep(0.015) - if len(pares) < PARES_OBJETIVO: + if len(pares) < n_objetivo: # Si predomina `red`, el problema es OSRM (caído, lento, malformado); - # si predomina `sin_ruta`, el bbox/SCC no alcanza; si predomina - # `distancia_corta`, hay que aumentar JITTERS_POR_INCIDENTE. + # si predomina `sin_ruta`, el bbox/SCC no alcanza (esperable en modo + # cartesiano por las anclas oceánicas); si predomina `distancia_corta`, + # subir el jitter o, en cartesiano, GRID_LADO_CARTESIANO. raise SystemExit( - f"Solo {len(pares)} pares válidos de {len(candidatos)} candidatos. " - f"Descartes por causa: {descartes}. Diagnóstico arriba." + f"Solo {len(pares)} pares válidos de {len(candidatos)} candidatos " + f"(modo={modo}). Descartes por causa: {descartes}. Diagnóstico arriba." ) return { - "version": "2", + "version": version, + "modo": modo, "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "bbox": [BBOX_LEFT, BBOX_BOTTOM, BBOX_RIGHT, BBOX_TOP], "osrm": { @@ -219,24 +300,32 @@ def generar_fixture() -> dict[str, Any]: "seed": SEED, "descartes": descartes, }, - "jitter": { - "radio_grados": JITTER_GRADOS, - "radio_metros_aprox": round(JITTER_GRADOS * 111_000.0, 1), - "distribucion": "uniform", - "aplicado_sobre": "destino (incidente); origen sin jitter", - "generador": ( - "random.Random(seed).uniform(-radio_grados, +radio_grados) por " - "componente lat y lon, independiente" - ), - "jitters_por_incidente": JITTERS_POR_INCIDENTE, - }, + "jitter": jitter_meta, "distancia_minima_m": DISTANCIA_MINIMA_M, + "n_objetivo": n_objetivo, "pares": pares, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--modo", + choices=[MODO_BASESXINCIDENTES, MODO_CARTESIANO], + default=MODO_BASESXINCIDENTES, + help=( + f"Estrategia de generación. '{MODO_BASESXINCIDENTES}' (default): " + "10 bases × 12 incidentes × jitter pequeño (fixture v2). " + f"'{MODO_CARTESIANO}': grilla cartesiana sobre el bbox + jitter amplio " + "(fixture v3, Ruta B del ADR-0016)." + ), + ) + parser.add_argument( + "--n-objetivo", + type=int, + default=PARES_OBJETIVO, + help=f"Objetivo de pares válidos (default: {PARES_OBJETIVO}).", + ) parser.add_argument( "--output", type=Path, @@ -245,12 +334,10 @@ def main() -> int: ) args = parser.parse_args() - logging.basicConfig( - level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" - ) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") inicio = time.perf_counter() - fixture = generar_fixture() + fixture = generar_fixture(modo=args.modo, n_objetivo=args.n_objetivo) args.output.parent.mkdir(parents=True, exist_ok=True) with args.output.open("w", encoding="utf-8") as f: