env-able 0.2.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.
- env_able/__init__.py +11 -0
- env_able/morph.py +680 -0
- env_able/vector.py +308 -0
- env_able-0.2.0.dist-info/METADATA +100 -0
- env_able-0.2.0.dist-info/RECORD +7 -0
- env_able-0.2.0.dist-info/WHEEL +4 -0
- env_able-0.2.0.dist-info/licenses/LICENSE +21 -0
env_able/__init__.py
ADDED
env_able/morph.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""
|
|
2
|
+
env_able.morph — universal format translation layer
|
|
3
|
+
|
|
4
|
+
e.morph(input_path, output_path, **kwargs)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
import geopandas as gpd
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import fiona
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Format registry ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
_EXT_TO_FORMAT: Dict[str, str] = {
|
|
24
|
+
".shp": "shp",
|
|
25
|
+
".gpkg": "gpkg",
|
|
26
|
+
".gdb": "gdb",
|
|
27
|
+
".csv": "csv",
|
|
28
|
+
".xlsx": "xlsx",
|
|
29
|
+
".xls": "xls",
|
|
30
|
+
".dbf": "dbf",
|
|
31
|
+
".geojson": "geojson",
|
|
32
|
+
".json": "json",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_FORMAT_TO_DRIVER: Dict[str, Optional[str]] = {
|
|
36
|
+
"shp": "ESRI Shapefile",
|
|
37
|
+
"gpkg": "GPKG",
|
|
38
|
+
"gdb": "OpenFileGDB",
|
|
39
|
+
"csv": None,
|
|
40
|
+
"xlsx": None,
|
|
41
|
+
"xls": None,
|
|
42
|
+
"dbf": "ESRI Shapefile",
|
|
43
|
+
"geojson": "GeoJSON",
|
|
44
|
+
"json": None,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_SPATIAL_FORMATS = {"shp", "gpkg", "gdb", "geojson"}
|
|
48
|
+
_TABULAR_FORMATS = {"csv", "xlsx", "xls", "dbf"}
|
|
49
|
+
_PANDAS_FORMATS = {"csv", "xlsx", "xls"}
|
|
50
|
+
_MULTI_LAYER_FORMATS = {"gpkg", "gdb"}
|
|
51
|
+
|
|
52
|
+
_GEOJSON_CRS = "EPSG:4326"
|
|
53
|
+
|
|
54
|
+
_WKT_COL_NAMES = {"geometry", "geom", "wkt", "shape", "geo", "wkt_geom", "the_geom"}
|
|
55
|
+
_LAT_COL_NAMES = {"lat", "latitude", "y", "ylat", "y_coord", "ycoord", "y_lat"}
|
|
56
|
+
_LON_COL_NAMES = {"lon", "long", "longitude", "x", "xlon", "x_coord", "xcoord", "x_lon"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Utilities ─────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _sanitize_name(name: str, max_len: int = 255) -> str:
|
|
62
|
+
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
|
|
63
|
+
name = name.strip(". ")
|
|
64
|
+
return name[:max_len] or "layer"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_shp_fields(columns: List[str]) -> Dict[str, str]:
|
|
68
|
+
"""Produce original → truncated mapping for shapefile 10-char field limit."""
|
|
69
|
+
seen: Dict[str, int] = {}
|
|
70
|
+
result: Dict[str, str] = {}
|
|
71
|
+
for col in columns:
|
|
72
|
+
safe = re.sub(r"[^A-Za-z0-9_]", "_", col)[:10]
|
|
73
|
+
if not safe or safe[0].isdigit():
|
|
74
|
+
safe = ("F_" + safe)[:10]
|
|
75
|
+
base = safe
|
|
76
|
+
counter = 0
|
|
77
|
+
while safe in seen and seen[safe] != col:
|
|
78
|
+
counter += 1
|
|
79
|
+
suffix = str(counter)
|
|
80
|
+
safe = base[: 10 - len(suffix)] + suffix
|
|
81
|
+
seen[safe] = col
|
|
82
|
+
if safe != col:
|
|
83
|
+
result[col] = safe
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detect_format(path: Path) -> Optional[str]:
|
|
88
|
+
return _EXT_TO_FORMAT.get(path.suffix.lower())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _list_fiona_layers(path: Path) -> List[str]:
|
|
92
|
+
try:
|
|
93
|
+
return fiona.listlayers(str(path))
|
|
94
|
+
except Exception:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _looks_like_wkt(value: Any) -> bool:
|
|
99
|
+
if not isinstance(value, str):
|
|
100
|
+
return False
|
|
101
|
+
s = value.strip().upper()
|
|
102
|
+
return any(s.startswith(k) for k in (
|
|
103
|
+
"POINT", "LINESTRING", "POLYGON", "MULTIPOINT",
|
|
104
|
+
"MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION",
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _looks_like_lat(series: pd.Series) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
num = pd.to_numeric(series.dropna(), errors="coerce").dropna()
|
|
111
|
+
return len(num) > 0 and num.between(-90, 90).all()
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _looks_like_lon(series: pd.Series) -> bool:
|
|
117
|
+
try:
|
|
118
|
+
num = pd.to_numeric(series.dropna(), errors="coerce").dropna()
|
|
119
|
+
return len(num) > 0 and num.between(-180, 180).all()
|
|
120
|
+
except Exception:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Smart geometry detection ──────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class _GeomHint:
|
|
128
|
+
kind: str # "wkt" | "xy" | "none"
|
|
129
|
+
wkt_col: Optional[str] = None
|
|
130
|
+
x_col: Optional[str] = None
|
|
131
|
+
y_col: Optional[str] = None
|
|
132
|
+
confidence: str = "low" # "high" | "medium" | "low"
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def found(self) -> bool:
|
|
136
|
+
return self.kind != "none"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _detect_geometry(df: pd.DataFrame) -> _GeomHint:
|
|
140
|
+
"""
|
|
141
|
+
Scan a DataFrame for geometry columns without user hints.
|
|
142
|
+
|
|
143
|
+
Priority:
|
|
144
|
+
1. Column name matches known WKT names → confirm by sampling values
|
|
145
|
+
2. Column name matches known lat/lon names → confirm by value range
|
|
146
|
+
3. Any column whose values parse as WKT
|
|
147
|
+
4. Any numeric pair that looks like lat/lon by value range
|
|
148
|
+
"""
|
|
149
|
+
cols_lower = {c: c.lower().strip() for c in df.columns}
|
|
150
|
+
|
|
151
|
+
# ── pass 1: name-based WKT match ─────────────────────────────────────────
|
|
152
|
+
for col, lower in cols_lower.items():
|
|
153
|
+
if lower in _WKT_COL_NAMES:
|
|
154
|
+
sample = df[col].dropna().iloc[0] if not df[col].dropna().empty else None
|
|
155
|
+
if sample and _looks_like_wkt(sample):
|
|
156
|
+
return _GeomHint(kind="wkt", wkt_col=col, confidence="high")
|
|
157
|
+
|
|
158
|
+
# ── pass 2: name-based lat/lon match ─────────────────────────────────────
|
|
159
|
+
lat_col = next((c for c, l in cols_lower.items() if l in _LAT_COL_NAMES), None)
|
|
160
|
+
lon_col = next((c for c, l in cols_lower.items() if l in _LON_COL_NAMES), None)
|
|
161
|
+
if lat_col and lon_col:
|
|
162
|
+
return _GeomHint(kind="xy", x_col=lon_col, y_col=lat_col, confidence="high")
|
|
163
|
+
|
|
164
|
+
# ── pass 3: value-based WKT scan ─────────────────────────────────────────
|
|
165
|
+
for col in df.columns:
|
|
166
|
+
sample = df[col].dropna().iloc[0] if not df[col].dropna().empty else None
|
|
167
|
+
if sample and _looks_like_wkt(sample):
|
|
168
|
+
return _GeomHint(kind="wkt", wkt_col=col, confidence="medium")
|
|
169
|
+
|
|
170
|
+
# ── pass 4: value-based lat/lon scan ─────────────────────────────────────
|
|
171
|
+
numeric_cols = df.select_dtypes(include="number").columns.tolist()
|
|
172
|
+
lat_candidates = [c for c in numeric_cols if _looks_like_lat(df[c])]
|
|
173
|
+
lon_candidates = [c for c in numeric_cols if _looks_like_lon(df[c])]
|
|
174
|
+
|
|
175
|
+
if lat_candidates and lon_candidates:
|
|
176
|
+
y = lat_candidates[0]
|
|
177
|
+
x = next((c for c in lon_candidates if c != y), None)
|
|
178
|
+
if x:
|
|
179
|
+
return _GeomHint(kind="xy", x_col=x, y_col=y, confidence="medium")
|
|
180
|
+
|
|
181
|
+
return _GeomHint(kind="none")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── InputSpec ─────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class InputSpec:
|
|
188
|
+
path: Path
|
|
189
|
+
format: str
|
|
190
|
+
driver: Optional[str]
|
|
191
|
+
is_spatial: bool
|
|
192
|
+
is_multi_layer: bool
|
|
193
|
+
layers: List[str]
|
|
194
|
+
raw_path: str
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_path(cls, raw: str) -> "InputSpec":
|
|
198
|
+
path = Path(raw)
|
|
199
|
+
|
|
200
|
+
if not path.exists():
|
|
201
|
+
raise FileNotFoundError(
|
|
202
|
+
f"env-able | morph: input not found — '{raw}'\n"
|
|
203
|
+
f" Check the path and make sure the file or folder exists."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
fmt = _detect_format(path)
|
|
207
|
+
if fmt is None:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"env-able | morph: unrecognised input format for '{raw}'.\n"
|
|
210
|
+
f" Supported: shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
driver = _FORMAT_TO_DRIVER.get(fmt)
|
|
214
|
+
is_spatial = fmt in _SPATIAL_FORMATS
|
|
215
|
+
is_multi = fmt in _MULTI_LAYER_FORMATS
|
|
216
|
+
|
|
217
|
+
layers: List[str] = []
|
|
218
|
+
if is_multi:
|
|
219
|
+
layers = _list_fiona_layers(path)
|
|
220
|
+
if not layers:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"env-able | morph: no layers found in '{raw}'.\n"
|
|
223
|
+
f" The file may be empty or corrupted."
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
layers = [path.stem]
|
|
227
|
+
|
|
228
|
+
if fmt == "json":
|
|
229
|
+
is_spatial = _json_is_geojson(path)
|
|
230
|
+
|
|
231
|
+
return cls(
|
|
232
|
+
path=path, format=fmt, driver=driver,
|
|
233
|
+
is_spatial=is_spatial, is_multi_layer=is_multi,
|
|
234
|
+
layers=layers, raw_path=raw,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _json_is_geojson(path: Path) -> bool:
|
|
239
|
+
try:
|
|
240
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
241
|
+
data = json.load(f)
|
|
242
|
+
return isinstance(data, dict) and data.get("type") in (
|
|
243
|
+
"FeatureCollection", "Feature",
|
|
244
|
+
"Point", "LineString", "Polygon",
|
|
245
|
+
"MultiPoint", "MultiLineString", "MultiPolygon",
|
|
246
|
+
)
|
|
247
|
+
except Exception:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── OutputSpec ────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class OutputSpec:
|
|
255
|
+
path: Path
|
|
256
|
+
format: str
|
|
257
|
+
driver: Optional[str]
|
|
258
|
+
is_spatial: bool
|
|
259
|
+
is_directory: bool
|
|
260
|
+
multiple_outputs: bool
|
|
261
|
+
layer_name: str
|
|
262
|
+
name: str
|
|
263
|
+
overwrite: bool = True
|
|
264
|
+
crs: Optional[str] = None
|
|
265
|
+
epsg: Optional[int] = None
|
|
266
|
+
geometry_type: Optional[str] = None
|
|
267
|
+
has_z: bool = False
|
|
268
|
+
has_m: bool = False
|
|
269
|
+
raw_path: str = ""
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def from_path(cls, raw: str, inp: InputSpec, **kwargs) -> "OutputSpec":
|
|
273
|
+
path = Path(raw)
|
|
274
|
+
is_dir = raw.endswith("/") or raw.endswith("\\") or not path.suffix
|
|
275
|
+
|
|
276
|
+
if is_dir:
|
|
277
|
+
multiple = inp.is_multi_layer or len(inp.layers) > 1
|
|
278
|
+
return cls(
|
|
279
|
+
path=path, format="dir", driver=None,
|
|
280
|
+
is_spatial=inp.is_spatial, is_directory=True,
|
|
281
|
+
multiple_outputs=multiple, layer_name="", name=path.name,
|
|
282
|
+
raw_path=raw, crs=kwargs.get("crs"),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
fmt = _detect_format(path)
|
|
286
|
+
if fmt is None:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"env-able | morph: unrecognised output format for '{raw}'.\n"
|
|
289
|
+
f" Supported: shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
driver = _FORMAT_TO_DRIVER.get(fmt)
|
|
293
|
+
is_spatial = fmt in _SPATIAL_FORMATS
|
|
294
|
+
|
|
295
|
+
parts = path.stem.split(".")
|
|
296
|
+
if len(parts) >= 2:
|
|
297
|
+
name = _sanitize_name(parts[0])
|
|
298
|
+
layer_name = _sanitize_name(parts[-1])
|
|
299
|
+
else:
|
|
300
|
+
name = _sanitize_name(path.stem)
|
|
301
|
+
layer_name = name
|
|
302
|
+
|
|
303
|
+
multiple = (
|
|
304
|
+
(inp.is_multi_layer or len(inp.layers) > 1)
|
|
305
|
+
and fmt not in _MULTI_LAYER_FORMATS
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
crs = kwargs.get("crs")
|
|
309
|
+
if fmt == "geojson" and not crs:
|
|
310
|
+
crs = _GEOJSON_CRS
|
|
311
|
+
|
|
312
|
+
epsg: Optional[int] = None
|
|
313
|
+
if crs and str(crs).upper().startswith("EPSG:"):
|
|
314
|
+
try:
|
|
315
|
+
epsg = int(str(crs).split(":")[1])
|
|
316
|
+
except ValueError:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
return cls(
|
|
320
|
+
path=path, format=fmt, driver=driver,
|
|
321
|
+
is_spatial=is_spatial, is_directory=False,
|
|
322
|
+
multiple_outputs=multiple, layer_name=layer_name, name=name,
|
|
323
|
+
raw_path=raw, crs=crs, epsg=epsg,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── Reader ────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
class _Reader:
|
|
330
|
+
def __init__(self, spec: InputSpec):
|
|
331
|
+
self.spec = spec
|
|
332
|
+
|
|
333
|
+
def spatial(self, layer: Optional[str] = None) -> gpd.GeoDataFrame:
|
|
334
|
+
s = self.spec
|
|
335
|
+
if s.format in {"shp", "dbf"}:
|
|
336
|
+
return gpd.read_file(str(s.path))
|
|
337
|
+
if s.format in {"gpkg", "gdb"}:
|
|
338
|
+
return gpd.read_file(str(s.path), layer=layer)
|
|
339
|
+
if s.format in {"geojson", "json"}:
|
|
340
|
+
return gpd.read_file(str(s.path))
|
|
341
|
+
raise ValueError(f"env-able | morph: cannot read '{s.format}' as spatial.")
|
|
342
|
+
|
|
343
|
+
def tabular(self) -> pd.DataFrame:
|
|
344
|
+
s = self.spec
|
|
345
|
+
if s.format == "csv":
|
|
346
|
+
return pd.read_csv(str(s.path))
|
|
347
|
+
if s.format == "xlsx":
|
|
348
|
+
return pd.read_excel(str(s.path), engine="openpyxl")
|
|
349
|
+
if s.format == "xls":
|
|
350
|
+
return pd.read_excel(str(s.path), engine="xlrd")
|
|
351
|
+
if s.format == "dbf":
|
|
352
|
+
try:
|
|
353
|
+
import dbfread
|
|
354
|
+
return pd.DataFrame(iter(dbfread.DBF(str(s.path))))
|
|
355
|
+
except ImportError:
|
|
356
|
+
gdf = gpd.read_file(str(s.path))
|
|
357
|
+
return pd.DataFrame(gdf.drop(columns="geometry", errors="ignore"))
|
|
358
|
+
if s.format == "json":
|
|
359
|
+
return pd.read_json(str(s.path))
|
|
360
|
+
raise ValueError(f"env-able | morph: cannot read '{s.format}' as tabular.")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── Writer ────────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
class _Writer:
|
|
366
|
+
def __init__(self, spec: OutputSpec):
|
|
367
|
+
self.spec = spec
|
|
368
|
+
|
|
369
|
+
def _ensure_dir(self, path: Path):
|
|
370
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
|
|
372
|
+
def _apply_crs(self, gdf: gpd.GeoDataFrame, fmt: Optional[str] = None) -> gpd.GeoDataFrame:
|
|
373
|
+
target_crs = self.spec.crs
|
|
374
|
+
if fmt == "geojson":
|
|
375
|
+
target_crs = _GEOJSON_CRS
|
|
376
|
+
if not target_crs:
|
|
377
|
+
return gdf
|
|
378
|
+
if gdf.crs is None:
|
|
379
|
+
return gdf.set_crs(target_crs)
|
|
380
|
+
if str(gdf.crs) != str(target_crs):
|
|
381
|
+
return gdf.to_crs(target_crs)
|
|
382
|
+
return gdf
|
|
383
|
+
|
|
384
|
+
def _fix_shp_fields(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
385
|
+
non_geom = [c for c in gdf.columns if c != "geometry"]
|
|
386
|
+
renames = _safe_shp_fields(non_geom)
|
|
387
|
+
if renames:
|
|
388
|
+
warnings.warn(
|
|
389
|
+
"env-able | morph: shapefile field names truncated to 10 chars:\n"
|
|
390
|
+
+ "\n".join(f" '{k}' → '{v}'" for k, v in renames.items()),
|
|
391
|
+
UserWarning, stacklevel=5,
|
|
392
|
+
)
|
|
393
|
+
gdf = gdf.rename(columns=renames)
|
|
394
|
+
return gdf
|
|
395
|
+
|
|
396
|
+
def spatial(self, gdf: gpd.GeoDataFrame, dest: Path, layer: str):
|
|
397
|
+
fmt = _detect_format(dest) if self.spec.is_directory else self.spec.format
|
|
398
|
+
gdf = self._apply_crs(gdf, fmt)
|
|
399
|
+
self._ensure_dir(dest.parent)
|
|
400
|
+
|
|
401
|
+
if fmt == "shp":
|
|
402
|
+
gdf = self._fix_shp_fields(gdf)
|
|
403
|
+
gdf.to_file(str(dest), driver="ESRI Shapefile")
|
|
404
|
+
|
|
405
|
+
elif fmt == "gpkg":
|
|
406
|
+
gdf.to_file(str(dest), layer=layer, driver="GPKG")
|
|
407
|
+
|
|
408
|
+
elif fmt == "gdb":
|
|
409
|
+
try:
|
|
410
|
+
gdf.to_file(str(dest), layer=layer, driver="OpenFileGDB")
|
|
411
|
+
except Exception as e:
|
|
412
|
+
raise IOError(
|
|
413
|
+
f"env-able | morph: GDB write failed for '{dest}'.\n"
|
|
414
|
+
f" Your GDAL build may not support GDB creation.\n"
|
|
415
|
+
f" Try outputting to GPKG instead.\n"
|
|
416
|
+
f" Original error: {e}"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
elif fmt in {"geojson", "json"}:
|
|
420
|
+
gdf.to_file(str(dest), driver="GeoJSON")
|
|
421
|
+
|
|
422
|
+
else:
|
|
423
|
+
raise ValueError(
|
|
424
|
+
f"env-able | morph: cannot write spatial data to '{fmt}'.\n"
|
|
425
|
+
f" Spatial output formats: shp, gpkg, gdb, geojson, json"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def tabular(self, df: pd.DataFrame, dest: Path):
|
|
429
|
+
fmt = _detect_format(dest) if self.spec.is_directory else self.spec.format
|
|
430
|
+
self._ensure_dir(dest.parent)
|
|
431
|
+
|
|
432
|
+
if fmt == "csv":
|
|
433
|
+
df.to_csv(str(dest), index=False)
|
|
434
|
+
elif fmt == "xlsx":
|
|
435
|
+
df.to_excel(str(dest), index=False, engine="openpyxl")
|
|
436
|
+
elif fmt == "xls":
|
|
437
|
+
warnings.warn(
|
|
438
|
+
"env-able | morph: .xls is a legacy format — consider .xlsx.",
|
|
439
|
+
UserWarning, stacklevel=5,
|
|
440
|
+
)
|
|
441
|
+
df.to_excel(str(dest), index=False)
|
|
442
|
+
elif fmt in {"json", "geojson"}:
|
|
443
|
+
df.to_json(str(dest), orient="records", indent=2)
|
|
444
|
+
elif fmt == "dbf":
|
|
445
|
+
raise NotImplementedError(
|
|
446
|
+
"env-able | morph: writing standalone .dbf is not supported.\n"
|
|
447
|
+
" Try CSV or XLSX instead."
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
raise ValueError(f"env-able | morph: cannot write tabular data to '{fmt}'.")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── MorphEngine ───────────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
class MorphEngine:
|
|
456
|
+
def __init__(self, inp: InputSpec, out: OutputSpec, kwargs: Dict[str, Any]):
|
|
457
|
+
self.inp = inp
|
|
458
|
+
self.out = out
|
|
459
|
+
self.kwargs = kwargs
|
|
460
|
+
self.reader = _Reader(inp)
|
|
461
|
+
self.writer = _Writer(out)
|
|
462
|
+
|
|
463
|
+
def _resolve_output(self, layer: str) -> Tuple[Path, str]:
|
|
464
|
+
out = self.out
|
|
465
|
+
safe = _sanitize_name(layer)
|
|
466
|
+
|
|
467
|
+
if out.is_directory:
|
|
468
|
+
ext = ".gpkg" if self.inp.is_spatial else ".csv"
|
|
469
|
+
return out.path / f"{safe}{ext}", safe
|
|
470
|
+
|
|
471
|
+
if out.format in _MULTI_LAYER_FORMATS:
|
|
472
|
+
return out.path, safe
|
|
473
|
+
|
|
474
|
+
if out.multiple_outputs:
|
|
475
|
+
return out.path.parent / f"{safe}{out.path.suffix}", safe
|
|
476
|
+
|
|
477
|
+
return out.path, out.layer_name or safe
|
|
478
|
+
|
|
479
|
+
def _build_geometry(self, df: pd.DataFrame) -> gpd.GeoDataFrame:
|
|
480
|
+
x_col = self.kwargs.get("x_col")
|
|
481
|
+
y_col = self.kwargs.get("y_col")
|
|
482
|
+
wkt_col = self.kwargs.get("wkt_col")
|
|
483
|
+
crs = self.kwargs.get("crs") or self.out.crs
|
|
484
|
+
|
|
485
|
+
if wkt_col or (x_col and y_col):
|
|
486
|
+
return self._build_from_explicit(df, x_col, y_col, wkt_col, crs)
|
|
487
|
+
|
|
488
|
+
hint = _detect_geometry(df)
|
|
489
|
+
|
|
490
|
+
if not hint.found:
|
|
491
|
+
raise ValueError(
|
|
492
|
+
"env-able | morph: could not find geometry columns in the source data.\n"
|
|
493
|
+
" Scanned for common WKT and lat/lon column names — none matched.\n"
|
|
494
|
+
" Provide explicit columns:\n"
|
|
495
|
+
" e.morph(..., x_col='LON', y_col='LAT', crs='EPSG:4326')\n"
|
|
496
|
+
" e.morph(..., wkt_col='GEOMETRY', crs='EPSG:4326')"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
confidence_note = "" if hint.confidence == "high" else (
|
|
500
|
+
f"\n Confidence: {hint.confidence} — verify the output looks correct."
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if hint.kind == "wkt":
|
|
504
|
+
print(f"env-able | morph: auto-detected WKT column '{hint.wkt_col}'.{confidence_note}")
|
|
505
|
+
return self._build_from_explicit(df, None, None, hint.wkt_col, crs)
|
|
506
|
+
|
|
507
|
+
if hint.kind == "xy":
|
|
508
|
+
print(
|
|
509
|
+
f"env-able | morph: auto-detected coordinate columns "
|
|
510
|
+
f"x='{hint.x_col}' y='{hint.y_col}'.{confidence_note}"
|
|
511
|
+
)
|
|
512
|
+
return self._build_from_explicit(df, hint.x_col, hint.y_col, None, crs)
|
|
513
|
+
|
|
514
|
+
raise ValueError("env-able | morph: geometry detection failed unexpectedly.")
|
|
515
|
+
|
|
516
|
+
def _build_from_explicit(
|
|
517
|
+
self,
|
|
518
|
+
df: pd.DataFrame,
|
|
519
|
+
x_col: Optional[str],
|
|
520
|
+
y_col: Optional[str],
|
|
521
|
+
wkt_col: Optional[str],
|
|
522
|
+
crs: Optional[str],
|
|
523
|
+
) -> gpd.GeoDataFrame:
|
|
524
|
+
if wkt_col:
|
|
525
|
+
if wkt_col not in df.columns:
|
|
526
|
+
raise ValueError(
|
|
527
|
+
f"env-able | morph: wkt_col '{wkt_col}' not found.\n"
|
|
528
|
+
f" Available columns: {list(df.columns)}"
|
|
529
|
+
)
|
|
530
|
+
from shapely import wkt as swkt
|
|
531
|
+
gdf = gpd.GeoDataFrame(df, geometry=df[wkt_col].apply(swkt.loads))
|
|
532
|
+
|
|
533
|
+
elif x_col and y_col:
|
|
534
|
+
for col in (x_col, y_col):
|
|
535
|
+
if col not in df.columns:
|
|
536
|
+
raise ValueError(
|
|
537
|
+
f"env-able | morph: column '{col}' not found.\n"
|
|
538
|
+
f" Available columns: {list(df.columns)}"
|
|
539
|
+
)
|
|
540
|
+
gdf = gpd.GeoDataFrame(
|
|
541
|
+
df, geometry=gpd.points_from_xy(df[x_col], df[y_col])
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
raise ValueError(
|
|
545
|
+
"env-able | morph: provide x_col + y_col or wkt_col to build geometry."
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if not crs:
|
|
549
|
+
raise ValueError(
|
|
550
|
+
"env-able | morph: crs is required when building geometry from tabular data.\n"
|
|
551
|
+
" Example: e.morph('data.csv', 'data.shp', crs='EPSG:4326')\n"
|
|
552
|
+
" If you're not sure, EPSG:4326 (WGS84) is a safe starting point for lat/lon data."
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
return gdf.set_crs(crs)
|
|
556
|
+
|
|
557
|
+
def run(self) -> List[str]:
|
|
558
|
+
inp, out = self.inp, self.out
|
|
559
|
+
outputs: List[str] = []
|
|
560
|
+
|
|
561
|
+
# tabular → tabular
|
|
562
|
+
if inp.format in _TABULAR_FORMATS and out.format in _TABULAR_FORMATS | {"json"}:
|
|
563
|
+
df = self.reader.tabular()
|
|
564
|
+
dest, _ = self._resolve_output(inp.path.stem)
|
|
565
|
+
self.writer.tabular(df, dest)
|
|
566
|
+
print(f"env-able | morph: {inp.raw_path} → {dest} ({len(df)} rows)")
|
|
567
|
+
return [str(dest)]
|
|
568
|
+
|
|
569
|
+
# tabular → spatial
|
|
570
|
+
if inp.format in _TABULAR_FORMATS and (out.format in _SPATIAL_FORMATS or out.is_directory):
|
|
571
|
+
df = self.reader.tabular()
|
|
572
|
+
gdf = self._build_geometry(df)
|
|
573
|
+
dest, layer = self._resolve_output(out.layer_name or inp.path.stem)
|
|
574
|
+
self.writer.spatial(gdf, dest, layer)
|
|
575
|
+
print(f"env-able | morph: {inp.raw_path} → {dest} ({len(gdf)} features, {gdf.crs})")
|
|
576
|
+
return [str(dest)]
|
|
577
|
+
|
|
578
|
+
# JSON → depends on content
|
|
579
|
+
if inp.format == "json":
|
|
580
|
+
if inp.is_spatial:
|
|
581
|
+
gdf = self.reader.spatial()
|
|
582
|
+
dest, layer = self._resolve_output(inp.path.stem)
|
|
583
|
+
self.writer.spatial(gdf, dest, layer)
|
|
584
|
+
print(f"env-able | morph: {inp.raw_path} → {dest} ({len(gdf)} features)")
|
|
585
|
+
else:
|
|
586
|
+
df = self.reader.tabular()
|
|
587
|
+
if out.format in _SPATIAL_FORMATS or out.is_directory:
|
|
588
|
+
gdf = self._build_geometry(df)
|
|
589
|
+
dest, layer = self._resolve_output(inp.path.stem)
|
|
590
|
+
self.writer.spatial(gdf, dest, layer)
|
|
591
|
+
print(f"env-able | morph: {inp.raw_path} → {dest} ({len(gdf)} features)")
|
|
592
|
+
else:
|
|
593
|
+
dest, _ = self._resolve_output(inp.path.stem)
|
|
594
|
+
self.writer.tabular(df, dest)
|
|
595
|
+
print(f"env-able | morph: {inp.raw_path} → {dest} ({len(df)} rows)")
|
|
596
|
+
return [str(dest)]
|
|
597
|
+
|
|
598
|
+
# spatial → tabular (geometry dropped)
|
|
599
|
+
if inp.is_spatial and out.format in _TABULAR_FORMATS | {"json"}:
|
|
600
|
+
layer = inp.layers[0] if inp.layers else None
|
|
601
|
+
gdf = self.reader.spatial(layer)
|
|
602
|
+
df = pd.DataFrame(gdf.drop(columns="geometry", errors="ignore"))
|
|
603
|
+
if inp.is_multi_layer and len(inp.layers) > 1:
|
|
604
|
+
warnings.warn(
|
|
605
|
+
f"env-able | morph: source has {len(inp.layers)} layers but "
|
|
606
|
+
f"'{out.format}' is single-table. Only '{layer}' was exported.\n"
|
|
607
|
+
f" Use a directory output (trailing slash) to export all layers.",
|
|
608
|
+
UserWarning, stacklevel=3,
|
|
609
|
+
)
|
|
610
|
+
self.writer.tabular(df, out.path)
|
|
611
|
+
print(f"env-able | morph: {inp.raw_path} → {out.path} ({len(df)} rows, geometry dropped)")
|
|
612
|
+
return [str(out.path)]
|
|
613
|
+
|
|
614
|
+
# spatial → spatial
|
|
615
|
+
if inp.is_spatial and (out.format in _SPATIAL_FORMATS or out.is_directory):
|
|
616
|
+
layers = inp.layers if inp.layers else [None]
|
|
617
|
+
|
|
618
|
+
for layer in layers:
|
|
619
|
+
gdf = self.reader.spatial(layer)
|
|
620
|
+
|
|
621
|
+
if gdf.empty:
|
|
622
|
+
warnings.warn(
|
|
623
|
+
f"env-able | morph: layer '{layer}' has no features — skipping.",
|
|
624
|
+
UserWarning, stacklevel=3,
|
|
625
|
+
)
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
dest, out_layer = self._resolve_output(layer or inp.path.stem)
|
|
629
|
+
self.writer.spatial(gdf, dest, out_layer)
|
|
630
|
+
outputs.append(str(dest))
|
|
631
|
+
print(f"env-able | morph: [{layer}] → {dest} ({len(gdf)} features, {gdf.crs})")
|
|
632
|
+
|
|
633
|
+
return outputs
|
|
634
|
+
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f"env-able | morph: no conversion path from '{inp.format}' to '{out.format}'.\n"
|
|
637
|
+
f" This combination may not be supported yet."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
def morph(input_path: str, output_path: str, **kwargs) -> List[str]:
|
|
644
|
+
"""
|
|
645
|
+
Intelligently convert one spatial or tabular data source to another.
|
|
646
|
+
|
|
647
|
+
Supported formats: shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json
|
|
648
|
+
|
|
649
|
+
Parameters
|
|
650
|
+
----------
|
|
651
|
+
input_path : str
|
|
652
|
+
Path to the source file or geodatabase.
|
|
653
|
+
output_path : str
|
|
654
|
+
Desired output path. Extension determines the format.
|
|
655
|
+
Trailing slash → directory output (one file per layer).
|
|
656
|
+
Dot notation → named layer: "roads.parcels.gpkg"
|
|
657
|
+
**kwargs
|
|
658
|
+
x_col : str — X/longitude column (auto-detected if not provided)
|
|
659
|
+
y_col : str — Y/latitude column (auto-detected if not provided)
|
|
660
|
+
wkt_col : str — WKT geometry column (auto-detected if not provided)
|
|
661
|
+
crs : str — coordinate system e.g. 'EPSG:4326' (required for tabular → spatial)
|
|
662
|
+
|
|
663
|
+
Returns
|
|
664
|
+
-------
|
|
665
|
+
list[str] — output file paths written
|
|
666
|
+
|
|
667
|
+
Examples
|
|
668
|
+
--------
|
|
669
|
+
e.morph("roads.shp", "roads.gpkg")
|
|
670
|
+
e.morph("county.gdb", "county.gpkg")
|
|
671
|
+
e.morph("county.gdb", "output_folder/")
|
|
672
|
+
e.morph("data.csv", "data.geojson", crs="EPSG:4326")
|
|
673
|
+
e.morph("data.csv", "data.shp", x_col="LON", y_col="LAT", crs="EPSG:4269")
|
|
674
|
+
e.morph("roads.gpkg", "roads.parcels.gpkg")
|
|
675
|
+
e.morph("data.json", "data.gpkg")
|
|
676
|
+
"""
|
|
677
|
+
inp = InputSpec.from_path(input_path)
|
|
678
|
+
out = OutputSpec.from_path(output_path, inp, **kwargs)
|
|
679
|
+
engine = MorphEngine(inp, out, kwargs)
|
|
680
|
+
return engine.run()
|
env_able/vector.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import geopandas as gpd
|
|
3
|
+
from shapely.validation import make_valid
|
|
4
|
+
|
|
5
|
+
# Unit conversion to meters
|
|
6
|
+
_UNITS = {
|
|
7
|
+
"meters": 1.0,
|
|
8
|
+
"metres": 1.0,
|
|
9
|
+
"m": 1.0,
|
|
10
|
+
"kilometers": 1000.0,
|
|
11
|
+
"kilometres": 1000.0,
|
|
12
|
+
"km": 1000.0,
|
|
13
|
+
"miles": 1609.344,
|
|
14
|
+
"mi": 1609.344,
|
|
15
|
+
"feet": 0.3048,
|
|
16
|
+
"ft": 0.3048,
|
|
17
|
+
"usfeet": 0.30480060960121924,
|
|
18
|
+
"us_feet": 0.30480060960121924,
|
|
19
|
+
"us feet": 0.30480060960121924,
|
|
20
|
+
"nautical miles": 1852.0,
|
|
21
|
+
"nm": 1852.0,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load(item, label):
|
|
26
|
+
if isinstance(item, gpd.GeoDataFrame):
|
|
27
|
+
return item
|
|
28
|
+
if not os.path.exists(item):
|
|
29
|
+
raise FileNotFoundError(
|
|
30
|
+
f"env-able | {label}: file not found — '{item}'\n"
|
|
31
|
+
f" Check the path and make sure the file exists."
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
gdf = gpd.read_file(item)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"env-able | {label}: could not read '{item}'\n"
|
|
38
|
+
f" Is it a valid shapefile, GeoPackage, or GeoJSON?\n"
|
|
39
|
+
f" Original error: {e}"
|
|
40
|
+
)
|
|
41
|
+
if gdf.empty:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"env-able | {label}: '{item}' loaded but contains no features."
|
|
44
|
+
)
|
|
45
|
+
return gdf
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fix_geometries(gdf, label):
|
|
49
|
+
invalid = ~gdf.geometry.is_valid
|
|
50
|
+
if invalid.any():
|
|
51
|
+
count = invalid.sum()
|
|
52
|
+
print(f"env-able | warning | {label}: {count} invalid geometry(s) found — auto-fixing.")
|
|
53
|
+
gdf = gdf.copy()
|
|
54
|
+
gdf.geometry = gdf.geometry.apply(lambda g: make_valid(g) if not g.is_valid else g)
|
|
55
|
+
return gdf
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_overlap(gdf1, gdf2):
|
|
59
|
+
b1 = gdf1.total_bounds
|
|
60
|
+
b2 = gdf2.total_bounds
|
|
61
|
+
if b1[2] < b2[0] or b1[0] > b2[2] or b1[3] < b2[1] or b1[1] > b2[3]:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"env-able | intersect: layers do not spatially overlap.\n"
|
|
64
|
+
f" Layer 1 extent: {b1}\n"
|
|
65
|
+
f" Layer 2 extent: {b2}\n"
|
|
66
|
+
f" Make sure both layers cover the same geographic area."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def Intersect(input_layer, intersect_layer, output=None):
|
|
71
|
+
"""
|
|
72
|
+
Computes geometric intersection of input features against a polygon layer.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
input_layer : str or GeoDataFrame
|
|
77
|
+
Point, line, or polygon layer to intersect.
|
|
78
|
+
intersect_layer : str or GeoDataFrame
|
|
79
|
+
Polygon layer to intersect against. Must be polygon geometry.
|
|
80
|
+
output : str, optional
|
|
81
|
+
File path to write the result (e.g. "result.gpkg", "result.shp").
|
|
82
|
+
If None, returns a GeoDataFrame.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
GeoDataFrame clipped to the intersection.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
input_layer = _load(input_layer, "input_layer")
|
|
90
|
+
intersect_layer = _load(intersect_layer, "intersect_layer")
|
|
91
|
+
|
|
92
|
+
# CRS checks
|
|
93
|
+
for label, lyr in [("input_layer", input_layer), ("intersect_layer", intersect_layer)]:
|
|
94
|
+
if lyr.crs is None:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"env-able | Intersect: {label} has no CRS defined.\n"
|
|
97
|
+
f" Set a CRS before running Intersect.\n"
|
|
98
|
+
f" Example: gdf = gdf.set_crs('EPSG:4326')"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# intersect layer must be polygon
|
|
102
|
+
intersect_types = set(intersect_layer.geometry.geom_type.unique())
|
|
103
|
+
if not intersect_types.issubset({"Polygon", "MultiPolygon"}):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"env-able | Intersect: intersect_layer must be polygon geometry.\n"
|
|
106
|
+
f" Got: {intersect_types}\n"
|
|
107
|
+
f" The intersect layer defines the boundary — it must be a polygon."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# reproject intersect layer to match input if needed
|
|
111
|
+
if intersect_layer.crs != input_layer.crs:
|
|
112
|
+
print(
|
|
113
|
+
f"env-able | warning | Intersect: CRS mismatch detected.\n"
|
|
114
|
+
f" input_layer CRS : {input_layer.crs}\n"
|
|
115
|
+
f" intersect_layer CRS : {intersect_layer.crs}\n"
|
|
116
|
+
f" Auto-reprojecting intersect_layer to match input."
|
|
117
|
+
)
|
|
118
|
+
intersect_layer = intersect_layer.to_crs(input_layer.crs)
|
|
119
|
+
|
|
120
|
+
# fix invalid geometries
|
|
121
|
+
input_layer = _fix_geometries(input_layer, "input_layer")
|
|
122
|
+
intersect_layer = _fix_geometries(intersect_layer, "intersect_layer")
|
|
123
|
+
|
|
124
|
+
# overlap check
|
|
125
|
+
_check_overlap(input_layer, intersect_layer)
|
|
126
|
+
|
|
127
|
+
# run intersect
|
|
128
|
+
result = gpd.overlay(input_layer, intersect_layer, how="intersection", keep_geom_type=True)
|
|
129
|
+
|
|
130
|
+
if result.empty:
|
|
131
|
+
print(
|
|
132
|
+
"env-able | warning | Intersect returned no features.\n"
|
|
133
|
+
" The layers overlap spatially but share no coincident features.\n"
|
|
134
|
+
" Check your data — this is often a CRS or snapping issue."
|
|
135
|
+
)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
# write output
|
|
139
|
+
if output:
|
|
140
|
+
try:
|
|
141
|
+
result.to_file(output)
|
|
142
|
+
print(f"env-able | Intersect: result written to '{output}' ({len(result)} features).")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
raise IOError(
|
|
145
|
+
f"env-able | Intersect: could not write to '{output}'\n"
|
|
146
|
+
f" Original error: {e}"
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
print(f"env-able | Intersect: complete — {len(result)} features returned.")
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def Buffer(input_layer, distance, unit="meters", output=None):
|
|
155
|
+
"""
|
|
156
|
+
Buffers input features by a given distance and unit.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
input_layer : str or GeoDataFrame
|
|
161
|
+
File path or GeoDataFrame to buffer.
|
|
162
|
+
distance : float
|
|
163
|
+
Buffer distance.
|
|
164
|
+
unit : str
|
|
165
|
+
Unit of distance. Options:
|
|
166
|
+
meters / m, kilometers / km, miles / mi,
|
|
167
|
+
feet / ft, usfeet / us_feet, nautical miles / nm
|
|
168
|
+
output : str, optional
|
|
169
|
+
File path to write the result. If None, returns a GeoDataFrame.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
GeoDataFrame with buffered geometries.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
gdf = _load(input_layer, "input")
|
|
177
|
+
|
|
178
|
+
if gdf.crs is None:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
"env-able | Buffer: input layer has no CRS defined.\n"
|
|
181
|
+
" Set a CRS before buffering.\n"
|
|
182
|
+
" Example: gdf = gdf.set_crs('EPSG:4326')"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
unit_key = unit.lower().strip()
|
|
186
|
+
if unit_key not in _UNITS:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"env-able | Buffer: unrecognised unit '{unit}'.\n"
|
|
189
|
+
f" Valid options: {', '.join(sorted(set(_UNITS.keys())))}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
distance_m = distance * _UNITS[unit_key]
|
|
193
|
+
|
|
194
|
+
gdf = _fix_geometries(gdf, "input")
|
|
195
|
+
|
|
196
|
+
# if geographic CRS (degrees), reproject to a metre-based CRS for buffering
|
|
197
|
+
if gdf.crs.is_geographic:
|
|
198
|
+
print(
|
|
199
|
+
f"env-able | warning | Buffer: input CRS is geographic ({gdf.crs}).\n"
|
|
200
|
+
f" Reprojecting to EPSG:3857 (Web Mercator) for accurate distance buffering.\n"
|
|
201
|
+
f" Result will be returned in EPSG:3857."
|
|
202
|
+
)
|
|
203
|
+
gdf = gdf.to_crs("EPSG:3857")
|
|
204
|
+
buffer_distance = distance_m
|
|
205
|
+
else:
|
|
206
|
+
# convert distance_m to the CRS native unit
|
|
207
|
+
crs_unit = gdf.crs.axis_info[0].unit_name.lower()
|
|
208
|
+
if "foot" in crs_unit or "feet" in crs_unit:
|
|
209
|
+
buffer_distance = distance_m / 0.3048
|
|
210
|
+
else:
|
|
211
|
+
buffer_distance = distance_m # assume metres
|
|
212
|
+
|
|
213
|
+
result = gdf.copy()
|
|
214
|
+
result.geometry = gdf.geometry.buffer(buffer_distance)
|
|
215
|
+
|
|
216
|
+
print(f"env-able | Buffer: complete — {len(result)} features buffered by {distance} {unit}.")
|
|
217
|
+
|
|
218
|
+
if output:
|
|
219
|
+
try:
|
|
220
|
+
result.to_file(output)
|
|
221
|
+
print(f"env-able | Buffer: result written to '{output}'.")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise IOError(
|
|
224
|
+
f"env-able | Buffer: could not write to '{output}'\n"
|
|
225
|
+
f" Original error: {e}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def Clip(input_layer, clip_layer, output=None):
|
|
232
|
+
"""
|
|
233
|
+
Clips input features to the extent of a polygon clip layer.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
input_layer : str or GeoDataFrame
|
|
238
|
+
Features to clip (point, line, or polygon).
|
|
239
|
+
clip_layer : str or GeoDataFrame
|
|
240
|
+
Polygon layer defining the clip boundary.
|
|
241
|
+
output : str, optional
|
|
242
|
+
File path to write the result. If None, returns a GeoDataFrame.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
GeoDataFrame clipped to the clip boundary.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
gdf = _load(input_layer, "input")
|
|
250
|
+
clip = _load(clip_layer, "clip")
|
|
251
|
+
|
|
252
|
+
# CRS checks
|
|
253
|
+
for i, lyr in enumerate([gdf, clip]):
|
|
254
|
+
if lyr.crs is None:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"env-able | Clip: input[{i}] has no CRS defined.\n"
|
|
257
|
+
f" Set a CRS before clipping.\n"
|
|
258
|
+
f" Example: gdf = gdf.set_crs('EPSG:4326')"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# clip layer must be polygon
|
|
262
|
+
clip_types = set(clip.geometry.geom_type.unique())
|
|
263
|
+
if not clip_types.issubset({"Polygon", "MultiPolygon"}):
|
|
264
|
+
raise ValueError(
|
|
265
|
+
f"env-able | Clip: clip layer must be polygon geometry.\n"
|
|
266
|
+
f" Got: {clip_types}\n"
|
|
267
|
+
f" The clip boundary must be a polygon."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# reproject clip to match input if needed
|
|
271
|
+
if clip.crs != gdf.crs:
|
|
272
|
+
print(
|
|
273
|
+
f"env-able | warning | Clip: CRS mismatch detected.\n"
|
|
274
|
+
f" Input CRS : {gdf.crs}\n"
|
|
275
|
+
f" Clip CRS : {clip.crs}\n"
|
|
276
|
+
f" Auto-reprojecting clip layer to match input."
|
|
277
|
+
)
|
|
278
|
+
clip = clip.to_crs(gdf.crs)
|
|
279
|
+
|
|
280
|
+
# fix geometries
|
|
281
|
+
gdf = _fix_geometries(gdf, "input")
|
|
282
|
+
clip = _fix_geometries(clip, "clip")
|
|
283
|
+
|
|
284
|
+
# overlap check
|
|
285
|
+
_check_overlap(gdf, clip)
|
|
286
|
+
|
|
287
|
+
result = gdf.clip(clip)
|
|
288
|
+
|
|
289
|
+
if result.empty:
|
|
290
|
+
print(
|
|
291
|
+
"env-able | warning | Clip returned no features.\n"
|
|
292
|
+
" The layers overlap spatially but no features fell within the clip boundary."
|
|
293
|
+
)
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
print(f"env-able | Clip: complete — {len(result)} features returned.")
|
|
297
|
+
|
|
298
|
+
if output:
|
|
299
|
+
try:
|
|
300
|
+
result.to_file(output)
|
|
301
|
+
print(f"env-able | Clip: result written to '{output}'.")
|
|
302
|
+
except Exception as e:
|
|
303
|
+
raise IOError(
|
|
304
|
+
f"env-able | Clip: could not write to '{output}'\n"
|
|
305
|
+
f" Original error: {e}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return result
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: env-able
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: AI-ready GIS toolkit for energy and subsurface workflows.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Requires-Dist: fiona
|
|
9
|
+
Requires-Dist: geopandas
|
|
10
|
+
Requires-Dist: openpyxl
|
|
11
|
+
Requires-Dist: xlrd
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# env-able
|
|
15
|
+
|
|
16
|
+
An open source spatial analysis library built for AI-driven GIS workflows. Designed to give AI systems like Claude reliable, hallucination-free tools for spatial operations in energy and subsurface contexts.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install env-able
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Functions
|
|
25
|
+
|
|
26
|
+
### `e.Intersect(input_layer, intersect_layer, output=None)`
|
|
27
|
+
Computes geometric intersection of input features against a polygon boundary.
|
|
28
|
+
- `input_layer` — point, line, or polygon (file path or GeoDataFrame)
|
|
29
|
+
- `intersect_layer` — polygon layer defining the intersection boundary
|
|
30
|
+
- `output` — file path to save result (`.gpkg`, `.shp`, etc.) — omit to return a GeoDataFrame
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import env_able as e
|
|
34
|
+
e.Intersect("wells.shp", "boundary.shp", "result.gpkg")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### `e.Buffer(input_layer, distance, unit="meters", output=None)`
|
|
40
|
+
Buffers input features by a given distance and unit.
|
|
41
|
+
- `input_layer` — point, line, or polygon (file path or GeoDataFrame)
|
|
42
|
+
- `distance` — numeric buffer distance
|
|
43
|
+
- `unit` — `meters`, `km`, `miles`, `feet`, `usfeet`, `nautical miles`
|
|
44
|
+
- `output` — file path to save result — omit to return a GeoDataFrame
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
e.Buffer("wells.shp", 1, "miles", "wells_buffer.gpkg")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### `e.Clip(input_layer, clip_layer, output=None)`
|
|
53
|
+
Clips input features to the extent of a polygon clip boundary.
|
|
54
|
+
- `input_layer` — point, line, or polygon (file path or GeoDataFrame)
|
|
55
|
+
- `clip_layer` — polygon layer defining the clip boundary
|
|
56
|
+
- `output` — file path to save result — omit to return a GeoDataFrame
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
e.Clip("roads.shp", "county.shp", "roads_clipped.gpkg")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
### `e.morph(input_path, output_path, **kwargs)`
|
|
65
|
+
Universal format translation. Converts between shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json with automatic CRS handling, field name fixes, and multi-layer support.
|
|
66
|
+
|
|
67
|
+
- `input_path` — source file or geodatabase
|
|
68
|
+
- `output_path` — destination file. Extension sets the format. Use trailing `/` for directory output (one file per layer). Use dot notation for named layers: `roads.parcels.gpkg`
|
|
69
|
+
- `x_col`, `y_col` — column names for X/Y coordinates (auto-detected if not provided)
|
|
70
|
+
- `wkt_col` — column containing WKT geometry (auto-detected if not provided)
|
|
71
|
+
- `crs` — coordinate reference system e.g. `EPSG:4326` (required for tabular → spatial)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
e.morph("roads.shp", "roads.gpkg")
|
|
75
|
+
e.morph("county.gdb", "county.gpkg")
|
|
76
|
+
e.morph("county.gdb", "output_folder/")
|
|
77
|
+
e.morph("owners.csv", "owners.geojson", crs="EPSG:4269")
|
|
78
|
+
e.morph("owners.csv", "owners.shp", x_col="LONGITUDE", y_col="LATITUDE", crs="EPSG:4269")
|
|
79
|
+
e.morph("roads.gpkg", "roads.parcels.gpkg")
|
|
80
|
+
e.morph("data.json", "data.gpkg")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Smart behavior:**
|
|
84
|
+
- GDB / GPKG with multiple layers → detects all layers automatically
|
|
85
|
+
- CRS mismatch → auto-reprojects
|
|
86
|
+
- Shapefile field name limit (10 chars) → auto-truncates with warnings
|
|
87
|
+
- Invalid output path → plain English error
|
|
88
|
+
- Empty layers → skipped with a warning, not a crash
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Changelog
|
|
93
|
+
|
|
94
|
+
### v0.1.0 — 2026-07-14
|
|
95
|
+
- Initial release of env-able
|
|
96
|
+
- `Intersect()`, `Buffer()`, `Clip()` with full guardrails
|
|
97
|
+
- `morph()` — universal format translation
|
|
98
|
+
- Smart geometry detection for WKT and lat/lon columns
|
|
99
|
+
- GeoJSON, JSON support with auto WGS84 reprojection
|
|
100
|
+
- Multi-layer GDB/GPKG support
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
env_able/__init__.py,sha256=N1WtPOLeqkeCKlFujncOFoZCHqjnhBkqn--yi1nU7Hg,174
|
|
2
|
+
env_able/morph.py,sha256=jJbsgAHxdLYt_I5JWC26jybSVIuJ0LAyCnKqUmjtY1s,25964
|
|
3
|
+
env_able/vector.py,sha256=EMHfwqkQKJGvqGxtL5U8iPBdWa0SbbhJo4rGK9f7yBg,10053
|
|
4
|
+
env_able-0.2.0.dist-info/METADATA,sha256=cbhMfSMmzUG3IBeycs_-auDrlKgfNEOlyeIOpA6p0f0,3599
|
|
5
|
+
env_able-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
env_able-0.2.0.dist-info/licenses/LICENSE,sha256=fe2sDjqFxN-_mnF9irpbjuM_YUKEUS_iJCYEU9JpD5w,1065
|
|
7
|
+
env_able-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 env-able
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|