cfasig 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cfasig/__init__.py +122 -0
- cfasig/archivo.py +375 -0
- cfasig/campos.py +18 -0
- cfasig/cli.py +107 -0
- cfasig/geometria.py +448 -0
- cfasig/proyeccion.py +34 -0
- cfasig/py.typed +0 -0
- cfasig/raster.py +507 -0
- cfasig-0.1.0.dist-info/METADATA +242 -0
- cfasig-0.1.0.dist-info/RECORD +14 -0
- cfasig-0.1.0.dist-info/WHEEL +5 -0
- cfasig-0.1.0.dist-info/entry_points.txt +2 -0
- cfasig-0.1.0.dist-info/licenses/LICENSE +21 -0
- cfasig-0.1.0.dist-info/top_level.txt +1 -0
cfasig/__init__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cfasig — Wrapper simple sobre geopandas/shapely para tareas de SIG.
|
|
3
|
+
|
|
4
|
+
La idea es que operaciones comunes (cargar, recortar, disolver, buffer,
|
|
5
|
+
combinar, guardar...) se llamen con una sola función en español, sin
|
|
6
|
+
tener que recordar la sintaxis de geopandas cada vez.
|
|
7
|
+
|
|
8
|
+
Uso rápido:
|
|
9
|
+
|
|
10
|
+
import cfasig as sig
|
|
11
|
+
|
|
12
|
+
ejido = sig.cargar("ejido.shp")
|
|
13
|
+
hidro = sig.cargar("hidrologia.shp")
|
|
14
|
+
|
|
15
|
+
hidro = sig.reproyectar(hidro, 32613)
|
|
16
|
+
hidro = sig.recortar(hidro, ejido)
|
|
17
|
+
hidro = sig.disolver(hidro)
|
|
18
|
+
hidro = sig.buffer(hidro, 20)
|
|
19
|
+
sig.guardar(hidro, "salida.shp")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .archivo import (
|
|
23
|
+
cargar,
|
|
24
|
+
cargar_puntos,
|
|
25
|
+
guardar,
|
|
26
|
+
convertir,
|
|
27
|
+
cargar_waypoints,
|
|
28
|
+
cargar_tracks,
|
|
29
|
+
)
|
|
30
|
+
from .proyeccion import reproyectar, asegurar_crs
|
|
31
|
+
from .campos import campo_seguro
|
|
32
|
+
from .geometria import (
|
|
33
|
+
recortar,
|
|
34
|
+
disolver,
|
|
35
|
+
buffer,
|
|
36
|
+
puntos_a_linea,
|
|
37
|
+
puntos_a_poligono,
|
|
38
|
+
linea_a_poligono,
|
|
39
|
+
quitar_solape,
|
|
40
|
+
combinar,
|
|
41
|
+
crear_cuadro,
|
|
42
|
+
limpiar_vacias,
|
|
43
|
+
reparar_geometrias,
|
|
44
|
+
calcular_superficie,
|
|
45
|
+
intersectar,
|
|
46
|
+
unir_atributos,
|
|
47
|
+
calcular_longitud,
|
|
48
|
+
simplificar,
|
|
49
|
+
cerrar_microhuecos,
|
|
50
|
+
subdividir_por_area,
|
|
51
|
+
fusionar_menores,
|
|
52
|
+
)
|
|
53
|
+
from .raster import (
|
|
54
|
+
cargar_raster,
|
|
55
|
+
guardar_raster,
|
|
56
|
+
recortar_raster,
|
|
57
|
+
rellenar_nodata,
|
|
58
|
+
remuestrear,
|
|
59
|
+
interpolar_mde,
|
|
60
|
+
rasterizar,
|
|
61
|
+
quemar_cauces,
|
|
62
|
+
combinar_categorias,
|
|
63
|
+
poligonizar,
|
|
64
|
+
acondicionar_mde,
|
|
65
|
+
direccion_flujo,
|
|
66
|
+
acumulacion_flujo,
|
|
67
|
+
etiquetar_cuencas,
|
|
68
|
+
calcular_aspecto,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
# archivo
|
|
73
|
+
"cargar",
|
|
74
|
+
"cargar_puntos",
|
|
75
|
+
"guardar",
|
|
76
|
+
"convertir",
|
|
77
|
+
"cargar_waypoints",
|
|
78
|
+
"cargar_tracks",
|
|
79
|
+
# proyeccion
|
|
80
|
+
"reproyectar",
|
|
81
|
+
"asegurar_crs",
|
|
82
|
+
# campos
|
|
83
|
+
"campo_seguro",
|
|
84
|
+
# geometria
|
|
85
|
+
"recortar",
|
|
86
|
+
"disolver",
|
|
87
|
+
"buffer",
|
|
88
|
+
"puntos_a_linea",
|
|
89
|
+
"puntos_a_poligono",
|
|
90
|
+
"linea_a_poligono",
|
|
91
|
+
"quitar_solape",
|
|
92
|
+
"combinar",
|
|
93
|
+
"crear_cuadro",
|
|
94
|
+
"limpiar_vacias",
|
|
95
|
+
"reparar_geometrias",
|
|
96
|
+
"calcular_superficie",
|
|
97
|
+
"intersectar",
|
|
98
|
+
"unir_atributos",
|
|
99
|
+
"calcular_longitud",
|
|
100
|
+
"simplificar",
|
|
101
|
+
"cerrar_microhuecos",
|
|
102
|
+
"subdividir_por_area",
|
|
103
|
+
"fusionar_menores",
|
|
104
|
+
# raster
|
|
105
|
+
"cargar_raster",
|
|
106
|
+
"guardar_raster",
|
|
107
|
+
"recortar_raster",
|
|
108
|
+
"rellenar_nodata",
|
|
109
|
+
"remuestrear",
|
|
110
|
+
"interpolar_mde",
|
|
111
|
+
"rasterizar",
|
|
112
|
+
"quemar_cauces",
|
|
113
|
+
"combinar_categorias",
|
|
114
|
+
"poligonizar",
|
|
115
|
+
"acondicionar_mde",
|
|
116
|
+
"direccion_flujo",
|
|
117
|
+
"acumulacion_flujo",
|
|
118
|
+
"etiquetar_cuencas",
|
|
119
|
+
"calcular_aspecto",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
__version__ = "0.1.0"
|
cfasig/archivo.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Entrada y salida de capas (leer/escribir archivos vectoriales)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager, nullcontext
|
|
8
|
+
from os import PathLike
|
|
9
|
+
|
|
10
|
+
import geopandas as gpd
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from shapely.geometry import LineString, MultiLineString
|
|
13
|
+
from shapely.geometry.base import BaseGeometry
|
|
14
|
+
|
|
15
|
+
from .proyeccion import _exigir_crs
|
|
16
|
+
|
|
17
|
+
# Formatos que exigen WGS84 lon/lat (EPSG:4326) y driver propio de GDAL.
|
|
18
|
+
# GPX y KML/KMZ solo hablan geográficas; hay que reproyectar al exportar.
|
|
19
|
+
_DRIVERS_GEO = {".gpx": "GPX", ".kml": "LIBKML", ".kmz": "LIBKML"}
|
|
20
|
+
|
|
21
|
+
# Avisos de GDAL/pyogrio (en inglés) traducidos: fragmento a buscar -> texto ES.
|
|
22
|
+
_AVISOS_ES = {
|
|
23
|
+
"Column names longer than 10 characters": "el formato SHP recorta los nombres de columna a 10 caracteres",
|
|
24
|
+
"created as String field": "GPX guardó un campo de fecha/hora como texto (limitación del formato)",
|
|
25
|
+
"Normalized/laundered field name": "GPX renombró un campo para ajustarlo a su esquema fijo",
|
|
26
|
+
"More than one layer found": "el archivo tiene varias capas; se leyó la predeterminada (usa capa= para elegir otra)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def _traducir_avisos() -> Iterator[None]:
|
|
32
|
+
"""Captura avisos de GDAL/pyogrio y los reemite en español.
|
|
33
|
+
|
|
34
|
+
Los conocidos salen como 'Aviso: ...'; los desconocidos se reemiten tal cual
|
|
35
|
+
para no ocultar nada nuevo.
|
|
36
|
+
"""
|
|
37
|
+
with warnings.catch_warnings(record=True) as capturados:
|
|
38
|
+
warnings.simplefilter("always")
|
|
39
|
+
yield
|
|
40
|
+
for a in capturados:
|
|
41
|
+
es = next((v for k, v in _AVISOS_ES.items() if k in str(a.message)), None)
|
|
42
|
+
if es:
|
|
43
|
+
print(f"Aviso: {es}")
|
|
44
|
+
else:
|
|
45
|
+
warnings.warn(a.message, a.category)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def _env(var: str, valor: str) -> Iterator[None]:
|
|
50
|
+
"""Fija una variable de entorno solo durante el bloque y luego la restaura."""
|
|
51
|
+
previo = os.environ.get(var)
|
|
52
|
+
os.environ[var] = valor
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
if previo is None:
|
|
57
|
+
del os.environ[var]
|
|
58
|
+
else:
|
|
59
|
+
os.environ[var] = previo
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cargar(
|
|
63
|
+
ruta: str | PathLike[str], capa: str | None = None, mostrar: bool = True
|
|
64
|
+
) -> gpd.GeoDataFrame:
|
|
65
|
+
"""Carga una capa vectorial (.shp, .gpkg, .geojson, .gpx...) como GeoDataFrame.
|
|
66
|
+
|
|
67
|
+
Parámetros
|
|
68
|
+
----------
|
|
69
|
+
ruta : str
|
|
70
|
+
Ruta al archivo.
|
|
71
|
+
capa : str, opcional
|
|
72
|
+
Nombre de la capa a leer. Necesario en formatos multicapa: GPX
|
|
73
|
+
(waypoints/routes/tracks) y GeoPackage con varias capas. Si es None
|
|
74
|
+
lee la primera/única.
|
|
75
|
+
mostrar : bool
|
|
76
|
+
Si True imprime nº de entidades y CRS.
|
|
77
|
+
|
|
78
|
+
Devuelve
|
|
79
|
+
--------
|
|
80
|
+
GeoDataFrame
|
|
81
|
+
"""
|
|
82
|
+
if not os.path.exists(ruta):
|
|
83
|
+
raise FileNotFoundError(f"no existe la ruta '{ruta}'")
|
|
84
|
+
with _traducir_avisos():
|
|
85
|
+
gdf = gpd.read_file(ruta, layer=capa)
|
|
86
|
+
if mostrar:
|
|
87
|
+
print(f"Cargado '{ruta}': {len(gdf)} entidades | CRS: {gdf.crs}")
|
|
88
|
+
if len(gdf) == 0:
|
|
89
|
+
print(f"Aviso: '{ruta}' no tiene entidades")
|
|
90
|
+
return gdf
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cargar_puntos(
|
|
94
|
+
ruta: str | PathLike[str],
|
|
95
|
+
x: str = "x",
|
|
96
|
+
y: str = "y",
|
|
97
|
+
epsg: int | None = None,
|
|
98
|
+
orden: str | None = None,
|
|
99
|
+
mostrar: bool = True,
|
|
100
|
+
) -> gpd.GeoDataFrame:
|
|
101
|
+
"""Carga un CSV o Excel con columnas de coordenadas como capa de puntos.
|
|
102
|
+
|
|
103
|
+
Parámetros
|
|
104
|
+
----------
|
|
105
|
+
x, y : str
|
|
106
|
+
Nombres de las columnas con la coordenada X (este/lon) e Y (norte/lat).
|
|
107
|
+
epsg : int, opcional
|
|
108
|
+
CRS de esas coordenadas (p.ej. 32613 para UTM 13N, 4326 para lon/lat).
|
|
109
|
+
El archivo de texto no lo trae, así que se define aquí. Sin él la capa
|
|
110
|
+
queda sin CRS y no podrás reproyectar ni medir áreas.
|
|
111
|
+
orden : str, opcional
|
|
112
|
+
Columna de secuencia por la que ordenar las filas antes de armar la
|
|
113
|
+
capa. Si es None se respeta el orden del archivo. Solo importa si luego
|
|
114
|
+
conviertes los puntos a línea o polígono.
|
|
115
|
+
|
|
116
|
+
Devuelve
|
|
117
|
+
--------
|
|
118
|
+
GeoDataFrame de puntos con todas las columnas del archivo como atributos.
|
|
119
|
+
"""
|
|
120
|
+
if not os.path.exists(ruta):
|
|
121
|
+
raise FileNotFoundError(f"no existe la ruta '{ruta}'")
|
|
122
|
+
ext = os.path.splitext(str(ruta))[1].lower()
|
|
123
|
+
leer = pd.read_excel if ext in (".xlsx", ".xls") else pd.read_csv
|
|
124
|
+
df = leer(ruta)
|
|
125
|
+
for col in (x, y):
|
|
126
|
+
if col not in df.columns:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"el archivo no tiene la columna '{col}'; columnas: {list(df.columns)}"
|
|
129
|
+
)
|
|
130
|
+
if orden is not None:
|
|
131
|
+
if orden not in df.columns:
|
|
132
|
+
raise ValueError(f"el archivo no tiene la columna de orden '{orden}'")
|
|
133
|
+
df = df.sort_values(orden)
|
|
134
|
+
gdf = gpd.GeoDataFrame(
|
|
135
|
+
df,
|
|
136
|
+
geometry=gpd.points_from_xy(df[x], df[y]),
|
|
137
|
+
crs=f"EPSG:{epsg}" if epsg else None,
|
|
138
|
+
)
|
|
139
|
+
if mostrar:
|
|
140
|
+
print(f"Cargado '{ruta}': {len(gdf)} puntos | CRS: {gdf.crs}")
|
|
141
|
+
if len(gdf) == 0:
|
|
142
|
+
print(f"Aviso: '{ruta}' no tiene filas")
|
|
143
|
+
return gdf
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def guardar(
|
|
147
|
+
gdf: gpd.GeoDataFrame,
|
|
148
|
+
ruta: str | PathLike[str],
|
|
149
|
+
gpx_como: str = "track",
|
|
150
|
+
mostrar: bool = True,
|
|
151
|
+
) -> str | PathLike[str]:
|
|
152
|
+
"""Guarda un GeoDataFrame en disco.
|
|
153
|
+
|
|
154
|
+
El formato se deduce de la extensión de `ruta`. Para .gpx/.kml/.kmz
|
|
155
|
+
reproyecta automáticamente a EPSG:4326 (esos formatos solo aceptan
|
|
156
|
+
lon/lat) y usa el driver adecuado. Así, convertir es cargar + guardar:
|
|
157
|
+
|
|
158
|
+
sig.guardar(sig.cargar("predio.shp"), "predio.kmz") # SHP -> KMZ
|
|
159
|
+
sig.guardar(sig.cargar("ruta.shp"), "ruta.gpx") # SHP -> GPX
|
|
160
|
+
sig.guardar(sig.cargar("nueva.gpx", capa="tracks"), "x.shp") # GPX -> SHP
|
|
161
|
+
|
|
162
|
+
Parámetros
|
|
163
|
+
----------
|
|
164
|
+
gpx_como : "track" | "route"
|
|
165
|
+
Solo aplica a GPX y a geometrías de línea. El driver GPX decide por
|
|
166
|
+
tipo de geometría: MultiLineString -> track, LineString -> route. Con
|
|
167
|
+
"track" (por defecto, que es como trabaja el GPS aquí) se envuelven
|
|
168
|
+
las líneas como MultiLineString. Los puntos siempre van a waypoints.
|
|
169
|
+
"""
|
|
170
|
+
if len(gdf) == 0:
|
|
171
|
+
print(f"Aviso: guardando capa vacía en '{ruta}'")
|
|
172
|
+
ext = os.path.splitext(str(ruta))[1].lower()
|
|
173
|
+
carpeta = os.path.dirname(os.path.abspath(ruta))
|
|
174
|
+
os.makedirs(carpeta, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
if ext in _DRIVERS_GEO:
|
|
177
|
+
gdf = _a_geografico(gdf, ext)
|
|
178
|
+
ctx = nullcontext()
|
|
179
|
+
if ext == ".gpx":
|
|
180
|
+
# extensiones ON para no perder atributos al escribir GPX.
|
|
181
|
+
ctx = _env("GPX_USE_EXTENSIONS", "YES")
|
|
182
|
+
# GPX reserva 'fid'; un SHP sin atributos trae esa columna y revienta.
|
|
183
|
+
gdf = gdf.drop(columns=[c for c in gdf.columns if c.lower() == "fid"])
|
|
184
|
+
gdf = _poligono_a_borde(gdf)
|
|
185
|
+
if gpx_como == "track":
|
|
186
|
+
gdf = _lineas_a_track(gdf)
|
|
187
|
+
with _traducir_avisos(), ctx:
|
|
188
|
+
gdf.to_file(ruta, driver=_DRIVERS_GEO[ext])
|
|
189
|
+
else:
|
|
190
|
+
with _traducir_avisos():
|
|
191
|
+
gdf.to_file(ruta)
|
|
192
|
+
|
|
193
|
+
if mostrar:
|
|
194
|
+
print(f"Guardado '{ruta}': {len(gdf)} entidades")
|
|
195
|
+
return ruta
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def convertir(
|
|
199
|
+
entrada: str | PathLike[str],
|
|
200
|
+
salida: str | PathLike[str],
|
|
201
|
+
capa: str | None = None,
|
|
202
|
+
gpx_como: str = "track",
|
|
203
|
+
mostrar: bool = True,
|
|
204
|
+
) -> str | PathLike[str]:
|
|
205
|
+
"""Convierte un archivo vectorial de un formato a otro (cargar + guardar).
|
|
206
|
+
|
|
207
|
+
sig.convertir("ruta.shp", "ruta.gpx") # SHP -> GPX
|
|
208
|
+
sig.convertir("nueva.gpx", "nueva.shp") # GPX -> SHP
|
|
209
|
+
sig.convertir("predio.shp", "predio.kmz") # SHP -> KMZ
|
|
210
|
+
|
|
211
|
+
Si la entrada es GPX y no se indica `capa`, elige automáticamente la que
|
|
212
|
+
trae datos: un GPX de solo tracks leído "a secas" sale vacío porque la
|
|
213
|
+
capa por defecto es waypoints. `gpx_como` aplica solo a la salida GPX.
|
|
214
|
+
"""
|
|
215
|
+
if str(entrada).lower().endswith(".gpx"):
|
|
216
|
+
gdf = _cargar_gpx_limpio(entrada, capa, mostrar=mostrar)
|
|
217
|
+
else:
|
|
218
|
+
gdf = cargar(entrada, capa=capa, mostrar=mostrar)
|
|
219
|
+
return guardar(gdf, salida, gpx_como=gpx_como, mostrar=mostrar)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cargar_gpx_limpio(
|
|
223
|
+
ruta: str | PathLike[str], capa: str | None, mostrar: bool = True
|
|
224
|
+
) -> gpd.GeoDataFrame:
|
|
225
|
+
"""Carga un GPX con la tabla de atributos limpia (estilo Global Mapper).
|
|
226
|
+
|
|
227
|
+
Waypoints -> cargar_waypoints; tracks -> cargar_tracks. Si no se indica
|
|
228
|
+
`capa`, elige la que trae datos. Rutas (routes) u otras capas caen al
|
|
229
|
+
lector genérico. ponytail: solo waypoints y tracks tienen tabla limpia,
|
|
230
|
+
que son los que exporta el GPS aquí; agrega routes si algún día hace falta.
|
|
231
|
+
"""
|
|
232
|
+
if capa is None:
|
|
233
|
+
capa = _capa_gpx_con_datos(ruta)
|
|
234
|
+
if capa == "waypoints":
|
|
235
|
+
return cargar_waypoints(ruta, mostrar=mostrar)
|
|
236
|
+
if capa == "tracks":
|
|
237
|
+
return cargar_tracks(ruta, mostrar=mostrar)
|
|
238
|
+
return cargar(ruta, capa=capa, mostrar=mostrar)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _capa_gpx_con_datos(ruta: str | PathLike[str]) -> str | None:
|
|
242
|
+
"""Primera capa GPX (waypoints > routes > tracks) que trae entidades.
|
|
243
|
+
|
|
244
|
+
ponytail: si un GPX mezclara waypoints y tracks devuelve solo el primero;
|
|
245
|
+
en ese caso pasa `capa=` explícito. No ha pasado con los archivos reales.
|
|
246
|
+
"""
|
|
247
|
+
for capa in ("waypoints", "routes", "tracks"):
|
|
248
|
+
try:
|
|
249
|
+
if len(gpd.read_file(ruta, layer=capa)) > 0:
|
|
250
|
+
return capa
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _a_geografico(gdf: gpd.GeoDataFrame, ext: str) -> gpd.GeoDataFrame:
|
|
257
|
+
"""Reproyecta a EPSG:4326 si hace falta; GPX/KML/KMZ lo exigen."""
|
|
258
|
+
_exigir_crs(gdf, "capa")
|
|
259
|
+
if gdf.crs.to_epsg() != 4326:
|
|
260
|
+
print(f"Reproyectando a EPSG:4326 para exportar a {ext}")
|
|
261
|
+
return gdf.to_crs(epsg=4326)
|
|
262
|
+
return gdf
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _poligono_a_borde(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
266
|
+
"""GPX no admite polígonos; exporta su contorno como línea."""
|
|
267
|
+
if set(gdf.geom_type) & {"Polygon", "MultiPolygon"}:
|
|
268
|
+
print("GPX no admite polígonos; exporto su contorno como línea")
|
|
269
|
+
gdf = gdf.copy()
|
|
270
|
+
gdf["geometry"] = gdf.boundary
|
|
271
|
+
return gdf
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _fmt_hora_es(dt: pd.Timestamp) -> str:
|
|
275
|
+
"""Formatea a 'dd/mm/aaaa hh:mm:ss a. m./p. m.' (formato local español)."""
|
|
276
|
+
if pd.isna(dt):
|
|
277
|
+
return "" # ponytail: track sin tiempo -> campo vacío
|
|
278
|
+
h12 = ((dt.hour + 11) % 12) + 1
|
|
279
|
+
suf = "a. m." if dt.hour < 12 else "p. m."
|
|
280
|
+
return f"{dt.day:02d}/{dt.month:02d}/{dt.year} {h12:02d}:{dt.minute:02d}:{dt.second:02d} {suf}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cargar_waypoints(
|
|
284
|
+
ruta: str | PathLike[str], mostrar: bool = True
|
|
285
|
+
) -> gpd.GeoDataFrame:
|
|
286
|
+
"""Carga los waypoints de un GPX en una tabla de atributos limpia.
|
|
287
|
+
|
|
288
|
+
Columnas de salida: NAME, LAYER (constante 'Waypoint'), ELEVATION, time
|
|
289
|
+
(ISO UTC tal cual, p.ej. 2026-02-21T16:09:47Z), sym. Descarta las ~18
|
|
290
|
+
columnas vacías del esquema fijo de GPX (magvar, hdop, link*, cmt...).
|
|
291
|
+
"""
|
|
292
|
+
with _traducir_avisos():
|
|
293
|
+
gdf = gpd.read_file(ruta, layer="waypoints")
|
|
294
|
+
# ele/time pueden faltar en GPX de otras fuentes; None en vez de reventar
|
|
295
|
+
ele = gdf.get("ele")
|
|
296
|
+
t = pd.to_datetime(gdf["time"], utc=True) if "time" in gdf.columns else None
|
|
297
|
+
salida = gpd.GeoDataFrame(
|
|
298
|
+
{
|
|
299
|
+
"NAME": gdf.get("name"),
|
|
300
|
+
"LAYER": "Waypoint", # ponytail: etiqueta de capa; no viene en el GPX
|
|
301
|
+
"ELEVATION": ele.round(6) if ele is not None else None,
|
|
302
|
+
"time": t.dt.strftime("%Y-%m-%dT%H:%M:%SZ") if t is not None else None,
|
|
303
|
+
"sym": gdf.get("sym"),
|
|
304
|
+
},
|
|
305
|
+
geometry=gdf.geometry,
|
|
306
|
+
crs=gdf.crs,
|
|
307
|
+
)
|
|
308
|
+
if mostrar:
|
|
309
|
+
print(f"Waypoints '{ruta}': {len(salida)} entidades")
|
|
310
|
+
return salida
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cargar_tracks(
|
|
314
|
+
ruta: str | PathLike[str], utc_offset: int = -6, mostrar: bool = True
|
|
315
|
+
) -> gpd.GeoDataFrame:
|
|
316
|
+
"""Carga los tracks de un GPX en una tabla de atributos limpia, una linea por tramo.
|
|
317
|
+
|
|
318
|
+
Una línea por tramo (<trkseg>). Columnas: NAME, LAYER (constante
|
|
319
|
+
'Tracklog'), gpxx_DisplayColor (el SHP lo recorta a 'gpxx_Displ'),
|
|
320
|
+
START_TIME, END_TIME. Los tiempos salen del primer/último punto del tramo,
|
|
321
|
+
convertidos a hora local (`utc_offset`, por defecto -6 = Jalisco) y con
|
|
322
|
+
formato español.
|
|
323
|
+
"""
|
|
324
|
+
with _traducir_avisos():
|
|
325
|
+
try:
|
|
326
|
+
meta = gpd.read_file(ruta, layer="tracks")
|
|
327
|
+
except Exception as e:
|
|
328
|
+
# GDAL no puede armar la geometria si un <trkseg> trae 1 solo punto
|
|
329
|
+
if "point array" in str(e):
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"'{ruta}' tiene un tramo de un solo punto y no se puede leer; "
|
|
332
|
+
"borra ese tramo en el GPS o edita el GPX"
|
|
333
|
+
) from e
|
|
334
|
+
raise
|
|
335
|
+
pts = gpd.read_file(ruta, layer="track_points")
|
|
336
|
+
pts = pts.sort_values(["track_fid", "track_seg_id", "track_seg_point_id"])
|
|
337
|
+
t_local = pd.to_datetime(pts["time"], utc=True) + pd.Timedelta(hours=utc_offset)
|
|
338
|
+
pts = pts.assign(_t=t_local.values)
|
|
339
|
+
|
|
340
|
+
filas = []
|
|
341
|
+
for (fid, _seg), sub in pts.groupby(["track_fid", "track_seg_id"], sort=True):
|
|
342
|
+
coords = [(p.x, p.y) for p in sub.geometry]
|
|
343
|
+
if len(coords) < 2:
|
|
344
|
+
continue # ponytail: un tramo de 1 punto no es línea, se salta
|
|
345
|
+
trk = meta.iloc[int(fid)]
|
|
346
|
+
color = re.search(
|
|
347
|
+
r"DisplayColor>([^<]+)<", str(trk.get("gpxx_TrackExtension", ""))
|
|
348
|
+
)
|
|
349
|
+
filas.append(
|
|
350
|
+
{
|
|
351
|
+
"NAME": trk.get("name"),
|
|
352
|
+
"LAYER": "Tracklog", # ponytail: etiqueta de capa constante
|
|
353
|
+
"gpxx_DisplayColor": color.group(1) if color else None,
|
|
354
|
+
"START_TIME": _fmt_hora_es(sub["_t"].iloc[0]),
|
|
355
|
+
"END_TIME": _fmt_hora_es(sub["_t"].iloc[-1]),
|
|
356
|
+
"geometry": LineString(coords),
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
salida = gpd.GeoDataFrame(filas, crs=pts.crs)
|
|
360
|
+
if mostrar:
|
|
361
|
+
print(f"Tracks '{ruta}': {len(salida)} tramos")
|
|
362
|
+
return salida
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _lineas_a_track(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
366
|
+
"""Envuelve LineString como MultiLineString para que GPX las escriba como track."""
|
|
367
|
+
|
|
368
|
+
def a_multi(g: BaseGeometry | None) -> BaseGeometry | None:
|
|
369
|
+
return (
|
|
370
|
+
MultiLineString([g]) if g is not None and g.geom_type == "LineString" else g
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
gdf = gdf.copy()
|
|
374
|
+
gdf["geometry"] = gdf.geometry.apply(a_multi)
|
|
375
|
+
return gdf
|
cfasig/campos.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Utilidades para manejar campos (columnas) de atributos."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def campo_seguro(gdf: gpd.GeoDataFrame, campo: str) -> str:
|
|
9
|
+
"""Devuelve un nombre de campo que no choque con los existentes.
|
|
10
|
+
|
|
11
|
+
Si `campo` no existe en la capa, lo devuelve tal cual.
|
|
12
|
+
Si ya existe, devuelve una variante única (campo_ab12) para no pisar datos.
|
|
13
|
+
"""
|
|
14
|
+
if campo not in gdf.columns:
|
|
15
|
+
return campo
|
|
16
|
+
nuevo = f"{campo}_{uuid.uuid4().hex[:4]}"
|
|
17
|
+
print(f"'{campo}' ya existe, uso '{nuevo}'")
|
|
18
|
+
return nuevo
|
cfasig/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI de cfasig: convierte uno o varios archivos entre formatos SIG.
|
|
2
|
+
|
|
3
|
+
Un solo comando. ponytail: sin subcomando porque hoy solo convierte. Si algun
|
|
4
|
+
dia agregas mas operaciones (buffer, recortar...), migra a subparsers y el uso
|
|
5
|
+
pasa a `cfasig convertir ARCHIVO TIPO`. El lote es simplemente varios archivos
|
|
6
|
+
(arrastrados a la terminal); sin glob ni flags porque el usuario no tecnico los
|
|
7
|
+
arrastra, no escribe comodines.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
TIPOS = ("shp", "gpkg", "geojson", "gpx", "kml", "kmz")
|
|
15
|
+
|
|
16
|
+
AYUDA = """\
|
|
17
|
+
uso: cfasig ARCHIVO [ARCHIVO ...] TIPO
|
|
18
|
+
|
|
19
|
+
Convierte uno o varios archivos entre formatos SIG.
|
|
20
|
+
|
|
21
|
+
argumentos:
|
|
22
|
+
ARCHIVO archivo(s) a convertir. Para varios, arrastralos juntos a la
|
|
23
|
+
terminal (quedan separados por espacios) y luego escribe el TIPO.
|
|
24
|
+
TIPO formato de salida: shp, gpkg, geojson, gpx, kml, kmz
|
|
25
|
+
|
|
26
|
+
ejemplos:
|
|
27
|
+
cfasig predio.shp kmz -> crea predio.kmz
|
|
28
|
+
cfasig ruta.gpx shp -> crea ruta.shp
|
|
29
|
+
cfasig a.shp b.shp c.shp kmz -> crea a.kmz, b.kmz, c.kmz
|
|
30
|
+
|
|
31
|
+
opciones avanzadas (normalmente no hacen falta):
|
|
32
|
+
--capa NOMBRE capa a leer si el archivo trae varias (GPX/GeoPackage)
|
|
33
|
+
--gpx-como {track,route} como escribir lineas al exportar a GPX (def: track)
|
|
34
|
+
-h, --ayuda muestra esta ayuda
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Parser(argparse.ArgumentParser):
|
|
39
|
+
"""Error breve y en español en vez del volcado tecnico de argparse."""
|
|
40
|
+
|
|
41
|
+
def error(self, message: str) -> None:
|
|
42
|
+
sys.stderr.write(
|
|
43
|
+
f"Error: {message}\nEscribe 'cfasig ayuda' para ver como se usa.\n"
|
|
44
|
+
)
|
|
45
|
+
raise SystemExit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build() -> _Parser:
|
|
49
|
+
p = _Parser(add_help=False)
|
|
50
|
+
p.add_argument(
|
|
51
|
+
"archivos", nargs="+"
|
|
52
|
+
) # uno o varios; argparse deja el ultimo para tipo
|
|
53
|
+
p.add_argument("tipo")
|
|
54
|
+
p.add_argument("--capa")
|
|
55
|
+
p.add_argument(
|
|
56
|
+
"--gpx-como", dest="gpx_como", choices=("track", "route"), default="track"
|
|
57
|
+
)
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
63
|
+
|
|
64
|
+
# Sin argumentos o pidiendo ayuda: muestra la ayuda y sale.
|
|
65
|
+
if not argv or argv[0] in ("ayuda", "help", "-h", "--ayuda", "--help"):
|
|
66
|
+
print(AYUDA)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
args = _build().parse_args(argv)
|
|
70
|
+
|
|
71
|
+
tipo = args.tipo.lower().lstrip(".")
|
|
72
|
+
if tipo not in TIPOS:
|
|
73
|
+
print(f"Error: tipo '{args.tipo}' no valido. Usa uno de: {', '.join(TIPOS)}.")
|
|
74
|
+
return 2
|
|
75
|
+
|
|
76
|
+
from . import convertir # import perezoso: la ayuda no carga geopandas
|
|
77
|
+
|
|
78
|
+
ok = fallo = 0
|
|
79
|
+
for archivo in args.archivos:
|
|
80
|
+
if not os.path.exists(archivo):
|
|
81
|
+
print(f"Error: no existe el archivo '{archivo}'.")
|
|
82
|
+
fallo += 1
|
|
83
|
+
continue
|
|
84
|
+
# Salida autonombrada: misma carpeta y nombre, extension nueva.
|
|
85
|
+
salida = os.path.splitext(archivo)[0] + "." + tipo
|
|
86
|
+
if os.path.abspath(salida) == os.path.abspath(archivo):
|
|
87
|
+
print(f"Aviso: '{archivo}' ya es {tipo}, se salta.")
|
|
88
|
+
continue
|
|
89
|
+
if os.path.exists(salida):
|
|
90
|
+
resp = input(f"'{salida}' ya existe. Sobrescribir? [s/N]: ").strip().lower()
|
|
91
|
+
if resp not in ("s", "si", "sí"):
|
|
92
|
+
print("Saltado.")
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
convertir(archivo, salida, capa=args.capa, gpx_como=args.gpx_como)
|
|
96
|
+
ok += 1
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"Error convirtiendo '{archivo}': {e}")
|
|
99
|
+
fallo += 1
|
|
100
|
+
|
|
101
|
+
if len(args.archivos) > 1:
|
|
102
|
+
print(f"\nListo: {ok} convertidos, {fallo} con error.")
|
|
103
|
+
return 1 if fallo else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|