feat(eda): primitivas geoespaciales del grupo eda (detección lat/lon + extensión + scatter)

Tres funciones puras nuevas del dominio datascience (tags eda + geospatial) que
sostienen el capítulo GEOSPATIAL del AutomaticEDA, delegadas a fn-constructor:

- detect_latlon_columns: identifica el par (lat, lon) por nombre de columna +
  rango de valores ([-90,90] / [-180,180]) desde profile['columns']. Devuelve
  {lat_col, lon_col, confidence, reason}. 9 tests.
- analyze_geo_extent: bbox, centroide, span haversine, conteo por zona/país
  (lookup offline con bounding boxes embebidos, KISS sin geopandas) y
  hemisferios. 7 tests.
- build_geo_scatter: prepara los puntos del scatter en orden [lon, lat] con
  downsampling determinista por paso fijo + aspect equirectangular 1/cos(lat)
  clampado. 6 tests.

Registradas en datascience/__init__.py. Todas pure, params_schema completo,
.md autosuficiente (Ejemplo + Cuando usarla + Gotchas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:29:33 +02:00
parent 415154d9a3
commit cd658cc703
10 changed files with 1169 additions and 0 deletions
@@ -0,0 +1,141 @@
"""Tests para detect_latlon_columns."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from detect_latlon_columns import detect_latlon_columns
# Keys that every result dict (success or failure) must expose.
_EXPECTED_KEYS = {"lat_col", "lon_col", "confidence", "reason"}
def _col(name, mn=None, mx=None, inferred="numeric"):
"""Build a minimal ColumnProfile-like dict for the tests."""
col = {"name": name, "inferred_type": inferred}
if mn is not None or mx is not None:
col["numeric"] = {"min": mn, "max": mx}
return col
def test_par_latitude_longitude_fuerte():
"""Golden: nombres latitude/longitude con rango valido -> par con confianza alta."""
columns = [
_col("id", mn=1, mx=9999, inferred="numeric"),
_col("latitude", mn=-45.0, mx=45.0),
_col("longitude", mn=-120.0, mx=120.0),
]
res = detect_latlon_columns(columns)
assert set(res.keys()) == _EXPECTED_KEYS
assert res["lat_col"] == "latitude"
assert res["lon_col"] == "longitude"
# Nombre fuerte (0.6) + rango (0.4) en ambos lados -> 1.0.
assert abs(res["confidence"] - 1.0) < 1e-9
assert "rango valido" in res["reason"]
def test_par_lat_lon_abreviado():
"""Golden: nombres abreviados lat/lon tambien se detectan como fuertes."""
columns = [
_col("lat", mn=40.0, mx=43.0),
_col("lon", mn=-4.0, mx=-1.0),
_col("precio", mn=0.0, mx=500.0),
]
res = detect_latlon_columns(columns)
assert res["lat_col"] == "lat"
assert res["lon_col"] == "lon"
assert abs(res["confidence"] - 1.0) < 1e-9
def test_par_x_y_debil_con_rango_valido():
"""Edge: x/y genericos solo cuentan como par debil cuando el rango encaja."""
columns = [
_col("y_coord", mn=-10.0, mx=10.0), # debil latitud
_col("x_coord", mn=-150.0, mx=150.0), # debil longitud
]
res = detect_latlon_columns(columns)
assert res["lat_col"] == "y_coord"
assert res["lon_col"] == "x_coord"
# Nombre debil (0.3) + rango (0.4) -> 0.7 en ambos lados.
assert abs(res["confidence"] - 0.7) < 1e-9
def test_nombre_lat_lon_pero_rango_fuera_no_detecta():
"""Edge: nombre lat/lon con rango fuera de bounds -> NO es coordenada."""
columns = [
_col("latitude", mn=-200.0, mx=200.0), # fuera de [-90, 90]
_col("longitude", mn=-120.0, mx=120.0), # valido, pero sin par lat
]
res = detect_latlon_columns(columns)
assert res["lat_col"] is None
assert res["lon_col"] is None
assert res["confidence"] == 0.0
assert isinstance(res["reason"], str) and res["reason"]
def test_par_fuerte_prevalece_sobre_debil():
"""Edge: con candidatos fuertes y debiles, gana el par de mayor confianza."""
columns = [
_col("latitude", mn=-45.0, mx=45.0), # fuerte lat
_col("y", mn=-30.0, mx=30.0), # debil lat
_col("longitude", mn=-120.0, mx=120.0), # fuerte lon
_col("x", mn=-100.0, mx=100.0), # debil lon
]
res = detect_latlon_columns(columns)
assert res["lat_col"] == "latitude"
assert res["lon_col"] == "longitude"
assert abs(res["confidence"] - 1.0) < 1e-9
def test_entradas_vacias_o_invalidas_no_lanzan():
"""Edge: sin columnas / vacio / no-lista / entradas no-dict -> dict None sin lanzar."""
for bad in ([], None, "no soy lista", 42, [1, 2, 3], [{}], [{"foo": "bar"}]):
res = detect_latlon_columns(bad)
assert set(res.keys()) == _EXPECTED_KEYS
assert res["lat_col"] is None
assert res["lon_col"] is None
assert res["confidence"] == 0.0
assert isinstance(res["reason"], str)
def test_solo_latitud_sin_longitud_no_detecta():
"""Edge: solo hay latitud valida, falta la longitud -> sin par."""
columns = [
_col("latitude", mn=-45.0, mx=45.0),
_col("temperatura", mn=-5.0, mx=40.0),
]
res = detect_latlon_columns(columns)
assert res["lat_col"] is None
assert res["lon_col"] is None
assert res["confidence"] == 0.0
def test_deteccion_por_samples_cuando_falta_numeric():
"""Edge: sin bloque numeric, el rango se valida con samples."""
columns = [
{"name": "lat"}, # sin numeric ni inferred_type
{"name": "lon"},
]
samples = {
"lat": [10.5, 20.0, None, 30.25], # todos dentro de [-90, 90]
"lon": [-40.0, 50.5, 60.0], # todos dentro de [-180, 180]
}
res = detect_latlon_columns(columns, samples)
assert res["lat_col"] == "lat"
assert res["lon_col"] == "lon"
assert abs(res["confidence"] - 1.0) < 1e-9
def test_samples_fuera_de_rango_descarta():
"""Edge: samples fuera de bounds invalidan la columna pese al nombre fuerte."""
columns = [{"name": "lat"}, {"name": "lon"}]
samples = {
"lat": [10.0, 95.0], # 95 > 90 -> latitud invalida
"lon": [-40.0, 50.0],
}
res = detect_latlon_columns(columns, samples)
assert res["lat_col"] is None
assert res["lon_col"] is None
assert res["confidence"] == 0.0