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:
@@ -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.
|
||||
@@ -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 (1–19). 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 (1–19).
|
||||
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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user