mapsmith 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.
- mapsmith/__init__.py +3 -0
- mapsmith/catalog.py +98 -0
- mapsmith/engines/__init__.py +1 -0
- mapsmith/engines/dispatch.py +64 -0
- mapsmith/engines/duckdb_engine.py +103 -0
- mapsmith/engines/sedona_engine.py +62 -0
- mapsmith/engines/vector.py +156 -0
- mapsmith/jobs.py +87 -0
- mapsmith/provenance.py +75 -0
- mapsmith/server.py +179 -0
- mapsmith-0.1.0.dist-info/METADATA +143 -0
- mapsmith-0.1.0.dist-info/RECORD +15 -0
- mapsmith-0.1.0.dist-info/WHEEL +4 -0
- mapsmith-0.1.0.dist-info/entry_points.txt +2 -0
- mapsmith-0.1.0.dist-info/licenses/LICENSE +661 -0
mapsmith/__init__.py
ADDED
mapsmith/catalog.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Operation catalog for progressive discovery.
|
|
2
|
+
|
|
3
|
+
Agent accuracy collapses when hundreds of raw tools are exposed. MapSmith keeps
|
|
4
|
+
a small set of semantic MCP tools and lets agents *search* this catalog to find
|
|
5
|
+
what exists. Entries marked ``planned`` document the roadmap so the agent can
|
|
6
|
+
say "not yet" instead of hallucinating a capability.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
OPERATIONS: list[dict[str, str]] = [
|
|
12
|
+
{
|
|
13
|
+
"name": "describe_dataset",
|
|
14
|
+
"status": "available",
|
|
15
|
+
"category": "inspection",
|
|
16
|
+
"summary": "CRS, geometry types, schema, extent and feature count of a vector dataset",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "buffer_layer",
|
|
20
|
+
"status": "available",
|
|
21
|
+
"category": "vector",
|
|
22
|
+
"summary": "Metric buffer with automatic UTM estimation on geographic CRS",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "clip_layer",
|
|
26
|
+
"status": "available",
|
|
27
|
+
"category": "vector",
|
|
28
|
+
"summary": "Clip a layer with a mask layer (CRS-aligned automatically)",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "reproject_layer",
|
|
32
|
+
"status": "available",
|
|
33
|
+
"category": "vector",
|
|
34
|
+
"summary": "Reproject a layer to a target CRS (EPSG code or WKT)",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "spatial_join",
|
|
38
|
+
"status": "available",
|
|
39
|
+
"category": "vector",
|
|
40
|
+
"summary": "Join by spatial predicate (intersects/within/contains); auto-routed to "
|
|
41
|
+
"SedonaDB or DuckDB for speed, GeoPandas fallback",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "run_sql",
|
|
45
|
+
"status": "available",
|
|
46
|
+
"category": "sql",
|
|
47
|
+
"summary": "Spatial SQL (DuckDB dialect, ST_* functions) over GeoParquet and GDAL "
|
|
48
|
+
"formats; materializes GeoParquet outputs with provenance",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "get_provenance",
|
|
52
|
+
"status": "available",
|
|
53
|
+
"category": "provenance",
|
|
54
|
+
"summary": "Full lineage manifest of any MapSmith output",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "zonal_statistics",
|
|
58
|
+
"status": "planned",
|
|
59
|
+
"category": "raster",
|
|
60
|
+
"summary": "Statistics of a raster within vector zones (Rasterio engine)",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "hillshade",
|
|
64
|
+
"status": "planned",
|
|
65
|
+
"category": "raster",
|
|
66
|
+
"summary": "Terrain hillshading from a DEM",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "watershed",
|
|
70
|
+
"status": "planned",
|
|
71
|
+
"category": "hydrology",
|
|
72
|
+
"summary": "Watershed delineation (WhiteboxTools engine)",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "isochrone",
|
|
76
|
+
"status": "planned",
|
|
77
|
+
"category": "network",
|
|
78
|
+
"summary": "Travel-time polygons (Valhalla engine)",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "qgis_processing",
|
|
82
|
+
"status": "planned",
|
|
83
|
+
"category": "bridge",
|
|
84
|
+
"summary": "~900 QGIS/GRASS/SAGA algorithms via GPL-isolated subprocess sidecar",
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def search(query: str = "") -> list[dict[str, str]]:
|
|
90
|
+
"""Case-insensitive substring search across name, category and summary."""
|
|
91
|
+
q = query.strip().lower()
|
|
92
|
+
if not q:
|
|
93
|
+
return OPERATIONS
|
|
94
|
+
return [
|
|
95
|
+
op
|
|
96
|
+
for op in OPERATIONS
|
|
97
|
+
if q in op["name"].lower() or q in op["category"].lower() or q in op["summary"].lower()
|
|
98
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic geoprocessing engines. The LLM orchestrates; these compute."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Engine dispatcher: route each workload class to the fastest available engine.
|
|
2
|
+
|
|
3
|
+
The 2025-2026 benchmarks are unambiguous (see docs in the repo wiki):
|
|
4
|
+
- SedonaDB wins heavy joins/KNN by 10-180x, in-process, optional dependency.
|
|
5
|
+
- DuckDB spatial wins filters/aggregations/point-in-polygon (~2M rows/sec).
|
|
6
|
+
- GeoPandas/Shapely stays as the long-tail lane (<~1M features).
|
|
7
|
+
|
|
8
|
+
Engines are optional imports: the dispatcher degrades gracefully and the
|
|
9
|
+
provenance manifest always records which engine actually ran.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from functools import cache
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Workload(str, Enum):
|
|
19
|
+
SQL = "sql" # filters, aggregations, ad-hoc SQL
|
|
20
|
+
HEAVY_JOIN = "heavy_join" # polygon overlay, distance joins, KNN
|
|
21
|
+
SMALL_VECTOR = "small_vector" # long-tail ops on small data
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Preference order per workload class; first available wins.
|
|
25
|
+
_PREFERENCES: dict[Workload, list[str]] = {
|
|
26
|
+
Workload.SQL: ["duckdb", "geopandas"],
|
|
27
|
+
Workload.HEAVY_JOIN: ["sedonadb", "duckdb", "geopandas"],
|
|
28
|
+
Workload.SMALL_VECTOR: ["geopandas"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@cache
|
|
33
|
+
def available_engines() -> dict[str, bool]:
|
|
34
|
+
"""Probe optional engines once per process."""
|
|
35
|
+
status: dict[str, bool] = {"geopandas": True} # hard dependency
|
|
36
|
+
try:
|
|
37
|
+
import duckdb # noqa: F401
|
|
38
|
+
|
|
39
|
+
status["duckdb"] = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
status["duckdb"] = False
|
|
42
|
+
try:
|
|
43
|
+
import sedona.db # noqa: F401
|
|
44
|
+
|
|
45
|
+
status["sedonadb"] = True
|
|
46
|
+
except ImportError:
|
|
47
|
+
status["sedonadb"] = False
|
|
48
|
+
return status
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pick(workload: Workload, requested: str = "auto") -> str:
|
|
52
|
+
"""Pick the engine for a workload. `requested` may force a specific engine."""
|
|
53
|
+
engines = available_engines()
|
|
54
|
+
if requested != "auto":
|
|
55
|
+
if not engines.get(requested, False):
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"Engine '{requested}' is not available in this installation. "
|
|
58
|
+
f"Available: {[k for k, v in engines.items() if v]}"
|
|
59
|
+
)
|
|
60
|
+
return requested
|
|
61
|
+
for name in _PREFERENCES[workload]:
|
|
62
|
+
if engines.get(name, False):
|
|
63
|
+
return name
|
|
64
|
+
raise RuntimeError(f"No engine available for workload {workload}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""DuckDB spatial engine: SQL, filters, aggregations, point-in-polygon joins.
|
|
2
|
+
|
|
3
|
+
Benchmarks (2025-26): DuckDB's SPATIAL_JOIN operator does point-in-polygon at
|
|
4
|
+
~2M rows/sec on a laptop. The spatial extension is NOT bundled in the wheel:
|
|
5
|
+
we install it on first use (needs network once per environment).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import duckdb
|
|
14
|
+
|
|
15
|
+
from ..provenance import InputRecord, ProvenanceRecord
|
|
16
|
+
|
|
17
|
+
_PREVIEW_ROWS = 50
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _engine_info() -> dict[str, str]:
|
|
21
|
+
return {"name": "duckdb", "version": duckdb.__version__}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _connect() -> duckdb.DuckDBPyConnection:
|
|
25
|
+
con = duckdb.connect()
|
|
26
|
+
try:
|
|
27
|
+
con.install_extension("spatial")
|
|
28
|
+
except duckdb.Error:
|
|
29
|
+
pass # already installed in this environment, or offline with a cached copy
|
|
30
|
+
con.load_extension("spatial")
|
|
31
|
+
return con
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quote(path: str) -> str:
|
|
35
|
+
return str(path).replace("'", "''")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rel(path: str) -> str:
|
|
39
|
+
"""SQL relation for a dataset file: GeoParquet natively, other formats via GDAL."""
|
|
40
|
+
if str(path).lower().endswith(".parquet"):
|
|
41
|
+
return f"read_parquet('{_quote(path)}')"
|
|
42
|
+
return f"ST_Read('{_quote(path)}')"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_sql(query: str, output_path: str | None = None) -> dict[str, Any]:
|
|
46
|
+
"""Run spatial SQL. With output_path, materialize the result as GeoParquet."""
|
|
47
|
+
con = _connect()
|
|
48
|
+
record = ProvenanceRecord(
|
|
49
|
+
operation="run_sql",
|
|
50
|
+
parameters={"query": query},
|
|
51
|
+
inputs=[],
|
|
52
|
+
engine=_engine_info(),
|
|
53
|
+
)
|
|
54
|
+
if output_path:
|
|
55
|
+
con.sql(f"COPY ({query}) TO '{_quote(output_path)}' (FORMAT parquet)")
|
|
56
|
+
count = con.sql(
|
|
57
|
+
f"SELECT count(*) FROM read_parquet('{_quote(output_path)}')"
|
|
58
|
+
).fetchone()[0]
|
|
59
|
+
manifest = record.finish().write_for(output_path)
|
|
60
|
+
return {"output": str(output_path), "row_count": int(count), "provenance": str(manifest)}
|
|
61
|
+
result = con.sql(query)
|
|
62
|
+
rows = result.fetchmany(_PREVIEW_ROWS)
|
|
63
|
+
return {
|
|
64
|
+
"columns": [d[0] for d in result.description],
|
|
65
|
+
"rows": [[repr(v) if isinstance(v, (bytes, bytearray)) else v for v in r] for r in rows],
|
|
66
|
+
"truncated_at": _PREVIEW_ROWS,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def spatial_join(
|
|
71
|
+
left_path: str, right_path: str, output_path: str, predicate: str = "intersects"
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Attribute join by spatial predicate. GeoParquet-native fast path."""
|
|
74
|
+
predicates = {"intersects": "ST_Intersects", "within": "ST_Within", "contains": "ST_Contains"}
|
|
75
|
+
if predicate not in predicates:
|
|
76
|
+
raise ValueError(f"predicate must be one of {sorted(predicates)}, got {predicate!r}")
|
|
77
|
+
con = _connect()
|
|
78
|
+
record = ProvenanceRecord(
|
|
79
|
+
operation="spatial_join",
|
|
80
|
+
parameters={"predicate": predicate, "engine": "duckdb"},
|
|
81
|
+
inputs=[InputRecord.from_path(left_path), InputRecord.from_path(right_path)],
|
|
82
|
+
engine=_engine_info(),
|
|
83
|
+
)
|
|
84
|
+
fn = predicates[predicate]
|
|
85
|
+
query = f"""
|
|
86
|
+
SELECT l.*, r.* EXCLUDE (geometry)
|
|
87
|
+
FROM {_rel(left_path)} AS l
|
|
88
|
+
JOIN {_rel(right_path)} AS r
|
|
89
|
+
ON {fn}(l.geometry, r.geometry)
|
|
90
|
+
"""
|
|
91
|
+
con.sql(f"COPY ({query}) TO '{_quote(output_path)}' (FORMAT parquet)")
|
|
92
|
+
count = con.sql(f"SELECT count(*) FROM read_parquet('{_quote(output_path)}')").fetchone()[0]
|
|
93
|
+
manifest = record.finish().write_for(output_path)
|
|
94
|
+
return {
|
|
95
|
+
"output": str(output_path),
|
|
96
|
+
"feature_count": int(count),
|
|
97
|
+
"provenance": str(manifest),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def supports_inputs(*paths: str) -> bool:
|
|
102
|
+
"""The DuckDB fast path expects GeoParquet inputs with a 'geometry' column."""
|
|
103
|
+
return all(Path(p).suffix.lower() == ".parquet" for p in paths)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""SedonaDB engine: heavy overlays, distance joins, KNN — 10-180x on benchmarks.
|
|
2
|
+
|
|
3
|
+
Optional dependency (`pip install mapsmith[sedona]`). In-process Rust engine
|
|
4
|
+
(Arrow/DataFusion). API is pre-1.0: this wrapper stays deliberately thin.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..provenance import InputRecord, ProvenanceRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _engine_info() -> dict[str, str]:
|
|
15
|
+
import sedonadb # the wheel behind apache-sedona[db]
|
|
16
|
+
|
|
17
|
+
return {"name": "sedonadb", "version": getattr(sedonadb, "__version__", "unknown")}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def spatial_join(
|
|
21
|
+
left_path: str, right_path: str, output_path: str, predicate: str = "intersects"
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Spatial join on SedonaDB. Keeps left columns (right attributes omitted).
|
|
24
|
+
|
|
25
|
+
Note: inner join semantics — a left feature is repeated once per match,
|
|
26
|
+
like GeoPandas sjoin. Recorded in the provenance manifest.
|
|
27
|
+
"""
|
|
28
|
+
import sedona.db
|
|
29
|
+
|
|
30
|
+
predicates = {"intersects": "ST_Intersects", "within": "ST_Within", "contains": "ST_Contains"}
|
|
31
|
+
if predicate not in predicates:
|
|
32
|
+
raise ValueError(f"predicate must be one of {sorted(predicates)}, got {predicate!r}")
|
|
33
|
+
|
|
34
|
+
sd = sedona.db.connect()
|
|
35
|
+
record = ProvenanceRecord(
|
|
36
|
+
operation="spatial_join",
|
|
37
|
+
parameters={"predicate": predicate, "engine": "sedonadb", "columns": "left-only"},
|
|
38
|
+
inputs=[InputRecord.from_path(left_path), InputRecord.from_path(right_path)],
|
|
39
|
+
engine=_engine_info(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _load(path: str, view: str) -> None:
|
|
43
|
+
if str(path).lower().endswith(".parquet"):
|
|
44
|
+
sd.read_parquet(path).to_view(view)
|
|
45
|
+
else:
|
|
46
|
+
sd.read_pyogrio(path).to_view(view)
|
|
47
|
+
|
|
48
|
+
_load(left_path, "l")
|
|
49
|
+
_load(right_path, "r")
|
|
50
|
+
fn = predicates[predicate]
|
|
51
|
+
result = sd.sql(f"SELECT l.* FROM l JOIN r ON {fn}(l.geometry, r.geometry)")
|
|
52
|
+
result.to_parquet(output_path)
|
|
53
|
+
count = sd.sql(
|
|
54
|
+
"SELECT count(*) FROM l JOIN r ON " + f"{fn}(l.geometry, r.geometry)"
|
|
55
|
+
).to_pandas()
|
|
56
|
+
feature_count = int(count.iloc[0, 0])
|
|
57
|
+
manifest = record.finish().write_for(output_path)
|
|
58
|
+
return {
|
|
59
|
+
"output": str(output_path),
|
|
60
|
+
"feature_count": feature_count,
|
|
61
|
+
"provenance": str(manifest),
|
|
62
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Vector operations on the permissive GeoPandas/Shapely stack.
|
|
2
|
+
|
|
3
|
+
Design rules:
|
|
4
|
+
- Metric operations on geographic CRS are never silent: we estimate a UTM CRS,
|
|
5
|
+
record the decision in provenance, and reproject back.
|
|
6
|
+
- Every writer emits a provenance manifest next to the output.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import geopandas as gpd
|
|
14
|
+
|
|
15
|
+
from ..provenance import InputRecord, ProvenanceRecord
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _engine_info() -> dict[str, str]:
|
|
19
|
+
return {"name": "geopandas", "version": gpd.__version__}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def describe(path: str) -> dict[str, Any]:
|
|
23
|
+
gdf = gpd.read_file(path)
|
|
24
|
+
bounds = gdf.total_bounds
|
|
25
|
+
return {
|
|
26
|
+
"path": str(path),
|
|
27
|
+
"crs": str(gdf.crs) if gdf.crs else None,
|
|
28
|
+
"feature_count": len(gdf),
|
|
29
|
+
"geometry_types": sorted(gdf.geom_type.dropna().unique().tolist()),
|
|
30
|
+
"fields": {c: str(t) for c, t in gdf.dtypes.items() if c != gdf.geometry.name},
|
|
31
|
+
"extent": {
|
|
32
|
+
"minx": float(bounds[0]),
|
|
33
|
+
"miny": float(bounds[1]),
|
|
34
|
+
"maxx": float(bounds[2]),
|
|
35
|
+
"maxy": float(bounds[3]),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def buffer(input_path: str, distance_meters: float, output_path: str) -> dict[str, Any]:
|
|
41
|
+
gdf = gpd.read_file(input_path)
|
|
42
|
+
if gdf.crs is None:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"{input_path} has no CRS. Refusing to buffer without knowing the units — "
|
|
45
|
+
"assign a CRS first (see reproject_layer)."
|
|
46
|
+
)
|
|
47
|
+
record = ProvenanceRecord(
|
|
48
|
+
operation="buffer_layer",
|
|
49
|
+
parameters={"distance_meters": distance_meters},
|
|
50
|
+
inputs=[InputRecord.from_path(input_path, crs=str(gdf.crs))],
|
|
51
|
+
engine=_engine_info(),
|
|
52
|
+
)
|
|
53
|
+
original_crs = gdf.crs
|
|
54
|
+
if original_crs.is_geographic:
|
|
55
|
+
analysis_crs = gdf.estimate_utm_crs()
|
|
56
|
+
record.crs_decisions = {
|
|
57
|
+
"analysis_crs": str(analysis_crs),
|
|
58
|
+
"reason": "estimated UTM zone for metric buffering on a geographic CRS",
|
|
59
|
+
}
|
|
60
|
+
buffered = gdf.to_crs(analysis_crs)
|
|
61
|
+
buffered["geometry"] = buffered.geometry.buffer(distance_meters)
|
|
62
|
+
buffered = buffered.to_crs(original_crs)
|
|
63
|
+
else:
|
|
64
|
+
record.crs_decisions = {
|
|
65
|
+
"analysis_crs": str(original_crs),
|
|
66
|
+
"reason": "input CRS is already projected; distance interpreted in its units",
|
|
67
|
+
}
|
|
68
|
+
buffered = gdf.copy()
|
|
69
|
+
buffered["geometry"] = buffered.geometry.buffer(distance_meters)
|
|
70
|
+
buffered.to_file(output_path)
|
|
71
|
+
manifest = record.finish().write_for(output_path)
|
|
72
|
+
return {
|
|
73
|
+
"output": str(output_path),
|
|
74
|
+
"feature_count": len(buffered),
|
|
75
|
+
"provenance": str(manifest),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def clip(input_path: str, mask_path: str, output_path: str) -> dict[str, Any]:
|
|
80
|
+
gdf = gpd.read_file(input_path)
|
|
81
|
+
mask = gpd.read_file(mask_path)
|
|
82
|
+
record = ProvenanceRecord(
|
|
83
|
+
operation="clip_layer",
|
|
84
|
+
parameters={},
|
|
85
|
+
inputs=[
|
|
86
|
+
InputRecord.from_path(input_path, crs=str(gdf.crs)),
|
|
87
|
+
InputRecord.from_path(mask_path, crs=str(mask.crs)),
|
|
88
|
+
],
|
|
89
|
+
engine=_engine_info(),
|
|
90
|
+
)
|
|
91
|
+
if gdf.crs != mask.crs:
|
|
92
|
+
mask = mask.to_crs(gdf.crs)
|
|
93
|
+
record.crs_decisions = {
|
|
94
|
+
"analysis_crs": str(gdf.crs),
|
|
95
|
+
"reason": "mask reprojected to the input layer CRS before clipping",
|
|
96
|
+
}
|
|
97
|
+
clipped = gpd.clip(gdf, mask)
|
|
98
|
+
clipped.to_file(output_path)
|
|
99
|
+
manifest = record.finish().write_for(output_path)
|
|
100
|
+
return {
|
|
101
|
+
"output": str(output_path),
|
|
102
|
+
"feature_count": len(clipped),
|
|
103
|
+
"provenance": str(manifest),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def reproject(input_path: str, target_crs: str, output_path: str) -> dict[str, Any]:
|
|
108
|
+
gdf = gpd.read_file(input_path)
|
|
109
|
+
record = ProvenanceRecord(
|
|
110
|
+
operation="reproject_layer",
|
|
111
|
+
parameters={"target_crs": target_crs},
|
|
112
|
+
inputs=[InputRecord.from_path(input_path, crs=str(gdf.crs))],
|
|
113
|
+
engine=_engine_info(),
|
|
114
|
+
)
|
|
115
|
+
reprojected = gdf.to_crs(target_crs)
|
|
116
|
+
reprojected.to_file(output_path)
|
|
117
|
+
manifest = record.finish().write_for(output_path)
|
|
118
|
+
return {
|
|
119
|
+
"output": str(output_path),
|
|
120
|
+
"crs": str(reprojected.crs),
|
|
121
|
+
"provenance": str(manifest),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def spatial_join(
|
|
126
|
+
left_path: str, right_path: str, output_path: str, predicate: str = "intersects"
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
allowed = {"intersects", "within", "contains"}
|
|
129
|
+
if predicate not in allowed:
|
|
130
|
+
raise ValueError(f"predicate must be one of {sorted(allowed)}, got {predicate!r}")
|
|
131
|
+
left = gpd.read_file(left_path)
|
|
132
|
+
right = gpd.read_file(right_path)
|
|
133
|
+
record = ProvenanceRecord(
|
|
134
|
+
operation="spatial_join",
|
|
135
|
+
parameters={"predicate": predicate},
|
|
136
|
+
inputs=[
|
|
137
|
+
InputRecord.from_path(left_path, crs=str(left.crs)),
|
|
138
|
+
InputRecord.from_path(right_path, crs=str(right.crs)),
|
|
139
|
+
],
|
|
140
|
+
engine=_engine_info(),
|
|
141
|
+
)
|
|
142
|
+
if left.crs != right.crs:
|
|
143
|
+
right = right.to_crs(left.crs)
|
|
144
|
+
record.crs_decisions = {
|
|
145
|
+
"analysis_crs": str(left.crs),
|
|
146
|
+
"reason": "right layer reprojected to the left layer CRS before joining",
|
|
147
|
+
}
|
|
148
|
+
joined = gpd.sjoin(left, right, predicate=predicate, how="inner")
|
|
149
|
+
joined = joined.drop(columns=[c for c in ("index_right",) if c in joined.columns])
|
|
150
|
+
joined.to_file(output_path)
|
|
151
|
+
manifest = record.finish().write_for(output_path)
|
|
152
|
+
return {
|
|
153
|
+
"output": str(output_path),
|
|
154
|
+
"feature_count": len(joined),
|
|
155
|
+
"provenance": str(manifest),
|
|
156
|
+
}
|
mapsmith/jobs.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Durable job ledger on Postgres (optional).
|
|
2
|
+
|
|
3
|
+
Every tool call is modeled as a job row from day one (id, operation, params,
|
|
4
|
+
status, artifact, manifest). Execution stays in-process for now; the same
|
|
5
|
+
table will back the MCP Tasks extension and a worker queue later — designed-in
|
|
6
|
+
seam, deferred infrastructure.
|
|
7
|
+
|
|
8
|
+
If DATABASE_URL is unset (or psycopg is not installed) the ledger is a no-op:
|
|
9
|
+
local/stdio users need zero infrastructure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import uuid
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_DDL = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS mapsmith_jobs (
|
|
22
|
+
id UUID PRIMARY KEY,
|
|
23
|
+
operation TEXT NOT NULL,
|
|
24
|
+
params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
26
|
+
error TEXT,
|
|
27
|
+
artifact TEXT,
|
|
28
|
+
manifest JSONB,
|
|
29
|
+
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
30
|
+
finished_at TIMESTAMPTZ
|
|
31
|
+
);
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_schema_ready = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _connect():
|
|
38
|
+
url = os.environ.get("DATABASE_URL")
|
|
39
|
+
if not url:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
import psycopg
|
|
43
|
+
except ImportError:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return psycopg.connect(url, autocommit=True)
|
|
47
|
+
except Exception: # noqa: BLE001 — the ledger must never take the server down
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ensure_schema(conn) -> None:
|
|
52
|
+
global _schema_ready
|
|
53
|
+
if not _schema_ready:
|
|
54
|
+
conn.execute(_DDL)
|
|
55
|
+
_schema_ready = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def job(operation: str, params: dict[str, Any]):
|
|
60
|
+
"""Record a tool execution as a durable job row (no-op without DATABASE_URL)."""
|
|
61
|
+
conn = _connect()
|
|
62
|
+
job_id = str(uuid.uuid4())
|
|
63
|
+
if conn is not None:
|
|
64
|
+
_ensure_schema(conn)
|
|
65
|
+
conn.execute(
|
|
66
|
+
"INSERT INTO mapsmith_jobs (id, operation, params) VALUES (%s, %s, %s)",
|
|
67
|
+
(job_id, operation, json.dumps(params, default=str)),
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
result: dict[str, Any] = {}
|
|
71
|
+
yield job_id, result
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
if conn is not None:
|
|
74
|
+
conn.execute(
|
|
75
|
+
"UPDATE mapsmith_jobs SET status='failed', error=%s, finished_at=now() "
|
|
76
|
+
"WHERE id=%s",
|
|
77
|
+
(str(exc)[:2000], job_id),
|
|
78
|
+
)
|
|
79
|
+
conn.close()
|
|
80
|
+
raise
|
|
81
|
+
if conn is not None:
|
|
82
|
+
conn.execute(
|
|
83
|
+
"UPDATE mapsmith_jobs SET status='completed', artifact=%s, finished_at=now() "
|
|
84
|
+
"WHERE id=%s",
|
|
85
|
+
(result.get("output"), job_id),
|
|
86
|
+
)
|
|
87
|
+
conn.close()
|
mapsmith/provenance.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Lineage manifests for every MapSmith output.
|
|
2
|
+
|
|
3
|
+
Every operation that writes a dataset also writes ``<output>.provenance.json``
|
|
4
|
+
next to it. The manifest is the product's core promise: any result can be
|
|
5
|
+
audited and re-run bit-identical without an LLM in the loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _utcnow() -> str:
|
|
21
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sha256_of(path: str | Path, chunk_size: int = 1 << 20) -> str:
|
|
25
|
+
h = hashlib.sha256()
|
|
26
|
+
with open(path, "rb") as f:
|
|
27
|
+
while chunk := f.read(chunk_size):
|
|
28
|
+
h.update(chunk)
|
|
29
|
+
return h.hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class InputRecord:
|
|
34
|
+
path: str
|
|
35
|
+
sha256: str
|
|
36
|
+
crs: str | None = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_path(cls, path: str | Path, crs: str | None = None) -> InputRecord:
|
|
40
|
+
return cls(path=str(path), sha256=sha256_of(path), crs=crs)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ProvenanceRecord:
|
|
45
|
+
operation: str
|
|
46
|
+
parameters: dict[str, Any]
|
|
47
|
+
inputs: list[InputRecord]
|
|
48
|
+
crs_decisions: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
engine: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
mapsmith_version: str = __version__
|
|
51
|
+
started_at: str = field(default_factory=_utcnow)
|
|
52
|
+
finished_at: str | None = None
|
|
53
|
+
|
|
54
|
+
def finish(self) -> ProvenanceRecord:
|
|
55
|
+
self.finished_at = _utcnow()
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def write_for(self, output_path: str | Path) -> Path:
|
|
59
|
+
"""Write the manifest next to the output it describes."""
|
|
60
|
+
manifest_path = Path(f"{output_path}.provenance.json")
|
|
61
|
+
manifest_path.write_text(
|
|
62
|
+
json.dumps(asdict(self), indent=2, ensure_ascii=False), encoding="utf-8"
|
|
63
|
+
)
|
|
64
|
+
return manifest_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_provenance(output_path: str | Path) -> dict[str, Any]:
|
|
68
|
+
"""Read the lineage manifest of a MapSmith output, if present."""
|
|
69
|
+
manifest_path = Path(f"{output_path}.provenance.json")
|
|
70
|
+
if not manifest_path.exists():
|
|
71
|
+
raise FileNotFoundError(
|
|
72
|
+
f"No provenance manifest found for {output_path}. "
|
|
73
|
+
"Either it was not produced by MapSmith or the manifest was moved."
|
|
74
|
+
)
|
|
75
|
+
return json.loads(manifest_path.read_text(encoding="utf-8"))
|