From fa7635780d538ffd3a2787dc41fe9c9e93293551 Mon Sep 17 00:00:00 2001 From: Jacket-69 Date: Thu, 21 May 2026 13:51:17 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(h4-5):=20calibraci=C3=B3n=20parcial=20?= =?UTF-8?q?CP-01c=20+=20ADR-0020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tareas H4-cal-1 y H4-cal-2 del ADR-0013 ejecutadas: H4-cal-1: `cargar_grafo_iv_region(factor_calibracion: float = 1.0)`. Aplica multiplicador al speed_kph de cada arista in-memory tras la carga (no persiste al GraphML). Default 1.0 preserva paridad RT-02 12/12 OK; tests del adapter no afectados. H4-cal-2: nuevo módulo experimental `domain/routing/a_estrella_calibrado.py` con state extendido `(nodo, nodo_previo)` y turn_penalty_s=2.0 por giro >30°. NO reemplaza al A* operativo — vive separado para no romper la paridad bit-exacta con Java. Helpers `_bearing_grados` y `_delta_bearing` para cálculo angular sobre coordenadas geográficas. H4-cal-eval: nuevo test integration `test_cp01c_calibracion_y_turn_penalty` medido sobre los 100 pares del fixture OSRM. Resultado: 27/100 dentro de ±15 % (mediana 0.250, p75=0.367). El criterio CP-01c (≥85/100) NO se alcanza — consistente con la predicción de ADR-0011 §Diagnóstico (68 % de outliers atribuidos a snap-to-node, no tratado en H4). Decisión documental: - ADR-0013 sigue `proposed` con sección "Resultado de ejecución". - ADR-0020 nuevo, `accepted`: congela el resultado parcial y eleva H5-cal-3 snap-to-edge de "stretch" a "bloqueante" para promover ADR-0013 a accepted. - Test marcado `@pytest.mark.xfail(strict=True)` con razón que apunta a ADR-0020. Al cerrar H5-cal-3, quitar el xfail + promover 0013 en el mismo PR. Tests: - 10 UT del A* calibrado experimental (bearing, delta-bearing, ruta recta, giro 90° penalty, origen=destino, sin ruta, factor inválido, turn_penalty=0 equivale al A* simple). - 5 UT del factor_calibracion (escala 0.85, identidad 1.0, fallback para aristas sin speed_kph, ValueError para factores ≤ 0). Suite total 257/257 + 1 xfail intencional. Cobertura global 90.33%. --- .../sentinel_dispatch/adapters/grafo_osmnx.py | 29 +++- .../domain/routing/a_estrella_calibrado.py | 161 ++++++++++++++++++ .../tests/integration/test_routing_vs_osrm.py | 101 +++++++++++ .../unit/adapters/test_factor_calibracion.py | 66 +++++++ .../routing/test_a_estrella_calibrado.py | 154 +++++++++++++++++ .../0013-cp01c-criterio-calibrado.md | 44 +++++ ...20-cp01c-parcial-snap-to-edge-necesario.md | 104 +++++++++++ 7 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 core-python/src/sentinel_dispatch/domain/routing/a_estrella_calibrado.py create mode 100644 core-python/tests/unit/adapters/test_factor_calibracion.py create mode 100644 core-python/tests/unit/domain/routing/test_a_estrella_calibrado.py create mode 100644 docs/architecture/decisions/0020-cp01c-parcial-snap-to-edge-necesario.md diff --git a/core-python/src/sentinel_dispatch/adapters/grafo_osmnx.py b/core-python/src/sentinel_dispatch/adapters/grafo_osmnx.py index 4ff6620..70a7727 100644 --- a/core-python/src/sentinel_dispatch/adapters/grafo_osmnx.py +++ b/core-python/src/sentinel_dispatch/adapters/grafo_osmnx.py @@ -94,6 +94,7 @@ def cargar_grafo_iv_region( bbox: tuple[float, float, float, float] = BBOX_IV_REGION, ruta_cache: Path = GRAPHML_PATH, forzar_descarga: bool = False, + factor_calibracion: float = 1.0, ) -> nx.MultiDiGraph: """Carga el grafo vial de la conurbación La Serena-Coquimbo, con caché local. @@ -110,15 +111,27 @@ def cargar_grafo_iv_region( Path al archivo ``.graphml`` de caché. Por defecto :data:`GRAPHML_PATH`. forzar_descarga: Si ``True``, ignora la caché existente y re-descarga. + factor_calibracion: + Multiplicador aplicado al ``speed_kph`` de cada arista tras la carga + (ADR-0013 §H4-cal-1). Default ``1.0`` (sin cambio). Usar ``0.85`` + para acercar las velocidades efectivas al perfil ``car.lua`` de OSRM + (CP-01c). NO se persiste a disco — la calibración vive solo en memoria + para no contaminar el grafo cacheado ni la paridad RT-02. Retorna ------- nx.MultiDiGraph - Grafo vial con atributo ``speed_kph`` en todas las aristas. + Grafo vial con atributo ``speed_kph`` en todas las aristas, opcionalmente + escalado por ``factor_calibracion``. """ + if factor_calibracion <= 0: + raise ValueError(f"factor_calibracion debe ser > 0, recibido: {factor_calibracion}") + if ruta_cache.exists() and not forzar_descarga: _log.info("Cargando grafo desde caché: %s", ruta_cache) grafo: nx.MultiDiGraph = ox.load_graphml(ruta_cache) + if factor_calibracion != 1.0: + _aplicar_factor_calibracion(grafo, factor_calibracion) return grafo _log.info("Descargando grafo vial desde OSM (bbox=%s)…", bbox) @@ -136,9 +149,23 @@ def cargar_grafo_iv_region( ruta_cache.parent.mkdir(parents=True, exist_ok=True) ox.save_graphml(grafo, filepath=ruta_cache) _log.info("Grafo persistido en: %s", ruta_cache) + + if factor_calibracion != 1.0: + _aplicar_factor_calibracion(grafo, factor_calibracion) return grafo +def _aplicar_factor_calibracion(grafo: nx.MultiDiGraph, factor: float) -> None: + """Escala el ``speed_kph`` de cada arista por ``factor`` (in-place). + + Solo afecta el grafo en memoria; el archivo GraphML cacheado en disco no + se modifica. ADR-0013 §H4-cal-1. + """ + for _u, _v, data in grafo.edges(data=True): + speed = data.get("speed_kph", MAXSPEED_FALLBACK_KMH) + data["speed_kph"] = float(speed) * factor + + # --------------------------------------------------------------------------- # Adapter — implementación del puerto GrafoVial # --------------------------------------------------------------------------- diff --git a/core-python/src/sentinel_dispatch/domain/routing/a_estrella_calibrado.py b/core-python/src/sentinel_dispatch/domain/routing/a_estrella_calibrado.py new file mode 100644 index 0000000..a4af894 --- /dev/null +++ b/core-python/src/sentinel_dispatch/domain/routing/a_estrella_calibrado.py @@ -0,0 +1,161 @@ +"""A* experimental con turn penalty simple (ADR-0013 §H4-cal-2). + +Variante calibrada del :func:`a_estrella` que añade un costo aditivo +``turn_penalty_s`` cuando el cambio de bearing entre la arista entrante y +la arista saliente supera ``bearing_umbral_grados``. Modela +groseramente el comportamiento de OSRM `car.lua`, que penaliza giros +fuertes en intersecciones. + +**Aislamiento de producción**: esta función NO reemplaza a +:func:`a_estrella` en el orquestador. Vive como módulo separado para +preservar la paridad bit-exacta Java↔Python de RT-02 (ADR-0017). Se +invoca exclusivamente desde el test de calibración CP-01c +(``test_routing_vs_osrm.py::test_cp01c_calibracion_y_turn_penalty``). + +Si futura V2 incorpora turn penalty al A* operativo, este módulo se +vuelve obsoleto y se reescribe el A* principal (decisión costosa de +revertir; ameritaría ADR nuevo). +""" + +from __future__ import annotations + +import heapq +import math +from typing import TYPE_CHECKING + +from sentinel_dispatch.domain.routing.heuristica import haversine_segundos +from sentinel_dispatch.domain.routing.tipos import NodoId, NoRutaDisponibleError + +if TYPE_CHECKING: + from sentinel_dispatch.domain.routing.grafo_vial import GrafoVial + + +def _bearing_grados(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Bearing inicial entre dos coordenadas en grados decimales, [0, 360).""" + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + delta_lambda = math.radians(lon2 - lon1) + y = math.sin(delta_lambda) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(delta_lambda) + return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0 + + +def _delta_bearing(b1: float, b2: float) -> float: + """Diferencia angular menor entre dos bearings (en grados, [0, 180]).""" + diff = abs(b2 - b1) % 360.0 + return min(diff, 360.0 - diff) + + +def a_estrella_calibrado( + grafo: GrafoVial, + origen: NodoId, + destino: NodoId, + *, + factor_hora: float = 1.0, + factor_sirena: float = 1.0, + turn_penalty_s: float = 2.0, + bearing_umbral_grados: float = 30.0, +) -> tuple[float, list[NodoId]]: + """A* con penalización aditiva por giros pronunciados. + + Estado extendido: ``(nodo, nodo_previo)``. La arista entrante se + representa implícitamente por el nodo previo (suficiente para grafos + sin múltiples aristas paralelas con bearings distintos — en práctica + las pocas aristas paralelas de OSMnx comparten dirección general). + + Penalty: si ``|bearing(prev→actual) − bearing(actual→vecino)| > umbral`` + se suma ``turn_penalty_s`` al g_score del vecino. Para el primer paso + (sin nodo previo) no aplica penalty. + + Args: + grafo: instancia del port :class:`GrafoVial` (solo lectura). + origen, destino: NodoIds. + factor_hora, factor_sirena: idénticos al A* original. + turn_penalty_s: segundos a sumar por giro pronunciado (default 2.0, + valor citado en ADR-0013). + bearing_umbral_grados: umbral de Δbearing para considerar "giro + pronunciado" (default 30°, valor citado en ADR-0013). + + Returns: + Tupla ``(eta_segundos, ruta_de_nodos)``. + + Raises: + NoRutaDisponibleError: si no existe camino. + ValueError: si los factores son ≤ 0. + """ + if factor_hora <= 0: + raise ValueError(f"factor_hora debe ser > 0, recibido: {factor_hora}") + if factor_sirena <= 0: + raise ValueError(f"factor_sirena debe ser > 0, recibido: {factor_sirena}") + if turn_penalty_s < 0: + raise ValueError(f"turn_penalty_s debe ser >= 0, recibido: {turn_penalty_s}") + + if origen == destino: + return (0.0, [origen]) + + lat_destino, lon_destino = grafo.coordenadas(destino) + + # Estado del open-set: (nodo_actual, nodo_previo). Para el origen, previo=None. + g_score: dict[tuple[NodoId, NodoId | None], float] = {(origen, None): 0.0} + padre: dict[tuple[NodoId, NodoId | None], tuple[NodoId, NodoId | None]] = {} + + contador: int = 0 + lat_origen, lon_origen = grafo.coordenadas(origen) + h_origen = haversine_segundos(lat_origen, lon_origen, lat_destino, lon_destino) + heap: list[tuple[float, int, NodoId, NodoId | None]] = [(h_origen, contador, origen, None)] + + while heap: + _, _, nodo_actual, nodo_prev = heapq.heappop(heap) + estado_actual: tuple[NodoId, NodoId | None] = (nodo_actual, nodo_prev) + g_actual = g_score.get(estado_actual, math.inf) + + if nodo_actual == destino: + return (g_actual, _reconstruir_ruta(padre, estado_actual, origen)) + + # Pre-calcula bearing de la arista entrante (si existe nodo previo) + bearing_in: float | None = None + if nodo_prev is not None: + lat_prev, lon_prev = grafo.coordenadas(nodo_prev) + lat_act, lon_act = grafo.coordenadas(nodo_actual) + bearing_in = _bearing_grados(lat_prev, lon_prev, lat_act, lon_act) + + for arista in grafo.vecinos(nodo_actual): + velocidad_ms = arista.velocidad_efectiva_kmh * 1000.0 / 3600.0 + peso = arista.longitud_m / (velocidad_ms * factor_hora * factor_sirena) + + penalty = 0.0 + if bearing_in is not None: + lat_act, lon_act = grafo.coordenadas(nodo_actual) + lat_vec, lon_vec = grafo.coordenadas(arista.destino) + bearing_out = _bearing_grados(lat_act, lon_act, lat_vec, lon_vec) + if _delta_bearing(bearing_in, bearing_out) > bearing_umbral_grados: + penalty = turn_penalty_s + + g_tentativo = g_actual + peso + penalty + estado_vecino: tuple[NodoId, NodoId | None] = (arista.destino, nodo_actual) + + if g_tentativo < g_score.get(estado_vecino, math.inf): + g_score[estado_vecino] = g_tentativo + padre[estado_vecino] = estado_actual + lat_vec, lon_vec = grafo.coordenadas(arista.destino) + h_vec = haversine_segundos(lat_vec, lon_vec, lat_destino, lon_destino) + contador += 1 + heapq.heappush(heap, (g_tentativo + h_vec, contador, arista.destino, nodo_actual)) + + raise NoRutaDisponibleError(f"sin ruta entre {origen} y {destino}") + + +def _reconstruir_ruta( + padre: dict[tuple[NodoId, NodoId | None], tuple[NodoId, NodoId | None]], + estado_final: tuple[NodoId, NodoId | None], + origen: NodoId, +) -> list[NodoId]: + """Reconstruye la ruta de nodos desde el estado final hasta el origen.""" + ruta: list[NodoId] = [] + estado: tuple[NodoId, NodoId | None] | None = estado_final + while estado is not None and estado[0] != origen: + ruta.append(estado[0]) + estado = padre.get(estado) + ruta.append(origen) + ruta.reverse() + return ruta diff --git a/core-python/tests/integration/test_routing_vs_osrm.py b/core-python/tests/integration/test_routing_vs_osrm.py index 2ed29ab..af5c37f 100644 --- a/core-python/tests/integration/test_routing_vs_osrm.py +++ b/core-python/tests/integration/test_routing_vs_osrm.py @@ -33,6 +33,7 @@ cargar_grafo_iv_region, ) from sentinel_dispatch.domain.routing.a_estrella import a_estrella +from sentinel_dispatch.domain.routing.a_estrella_calibrado import a_estrella_calibrado _log = logging.getLogger(__name__) @@ -42,6 +43,15 @@ MINIMO_DENTRO: int = 75 """Cantidad mínima de pares dentro de tolerancia exigida por CP-01a.""" +TOLERANCIA_DURACION_CP01C: float = 0.15 +"""Tolerancia relativa de duration tras calibración (CP-01c, ADR-0013).""" + +MINIMO_DENTRO_CP01C: int = 85 +"""Cantidad mínima de pares dentro de tolerancia exigida por CP-01c.""" + +FACTOR_CALIBRACION: float = 0.85 +"""Factor a aplicar al speed cascade para acercarlo al perfil OSRM (ADR-0013).""" + FIXTURE_PATH: Path = Path(__file__).resolve().parents[1] / "fixtures" / "osrm_oracle.json" @@ -186,3 +196,94 @@ def test_a_estrella_vs_osrm_paridad_distancia( f"{TOLERANCIA_DISTANCIA}, mínimo exigido: {MINIMO_DENTRO}. " f"Distribución observada: {_resumen_distribucion(errores_distancia)}" ) + + +@pytest.fixture(scope="module") +def adapter_calibrado() -> OsmnxGrafoVial: + """Carga el grafo Coquimbo con `factor_calibracion=0.85` (ADR-0013).""" + if not GRAPHML_PATH.exists(): + pytest.skip(f"GraphML ausente: {GRAPHML_PATH}. Generar con: make build-graph") + grafo = cargar_grafo_iv_region( + ruta_cache=GRAPHML_PATH, + forzar_descarga=False, + factor_calibracion=FACTOR_CALIBRACION, + ) + return OsmnxGrafoVial(grafo=grafo) + + +@pytest.mark.xfail( + reason=( + "CP-01c no alcanzable solo con calibración+turn penalty. " + "Spike H4-cal-eval (2026-05-21) midió 27/100 dentro de ±15% " + "(mediana 0.250). Snap-to-edge (H5, ADR-0016 Ruta A) es necesario " + "para llegar a ≥85/100. Documentado en ADR-0020." + ), + strict=True, +) +def test_cp01c_calibracion_y_turn_penalty( + fixture_osrm: dict[str, object], + adapter_calibrado: OsmnxGrafoVial, +) -> None: + """CP-01c (ADR-0013): ≥85/100 pares con |Δ_duration|/t_OSRM ≤ 0.15. + + Aplica las dos mejoras de calibración del ADR-0013: + - speed cascade × 0.85 (vía `cargar_grafo_iv_region(factor_calibracion=0.85)`). + - turn penalty 2.0 s por giro >30° (vía `a_estrella_calibrado`). + + No invalida CP-01a: este test reporta duration usando un A* experimental + SEPARADO del A* operativo. La paridad bit-exacta con Java (RT-02) sigue + intacta. + + **Estado actual (2026-05-21)**: `xfail` esperado hasta que snap-to-edge + (H5 Ruta A) reduzca los outliers atribuidos a snap (~68 % de la dispersión + según ADR-0011 §Diagnóstico). Ver ADR-0020. + """ + pares = fixture_osrm["pares"] + assert isinstance(pares, list) + assert len(pares) == 100 + + errores_duracion: list[float] = [] + 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) + t_osrm = float(par["duration_s"]) + + nodo_origen = adapter_calibrado.nodo_mas_cercano( + float(origen_coord["lat"]), float(origen_coord["lon"]) + ) + nodo_destino = adapter_calibrado.nodo_mas_cercano( + float(destino_coord["lat"]), float(destino_coord["lon"]) + ) + + t_propio, _ruta = a_estrella_calibrado( + adapter_calibrado, + nodo_origen, + nodo_destino, + factor_hora=1.0, + factor_sirena=1.0, + ) + + if t_osrm > 0.0: + errores_duracion.append(abs(t_propio - t_osrm) / t_osrm) + + dentro = sum(1 for e in errores_duracion if e <= TOLERANCIA_DURACION_CP01C) + + _log.info( + "CP-01c (calibración + turn penalty): %s", + _resumen_distribucion(errores_duracion), + ) + _log.info( + "Veredicto CP-01c: dentro=%d/100 (mínimo %d con tol ±%.0f%%)", + dentro, + MINIMO_DENTRO_CP01C, + TOLERANCIA_DURACION_CP01C * 100, + ) + + assert dentro >= MINIMO_DENTRO_CP01C, ( + f"CP-01c falla: solo {dentro}/100 pares con |Δ_duration|/t_OSRM ≤ " + f"{TOLERANCIA_DURACION_CP01C}, mínimo exigido: {MINIMO_DENTRO_CP01C}. " + f"Distribución observada: {_resumen_distribucion(errores_duracion)}" + ) diff --git a/core-python/tests/unit/adapters/test_factor_calibracion.py b/core-python/tests/unit/adapters/test_factor_calibracion.py new file mode 100644 index 0000000..304c6f3 --- /dev/null +++ b/core-python/tests/unit/adapters/test_factor_calibracion.py @@ -0,0 +1,66 @@ +"""UT del `factor_calibracion` en `cargar_grafo_iv_region` (ADR-0013 §H4-cal-1).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from sentinel_dispatch.adapters.grafo_osmnx import ( + MAXSPEED_FALLBACK_KMH, + _aplicar_factor_calibracion, +) + +if TYPE_CHECKING: + import networkx as nx + + +class TestAplicarFactorCalibracion: + def test_factor_0_85_escala_speed_kph_inplace( + self, grafo_iv_region_sintetico: nx.MultiDiGraph + ) -> None: + """factor=0.85 → speed_kph original × 0.85 en cada arista.""" + speeds_originales = [ + data["speed_kph"] for _u, _v, data in grafo_iv_region_sintetico.edges(data=True) + ] + _aplicar_factor_calibracion(grafo_iv_region_sintetico, 0.85) + speeds_post = [ + data["speed_kph"] for _u, _v, data in grafo_iv_region_sintetico.edges(data=True) + ] + assert speeds_post == pytest.approx([s * 0.85 for s in speeds_originales]) + + def test_factor_1_0_es_identidad(self, grafo_iv_region_sintetico: nx.MultiDiGraph) -> None: + speeds_originales = [ + data["speed_kph"] for _u, _v, data in grafo_iv_region_sintetico.edges(data=True) + ] + _aplicar_factor_calibracion(grafo_iv_region_sintetico, 1.0) + speeds_post = [ + data["speed_kph"] for _u, _v, data in grafo_iv_region_sintetico.edges(data=True) + ] + assert speeds_post == speeds_originales + + def test_arista_sin_speed_kph_usa_fallback( + self, grafo_iv_region_sintetico: nx.MultiDiGraph + ) -> None: + """Si una arista carece de speed_kph, usa MAXSPEED_FALLBACK_KMH × factor.""" + # Borrar el atributo de una arista para simular el caso degenerado. + u, v, _key = next(iter(grafo_iv_region_sintetico.edges(keys=True))) + del grafo_iv_region_sintetico[u][v][0]["speed_kph"] + + _aplicar_factor_calibracion(grafo_iv_region_sintetico, 0.5) + nueva = grafo_iv_region_sintetico[u][v][0]["speed_kph"] + assert nueva == pytest.approx(MAXSPEED_FALLBACK_KMH * 0.5) + + +class TestCargarGrafoConFactor: + def test_factor_calibracion_negativo_lanza_value_error(self, tmp_path: object) -> None: + from sentinel_dispatch.adapters.grafo_osmnx import cargar_grafo_iv_region + + with pytest.raises(ValueError, match="factor_calibracion"): + cargar_grafo_iv_region(factor_calibracion=-0.1) + + def test_factor_calibracion_cero_lanza_value_error(self) -> None: + from sentinel_dispatch.adapters.grafo_osmnx import cargar_grafo_iv_region + + with pytest.raises(ValueError, match="factor_calibracion"): + cargar_grafo_iv_region(factor_calibracion=0.0) diff --git a/core-python/tests/unit/domain/routing/test_a_estrella_calibrado.py b/core-python/tests/unit/domain/routing/test_a_estrella_calibrado.py new file mode 100644 index 0000000..e45d1d8 --- /dev/null +++ b/core-python/tests/unit/domain/routing/test_a_estrella_calibrado.py @@ -0,0 +1,154 @@ +"""UT del A* calibrado experimental (ADR-0013 §H4-cal-2).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pytest + +from sentinel_dispatch.domain.routing.a_estrella_calibrado import ( + _bearing_grados, + _delta_bearing, + a_estrella_calibrado, +) +from sentinel_dispatch.domain.routing.tipos import Arista, NoRutaDisponibleError + +if TYPE_CHECKING: + from collections.abc import Iterable + + +# --------------------------------------------------------------------------- +# Fake grafo lineal para tests +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _FakeGrafo: + """Grafo trivial: aristas y coords explícitas.""" + + aristas_por_nodo: dict[int, list[Arista]] + coords_por_nodo: dict[int, tuple[float, float]] + + def vecinos(self, nodo: int) -> Iterable[Arista]: + return self.aristas_por_nodo.get(nodo, []) + + def coordenadas(self, nodo: int) -> tuple[float, float]: + return self.coords_por_nodo[nodo] + + def nodo_mas_cercano(self, lat: float, lon: float) -> int: # pragma: no cover + return 0 + + def distancia_snap_m(self, lat: float, lon: float, nodo: int) -> float: # pragma: no cover + return 0.0 + + +def _arista(origen: int, destino: int, longitud_m: float, kmh: float = 36.0) -> Arista: + return Arista( + origen=origen, + destino=destino, + longitud_m=longitud_m, + velocidad_efectiva_kmh=kmh, + ) + + +# --------------------------------------------------------------------------- +# _bearing_grados / _delta_bearing +# --------------------------------------------------------------------------- + + +class TestBearing: + def test_bearing_norte_es_cero(self) -> None: + b = _bearing_grados(0.0, 0.0, 1.0, 0.0) + assert b == pytest.approx(0.0, abs=0.5) + + def test_bearing_este_es_90(self) -> None: + b = _bearing_grados(0.0, 0.0, 0.0, 1.0) + assert b == pytest.approx(90.0, abs=0.5) + + def test_delta_bearing_es_simetrico(self) -> None: + assert _delta_bearing(10.0, 350.0) == pytest.approx(20.0) + assert _delta_bearing(350.0, 10.0) == pytest.approx(20.0) + + def test_delta_bearing_max_es_180(self) -> None: + assert _delta_bearing(0.0, 180.0) == pytest.approx(180.0) + + +# --------------------------------------------------------------------------- +# A* calibrado vs sin penalty +# --------------------------------------------------------------------------- + + +class TestAEstrellaCalibrado: + def test_ruta_recta_sin_penalty_devuelve_eta_simple(self) -> None: + """Ruta 1→2→3 hacia el este: sin giros, no aplica penalty.""" + # Coords: 1@(0,0), 2@(0,0.001), 3@(0,0.002). Aristas 1→2 y 2→3 de + # 100 m a 36 km/h = 10 s cada una. Total 20 s. + coords = {1: (0.0, 0.0), 2: (0.0, 0.001), 3: (0.0, 0.002)} + aristas = { + 1: [_arista(1, 2, 100.0)], + 2: [_arista(2, 3, 100.0)], + 3: [], + } + grafo = _FakeGrafo(aristas_por_nodo=aristas, coords_por_nodo=coords) + eta, ruta = a_estrella_calibrado( + grafo, + origen=1, + destino=3, + turn_penalty_s=2.0, + bearing_umbral_grados=30.0, + ) + assert ruta == [1, 2, 3] + assert eta == pytest.approx(20.0, abs=0.5) + + def test_giro_90_grados_aplica_turn_penalty(self) -> None: + """Ruta 1→2→3 con giro de 90° en 2 suma turn_penalty_s al eta.""" + # Coords: 1@(0,0), 2@(0,0.001) este, 3@(0.001,0.001) norte → giro 90°. + coords = {1: (0.0, 0.0), 2: (0.0, 0.001), 3: (0.001, 0.001)} + aristas = { + 1: [_arista(1, 2, 100.0)], + 2: [_arista(2, 3, 100.0)], + 3: [], + } + grafo = _FakeGrafo(aristas_por_nodo=aristas, coords_por_nodo=coords) + eta, ruta = a_estrella_calibrado( + grafo, + origen=1, + destino=3, + turn_penalty_s=2.0, + bearing_umbral_grados=30.0, + ) + assert ruta == [1, 2, 3] + # 100/10 + 100/10 + 2.0 (penalty del giro 90°) = 22 s + assert eta == pytest.approx(22.0, abs=0.5) + + def test_origen_igual_destino_eta_cero(self) -> None: + coords = {1: (0.0, 0.0)} + grafo = _FakeGrafo(aristas_por_nodo={1: []}, coords_por_nodo=coords) + eta, ruta = a_estrella_calibrado(grafo, 1, 1) + assert eta == 0.0 + assert ruta == [1] + + def test_sin_ruta_lanza_excepcion(self) -> None: + coords = {1: (0.0, 0.0), 2: (0.0, 0.001)} + grafo = _FakeGrafo(aristas_por_nodo={1: [], 2: []}, coords_por_nodo=coords) + with pytest.raises(NoRutaDisponibleError): + a_estrella_calibrado(grafo, 1, 2) + + def test_factor_hora_invalido_lanza_value_error(self) -> None: + coords = {1: (0.0, 0.0), 2: (0.0, 0.001)} + grafo = _FakeGrafo(aristas_por_nodo={1: [_arista(1, 2, 100.0)]}, coords_por_nodo=coords) + with pytest.raises(ValueError, match="factor_hora"): + a_estrella_calibrado(grafo, 1, 2, factor_hora=0.0) + + def test_turn_penalty_cero_equivale_al_a_star_simple(self) -> None: + """Con turn_penalty_s=0, dos rutas con/sin giro deben dar el mismo eta.""" + coords = {1: (0.0, 0.0), 2: (0.0, 0.001), 3: (0.001, 0.001)} + aristas = { + 1: [_arista(1, 2, 100.0)], + 2: [_arista(2, 3, 100.0)], + 3: [], + } + grafo = _FakeGrafo(aristas_por_nodo=aristas, coords_por_nodo=coords) + eta, _ = a_estrella_calibrado(grafo, origen=1, destino=3, turn_penalty_s=0.0) + assert eta == pytest.approx(20.0, abs=0.5) diff --git a/docs/architecture/decisions/0013-cp01c-criterio-calibrado.md b/docs/architecture/decisions/0013-cp01c-criterio-calibrado.md index de74f82..9bc9145 100644 --- a/docs/architecture/decisions/0013-cp01c-criterio-calibrado.md +++ b/docs/architecture/decisions/0013-cp01c-criterio-calibrado.md @@ -47,6 +47,50 @@ Tres pasos secuenciales, cada uno como sub-PR independiente dentro de H4: Cada paso es un commit/PR separable porque cada uno tiene un efecto medible aislado: la *calibración* y los *turn penalties* mueven la distribución de `duration`, no la de `distance` — CP-01a seguirá pasando si CP-01c pasa. +## Resultado de ejecución H4-cal-eval (2026-05-21) + +**Status**: sigue `proposed`. Las dos mejoras se ejecutaron pero el criterio numérico **no se alcanza** sin la mejora 3 (snap-to-edge), que está en H5. + +### Implementación de las mejoras 1 y 2 + +- **H4-cal-1** ✅: `cargar_grafo_iv_region(factor_calibracion=0.85)` aplica el factor in-memory al `speed_kph` de cada arista. No persiste al GraphML cacheado para no contaminar la paridad RT-02. +- **H4-cal-2** ✅: `domain/routing/a_estrella_calibrado.py` implementa el A* extendido con state `(nodo, nodo_previo)` y `turn_penalty_s=2.0` por giro `>30°`. Vive como módulo separado del A* operativo; preserva paridad bit-exacta RT-02 (CI `compare` 12/12 OK). + +### Medición sobre `osrm_oracle.json` (100 pares) + +`test_routing_vs_osrm.py::test_cp01c_calibracion_y_turn_penalty`: + +| Métrica | Valor | +|---|---| +| dentro ±15 % | **27/100** (mínimo CP-01c: 85/100) | +| mediana | 0.250 | +| p75 | 0.367 | +| p95 | 0.836 | +| ±5 % | 6/100 | +| ±10 % | 20/100 | +| ±20 % | 37/100 | +| ±30 % | 65/100 | +| ±50 % | 88/100 | + +**Veredicto**: CP-01c **NO alcanzado** con calibración + turn penalty sola. La mejora vs el A* original es real (mediana cae de ~0.38 a 0.25, distancia ±30 % sube de 65 → 65 sin cambio neto a ±30 %, pero ±20 % se llena bien), pero el sweet spot de mejora cae en ±20 %, no en ±15 %. + +### Análisis: por qué el ±15 % no se cierra + +Recordando la descomposición empírica de outliers ([ADR-0011](0011-reformulacion-criterio-it01.md) §Diagnóstico): + +- 68 % de outliers atribuidos a **snap-to-node**. +- 14 % a **filtrado de vías** (`car.lua`). +- 18 % residual. + +Las mejoras 1 y 2 atacan el residual + parte del filtrado; el 68 % de snap-to-node **sigue sin tratamiento**. Por eso el criterio se acerca pero no llega. + +### Decisión + +1. **Este ADR sigue `proposed`**, no `accepted`. +2. **Test integration `test_cp01c_calibracion_y_turn_penalty` marcado `xfail` `strict=True`** con razón documentada: "Esperar H5 Ruta A snap-to-edge". +3. **ADR-0020 nuevo** ([0020-cp01c-parcial-snap-to-edge-necesario.md](0020-cp01c-parcial-snap-to-edge-necesario.md)) congela el resultado parcial y plantea el plan H5. +4. **No bloquea cierre de H4**: CP-01c no era requisito de H4, solo objetivo. Las mejoras 1 y 2 quedan en el código del repo para que H5 las consuma. + ## Por qué placeholder y no implementación inmediata - **H2 ya cerró** (PR #5 mergeado 2026-05-18) con CP-01a/b. Reabrir H2 para meter calibración rompe la disciplina de hitos del cronograma académico. diff --git a/docs/architecture/decisions/0020-cp01c-parcial-snap-to-edge-necesario.md b/docs/architecture/decisions/0020-cp01c-parcial-snap-to-edge-necesario.md new file mode 100644 index 0000000..0584eee --- /dev/null +++ b/docs/architecture/decisions/0020-cp01c-parcial-snap-to-edge-necesario.md @@ -0,0 +1,104 @@ +--- +adr: 0020 +title: CP-01c parcial — snap-to-edge necesario en H5 para cerrar el criterio +status: accepted +date: 2026-05-21 +deciders: Benjamin López +tags: [adr, routing, calibracion, h4, h5, cp01c] +--- + +# ADR 0020 — CP-01c parcial: snap-to-edge necesario para cerrar + +## Contexto + +[ADR-0013](0013-cp01c-criterio-calibrado.md) propuso `CP-01c = duration ±15 % en ≥ 85/100 pares` como objetivo de paridad post-calibración, descomponiéndolo en tres mejoras (factor 0.85, turn penalty, snap-to-edge). El plan original asumía que las dos primeras (H4) serían suficientes y la tercera (H5) era *stretch*. + +El experimento H4-cal-eval ejecutado el 2026-05-21 refutó esa hipótesis: aplicando solo las mejoras 1 y 2, el criterio se queda en **27/100 dentro de ±15 %**, con mediana 0.250. La predicción del ADR-0011 ya advertía que el 68 % de los outliers se atribuían a snap-to-node, así que esto es consistente con el análisis previo: sin snap-to-edge, el 68 % de la dispersión no se reduce. + +Este ADR cierra formalmente el ciclo de calibración H4 reconociendo que **CP-01c es alcanzable, pero no en H4 — requiere snap-to-edge en H5**. + +## Decisión + +1. **ADR-0013 sigue `proposed`** (criterio numérico no alcanzado). No se promueve a `accepted` hasta que H5-cal-3 entregue snap-to-edge y se mida sobre el mismo fixture. +2. **CP-01c se cierra empíricamente en H5**, no en H4. La tarea H5-cal-3 del [ADR-0016](0016-camino-95-cp01a.md) §"Ruta A" se vuelve **bloqueante** para `accepted` de ADR-0013. +3. **El criterio CP-01c-strict (±10 % en ≥ 90/100)** queda como objetivo *stretch* dependiente de la fixture v3 N≥300 (Ruta B) — sin compromiso. +4. **Las mejoras 1 y 2 quedan integradas** en el repo (función `cargar_grafo_iv_region(factor_calibracion=...)` y módulo `domain/routing/a_estrella_calibrado.py`). H5-cal-3 las consume; no se rehacen. + +### Resultado parcial congelado (corrida 2026-05-21) + +| Criterio | Medido | Mínimo CP-01c | Δ | +|---|---|---|---| +| dentro ±15 % | 27/100 | 85/100 | -58 | +| mediana | 0.250 | objetivo ~0.15 | +0.10 | +| dentro ±20 % | 37/100 | — (informativo) | — | +| dentro ±30 % | 65/100 | (CP-01a passing: 78) | — | + +**Conclusión empírica**: la calibración movió la curva pero el snap explica ~73 % de la dispersión restante. Sin atacar snap, no se cierra ±15 %. + +## Por qué este ADR (separado del 0013) + +ADR-0013 quedó como **placeholder de un objetivo**. Marcarlo `accepted` ahora sería falso (el criterio no se cumple); marcarlo `rejected` sería excesivo (el criterio sigue siendo el plan, solo que llega más tarde). Mantenerlo `proposed` con un sub-ADR explicando el delta es la forma honesta: + +- ADR-0013 = qué queremos lograr (sigue vigente). +- ADR-0020 = qué medimos y qué falta (este ADR, accepted). + +Cuando H5-cal-3 entregue, ADR-0013 pasa a `accepted` con referencia cruzada a ADR-0016 §H5-cal-3. + +## Plan H5 (heredado de ADR-0016 Ruta A) + +Tareas con esfuerzo estimado (en horas-hombre): + +| ID | Tarea | Esfuerzo | Salida | +|---|---|---|---| +| H5-cal-3a | Agregar `coord_a_posicion_en_arista(lat, lon) → PosicionEnArista(arista, fraccion)` al port `GrafoVial` + implementación OSMnx | 3-4 h | Posición interpolada exacta sobre arista, no nodo | +| H5-cal-3b | Adaptar `a_estrella_calibrado` para origen/destino en mitad de arista | 2-3 h | A* con costos parciales por el segmento truncado | +| H5-cal-3c | Re-correr `test_cp01c_calibracion_y_turn_penalty`; si pasa, promover ADR-0013 a `accepted` | 1 h + iteración | Test deja de ser `xfail` | + +**Esfuerzo total esperado: 6-8 h.** Cabe holgado en H5 (deadline 2026-07-15) con simulación + informe. + +### Decisión arquitectónica anticipada para H5-cal-3a + +Cambiar la firma del port `GrafoVial` agregando un método nuevo es decisión costosa (todo adapter debe implementarlo). Alternativas para considerar en H5: + +- **(a)** Agregar `coord_a_posicion_en_arista` al port. Pros: explícito, type-safe. Contras: rompe `OsmnxGrafoVial` y cualquier fake de test que implementaba `GrafoVial`. +- **(b)** Helper libre en `domain/routing/snap_to_edge.py` que recibe el grafo y devuelve la posición. Pros: no toca el port. Contras: invierte la dirección de la dependencia respecto a Ports & Adapters. + +Recomendación para H5: opción (a) con `Protocol` opcional `GrafoVialConSnapEdge(GrafoVial)` que extiende sin romper el contrato base. Decisión final cuando se inicie H5-cal-3. + +## Paridad RT-02 (Java vs Python) tras snap-to-edge + +Punto importante: snap-to-edge cambia las rutas que el A* devuelve (los nodos del path pueden diferir marginalmente vs snap-to-node). Si Java no se porta simultáneamente, el job `compare` puede divergir. + +**Plan v1**: snap-to-edge **solo en el A* calibrado experimental** (no en el A* operativo). El `run-dataset` operativo sigue usando el A* original sin snap-to-edge. El test CP-01c usa el calibrado. **Resultado**: paridad RT-02 12/12 OK se preserva; CP-01c se cierra en módulo experimental. + +Si se quiere snap-to-edge operativo (acerca duration al de OSRM en producción), eso es decisión separada que rompería paridad ±5 % de ADR-0008. No es scope v1. + +## Consecuencias + +### Positivas + +- **CP-01c no queda como promesa vacía**: el plan H5 tiene tareas concretas con esfuerzo estimado. +- **La defensa académica gana coherencia**: "medimos, falló, hicimos análisis empírico de outliers, identificamos snap como responsable mayor, lo dejamos para H5 con plan numérico". Mejor que prometer y entregar sin medir. +- **Las mejoras 1 y 2 quedan capitalizadas** — no se descartan; H5 las consume. + +### Negativas / costo + +- CP-01c no se cierra en H4 como el plan original asumía. H5 se carga con una tarea adicional de 6-8 h. +- El `xfail` test integration es un recordatorio público de la deuda. Si alguien lo corre con `--runxfail`, falla ruidosamente. + +### Neutras + +- ADR-0016 §H5-cal-3 sigue siendo el plan vinculante; este ADR sólo eleva su prioridad de "stretch" a "necesario". + +## Cumplimiento / verificación + +- `core-python/tests/integration/test_routing_vs_osrm.py::test_cp01c_calibracion_y_turn_penalty` marcado `@pytest.mark.xfail(strict=True)` con razón que apunta a este ADR. +- `docs/quality/trazabilidad.md` CP-01c referencia ambos ADRs (0013 y 0020). +- Cuando H5-cal-3c haga pasar el test, **quitar el `xfail` Y promover ADR-0013 a `accepted`** en el mismo PR (atomicidad documental). + +## Referencias + +- [ADR-0011](0011-reformulacion-criterio-it01.md) — descomposición empírica de outliers que predijo este resultado. +- [ADR-0013](0013-cp01c-criterio-calibrado.md) — placeholder original; sigue `proposed`. +- [ADR-0016](0016-camino-95-cp01a.md) — Ruta A (snap-to-edge) y Ruta B (fixture v3) hacia 95 %. +- `tests/integration/test_routing_vs_osrm.py::test_cp01c_calibracion_y_turn_penalty` — test que mide el delta. From 273ad2f92dcffb3c58922d422bd3ca445b92233a Mon Sep 17 00:00:00 2001 From: Jacket-69 Date: Thu, 21 May 2026 13:51:17 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(quality):=20CP-01c=20=F0=9F=9F=A1=20H5?= =?UTF-8?q?=20en=20trazabilidad=20+=20entrada=20H4-5=20en=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trazabilidad: nueva fila para CP-01c apuntando a `grafo_osmnx.py` (`cargar_grafo_iv_region(factor_calibracion=0.85)`) y a `domain/routing/a_estrella_calibrado.py`. Estado 🟡 H5 (calibración parcial en H4, snap-to-edge bloqueante en H5). Referencias a ADR-0013 y ADR-0020. CHANGELOG: entrada "H4 fase 5: calibración parcial CP-01c + ADR-0020" con los números medidos (27/100 ±15 %, mediana 0.250) y la decisión de mantener ADR-0013 proposed. --- CHANGELOG.md | 13 +++++++++++++ docs/quality/trazabilidad.md | 1 + 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcad076..cf22f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ Versionado: una entrada por **entrega académica** del semestre (no SemVer estri ## [Unreleased] +### Added — H4 fase 5: calibración parcial CP-01c + ADR-0020 (2026-05-21) +- Ejecutadas las tareas H4-cal-1 y H4-cal-2 del [ADR-0013](docs/architecture/decisions/0013-cp01c-criterio-calibrado.md): + - **H4-cal-1** ✅: parámetro `factor_calibracion: float = 1.0` agregado a `cargar_grafo_iv_region`. Aplica multiplicador al `speed_kph` de cada arista in-memory tras la carga (no persiste al GraphML cacheado). Default `1.0` preserva paridad RT-02 12/12 OK. + - **H4-cal-2** ✅: nuevo módulo experimental [`domain/routing/a_estrella_calibrado.py`](core-python/src/sentinel_dispatch/domain/routing/a_estrella_calibrado.py) con state extendido `(nodo, nodo_previo)` y `turn_penalty_s=2.0` por giro `>30°`. **No reemplaza** al A* operativo — vive separado para no romper la paridad bit-exacta con Java. +- **H4-cal-eval** parcial: nuevo test integration `test_cp01c_calibracion_y_turn_penalty`. **Resultado medido (2026-05-21)**: 27/100 dentro de ±15 % (mediana 0.250, p75=0.367, p95=0.836). El criterio CP-01c (≥85/100) **NO se alcanza** con calibración+turn penalty solas — el 68 % de la dispersión sigue atribuida a snap-to-node (predicho por ADR-0011 §Diagnóstico). +- [ADR-0013](docs/architecture/decisions/0013-cp01c-criterio-calibrado.md) actualizado con sección "Resultado de ejecución H4-cal-eval"; sigue `status: proposed` (criterio numérico no alcanzado). +- [ADR-0020](docs/architecture/decisions/0020-cp01c-parcial-snap-to-edge-necesario.md) nuevo, `accepted`: congela el resultado parcial, explica por qué snap-to-edge (H5 Ruta A) es **bloqueante** para promover ADR-0013 a accepted, planifica las 3 sub-tareas H5-cal-3a/b/c con esfuerzo estimado 6-8 h. +- Test marcado `@pytest.mark.xfail(strict=True)` con razón que apunta a ADR-0020. Cuando H5-cal-3 entregue, quitar `xfail` y promover ADR-0013 a `accepted` en el mismo PR. +- 10 UT del A* calibrado experimental en `tests/unit/domain/routing/test_a_estrella_calibrado.py` (bearing/delta-bearing + 6 tests del algoritmo: ruta recta sin penalty, giro 90° aplica penalty, origen=destino, sin ruta, factor_hora inválido, turn_penalty=0 equivale al A* simple). + +### Changed — H4 fase 5 +- `docs/quality/trazabilidad.md`: nueva fila para **CP-01c 🟡 H5** con referencias a ADR-0013 y ADR-0020. + ### Added — H4 fase 4: spike performance CP-12 + ADR-0019 (RN-05 / CP-12, 2026-05-21) - Nuevo script reproducible [`tools/spike_cp12_performance.py`](tools/spike_cp12_performance.py): genera 50 unidades sintéticas (30 Avanzada / 20 Básica) distribuidas en grilla regular sobre la bbox conurbación La Serena-Coquimbo, 1 incidente Echo en el centro, carga del grafo `coquimbo.graphml` excluida del wall-clock, 10 corridas warm-cache, reporta p50/p95/max/media + JSON crudo en `tools/_out/spike_cp12_resultado.json`. - **Resultado del spike (corrida 2026-05-21)**: p50 = 1884.6 ms, p95 = 1941.6 ms, max = 1975.1 ms, media = 1895.8 ms. El criterio SRS (≤ 1000 ms) **no se cumple** con A* secuencial; cada A* sobre ~16 K nodos toma ~37 ms × 50 unidades. diff --git a/docs/quality/trazabilidad.md b/docs/quality/trazabilidad.md index e9f35e1..c34ca00 100644 --- a/docs/quality/trazabilidad.md +++ b/docs/quality/trazabilidad.md @@ -41,6 +41,7 @@ La matriz cubre los **doce Requisitos Funcionales** (RF-01..RF-12), las **diez R | **RF-10** Detección de saturación y candidatas a re-dirección | `application/` → [`saturacion.py`](../../core-python/src/sentinel_dispatch/application/saturacion.py) | `detectar_saturacion(flota, progreso_por_unidad)` → `EstadoSaturacion(saturada, candidatas_redireccion)`; candidatas EnRuta ordenadas por `(progreso_pct asc, unidad.id lex asc)`; default conservador `progreso=0.0` para EnRuta sin progreso provisto | [CP-10](../SRS.md#213-casos-de-prueba) flota saturada | Sistema reporta saturación cuando ninguna unidad está en `DISPONIBLE`; lista candidatas EnRuta para re-dirección manual del operador | ✅ H3 fase 3 | | **RF-11** Exportación de logs CSV/JSON | `adapters/exportador.py` + `interfaces/cli/export_cmd.py` | `exportar_a_csv(eventos, path)` y `exportar_a_json(eventos, path)`; CLI `sentinel export --formato {csv,json} --in EVENTOS.jsonl --out PATH`. CSV con flatten de `payload_*` y encoding `utf-8-sig` (BOM Excel); JSON como array indentado sin BOM | `test_exportador.py` (14 UT — flatten, CSV/JSON Normal/Borde, end-to-end CLI con archivo inexistente o corrupto) | Logs derivados con union de columnas para payloads heterogéneos; el log canónico JSONL no se modifica (RN-03 preservado, este subcomando solo lee) | ✅ | | **RF-12** Modo simulación sobre flota ficticia | `application/simulacion.py` + `interfaces/cli/simular_cmd.py` | `simular(incidentes, flota_ficticia, grafo, *, repositorio_eventos=None) → ReporteSimulacion`. Sin evolución temporal entre incidentes (cada uno ve la flota inicial). Persistencia **opt-in** vía `repositorio_eventos` para no contaminar el log operativo. CLI: `sentinel simular --flota --incidentes --graph --out [--persistir-en]` | `test_simulacion.py` (7 UT — Normal/Borde/RN/Métricas: resultados+métricas correctas, sin evolución temporal, lista vacía, flota vacía=100% saturación, default no escribe a repo, con repo escribe N eventos con prefijo `SD-SIM-`, pcts suman 100) | Reporte agregado: lista de `ResultadoDespacho` + pct por motivo + ETA media/p95. El reporte JSON queda etiquetado `"modo": "simulacion"` para distinguir del modo operativo | ✅ | +| **CP-01c** Paridad post-calibración duration ±15 % en ≥ 85/100 | `adapters/grafo_osmnx.py:cargar_grafo_iv_region(factor_calibracion=0.85)` + `domain/routing/a_estrella_calibrado.py` (A* experimental con turn penalty) | Medición en `test_routing_vs_osrm.py::test_cp01c_calibracion_y_turn_penalty` | Spike H4-cal-eval (2026-05-21): 27/100 dentro de ±15 % (mediana 0.250). Mejoró vs A* original pero NO alcanzó el criterio. Snap-to-edge (H5 Ruta A) es necesario — documentado en [ADR-0020](../architecture/decisions/0020-cp01c-parcial-snap-to-edge-necesario.md). Test marcado `xfail` strict. | 🟡 H5 (calibración H4 parcial, snap-to-edge bloqueante) | ## 3. Reglas de Negocio