feat: extraccion masiva footprint_aurgi (41 funcs + 4 types + stack Docker geo)

Extrae al registry funciones del proyecto interno footprint_aurgi:
- core (6): slugify_ascii, normalize_for_join, cp_provincia_es, infer_provincia_from_cp, safe_read_csv_fallback, csv_to_parquet_duckdb
- geo puras (7): haversine_km, point_in_ring, point_in_polygon, point_in_polygons_bbox, polygon_bbox, extent_with_padding, distance_bucket
- geo I/O (4): load_geojson_polygons, load_boundary_gdf, add_basemap_osm, add_basemap_with_timeout
- valhalla client (4): valhalla_route, valhalla_isochrone, valhalla_isochrones_async, valhalla_matrix_1_to_n
- datascience stats (7): trimmed_mean, geometric_mean, detect_distribution_type, best_central_tendency, summary_stats, kde_density_levels, alpha_shape_concave_hull
- datascience fuzzy (3): fuzzy_merge_adaptive (rapidfuzz), words_to_dataset, remove_words_from_column
- datascience viz (2): plot_kde_2d, plot_heatmap_log
- infra (4): compress_pdf_ghostscript, render_table_page_pdfpages, add_header_logo, osm2pgsql_ingest
- pipelines (4): setup_geo_stack_docker, compute_centers_reachability, generate_isochrones_by_zone, count_points_per_zone
- types geo (4): LonLat, BBox, IsochroneRequest, Centro

Incluye:
- apps/footprint_geo_stack/ (PostGIS + Martin + Valhalla via docker-compose)
- 131/132 tests pasan (1 skip esperado: osm2pgsql en PATH)
- Issue tracker dev/issues/0052-footprint-aurgi-extraction.md
- Atribucion uniforme: source_repo internal:footprint_aurgi, source_license internal-aurgi
- Build con 9 agentes en paralelo (8 wave 1 + 1 wave 2 pipelines)

Tambien commitea trabajo previo no commiteado: aggregate_extraction_results, chunk_with_overlap, clean_pdf_text, merge_entity_aliases, extract_graph_gliner2, extract_relations_mrebel, extract_triples_spacy_es, gliner2/mrebel/marianmt/rebel/spacy_es load_model, parse_rebel_output, translate_es_to_en, issue 0050/0051.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 23:35:22 +02:00
parent f73ea072bd
commit faac610745
193 changed files with 13146 additions and 3 deletions
View File
+52
View File
@@ -0,0 +1,52 @@
---
name: add_basemap_osm
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def add_basemap_osm(ax: Axes, zoom: int = 9, cache_dir: str | Path | None = None) -> None"
description: "Añade un basemap OpenStreetMap Mapnik a un Axes de matplotlib usando contextily. Captura silenciosamente errores de red — nunca lanza."
tags: [geo, visualization, basemap, osm, contextily, matplotlib]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["contextily", "matplotlib"]
params:
- name: ax
desc: "matplotlib Axes con extensión proyectada. El CRS debe ser EPSG:3857 para coincidir con los tiles OSM."
- name: zoom
desc: "Nivel de zoom de los tiles (1-19). A mayor zoom, mayor resolución pero más requests."
- name: cache_dir
desc: "Directorio opcional para cachear tiles descargados. None usa el cache por defecto de contextily."
output: "None. Modifica el Axes in-place añadiendo el basemap como imagen de fondo."
tested: true
tests:
- "no lanza excepción con Axes válido"
test_file_path: "python/functions/geo/tests/test_add_basemap_osm.py"
file_path: "python/functions/geo/add_basemap_osm.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/src/recomendador_centros.py:220"
---
## Ejemplo
```python
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from geo.add_basemap_osm import add_basemap_osm
fig, ax = plt.subplots()
ax.set_xlim(-430000, -350000)
ax.set_ylim(4500000, 4600000)
add_basemap_osm(ax, zoom=10)
fig.savefig("mapa.png")
```
## Notas
Requiere contextily. Si contextily no está instalado, retorna sin hacer nada. Errores de red (timeout, sin conexión, tile no disponible) se capturan con `except Exception: pass` para no interrumpir el pipeline de generación de reportes.
+38
View File
@@ -0,0 +1,38 @@
"""Add an OpenStreetMap basemap to a matplotlib Axes."""
from __future__ import annotations
from pathlib import Path
def add_basemap_osm(
ax: "Axes",
zoom: int = 9,
cache_dir: "str | Path | None" = None,
) -> None:
"""Add an OpenStreetMap Mapnik basemap to a matplotlib Axes.
Uses contextily to fetch and render map tiles. Network errors and tile
fetch failures are silently captured — the map will render without a
basemap rather than raising.
Args:
ax: matplotlib Axes with a projected extent (CRS must match tile CRS,
typically EPSG:3857). The caller is responsible for ensuring the
Axes limits are set before calling this function.
zoom: Tile zoom level (119). Higher values fetch more tiles and
produce a sharper basemap at the cost of more network requests.
cache_dir: Optional directory for caching downloaded tiles. If None,
contextily uses its default cache location.
"""
try:
import contextily as ctx # type: ignore
except ImportError:
return
try:
if cache_dir is not None:
ctx.set_cache_dir(str(cache_dir))
ctx.add_basemap(ax, source=ctx.providers.OpenStreetMap.Mapnik, zoom=zoom)
except Exception: # noqa: BLE001
pass
@@ -0,0 +1,56 @@
---
name: add_basemap_with_timeout
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def add_basemap_with_timeout(ax: Axes, zoom: int = 9, cache_dir: str | Path | None = None, timeout_s: float = 15.0) -> bool"
description: "Igual que add_basemap_osm pero con timeout SIGALRM. Retorna True si cargó el basemap, False si timeout o error. Solo Unix — en Windows retorna False inmediatamente."
tags: [geo, visualization, basemap, osm, contextily, matplotlib, timeout]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["contextily", "matplotlib", "signal"]
params:
- name: ax
desc: "matplotlib Axes con extensión proyectada en EPSG:3857."
- name: zoom
desc: "Nivel de zoom de los tiles (1-19)."
- name: cache_dir
desc: "Directorio opcional para cachear tiles."
- name: timeout_s
desc: "Segundos máximos para la descarga de tiles. Default 15.0."
output: "True si el basemap se añadió correctamente; False en timeout, error de red, o Windows."
tested: true
tests:
- "timeout muy corto retorna False sin colgar"
test_file_path: "python/functions/geo/tests/test_add_basemap_with_timeout.py"
file_path: "python/functions/geo/add_basemap_with_timeout.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/src/reporte_clientes_aurgi.py:69"
---
## Ejemplo
```python
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from geo.add_basemap_with_timeout import add_basemap_with_timeout
fig, ax = plt.subplots()
ax.set_xlim(-430000, -350000)
ax.set_ylim(4500000, 4600000)
ok = add_basemap_with_timeout(ax, zoom=10, timeout_s=20.0)
if not ok:
print("Basemap no disponible (sin red o timeout)")
fig.savefig("mapa.png")
```
## Notas
Limitación: usa SIGALRM que solo está disponible en Unix/Linux/macOS. En Windows retorna False inmediatamente sin intentar la descarga. El timeout usa `signal.setitimer(ITIMER_REAL, ...)` que tiene resolución de microsegundos. El handler original de SIGALRM se restaura en el bloque finally.
@@ -0,0 +1,57 @@
"""Add an OSM basemap with a SIGALRM-based timeout (Unix only)."""
from __future__ import annotations
import signal
from pathlib import Path
def add_basemap_with_timeout(
ax: "Axes",
zoom: int = 9,
cache_dir: "str | Path | None" = None,
timeout_s: float = 15.0,
) -> bool:
"""Add an OpenStreetMap basemap with a hard timeout (Unix only).
Uses SIGALRM to enforce a wall-clock timeout on the tile fetch. If the
download does not complete within ``timeout_s`` seconds the function
returns False without modifying the Axes further.
**Unix only** — SIGALRM is not available on Windows. On Windows this
function always returns False immediately.
Args:
ax: matplotlib Axes with projected extent (EPSG:3857).
zoom: Tile zoom level (119).
cache_dir: Optional tile cache directory.
timeout_s: Maximum seconds to wait for tiles. Default 15.0.
Returns:
True if the basemap was added successfully; False on timeout or error.
"""
if not hasattr(signal, "SIGALRM"):
# Windows — SIGALRM not available
return False
try:
import contextily as ctx # type: ignore
except ImportError:
return False
def _handler(signum: int, frame: object) -> None: # noqa: ARG001
raise TimeoutError("basemap fetch timed out")
old_handler = signal.signal(signal.SIGALRM, _handler)
signal.setitimer(signal.ITIMER_REAL, timeout_s)
try:
if cache_dir is not None:
ctx.set_cache_dir(str(cache_dir))
ctx.add_basemap(ax, source=ctx.providers.OpenStreetMap.Mapnik, zoom=zoom)
return True
except Exception: # noqa: BLE001
return False
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, old_handler)
+47
View File
@@ -0,0 +1,47 @@
---
id: distance_bucket_py_geo
name: distance_bucket
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "distance_bucket(distance_km: float) -> str"
description: "Clasifica una distancia en km en uno de los buckets: 0-5, 5-10, 10-20, 20-40, 40-80, 80-160, 160+."
tags: [geo, distance, bucket, classification]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from geo.distance_bucket import distance_bucket
distance_bucket(3.0) # "0-5"
distance_bucket(7.0) # "5-10"
distance_bucket(200.0) # "160+"
tested: true
tests: ["bucket_0_5", "bucket_5_10", "bucket_borde_exacto", "bucket_160_mas"]
test_file_path: "python/functions/geo/tests/test_distance_bucket.py"
file_path: "python/functions/geo/distance_bucket.py"
params:
- {name: distance_km, desc: "distancia en kilometros a clasificar (valor >= 0)"}
output: "cadena con el rango al que pertenece la distancia, p.ej. '0-5' o '160+'"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:678"
---
## Ejemplo
```python
from geo.distance_bucket import distance_bucket
distance_bucket(3.0) # "0-5"
distance_bucket(50.0) # "40-80"
distance_bucket(200.0) # "160+"
```
## Notas
Los bordes son inclusivos por la izquierda: distance_km <= edge retorna el bucket.
+19
View File
@@ -0,0 +1,19 @@
"""Clasifica una distancia en km en un bucket de rango predefinido."""
def distance_bucket(distance_km: float) -> str:
"""Asigna la distancia a un bucket de rango: 0-5, 5-10, 10-20, 20-40, 40-80, 80-160, 160+.
Args:
distance_km: distancia en kilometros (valor >= 0).
Returns:
Cadena con el rango al que pertenece la distancia, p.ej. "0-5" o "160+".
"""
edges = [5, 10, 20, 40, 80, 160]
start = 0
for edge in edges:
if distance_km <= edge:
return f"{start}-{edge}"
start = edge
return "160+"
@@ -0,0 +1,46 @@
---
id: extent_with_padding_py_geo
name: extent_with_padding
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "extent_with_padding(bounds: tuple[float, float, float, float], pad_ratio: float = 0.05) -> tuple[float, float, float, float]"
description: "Expande un bounding box (minx,miny,maxx,maxy) anadiendo un margen proporcional. La salida es (minx-padx, maxx+padx, miny-pady, maxy+pady)."
tags: [geo, bbox, extent, padding, matplotlib]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from geo.extent_with_padding import extent_with_padding
extent = extent_with_padding((0.0, 0.0, 10.0, 10.0), 0.1)
# (-1.0, 11.0, -1.0, 11.0)
tested: true
tests: ["bbox_cuadrado_con_10_pct", "pad_ratio_cero_no_cambia"]
test_file_path: "python/functions/geo/tests/test_extent_with_padding.py"
file_path: "python/functions/geo/extent_with_padding.py"
params:
- {name: bounds, desc: "bounding box de entrada como tupla (minx, miny, maxx, maxy)"}
- {name: pad_ratio, desc: "fraccion del ancho/alto a anadir como margen por cada lado; por defecto 0.05 (5%)"}
output: "tupla (minx-padx, maxx+padx, miny-pady, maxy+pady) con el extent expandido"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/src/reporte_clientes_aurgi.py:90"
---
## Ejemplo
```python
from geo.extent_with_padding import extent_with_padding
extent_with_padding((0.0, 0.0, 10.0, 10.0), 0.1)
# (-1.0, 11.0, -1.0, 11.0)
```
## Notas
El orden de salida (minx, maxx, miny, maxy) es el que espera matplotlib ax.set_xlim/set_ylim.
@@ -0,0 +1,22 @@
"""Expande un bounding box con un margen proporcional."""
def extent_with_padding(
bounds: tuple[float, float, float, float],
pad_ratio: float = 0.05,
) -> tuple[float, float, float, float]:
"""Retorna un extent expandido con padding proporcional al tamano del bbox.
El orden de salida es (minx, maxx, miny, maxy), conveniente para ejes de matplotlib.
Args:
bounds: tupla (minx, miny, maxx, maxy) del bounding box original.
pad_ratio: fraccion del ancho/alto a anadir como margen por cada lado (por defecto 0.05).
Returns:
Tupla (minx - padx, maxx + padx, miny - pady, maxy + pady).
"""
minx, miny, maxx, maxy = bounds
pad_x = (maxx - minx) * pad_ratio
pad_y = (maxy - miny) * pad_ratio
return (minx - pad_x, maxx + pad_x, miny - pad_y, maxy + pad_y)
+47
View File
@@ -0,0 +1,47 @@
---
id: haversine_km_py_geo
name: haversine_km
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float"
description: "Calcula la distancia en kilometros entre dos puntos lon/lat usando la formula de Haversine con R=6371.0."
tags: [geo, distance, haversine, coordinates]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["math"]
example: |
from geo.haversine_km import haversine_km
d = haversine_km(-3.7038, 40.4168, 2.1686, 41.3874) # Madrid -> Barcelona ~504 km
tested: true
tests: ["madrid_barcelona_aproximado", "misma_coordenada_es_cero"]
test_file_path: "python/functions/geo/tests/test_haversine_km.py"
file_path: "python/functions/geo/haversine_km.py"
params:
- {name: lon1, desc: "longitud del primer punto en grados decimales"}
- {name: lat1, desc: "latitud del primer punto en grados decimales"}
- {name: lon2, desc: "longitud del segundo punto en grados decimales"}
- {name: lat2, desc: "latitud del segundo punto en grados decimales"}
output: "distancia en kilometros entre los dos puntos"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:668"
---
## Ejemplo
```python
from geo.haversine_km import haversine_km
d = haversine_km(-3.7038, 40.4168, 2.1686, 41.3874)
# d ≈ 504.0 km
```
## Notas
Funcion pura. Usa R=6371.0 km (radio medio de la Tierra). No maneja NaN ni Inf.
+24
View File
@@ -0,0 +1,24 @@
"""Distancia en km entre dos puntos lon/lat usando la formula de Haversine."""
import math
def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
"""Calcula la distancia en kilometros entre dos puntos dados en grados decimales.
Args:
lon1: longitud del primer punto en grados.
lat1: latitud del primer punto en grados.
lon2: longitud del segundo punto en grados.
lat2: latitud del segundo punto en grados.
Returns:
Distancia en kilometros entre los dos puntos.
"""
r = 6371.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a))
+46
View File
@@ -0,0 +1,46 @@
---
name: load_boundary_gdf
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def load_boundary_gdf(path: str | Path, crs: str = 'EPSG:4326') -> GeoDataFrame"
description: "Lee un GeoJSON con geopandas y normaliza el CRS. Si el archivo no tiene CRS lo asigna; si ya tiene CRS lo reproyecta al solicitado."
tags: [geo, geojson, geopandas, crs, boundary, io]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["geopandas", "pathlib"]
params:
- name: path
desc: "Ruta al archivo GeoJSON."
- name: crs
desc: "CRS destino en formato EPSG. Default EPSG:4326 (WGS84)."
output: "GeoDataFrame con el CRS solicitado. Cada fila es una feature del GeoJSON."
tested: true
tests:
- "retorna GeoDataFrame con CRS EPSG:4326"
- "archivo inexistente lanza FileNotFoundError"
test_file_path: "python/functions/geo/tests/test_load_boundary_gdf.py"
file_path: "python/functions/geo/load_boundary_gdf.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/src/recomendador_centros.py:199"
---
## Ejemplo
```python
from geo.load_boundary_gdf import load_boundary_gdf
gdf = load_boundary_gdf("boundary.geojson", crs="EPSG:4326")
print(gdf.crs) # EPSG:4326
print(gdf.shape) # (n_features, n_columns)
```
## Notas
Requiere geopandas. Si el archivo ya tiene CRS se llama `to_crs`; si no tiene CRS se llama `set_crs` para evitar advertencias de geopandas. Lanza FileNotFoundError antes de llamar a geopandas si el archivo no existe.
+40
View File
@@ -0,0 +1,40 @@
"""Load a GeoJSON boundary as a GeoDataFrame with normalized CRS."""
from __future__ import annotations
from pathlib import Path
def load_boundary_gdf(
path: "str | Path",
crs: str = "EPSG:4326",
) -> "GeoDataFrame":
"""Load a GeoJSON file as a GeoDataFrame with a normalized CRS.
Args:
path: Path to the GeoJSON file.
crs: Target CRS string (e.g. "EPSG:4326"). If the file has no CRS
set, it is assigned this CRS. If the file already has a CRS,
it is reprojected to this CRS.
Returns:
GeoDataFrame with the requested CRS set.
Raises:
FileNotFoundError: If the file does not exist.
fiona.errors.DriverError: If the file is not a valid GeoJSON.
"""
import geopandas as gpd # type: ignore
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"GeoJSON file not found: {p}")
gdf = gpd.read_file(str(p))
if gdf.crs is None:
gdf = gdf.set_crs(crs)
else:
gdf = gdf.to_crs(crs)
return gdf
@@ -0,0 +1,44 @@
---
name: load_geojson_polygons
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def load_geojson_polygons(path: str | Path) -> list[list[list[tuple[float, float]]]]"
description: "Lee un GeoJSON y devuelve los polígonos como listas de anillos de tuplas (x, y). Polygon produce 1 polígono; MultiPolygon produce N polígonos."
tags: [geo, geojson, polygon, io, parse]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "pathlib"]
params:
- name: path
desc: "Ruta al archivo GeoJSON. Puede ser str o Path."
output: "Lista de polígonos. Cada polígono es lista de anillos; cada anillo es lista de tuplas (x, y) float."
tested: true
tests:
- "polygon simple produce 1 polígono con 1 anillo"
- "archivo inexistente lanza FileNotFoundError"
test_file_path: "python/functions/geo/tests/test_load_geojson_polygons.py"
file_path: "python/functions/geo/load_geojson_polygons.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:688"
---
## Ejemplo
```python
from geo.load_geojson_polygons import load_geojson_polygons
polygons = load_geojson_polygons("boundary.geojson")
# polygons[0] → lista de anillos del primer polígono
# polygons[0][0][0] → (lon, lat) del primer punto del exterior
```
## Notas
Soporta Polygon y MultiPolygon. Geometrías nulas se omiten. Los puntos de cada anillo se convierten a tuplas (x, y) descartando la coordenada Z si existe. Lanza FileNotFoundError si el archivo no existe.
@@ -0,0 +1,57 @@
"""Load polygon rings from a GeoJSON file."""
from __future__ import annotations
import json
from pathlib import Path
def load_geojson_polygons(
path: "str | Path",
) -> "list[list[list[tuple[float, float]]]]":
"""Load polygons from a GeoJSON file.
Reads every feature geometry. Polygon features produce one polygon;
MultiPolygon features produce N polygons. Each polygon is a list of rings
where each ring is a list of (x, y) float tuples.
Args:
path: Path to the GeoJSON file.
Returns:
List of polygons. Each polygon is a list of rings (exterior + holes).
Each ring is a list of (x, y) tuples.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the file is not valid GeoJSON or contains unsupported
geometry types.
"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"GeoJSON file not found: {p}")
with p.open(encoding="utf-8") as f:
data = json.load(f)
features = data.get("features", [])
result: list[list[list[tuple[float, float]]]] = []
for feature in features:
geom = feature.get("geometry")
if geom is None:
continue
gtype = geom.get("type")
coords = geom.get("coordinates", [])
if gtype == "Polygon":
# coords = [exterior_ring, *hole_rings]
rings = [[(x, y) for x, y, *_ in ring] for ring in coords]
result.append(rings)
elif gtype == "MultiPolygon":
# coords = [polygon, ...] each polygon = [ring, ...]
for polygon_coords in coords:
rings = [[(x, y) for x, y, *_ in ring] for ring in polygon_coords]
result.append(rings)
return result
+51
View File
@@ -0,0 +1,51 @@
---
id: point_in_polygon_py_geo
name: point_in_polygon
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "point_in_polygon(lon: float, lat: float, polygon: list[list[tuple[float, float]]]) -> bool"
description: "Determina si el punto esta dentro del poligono GeoJSON (exterior + holes). True si esta en el anillo exterior y NO en ningun hole."
tags: [geo, polygon, point-in-polygon, holes]
uses_functions: [point_in_ring_py_geo]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from geo.point_in_polygon import point_in_polygon
outer = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
hole = [(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)]
point_in_polygon(0.5, 0.5, [outer, hole]) # True (exterior, fuera del hole)
point_in_polygon(2.0, 2.0, [outer, hole]) # False (dentro del hole)
tested: true
tests: ["punto_en_exterior", "punto_en_hole", "punto_fuera", "poligono_vacio"]
test_file_path: "python/functions/geo/tests/test_point_in_polygon.py"
file_path: "python/functions/geo/point_in_polygon.py"
params:
- {name: lon, desc: "longitud del punto a comprobar en grados decimales"}
- {name: lat, desc: "latitud del punto a comprobar en grados decimales"}
- {name: polygon, desc: "lista de anillos; el primero es el exterior, el resto son agujeros (holes)"}
output: "True si el punto esta dentro del poligono y fuera de todos los holes"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:733"
---
## Ejemplo
```python
from geo.point_in_polygon import point_in_polygon
outer = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
hole = [(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)]
point_in_polygon(0.5, 0.5, [outer, hole]) # True
point_in_polygon(2.0, 2.0, [outer, hole]) # False
```
## Notas
Compone point_in_ring. Soporta el formato de coordenadas GeoJSON (lista de anillos).
+28
View File
@@ -0,0 +1,28 @@
"""Comprobacion de punto dentro de poligono con soporte de agujeros (holes)."""
from python.functions.geo.point_in_ring import point_in_ring
def point_in_polygon(lon: float, lat: float, polygon: list[list[tuple[float, float]]]) -> bool:
"""Determina si el punto (lon, lat) esta dentro del poligono.
polygon[0] es el anillo exterior. polygon[1:] son agujeros (holes).
Retorna True si el punto esta en el exterior y NO en ningun agujero.
Args:
lon: longitud del punto en grados.
lat: latitud del punto en grados.
polygon: lista de anillos; el primero es el exterior, el resto son agujeros.
Returns:
True si el punto esta dentro del poligono (y fuera de todos los holes).
"""
if not polygon:
return False
outer = polygon[0]
if not point_in_ring(lon, lat, outer):
return False
for hole in polygon[1:]:
if point_in_ring(lon, lat, hole):
return False
return True
@@ -0,0 +1,53 @@
---
id: point_in_polygons_bbox_py_geo
name: point_in_polygons_bbox
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "point_in_polygons_bbox(lon: float, lat: float, polygons: list[list[list[tuple[float, float]]]], bboxes: list[tuple[float, float, float, float]]) -> bool"
description: "Comprueba si el punto esta en CUALQUIER poligono de la lista usando prefiltraje por bounding box para mayor rendimiento."
tags: [geo, polygon, point-in-polygon, bbox, batch]
uses_functions: [point_in_polygon_py_geo]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from geo.point_in_polygons_bbox import point_in_polygons_bbox
from geo.polygon_bbox import polygon_bbox
p1 = [[(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)]]
p2 = [[(5.0,5.0),(6.0,5.0),(6.0,6.0),(5.0,6.0)]]
bboxes = [polygon_bbox(p1), polygon_bbox(p2)]
point_in_polygons_bbox(0.5, 0.5, [p1, p2], bboxes) # True
tested: true
tests: ["punto_en_primer_poligono", "punto_en_segundo_poligono", "punto_fuera_de_todos"]
test_file_path: "python/functions/geo/tests/test_point_in_polygons_bbox.py"
file_path: "python/functions/geo/point_in_polygons_bbox.py"
params:
- {name: lon, desc: "longitud del punto a comprobar en grados decimales"}
- {name: lat, desc: "latitud del punto a comprobar en grados decimales"}
- {name: polygons, desc: "lista de poligonos, cada uno como lista de anillos (exterior + holes)"}
- {name: bboxes, desc: "lista de bboxes precalculados (minx, miny, maxx, maxy) para cada poligono, en el mismo orden"}
output: "True si el punto esta dentro de al menos uno de los poligonos"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:745"
---
## Ejemplo
```python
from geo.point_in_polygons_bbox import point_in_polygons_bbox
from geo.polygon_bbox import polygon_bbox
p1 = [[(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)]]
bboxes = [polygon_bbox(p1)]
point_in_polygons_bbox(0.5, 0.5, [p1], bboxes) # True
```
## Notas
Los bboxes deben precalcularse con polygon_bbox y pasarse en el mismo orden que polygons.
@@ -0,0 +1,31 @@
"""Comprobacion de punto contra una lista de poligonos con prefiltraje por bbox."""
from python.functions.geo.point_in_polygon import point_in_polygon
def point_in_polygons_bbox(
lon: float,
lat: float,
polygons: list[list[list[tuple[float, float]]]],
bboxes: list[tuple[float, float, float, float]],
) -> bool:
"""Determina si el punto (lon, lat) esta dentro de CUALQUIER poligono de la lista.
Aplica un prefiltraje por bounding box antes de ejecutar el ray casting completo,
lo que mejora el rendimiento cuando hay muchos poligonos.
Args:
lon: longitud del punto en grados.
lat: latitud del punto en grados.
polygons: lista de poligonos, cada uno como lista de anillos (exterior + holes).
bboxes: lista de bboxes precalculados (minx, miny, maxx, maxy) para cada poligono.
Returns:
True si el punto esta dentro de al menos uno de los poligonos.
"""
for polygon, (minx, miny, maxx, maxy) in zip(polygons, bboxes):
if lon < minx or lon > maxx or lat < miny or lat > maxy:
continue
if point_in_polygon(lon, lat, polygon):
return True
return False
+48
View File
@@ -0,0 +1,48 @@
---
id: point_in_ring_py_geo
name: point_in_ring
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "point_in_ring(lon: float, lat: float, ring: list[tuple[float, float]]) -> bool"
description: "Determina si el punto (lon, lat) esta dentro de un anillo poligonal usando ray casting. Retorna False si len(ring) < 3."
tags: [geo, polygon, ray-casting, point-in-polygon]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from geo.point_in_ring import point_in_ring
ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
inside = point_in_ring(0.5, 0.5, ring) # True
tested: true
tests: ["punto_dentro_cuadrado", "punto_fuera_cuadrado", "ring_menor_3_vertices"]
test_file_path: "python/functions/geo/tests/test_point_in_ring.py"
file_path: "python/functions/geo/point_in_ring.py"
params:
- {name: lon, desc: "longitud del punto a comprobar en grados decimales"}
- {name: lat, desc: "latitud del punto a comprobar en grados decimales"}
- {name: ring, desc: "lista de vertices (lon, lat) que forman el anillo cerrado"}
output: "True si el punto esta dentro del anillo, False en caso contrario"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:715"
---
## Ejemplo
```python
from geo.point_in_ring import point_in_ring
ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
point_in_ring(0.5, 0.5, ring) # True
point_in_ring(2.0, 2.0, ring) # False
```
## Notas
Algoritmo de ray casting clasico. El epsilon 1e-15 en el denominador evita division por cero en aristas horizontales.
+31
View File
@@ -0,0 +1,31 @@
"""Ray casting para determinar si un punto esta dentro de un anillo (ring) poligonal."""
def point_in_ring(lon: float, lat: float, ring: list[tuple[float, float]]) -> bool:
"""Determina si el punto (lon, lat) esta dentro del anillo cerrado ring.
Usa el algoritmo de ray casting. Retorna False si len(ring) < 3.
Args:
lon: longitud del punto en grados.
lat: latitud del punto en grados.
ring: lista de vertices (lon, lat) que forman el anillo.
Returns:
True si el punto esta dentro del anillo, False en caso contrario.
"""
inside = False
n = len(ring)
if n < 3:
return False
j = n - 1
for i in range(n):
xi, yi = ring[i]
xj, yj = ring[j]
intersects = ((yi > lat) != (yj > lat)) and (
lon < (xj - xi) * (lat - yi) / (yj - yi + 1e-15) + xi
)
if intersects:
inside = not inside
j = i
return inside
+45
View File
@@ -0,0 +1,45 @@
---
id: polygon_bbox_py_geo
name: polygon_bbox
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: pure
signature: "polygon_bbox(polygon: list[list[tuple[float, float]]]) -> tuple[float, float, float, float]"
description: "Calcula el bounding box (minx, miny, maxx, maxy) que envuelve todos los anillos de un poligono."
tags: [geo, polygon, bbox, bounds]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from geo.polygon_bbox import polygon_bbox
ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
bbox = polygon_bbox([ring]) # (0.0, 0.0, 1.0, 1.0)
tested: true
tests: ["cuadrado_unitario", "poligono_con_hole"]
test_file_path: "python/functions/geo/tests/test_polygon_bbox.py"
file_path: "python/functions/geo/polygon_bbox.py"
params:
- {name: polygon, desc: "lista de anillos [(lon, lat)]; puede incluir anillo exterior y agujeros"}
output: "tupla (minx, miny, maxx, maxy) con las coordenadas extremas del poligono"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "zonas_mapas_aurgi/backend/app.py:709"
---
## Ejemplo
```python
from geo.polygon_bbox import polygon_bbox
ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
polygon_bbox([ring]) # (0.0, 0.0, 1.0, 1.0)
```
## Notas
Recorre todos los anillos (exterior + holes) para calcular el bbox global.
+15
View File
@@ -0,0 +1,15 @@
"""Calculo del bounding box (minx, miny, maxx, maxy) de un poligono."""
def polygon_bbox(polygon: list[list[tuple[float, float]]]) -> tuple[float, float, float, float]:
"""Calcula el bounding box que envuelve todos los anillos del poligono.
Args:
polygon: lista de anillos [(lon, lat), ...]; puede tener varios anillos (exterior + holes).
Returns:
Tupla (minx, miny, maxx, maxy) con las coordenadas extremas del poligono.
"""
xs = [pt[0] for ring in polygon for pt in ring]
ys = [pt[1] for ring in polygon for pt in ring]
return min(xs), min(ys), max(xs), max(ys)
@@ -0,0 +1,22 @@
"""Tests para add_basemap_osm."""
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from geo.add_basemap_osm import add_basemap_osm
def test_no_lanza_excepcion_con_Axes_valido():
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.set_xlim(-430000, -350000)
ax.set_ylim(4500000, 4600000)
# Must not raise regardless of network availability
add_basemap_osm(ax, zoom=5)
plt.close(fig)
@@ -0,0 +1,23 @@
"""Tests para add_basemap_with_timeout."""
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from geo.add_basemap_with_timeout import add_basemap_with_timeout
def test_timeout_muy_corto_retorna_False_sin_colgar():
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.set_xlim(-430000, -350000)
ax.set_ylim(4500000, 4600000)
# 0.001 s timeout — should fail/timeout fast and return False
result = add_basemap_with_timeout(ax, zoom=9, timeout_s=0.001)
plt.close(fig)
assert result is False, f"expected False with 0.001s timeout, got {result}"
@@ -0,0 +1,25 @@
"""Tests para distance_bucket."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.distance_bucket import distance_bucket
def test_bucket_0_5():
assert distance_bucket(3.0) == "0-5"
def test_bucket_5_10():
assert distance_bucket(7.0) == "5-10"
def test_bucket_borde_exacto():
# 10 <= 10 → "5-10"
assert distance_bucket(10.0) == "5-10"
def test_bucket_160_mas():
assert distance_bucket(200.0) == "160+"
@@ -0,0 +1,19 @@
"""Tests para extent_with_padding."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.extent_with_padding import extent_with_padding
def test_bbox_cuadrado_con_10_pct():
result = extent_with_padding((0.0, 0.0, 10.0, 10.0), 0.1)
assert result == (-1.0, 11.0, -1.0, 11.0)
def test_pad_ratio_cero_no_cambia():
bounds = (2.0, 3.0, 8.0, 9.0)
result = extent_with_padding(bounds, 0.0)
assert result == (2.0, 8.0, 3.0, 9.0)
@@ -0,0 +1,18 @@
"""Tests para haversine_km."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.haversine_km import haversine_km
def test_madrid_barcelona_aproximado():
d = haversine_km(-3.7038, 40.4168, 2.1686, 41.3874)
assert abs(d - 504.0) < 2.0, f"Esperado ~504 km, got {d:.1f}"
def test_misma_coordenada_es_cero():
d = haversine_km(0.0, 0.0, 0.0, 0.0)
assert d == 0.0, f"Misma coordenada debe ser 0, got {d}"
@@ -0,0 +1,61 @@
"""Tests para load_boundary_gdf."""
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from geo.load_boundary_gdf import load_boundary_gdf
def _write_geojson(data: dict) -> Path:
f = tempfile.NamedTemporaryFile(
mode="w", suffix=".geojson", delete=False, encoding="utf-8"
)
json.dump(data, f)
f.close()
return Path(f.name)
def test_retorna_GeoDataFrame_con_CRS_EPSG4326():
geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-3.7, 40.4],
[-3.6, 40.4],
[-3.6, 40.5],
[-3.7, 40.5],
[-3.7, 40.4],
]
],
},
"properties": {"name": "test"},
}
],
}
path = _write_geojson(geojson)
try:
gdf = load_boundary_gdf(path, crs="EPSG:4326")
import geopandas as gpd # type: ignore
assert isinstance(gdf, gpd.GeoDataFrame), "result should be a GeoDataFrame"
assert gdf.crs is not None, "CRS should be set"
assert gdf.crs.to_epsg() == 4326, f"expected EPSG:4326, got {gdf.crs}"
assert len(gdf) == 1, f"expected 1 feature, got {len(gdf)}"
finally:
path.unlink(missing_ok=True)
def test_archivo_inexistente_lanza_FileNotFoundError():
import pytest
with pytest.raises(FileNotFoundError):
load_boundary_gdf("/tmp/this_file_does_not_exist_xyz.geojson")
@@ -0,0 +1,59 @@
"""Tests para load_geojson_polygons."""
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from geo.load_geojson_polygons import load_geojson_polygons
def _write_geojson(data: dict) -> Path:
f = tempfile.NamedTemporaryFile(
mode="w", suffix=".geojson", delete=False, encoding="utf-8"
)
json.dump(data, f)
f.close()
return Path(f.name)
def test_polygon_simple_produce_1_poligono_con_1_anillo():
geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-3.7, 40.4],
[-3.6, 40.4],
[-3.6, 40.5],
[-3.7, 40.5],
[-3.7, 40.4],
]
],
},
"properties": {},
}
],
}
path = _write_geojson(geojson)
try:
result = load_geojson_polygons(path)
assert len(result) == 1, f"expected 1 polygon, got {len(result)}"
assert len(result[0]) == 1, "expected 1 ring"
assert len(result[0][0]) >= 4, "ring should have >= 4 points"
assert isinstance(result[0][0][0], tuple), "points should be tuples"
finally:
path.unlink(missing_ok=True)
def test_archivo_inexistente_lanza_FileNotFoundError():
import pytest
with pytest.raises(FileNotFoundError):
load_geojson_polygons("/tmp/this_file_does_not_exist_xyz.geojson")
@@ -0,0 +1,29 @@
"""Tests para point_in_polygon."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.point_in_polygon import point_in_polygon
OUTER = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
HOLE = [(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)]
def test_punto_en_exterior():
# Punto en el anillo exterior, fuera del hole
assert point_in_polygon(0.5, 0.5, [OUTER, HOLE]) is True
def test_punto_en_hole():
# Punto dentro del hole → False
assert point_in_polygon(2.0, 2.0, [OUTER, HOLE]) is False
def test_punto_fuera():
assert point_in_polygon(10.0, 10.0, [OUTER, HOLE]) is False
def test_poligono_vacio():
assert point_in_polygon(0.5, 0.5, []) is False
@@ -0,0 +1,25 @@
"""Tests para point_in_polygons_bbox."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.point_in_polygons_bbox import point_in_polygons_bbox
from python.functions.geo.polygon_bbox import polygon_bbox
P1 = [[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]]
P2 = [[(5.0, 5.0), (6.0, 5.0), (6.0, 6.0), (5.0, 6.0)]]
BBOXES = [polygon_bbox(P1), polygon_bbox(P2)]
def test_punto_en_primer_poligono():
assert point_in_polygons_bbox(0.5, 0.5, [P1, P2], BBOXES) is True
def test_punto_en_segundo_poligono():
assert point_in_polygons_bbox(5.5, 5.5, [P1, P2], BBOXES) is True
def test_punto_fuera_de_todos():
assert point_in_polygons_bbox(10.0, 10.0, [P1, P2], BBOXES) is False
@@ -0,0 +1,22 @@
"""Tests para point_in_ring."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.point_in_ring import point_in_ring
SQUARE = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
def test_punto_dentro_cuadrado():
assert point_in_ring(0.5, 0.5, SQUARE) is True
def test_punto_fuera_cuadrado():
assert point_in_ring(2.0, 2.0, SQUARE) is False
def test_ring_menor_3_vertices():
assert point_in_ring(0.0, 0.0, [(0.0, 0.0), (1.0, 1.0)]) is False
@@ -0,0 +1,19 @@
"""Tests para polygon_bbox."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.geo.polygon_bbox import polygon_bbox
def test_cuadrado_unitario():
ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
assert polygon_bbox([ring]) == (0.0, 0.0, 1.0, 1.0)
def test_poligono_con_hole():
outer = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)]
hole = [(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)]
assert polygon_bbox([outer, hole]) == (0.0, 0.0, 5.0, 5.0)
@@ -0,0 +1,36 @@
"""Tests para valhalla_isochrone."""
from __future__ import annotations
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import httpx
import pytest
from valhalla_isochrone import valhalla_isochrone
def _valhalla_alive(url: str = "http://localhost:8002") -> bool:
try:
r = httpx.get(f"{url}/status", timeout=2.0)
return r.status_code < 500
except Exception:
return False
VALHALLA_OK = _valhalla_alive()
skip_if_no_valhalla = pytest.mark.skipif(
not VALHALLA_OK, reason="Valhalla no activo en :8002"
)
@skip_if_no_valhalla
def test_isócrona_10_min_madrid_contiene_features():
"""isócrona 10 min Madrid contiene features"""
gj = valhalla_isochrone(lat=40.4168, lon=-3.7038, minutes=10)
assert gj is not None, "Esperaba GeoJSON, obtuvo None"
assert "features" in gj, "GeoJSON no contiene 'features'"
assert len(gj["features"]) > 0, "features está vacío"
@@ -0,0 +1,43 @@
"""Tests para valhalla_isochrones_async."""
from __future__ import annotations
import asyncio
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import httpx
import pytest
from valhalla_isochrones_async import valhalla_isochrones_async
def _valhalla_alive(url: str = "http://localhost:8002") -> bool:
try:
r = httpx.get(f"{url}/status", timeout=2.0)
return r.status_code < 500
except Exception:
return False
VALHALLA_OK = _valhalla_alive()
skip_if_no_valhalla = pytest.mark.skipif(
not VALHALLA_OK, reason="Valhalla no activo en :8002"
)
@skip_if_no_valhalla
def test_3_puntos_madrid_retornan_lista_de_3():
"""3 puntos Madrid retornan lista de 3"""
pts = [
{"lat": 40.4168, "lon": -3.7038, "minutes": 10, "id": "sol"},
{"lat": 40.4530, "lon": -3.6883, "minutes": 10, "id": "retiro"},
{"lat": 40.4005, "lon": -3.7057, "minutes": 10, "id": "atocha"},
]
results = asyncio.run(valhalla_isochrones_async(pts))
assert len(results) == 3, f"Esperaba 3 resultados, obtuvo {len(results)}"
for i, gj in enumerate(results):
assert gj is not None, f"Resultado {i} es None"
assert "features" in gj, f"Resultado {i} no contiene 'features'"
@@ -0,0 +1,46 @@
"""Tests para valhalla_matrix_1_to_n."""
from __future__ import annotations
import math
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import httpx
import pytest
from valhalla_matrix_1_to_n import valhalla_matrix_1_to_n
def _valhalla_alive(url: str = "http://localhost:8002") -> bool:
try:
r = httpx.get(f"{url}/status", timeout=2.0)
return r.status_code < 500
except Exception:
return False
VALHALLA_OK = _valhalla_alive()
skip_if_no_valhalla = pytest.mark.skipif(
not VALHALLA_OK, reason="Valhalla no activo en :8002"
)
@skip_if_no_valhalla
def test_matrix_1_origen_2_destinos_retorna_2_dicts_con_meters_mayor_0():
"""matrix 1 origen 2 destinos retorna 2 dicts con meters > 0"""
origins = [(40.4168, -3.7038)] # Madrid
destinations = [
(41.3874, 2.1686), # Barcelona
(37.3886, -5.9823), # Sevilla
]
pairs = [(0, 0), (0, 1)]
results = valhalla_matrix_1_to_n(origins, destinations, pairs)
assert len(results) == 2, f"Esperaba 2 resultados, obtuvo {len(results)}"
for i, r in enumerate(results):
assert r["error"] == 0, f"Par {i} tiene error={r['error']}"
assert r["meters"] > 0, f"Par {i} tiene meters={r['meters']}"
assert not math.isnan(r["seconds"]), f"Par {i} tiene seconds=NaN"
@@ -0,0 +1,41 @@
"""Tests para valhalla_route."""
from __future__ import annotations
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import httpx
import pytest
from valhalla_route import valhalla_route
def _valhalla_alive(url: str = "http://localhost:8002") -> bool:
try:
r = httpx.get(f"{url}/status", timeout=2.0)
return r.status_code < 500
except Exception:
return False
VALHALLA_OK = _valhalla_alive()
skip_if_no_valhalla = pytest.mark.skipif(
not VALHALLA_OK, reason="Valhalla no activo en :8002"
)
@skip_if_no_valhalla
def test_ruta_madrid_barcelona_supera_500_km():
"""ruta Madrid-Barcelona supera 500 km"""
result = valhalla_route(
locations=[
{"lat": 40.4168, "lon": -3.7038},
{"lat": 41.3874, "lon": 2.1686},
]
)
assert result is not None, "Esperaba respuesta, obtuvo None"
summary = result["trip"]["summary"]
assert summary["length"] > 500, f"Distancia {summary['length']} km < 500 km"
@@ -0,0 +1,56 @@
---
name: valhalla_isochrone
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def valhalla_isochrone(lat: float, lon: float, minutes: int, base_url: str = 'http://localhost:8002', costing: str = 'auto', denoise: float = 0.6, generalize_m: int = 50, polygons: bool = True, timeout_s: float = 120.0) -> dict | None"
description: "Calcula la isócrona (área alcanzable en N minutos) de un punto usando Valhalla. Retorna GeoJSON dict con el polígono o None si error."
tags: [valhalla, isochrone, geo, http, geojson]
uses_functions: []
uses_types: []
returns: []
returns_optional: true
error_type: "error_go_core"
imports: [httpx]
params:
- name: lat
desc: "Latitud del punto de origen en grados decimales (WGS84)."
- name: lon
desc: "Longitud del punto de origen en grados decimales (WGS84)."
- name: minutes
desc: "Tiempo de viaje en minutos para el contorno de la isócrona."
- name: base_url
desc: "URL base del servidor Valhalla. Por defecto http://localhost:8002."
- name: costing
desc: "Modelo de coste: 'auto', 'bicycle', 'pedestrian', etc."
- name: denoise
desc: "Factor de suavizado del contorno (0-1). Valores menores dan contornos más fragmentados."
- name: generalize_m
desc: "Tolerancia de generalización de la geometría en metros."
- name: polygons
desc: "Si True retorna polígono cerrado; si False retorna línea del contorno."
- name: timeout_s
desc: "Timeout en segundos para la request HTTP."
output: "GeoJSON dict con campo 'features' conteniendo el polígono o línea de la isócrona, o None si el servidor no responde o retorna error."
tested: true
tests: ["isócrona 10 min Madrid contiene features"]
test_file_path: "python/functions/geo/tests/test_valhalla_isochrone.py"
file_path: "python/functions/geo/valhalla_isochrone.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/src/recomendador_centros.py"
---
## Ejemplo
```python
gj = valhalla_isochrone(lat=40.4168, lon=-3.7038, minutes=15)
if gj:
print(f"{len(gj['features'])} features en la isócrona de 15 min")
```
## Notas
Extraida de `_fetch_isochrone_polygon` en `recomendador_centros.py`. Parametrizada para ser reutilizable (el original usaba constantes globales VALHALLA_URL, CONCURRENCY, TIMEOUT_S). Retorna None ante cualquier excepcion.
@@ -0,0 +1,50 @@
"""Isócrona de un punto via Valhalla routing engine."""
from __future__ import annotations
import httpx
def valhalla_isochrone(
lat: float,
lon: float,
minutes: int,
base_url: str = "http://localhost:8002",
costing: str = "auto",
denoise: float = 0.6,
generalize_m: int = 50,
polygons: bool = True,
timeout_s: float = 120.0,
) -> dict | None:
"""Calcula la isócrona de un punto usando Valhalla.
Args:
lat: Latitud del punto de origen.
lon: Longitud del punto de origen.
minutes: Tiempo de viaje en minutos para el contorno.
base_url: URL base del servidor Valhalla.
costing: Modelo de coste ('auto', 'bicycle', 'pedestrian', etc.).
denoise: Factor de suavizado del contorno (0-1). Por defecto 0.6.
generalize_m: Tolerancia de generalización en metros. Por defecto 50.
polygons: Si True retorna polígono; si False retorna línea.
timeout_s: Timeout en segundos. Por defecto 120.
Returns:
GeoJSON dict con la isócrona o None si error.
"""
url = f"{base_url.rstrip('/')}/isochrone"
payload = {
"locations": [{"lat": lat, "lon": lon}],
"costing": costing,
"contours": [{"time": minutes}],
"polygons": polygons,
"denoise": denoise,
"generalize": generalize_m,
"format": "geojson",
}
try:
r = httpx.post(url, json=payload, timeout=httpx.Timeout(timeout_s))
r.raise_for_status()
return r.json()
except Exception:
return None
@@ -0,0 +1,59 @@
---
name: valhalla_isochrones_async
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "async def valhalla_isochrones_async(requests: list[dict], base_url: str = 'http://localhost:8002', costing: str = 'auto', concurrency: int = 6, timeout_s: float = 120.0, denoise: float = 0.6, generalize_m: int = 50) -> list[dict | None]"
description: "Calcula isócronas para múltiples puntos en paralelo usando httpx.AsyncClient y asyncio.Semaphore. Order-preserving: la lista retornada es paralela a la de entrada."
tags: [valhalla, isochrone, geo, http, async, asyncio, geojson, batch]
uses_functions: []
uses_types: []
returns: []
returns_optional: true
error_type: "error_go_core"
imports: [httpx, asyncio]
params:
- name: requests
desc: "Lista de dicts con 'lat' (float), 'lon' (float), 'minutes' (int) y opcionalmente 'id' (str). Un elemento por punto."
- name: base_url
desc: "URL base del servidor Valhalla. Por defecto http://localhost:8002."
- name: costing
desc: "Modelo de coste: 'auto', 'bicycle', 'pedestrian', etc."
- name: concurrency
desc: "Número máximo de requests HTTP simultáneas (Semaphore). Por defecto 6."
- name: timeout_s
desc: "Timeout en segundos por request individual."
- name: denoise
desc: "Factor de suavizado del contorno (0-1)."
- name: generalize_m
desc: "Tolerancia de generalización de la geometría en metros."
output: "Lista paralela a 'requests' con GeoJSON dict (campo 'features') o None por cada punto. Preserva el orden de entrada. Nunca lanza excepcion — fallos individuales se mapean a None."
tested: true
tests: ["3 puntos Madrid retornan lista de 3"]
test_file_path: "python/functions/geo/tests/test_valhalla_isochrones_async.py"
file_path: "python/functions/geo/valhalla_isochrones_async.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/src/generar_isochronas_aurgi.py"
---
## Ejemplo
```python
import asyncio
pts = [
{"lat": 40.4168, "lon": -3.7038, "minutes": 10, "id": "madrid_centro"},
{"lat": 40.4530, "lon": -3.6883, "minutes": 10, "id": "retiro"},
]
results = asyncio.run(valhalla_isochrones_async(pts))
for req, gj in zip(pts, results):
status = "ok" if gj else "error"
print(f"{req['id']}: {status}")
```
## Notas
Adaptada de `_run_isochrones` y `_fetch_isochrone` en `generar_isochronas_aurgi.py`. La versión original acoplaba pandas DataFrames y escritura a disco — esta versión es pandas-free y retorna datos en memoria. Usa asyncio.gather para preservar el orden de resultados.
@@ -0,0 +1,62 @@
"""Isócronas de múltiples puntos en paralelo via Valhalla (async)."""
from __future__ import annotations
import asyncio
import httpx
async def valhalla_isochrones_async(
requests: list[dict],
base_url: str = "http://localhost:8002",
costing: str = "auto",
concurrency: int = 6,
timeout_s: float = 120.0,
denoise: float = 0.6,
generalize_m: int = 50,
) -> list[dict | None]:
"""Calcula isócronas para múltiples puntos en paralelo usando Valhalla.
Args:
requests: Lista de dicts con 'lat' (float), 'lon' (float), 'minutes' (int)
y opcionalmente 'id' (str). Cada elemento genera una isócrona.
base_url: URL base del servidor Valhalla.
costing: Modelo de coste ('auto', 'bicycle', 'pedestrian', etc.).
concurrency: Número máximo de requests simultáneas.
timeout_s: Timeout en segundos por request.
denoise: Factor de suavizado del contorno (0-1).
generalize_m: Tolerancia de generalización en metros.
Returns:
Lista paralela a 'requests' con GeoJSON dict o None por cada punto.
Preserva el orden de entrada.
"""
url = f"{base_url.rstrip('/')}/isochrone"
sem = asyncio.Semaphore(concurrency)
timeout = httpx.Timeout(timeout_s)
async def _fetch_one(
client: httpx.AsyncClient,
req: dict,
) -> dict | None:
payload = {
"locations": [{"lat": float(req["lat"]), "lon": float(req["lon"])}],
"costing": costing,
"contours": [{"time": int(req["minutes"])}],
"polygons": True,
"denoise": denoise,
"generalize": generalize_m,
"format": "geojson",
}
try:
async with sem:
r = await client.post(url, json=payload)
r.raise_for_status()
return r.json()
except Exception:
return None
async with httpx.AsyncClient(timeout=timeout) as client:
tasks = [_fetch_one(client, req) for req in requests]
return list(await asyncio.gather(*tasks))
@@ -0,0 +1,65 @@
---
name: valhalla_matrix_1_to_n
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def valhalla_matrix_1_to_n(origins: list[tuple[float, float]], destinations: list[tuple[float, float]], pairs: list[tuple[int, int]], base_url: str = 'http://localhost:8002', costing: str = 'auto', max_targets_per_request: int = 400, max_distance_m: float = 30000.0, timeout_s: float = 120.0, concurrency: int = 12, search_radius_m: float = 0.0) -> list[dict]"
description: "Calcula distancias (metros) y tiempos (segundos) para pares origen-destino usando Valhalla sources_to_targets. Agrupa pares por origen para minimizar requests. Paraleliza con ThreadPoolExecutor. Pandas-free."
tags: [valhalla, matrix, geo, http, routing, distance, batch, threading]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, math, collections, concurrent.futures, threading, urllib.request]
params:
- name: origins
desc: "Lista de (lat, lon) en grados decimales WGS84. Son los posibles puntos de origen."
- name: destinations
desc: "Lista de (lat, lon) en grados decimales WGS84. Son los posibles destinos."
- name: pairs
desc: "Lista de (origin_idx, dest_idx) indicando qué pares calcular. Los índices referencian las listas origins y destinations."
- name: base_url
desc: "URL base del servidor Valhalla."
- name: costing
desc: "Modelo de coste: 'auto', 'bicycle', 'pedestrian', etc."
- name: max_targets_per_request
desc: "Máximo de destinos por request POST a Valhalla. Chunks más grandes son más eficientes pero pueden saturar el servidor."
- name: max_distance_m
desc: "Distancia en metros usada como fallback cuando un par falla (error=1)."
- name: timeout_s
desc: "Timeout en segundos por request HTTP."
- name: concurrency
desc: "Número de threads paralelos para las requests."
- name: search_radius_m
desc: "Radio en metros para snapping de coordenadas a la red viaria (0 = default de Valhalla)."
output: "Lista paralela a 'pairs' con dicts {\"meters\": float, \"seconds\": float, \"error\": int}. error=0 si OK, error=1 si Valhalla falló. En error: meters=max_distance_m, seconds=NaN."
tested: true
tests: ["matrix 1 origen 2 destinos retorna 2 dicts con meters > 0"]
test_file_path: "python/functions/geo/tests/test_valhalla_matrix_1_to_n.py"
file_path: "python/functions/geo/valhalla_matrix_1_to_n.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "ponderacion_isochronas/add_distancias_valhalla.py"
---
## Ejemplo
```python
origins = [(40.4168, -3.7038)] # Madrid
destinations = [
(41.3874, 2.1686), # Barcelona
(37.3886, -5.9823), # Sevilla
]
pairs = [(0, 0), (0, 1)] # Madrid->Barcelona, Madrid->Sevilla
results = valhalla_matrix_1_to_n(origins, destinations, pairs)
for (oi, di), r in zip(pairs, results):
print(f"pair ({oi},{di}): {r['meters']/1000:.1f} km, {r['seconds']/3600:.2f} h")
```
## Notas
Adaptada de `add_valhalla_meters_seconds_1_to_n` en `add_distancias_valhalla.py`. El original acoplaba pandas DataFrames, columnas por nombre, resume/checkpoint y verbose logging — esta versión es pandas-free con firma genérica basada en índices. Usa urllib.request (stdlib) para evitar dependencia de httpx en threads (httpx.Client no es thread-safe sin precauciones). Valhalla retorna distancia en km — se convierte a metros multiplicando por 1000.
@@ -0,0 +1,110 @@
"""Matriz de distancias y tiempos 1-a-N via Valhalla sources_to_targets."""
from __future__ import annotations
import math
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
from typing import Any
from urllib import request as urllib_request
import json
def valhalla_matrix_1_to_n(
origins: list[tuple[float, float]],
destinations: list[tuple[float, float]],
pairs: list[tuple[int, int]],
base_url: str = "http://localhost:8002",
costing: str = "auto",
max_targets_per_request: int = 400,
max_distance_m: float = 30_000.0,
timeout_s: float = 120.0,
concurrency: int = 12,
search_radius_m: float = 0.0,
) -> list[dict]:
"""Calcula distancias y tiempos para pares origen-destino usando Valhalla.
Args:
origins: Lista de (lat, lon) de los posibles orígenes.
destinations: Lista de (lat, lon) de los posibles destinos.
pairs: Lista de (origin_idx, dest_idx) con los índices a calcular.
base_url: URL base del servidor Valhalla.
costing: Modelo de coste ('auto', 'bicycle', 'pedestrian', etc.).
max_targets_per_request: Máximo de destinos por request a Valhalla.
max_distance_m: Distancia de fallback en metros cuando Valhalla falla.
timeout_s: Timeout en segundos por request.
concurrency: Número de threads paralelos.
search_radius_m: Radio de búsqueda de nodo en metros (0 = default).
Returns:
Lista paralela a 'pairs' con dicts {"meters": float, "seconds": float, "error": int}.
error=1 si Valhalla falló para ese par; meters=max_distance_m, seconds=NaN en error.
"""
url = f"{base_url.rstrip('/')}/sources_to_targets"
n_pairs = len(pairs)
meters: list[float] = [max_distance_m] * n_pairs
seconds: list[float] = [math.nan] * n_pairs
errors: list[int] = [1] * n_pairs
def _loc(lat: float, lon: float) -> dict[str, Any]:
loc: dict[str, Any] = {"lat": float(lat), "lon": float(lon)}
if search_radius_m and search_radius_m > 0:
loc["search_radius"] = float(search_radius_m)
return loc
def _post_json(payload: dict) -> dict:
data = json.dumps(payload).encode()
req = urllib_request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib_request.urlopen(req, timeout=timeout_s) as resp:
return json.loads(resp.read().decode("utf-8"))
# Group pair indices by origin index
groups: dict[int, list[int]] = defaultdict(list)
for pair_pos, (oi, _di) in enumerate(pairs):
groups[oi].append(pair_pos)
# Build tasks: (origin_idx, [pair_positions_chunk])
tasks: list[tuple[int, list[int]]] = []
for oi, pair_positions in groups.items():
for i in range(0, len(pair_positions), max_targets_per_request):
tasks.append((oi, pair_positions[i : i + max_targets_per_request]))
lock = Lock()
def _run_chunk(oi: int, chunk_positions: list[int]) -> None:
source = [_loc(*origins[oi])]
targets = [_loc(*destinations[pairs[pos][1]]) for pos in chunk_positions]
payload = {"sources": source, "targets": targets, "costing": costing}
try:
data = _post_json(payload)
matrix = data["sources_to_targets"][0]
with lock:
for j, pos in enumerate(chunk_positions):
cell = matrix[j]
d = cell.get("distance")
t = cell.get("time")
meters[pos] = d * 1000 if d is not None else max_distance_m
seconds[pos] = float(t) if t is not None else math.nan
errors[pos] = 0
except Exception:
with lock:
for pos in chunk_positions:
meters[pos] = max_distance_m
seconds[pos] = math.nan
errors[pos] = 1
with ThreadPoolExecutor(max_workers=concurrency) as ex:
futures = [ex.submit(_run_chunk, oi, chunk) for oi, chunk in tasks]
for fut in as_completed(futures):
fut.result() # propagate thread exceptions silently — already handled inside
return [
{"meters": meters[i], "seconds": seconds[i], "error": errors[i]}
for i in range(n_pairs)
]
+54
View File
@@ -0,0 +1,54 @@
---
name: valhalla_route
kind: function
lang: py
domain: geo
version: "1.0.0"
purity: impure
signature: "def valhalla_route(locations: list[dict], base_url: str = 'http://localhost:8002', costing: str = 'auto', units: str = 'metric', timeout_s: float = 60.0) -> dict | None"
description: "Calcula una ruta punto a punto usando el motor de enrutamiento Valhalla. POST a /route, retorna la respuesta JSON con trip.summary (length, time) o None si error."
tags: [valhalla, routing, geo, http, isochrone]
uses_functions: []
uses_types: []
returns: []
returns_optional: true
error_type: "error_go_core"
imports: [httpx]
params:
- name: locations
desc: "Lista de dicts con 'lat' y 'lon'. Minimo 2 puntos (origen y destino)."
- name: base_url
desc: "URL base del servidor Valhalla. Por defecto http://localhost:8002."
- name: costing
desc: "Modelo de coste de enrutamiento: 'auto', 'bicycle', 'pedestrian', etc."
- name: units
desc: "Unidades de distancia: 'metric' (km) o 'imperial' (millas)."
- name: timeout_s
desc: "Timeout en segundos para la request HTTP. Por defecto 60."
output: "Dict con la respuesta JSON de Valhalla (campo 'trip' con summary.length en km y summary.time en segundos), o None si el servidor no responde o retorna error."
tested: true
tests: ["ruta Madrid-Barcelona supera 500 km"]
test_file_path: "python/functions/geo/tests/test_valhalla_route.py"
file_path: "python/functions/geo/valhalla_route.py"
source_repo: "internal:footprint_aurgi"
source_license: "internal-aurgi"
source_file: "better_maps/scripts/test_valhalla_route.py"
---
## Ejemplo
```python
result = valhalla_route(
locations=[
{"lat": 40.4168, "lon": -3.7038}, # Madrid
{"lat": 41.3874, "lon": 2.1686}, # Barcelona
]
)
if result:
summary = result["trip"]["summary"]
print(f"{summary['length']} km, {summary['time'] / 3600:.2f} h")
```
## Notas
Retorna None ante cualquier excepcion (timeout, HTTP error, JSON invalido). Si necesitas el error concreto, usa httpx directamente. Requiere Valhalla activo en base_url.
+38
View File
@@ -0,0 +1,38 @@
"""Ruta punto a punto via Valhalla routing engine."""
from __future__ import annotations
import httpx
def valhalla_route(
locations: list[dict],
base_url: str = "http://localhost:8002",
costing: str = "auto",
units: str = "metric",
timeout_s: float = 60.0,
) -> dict | None:
"""Calcula una ruta entre una lista de ubicaciones usando Valhalla.
Args:
locations: Lista de dicts con 'lat' y 'lon' (al menos 2 puntos).
base_url: URL base del servidor Valhalla.
costing: Modelo de coste ('auto', 'bicycle', 'pedestrian', etc.).
units: Unidades de distancia ('metric' o 'imperial').
timeout_s: Timeout en segundos para la request HTTP.
Returns:
Respuesta JSON parseada con 'trip' o None si error.
"""
url = f"{base_url.rstrip('/')}/route"
payload = {
"locations": locations,
"costing": costing,
"units": units,
}
try:
r = httpx.post(url, json=payload, timeout=httpx.Timeout(timeout_s))
r.raise_for_status()
return r.json()
except Exception:
return None