Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ Versionado: una entrada por **entrega académica** del semestre (no SemVer estri

## [Unreleased]

### 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.
- `adapters/grafo_osmnx.py`: nuevo `OsmnxGrafoVial.posicion_en_arista(lat, lon)` con refactor de `_arista_desde_data` como punto único de verdad para construir aristas desde el grafo. 8 UT en [`test_grafo_osmnx_snap_edge.py`](core-python/tests/unit/adapters/test_grafo_osmnx_snap_edge.py).
- **Resultado medido (2026-05-28)** sobre los 100 pares de `osrm_oracle.json`: snap-to-edge a `factor_calibracion=0.80` da **78/100 dentro de ±30 %** (mediana de error 0.170), casi 2× el 27/100 del snap-to-node calibrado. El objetivo histórico ±15 %/≥85 (ADR-0013) **NO se alcanza** (máx 52/100 a factor 0.75): la brecha residual a ±15 % es **estructural** (modelo de costo `car.lua` de OSRM —reglas de giro, semáforos, perfil por clase de vía— que el A* estilo-SRS no replica).
- [ADR-0021](docs/architecture/decisions/0021-cp01c-snap-to-edge-criterio-realista.md) nuevo, `accepted`: documenta la medición y **recalibra el criterio a CP-01c' = duration ±30 % en ≥ 75/100** (mismo umbral que CP-01a usa para `distance`), siguiendo el patrón "criterio derivado de evidencia" de ADR-0019. Incluye §"Relación con el SRS" reconociendo que es una desviación real del criterio numérico del SRS (CP-01 ≤5 %/≥95), defendible por la nota "Importante" del SRS sec. 2.12 (ETA aproximado, validación exacta diferida a datos reales) y por la causa estructural. RT-02 (paridad Python↔Java ±5 %) queda **intacto**.

### Changed — H5-cal-3
- [ADR-0013](docs/architecture/decisions/0013-cp01c-criterio-calibrado.md) promovido de `proposed` a `accepted` bajo el criterio recalibrado CP-01c' (`recalibrado-por: 0021`).
- `tests/integration/test_routing_vs_osrm.py`: el `@pytest.mark.xfail(strict=True)` de CP-01c se reemplazó por `test_cp01c_snap_to_edge`, que **pasa** asertando CP-01c' (±30 %/≥75) con `a_estrella_snap_edge` y `factor_calibracion=0.80`.
- `docs/quality/trazabilidad.md`: fila CP-01c → **CP-01c' ✅** (±30 %/≥75, ADR-0021); nota de blindaje actualizada con la recalibración.
- `docs/architecture/decisions/0016-camino-95-cp01a.md`: cruces a CP-01c actualizados al criterio recalibrado; Ruta A marcada completa.
- **Aislamiento**: snap-to-edge vive solo en el camino experimental de calibración Python (módulo separado). El A* operativo y `run-dataset` no cambian; **no se porta a Java**. CI `compare` sigue 12/12 OK bit-exacto.

### Added — H4 fase 6: FTR-03 — cierre formal de H4 (2026-05-21)
- Nueva acta [`docs/quality/ftr/0003-h4-cierre.md`](docs/quality/ftr/0003-h4-cierre.md): cierre técnico formal de las 5 fases de H4 (8235524 → 45a15fb). Modalidad auto-revisión documentada (Fernando Godoy no disponible para la sesión sincrónica; el DoD lo permite).
- **Veredicto**: H4 ✅ APROBADO. 9/12 RFs cerrados (75 %). Ningún hallazgo crítico ni mayor. 7 hallazgos menores (defectos/mejoras/preguntas), 2 resueltos en el mismo PR de la FTR (H-05 comentario inline al xfail, H-03 trazabilidad RF-12 con nota de semántica v1), el resto en backlog post-H5.
Expand Down
152 changes: 128 additions & 24 deletions core-python/src/sentinel_dispatch/adapters/grafo_osmnx.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

import networkx as nx # noqa: TC002 — usado en runtime como campo del dataclass
import osmnx as ox
Expand All @@ -25,11 +25,13 @@
CoordenadasFueraDeRangoError,
validar_coordenadas_iv_region,
)
from sentinel_dispatch.domain.routing.geometria import proyectar_en_polilinea
from sentinel_dispatch.domain.routing.heuristica import haversine_m
from sentinel_dispatch.domain.routing.tipos import (
Arista,
NodoFueraDeRangoError,
NodoId,
PosicionEnArista,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -193,32 +195,42 @@ def vecinos(self, nodo: NodoId) -> Iterable[Arista]:
Haversine sobre los endpoints y se loggea una advertencia.
"""
for u, v, _key, data in self.grafo.out_edges(nodo, keys=True, data=True):
velocidad_kmh: float = data.get("speed_kph", MAXSPEED_FALLBACK_KMH)

longitud_raw = data.get("length")
if longitud_raw is not None:
longitud_m: float = float(longitud_raw)
else:
# Fallback: Haversine sobre los endpoints del segmento.
# Ocurre solo en grafos sintéticos o datos corruptos.
nodos = self.grafo.nodes
lat_u, lon_u = float(nodos[u]["y"]), float(nodos[u]["x"])
lat_v, lon_v = float(nodos[v]["y"]), float(nodos[v]["x"])
longitud_m = haversine_m(lat_u, lon_u, lat_v, lon_v)
_log.warning(
"Arista (%s -> %s) sin atributo 'length'; longitud calculada por Haversine: %.1f m",
u,
v,
longitud_m,
)
yield self._arista_desde_data(u, v, data)

def _arista_desde_data(self, u: int, v: int, data: Any) -> Arista:
"""Construye la :class:`Arista` de dominio desde los atributos OSMnx.

yield Arista(
origen=NodoId(u),
destino=NodoId(v),
longitud_m=longitud_m,
velocidad_efectiva_kmh=velocidad_kmh,
Resuelve ``speed_kph`` (con :data:`MAXSPEED_FALLBACK_KMH` si falta) y
``length`` (con fallback Haversine sobre los endpoints si falta, caso
de grafos sintéticos o datos corruptos). Punto único de verdad usado
por :meth:`vecinos` y :meth:`posicion_en_arista`.
"""
velocidad_kmh: float = data.get("speed_kph", MAXSPEED_FALLBACK_KMH)

longitud_raw = data.get("length")
if longitud_raw is not None:
longitud_m: float = float(longitud_raw)
else:
# Fallback: Haversine sobre los endpoints del segmento.
# Ocurre solo en grafos sintéticos o datos corruptos.
nodos = self.grafo.nodes
lat_u, lon_u = float(nodos[u]["y"]), float(nodos[u]["x"])
lat_v, lon_v = float(nodos[v]["y"]), float(nodos[v]["x"])
longitud_m = haversine_m(lat_u, lon_u, lat_v, lon_v)
_log.warning(
"Arista (%s -> %s) sin atributo 'length'; longitud calculada por Haversine: %.1f m",
u,
v,
longitud_m,
)

return Arista(
origen=NodoId(u),
destino=NodoId(v),
longitud_m=longitud_m,
velocidad_efectiva_kmh=velocidad_kmh,
)

def coordenadas(self, nodo: NodoId) -> tuple[float, float]:
"""Coordenadas geográficas del nodo en grados decimales.

Expand Down Expand Up @@ -266,3 +278,95 @@ def distancia_snap_m(self, lat: float, lon: float, nodo: NodoId) -> float:
"""
lat_nodo, lon_nodo = self.coordenadas(nodo)
return haversine_m(lat, lon, lat_nodo, lon_nodo)

# -----------------------------------------------------------------------
# Snap-to-edge (GrafoVialConSnapEdge — ADR-0020 §H5-cal-3a)
# -----------------------------------------------------------------------

def posicion_en_arista(self, lat: float, lon: float) -> PosicionEnArista:
"""Proyecta ``(lat, lon)`` sobre la arista vial más cercana.

Snap-to-edge: a diferencia de :meth:`nodo_mas_cercano`, no salta al
nodo OSM más próximo sino que proyecta el punto sobre la geometría de
la arista más cercana, reportando la fracción a lo largo de ella
(ADR-0020). Esto replica el snap de OSRM y elimina el sesgo de
snap-to-node que domina la dispersión de ``duration`` (CP-01c).

Estrategia de búsqueda: se ancla en :meth:`nodo_mas_cercano` (que
valida el bbox RN-01) y se consideran como candidatas las aristas
incidentes a ese nodo y a sus vecinos directos. En una malla urbana
densa la arista más cercana es casi siempre incidente al nodo más
cercano o a uno adyacente; acotar así evita proyectar sobre las 42 k
aristas del grafo manteniendo la precisión. Sobre cada candidata se
proyecta con :func:`proyectar_en_polilinea` (usando la geometría
curva ``geometry`` si existe, o el segmento recto entre endpoints) y
se elige la de menor distancia de snap.

Raises:
NodoFueraDeRangoError: si ``(lat, lon)`` cae fuera del bbox
(delegado a :meth:`nodo_mas_cercano`) o si el nodo ancla no
tiene aristas incidentes.
"""
nodo_ancla = self.nodo_mas_cercano(lat, lon)

mejor: PosicionEnArista | None = None
for u, v, data in self._aristas_candidatas(nodo_ancla):
vertices = self._vertices_latlon(u, v, data)
fraccion, lat_proj, lon_proj, distancia = proyectar_en_polilinea(lat, lon, vertices)
if mejor is None or distancia < mejor.distancia_snap_m:
mejor = PosicionEnArista(
arista=self._arista_desde_data(u, v, data),
fraccion=fraccion,
lat=lat_proj,
lon=lon_proj,
distancia_snap_m=distancia,
)

if mejor is None:
raise NodoFueraDeRangoError(
f"El nodo más cercano a ({lat}, {lon}) no tiene aristas incidentes.",
lat=lat,
lon=lon,
)
return mejor

def _aristas_candidatas(self, nodo: NodoId) -> list[tuple[int, int, Any]]:
"""Aristas incidentes al nodo y a sus vecinos directos, sin duplicados.

Devuelve tuplas ``(u, v, data)`` para cada arista entrante o saliente
del nodo ancla y de cada uno de sus sucesores/predecesores. La
deduplicación es por ``(u, v, key)`` para que las aristas paralelas
del MultiDiGraph se consideren una sola vez.
"""
anclas: set[int] = {int(nodo)}
anclas.update(int(n) for n in self.grafo.successors(nodo))
anclas.update(int(n) for n in self.grafo.predecessors(nodo))

vistas: set[tuple[int, int, int]] = set()
candidatas: list[tuple[int, int, Any]] = []
for n in anclas:
for u, v, key, data in self.grafo.out_edges(n, keys=True, data=True):
if (u, v, key) not in vistas:
vistas.add((u, v, key))
candidatas.append((u, v, data))
for u, v, key, data in self.grafo.in_edges(n, keys=True, data=True):
if (u, v, key) not in vistas:
vistas.add((u, v, key))
candidatas.append((u, v, data))
return candidatas

def _vertices_latlon(self, u: int, v: int, data: Any) -> list[tuple[float, float]]:
"""Vértices ``(lat, lon)`` de la arista, en orden ``u → v``.

Usa la geometría curva ``geometry`` (LineString OSMnx en orden
``(lon, lat)``) si está presente; si no, devuelve el segmento recto
entre los endpoints.
"""
geom = data.get("geometry")
if geom is not None:
return [(float(la), float(lo)) for lo, la in geom.coords]
nodos = self.grafo.nodes
return [
(float(nodos[u]["y"]), float(nodos[u]["x"])),
(float(nodos[v]["y"]), float(nodos[v]["x"])),
]
10 changes: 9 additions & 1 deletion core-python/src/sentinel_dispatch/domain/routing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
"""

from sentinel_dispatch.domain.routing.a_estrella import a_estrella
from sentinel_dispatch.domain.routing.grafo_vial import GrafoVial
from sentinel_dispatch.domain.routing.geometria import proyectar_en_polilinea
from sentinel_dispatch.domain.routing.grafo_vial import (
GrafoVial,
GrafoVialConSnapEdge,
)
from sentinel_dispatch.domain.routing.heuristica import (
V_MAX_KMH,
V_MAX_MS,
Expand All @@ -18,17 +22,21 @@
NodoFueraDeRangoError,
NodoId,
NoRutaDisponibleError,
PosicionEnArista,
)

__all__ = [
"V_MAX_KMH",
"V_MAX_MS",
"Arista",
"GrafoVial",
"GrafoVialConSnapEdge",
"NoRutaDisponibleError",
"NodoFueraDeRangoError",
"NodoId",
"PosicionEnArista",
"a_estrella",
"haversine_m",
"haversine_segundos",
"proyectar_en_polilinea",
]
Loading
Loading