gridforge-spatial 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.
gridforge/cli.py ADDED
@@ -0,0 +1,345 @@
1
+ """Command-line entry point for GridForge."""
2
+
3
+ # Typer expresses command parameters using function-call defaults.
4
+ # ruff: noqa: B008
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import geopandas as gpd
13
+ import typer
14
+ from pyproj import CRS
15
+
16
+ from gridforge import __version__
17
+ from gridforge.align.points import align_points
18
+ from gridforge.align.raster import align_raster
19
+ from gridforge.align.vector import align_polygons
20
+ from gridforge.demo import run_demo
21
+ from gridforge.errors import GridForgeError
22
+ from gridforge.grid.build import create_grid
23
+ from gridforge.grid.spec import GridSpec
24
+ from gridforge.io.datasets import (
25
+ _crs_label,
26
+ inspect_dataset,
27
+ load_points,
28
+ load_vector,
29
+ read_dataset,
30
+ write_dataset,
31
+ )
32
+ from gridforge.io.grid import read_grid, write_grid
33
+ from gridforge.join import join_features
34
+ from gridforge.provenance import write_provenance
35
+ from gridforge.validation import validate_dataset
36
+
37
+ app = typer.Typer(
38
+ add_completion=False,
39
+ no_args_is_help=True,
40
+ help="Build, align, join, and validate spatial grid datasets.",
41
+ )
42
+ grid_app = typer.Typer(no_args_is_help=True, help="Create canonical grids.")
43
+ align_app = typer.Typer(no_args_is_help=True, help="Align source data to a canonical grid.")
44
+ app.add_typer(grid_app, name="grid")
45
+ app.add_typer(align_app, name="align")
46
+
47
+
48
+ def _show_version(value: bool) -> None:
49
+ if value:
50
+ typer.echo(__version__)
51
+ raise typer.Exit()
52
+
53
+
54
+ @app.callback()
55
+ def main(
56
+ version: bool = typer.Option(
57
+ False,
58
+ "--version",
59
+ callback=_show_version,
60
+ is_eager=True,
61
+ help="Show the installed GridForge version and exit.",
62
+ ),
63
+ ) -> None:
64
+ """Create and validate deterministic grid-based spatial datasets."""
65
+
66
+
67
+ def _run(action):
68
+ try:
69
+ return action()
70
+ except (GridForgeError, OSError) as exc:
71
+ typer.echo(f"ERROR: {exc}", err=True)
72
+ raise typer.Exit(code=2) from exc
73
+
74
+
75
+ def _crs_arg(crs: Any) -> str | None:
76
+ if crs is None:
77
+ return None
78
+ return _crs_label(CRS.from_user_input(crs))
79
+
80
+
81
+ def _print_json(value: Any) -> None:
82
+ typer.echo(json.dumps(value, ensure_ascii=False, indent=2, default=str))
83
+
84
+
85
+ def _write_aligned(
86
+ dataset: gpd.GeoDataFrame,
87
+ output: Path,
88
+ *,
89
+ operation: str,
90
+ source: str | list[str],
91
+ grid: gpd.GeoDataFrame,
92
+ parameters: dict[str, Any],
93
+ ) -> None:
94
+ write_dataset(dataset, output)
95
+ write_provenance(
96
+ output,
97
+ operation=operation,
98
+ source=source,
99
+ grid=grid,
100
+ parameters=parameters,
101
+ )
102
+ typer.echo(f"Wrote {output}")
103
+ typer.echo(f"Wrote {output}.gridforge.json")
104
+
105
+
106
+ @app.command("inspect")
107
+ def inspect_command(
108
+ source: Path = typer.Argument(..., exists=True, readable=True),
109
+ source_crs: str | None = typer.Option(None, "--source-crs"),
110
+ x_column: str = typer.Option("x", "--x-column"),
111
+ y_column: str = typer.Option("y", "--y-column"),
112
+ json_output: bool = typer.Option(False, "--json"),
113
+ ) -> None:
114
+ """Inspect spatial metadata without guessing a missing source CRS."""
115
+ info = _run(
116
+ lambda: inspect_dataset(
117
+ source,
118
+ source_crs=source_crs,
119
+ x_column=x_column,
120
+ y_column=y_column,
121
+ )
122
+ )
123
+ if json_output:
124
+ _print_json(info.to_dict())
125
+ return
126
+ typer.echo(f"Path: {info.path}")
127
+ typer.echo(f"Kind: {info.kind}")
128
+ typer.echo(f"Geometry: {info.geometry_type or 'unknown'}")
129
+ typer.echo(f"CRS: {info.crs}")
130
+ typer.echo(f"Features: {info.feature_count if info.feature_count is not None else 'n/a'}")
131
+ typer.echo(f"Bands: {info.band_count if info.band_count is not None else 'n/a'}")
132
+ typer.echo(f"Bounds: {info.bounds if info.bounds is not None else 'n/a'}")
133
+
134
+
135
+ @grid_app.command("create")
136
+ def grid_create(
137
+ specification: Path = typer.Argument(..., exists=True, readable=True),
138
+ output: Path = typer.Option(..., "--output", "-o"),
139
+ ) -> None:
140
+ """Create a deterministic GeoParquet grid from a YAML specification."""
141
+
142
+ def action() -> None:
143
+ spec = GridSpec.from_yaml(specification)
144
+ grid = create_grid(spec)
145
+ write_grid(grid, output)
146
+ write_provenance(
147
+ output,
148
+ operation="grid_build",
149
+ source=str(specification),
150
+ grid=grid,
151
+ parameters={"grid_spec": spec.to_dict()},
152
+ )
153
+ typer.echo(f"Created {len(grid)} cells: {output}")
154
+ typer.echo(f"Wrote {output}.gridforge.json")
155
+
156
+ _run(action)
157
+
158
+
159
+ @align_app.command("points")
160
+ def align_points_command(
161
+ source: Path = typer.Argument(..., exists=True, readable=True),
162
+ grid_path: Path = typer.Option(..., "--grid"),
163
+ value: str = typer.Option(..., "--value"),
164
+ aggregation: str = typer.Option("mean", "--agg"),
165
+ output: Path = typer.Option(..., "--output", "-o"),
166
+ source_crs: str | None = typer.Option(None, "--source-crs"),
167
+ x_column: str = typer.Option("x", "--x-column"),
168
+ y_column: str = typer.Option("y", "--y-column"),
169
+ ) -> None:
170
+ """Aggregate one point attribute into canonical cells."""
171
+
172
+ def action() -> None:
173
+ grid = read_grid(grid_path)
174
+ points = load_points(
175
+ source,
176
+ x_column=x_column,
177
+ y_column=y_column,
178
+ source_crs=source_crs,
179
+ )
180
+ result = align_points(
181
+ points,
182
+ grid,
183
+ aggregations={value: aggregation},
184
+ source_crs=source_crs,
185
+ )
186
+ _write_aligned(
187
+ result,
188
+ output,
189
+ operation="point_alignment",
190
+ source=str(source),
191
+ grid=grid,
192
+ parameters={
193
+ "source_crs": _crs_arg(points.crs),
194
+ "target_crs": _crs_arg(grid.crs),
195
+ "x_column": x_column,
196
+ "y_column": y_column,
197
+ "aggregation": {value: aggregation},
198
+ },
199
+ )
200
+
201
+ _run(action)
202
+
203
+
204
+ @align_app.command("vector")
205
+ def align_vector_command(
206
+ source: Path = typer.Argument(..., exists=True, readable=True),
207
+ grid_path: Path = typer.Option(..., "--grid"),
208
+ output: Path = typer.Option(..., "--output", "-o"),
209
+ category: str | None = typer.Option(None, "--category"),
210
+ numeric_column: list[str] = typer.Option([], "--numeric-column"),
211
+ source_crs: str | None = typer.Option(None, "--source-crs"),
212
+ ) -> None:
213
+ """Aggregate polygon coverage, categories, and numeric attributes."""
214
+
215
+ def action() -> None:
216
+ grid = read_grid(grid_path)
217
+ vector = load_vector(source, source_crs=source_crs)
218
+ result = align_polygons(
219
+ vector,
220
+ grid,
221
+ category_column=category,
222
+ numeric_columns=numeric_column,
223
+ source_crs=source_crs,
224
+ )
225
+ _write_aligned(
226
+ result,
227
+ output,
228
+ operation="vector_alignment",
229
+ source=str(source),
230
+ grid=grid,
231
+ parameters={
232
+ "source_crs": _crs_arg(vector.crs),
233
+ "target_crs": _crs_arg(grid.crs),
234
+ "category_column": category,
235
+ "numeric_columns": numeric_column,
236
+ "coverage": "union area / cell area",
237
+ "weighted_aggregation": "intersection-area weighted mean",
238
+ },
239
+ )
240
+
241
+ _run(action)
242
+
243
+
244
+ @align_app.command("raster")
245
+ def align_raster_command(
246
+ source: Path = typer.Argument(..., exists=True, readable=True),
247
+ grid_path: Path = typer.Option(..., "--grid"),
248
+ aggregation: str = typer.Option(..., "--agg"),
249
+ output: Path = typer.Option(..., "--output", "-o"),
250
+ band: int = typer.Option(1, "--band", min=1),
251
+ source_crs: str | None = typer.Option(None, "--source-crs"),
252
+ resampling: str = typer.Option("nearest", "--resampling"),
253
+ value_name: str | None = typer.Option(None, "--value-name"),
254
+ ) -> None:
255
+ """Reproject and aggregate one raster band into canonical cells."""
256
+
257
+ def action() -> None:
258
+ grid = read_grid(grid_path)
259
+ result = align_raster(
260
+ source,
261
+ grid,
262
+ aggregation=aggregation,
263
+ band=band,
264
+ source_crs=source_crs,
265
+ resampling=resampling,
266
+ value_name=value_name,
267
+ )
268
+ operation = result.attrs.get("gridforge_operation", {})
269
+ _write_aligned(
270
+ result,
271
+ output,
272
+ operation="raster_alignment",
273
+ source=str(source),
274
+ grid=grid,
275
+ parameters={
276
+ "source_crs": operation.get("source_crs"),
277
+ "target_crs": operation.get("target_crs"),
278
+ "band": band,
279
+ "aggregation": aggregation,
280
+ "aggregation_resampling": operation.get("aggregation_resampling"),
281
+ "resampling": resampling,
282
+ "nodata": operation.get("nodata"),
283
+ },
284
+ )
285
+
286
+ _run(action)
287
+
288
+
289
+ @app.command("join")
290
+ def join_command(
291
+ grid_path: Path = typer.Argument(..., exists=True, readable=True),
292
+ feature_paths: list[Path] = typer.Argument(..., exists=True, readable=True),
293
+ output: Path = typer.Option(..., "--output", "-o"),
294
+ ) -> None:
295
+ """Join aligned GeoParquet feature datasets after identity validation."""
296
+
297
+ def action() -> None:
298
+ grid = read_grid(grid_path)
299
+ features = [read_dataset(path) for path in feature_paths]
300
+ result = join_features(grid, *features)
301
+ _write_aligned(
302
+ result,
303
+ output,
304
+ operation="feature_join",
305
+ source=[str(grid_path), *(str(path) for path in feature_paths)],
306
+ grid=grid,
307
+ parameters={"feature_datasets": len(features)},
308
+ )
309
+
310
+ _run(action)
311
+
312
+
313
+ @app.command("validate")
314
+ def validate_command(
315
+ dataset_path: Path = typer.Argument(..., exists=True, readable=True),
316
+ grid_path: Path | None = typer.Option(None, "--grid"),
317
+ json_output: bool = typer.Option(False, "--json"),
318
+ ) -> None:
319
+ """Validate one GridForge GeoParquet dataset; errors exit with status 2."""
320
+
321
+ def action():
322
+ dataset = read_dataset(dataset_path)
323
+ grid = read_grid(grid_path) if grid_path is not None else None
324
+ return validate_dataset(dataset, grid=grid)
325
+
326
+ report = _run(action)
327
+ if json_output:
328
+ _print_json(report.to_dict())
329
+ else:
330
+ typer.echo(report.status)
331
+ for finding in report.findings:
332
+ count = f" ({finding.count})" if finding.count is not None else ""
333
+ typer.echo(f"{finding.severity}: {finding.message}{count}")
334
+ if report.exit_code:
335
+ raise typer.Exit(code=report.exit_code)
336
+
337
+
338
+ @app.command("demo")
339
+ def demo_command(
340
+ output_dir: Path = typer.Option(Path("gridforge-demo"), "--output-dir"),
341
+ ) -> None:
342
+ """Run a small, synthetic point/vector/raster-to-grid workflow."""
343
+ report = _run(lambda: run_demo(output_dir))
344
+ _print_json(report.to_dict())
345
+ typer.echo(f"Demo complete: {output_dir.resolve()}")
gridforge/demo.py ADDED
@@ -0,0 +1,173 @@
1
+ """Synthetic end-to-end workflow for the GridForge command-line demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import geopandas as gpd
8
+ import numpy as np
9
+ import pandas as pd
10
+ import rasterio
11
+ from affine import Affine
12
+ from shapely.geometry import box
13
+
14
+ from gridforge.align.points import align_points
15
+ from gridforge.align.raster import align_raster
16
+ from gridforge.align.vector import align_polygons
17
+ from gridforge.errors import DatasetError
18
+ from gridforge.grid.build import create_grid
19
+ from gridforge.grid.spec import GridSpec
20
+ from gridforge.io.datasets import load_points, write_dataset
21
+ from gridforge.io.grid import write_grid
22
+ from gridforge.join import join_features
23
+ from gridforge.provenance import write_provenance
24
+ from gridforge.validation import ValidationReport, validate_dataset
25
+
26
+
27
+ def _write_aligned(
28
+ dataset: gpd.GeoDataFrame,
29
+ path: Path,
30
+ *,
31
+ operation: str,
32
+ source: str | list[str],
33
+ grid: gpd.GeoDataFrame,
34
+ parameters: dict[str, object],
35
+ ) -> None:
36
+ write_dataset(dataset, path)
37
+ write_provenance(
38
+ path,
39
+ operation=operation,
40
+ source=source,
41
+ grid=grid,
42
+ parameters=parameters,
43
+ )
44
+
45
+
46
+ def run_demo(output_dir: str | Path) -> ValidationReport:
47
+ """Create synthetic spatial inputs and run the complete v0.1 workflow."""
48
+ directory = Path(output_dir)
49
+ directory.mkdir(parents=True, exist_ok=True)
50
+ specification_path = directory / "grid.yaml"
51
+ specification_path.write_text(
52
+ """crs: EPSG:3857
53
+ cell_size: 20
54
+ bounds:
55
+ min_x: 0
56
+ min_y: 0
57
+ max_x: 80
58
+ max_y: 80
59
+ origin:
60
+ x: 0
61
+ y: 80
62
+ """,
63
+ encoding="utf-8",
64
+ )
65
+ spec = GridSpec.from_yaml(specification_path)
66
+ grid = create_grid(spec)
67
+ grid_path = directory / "grid.parquet"
68
+ write_grid(grid, grid_path)
69
+ write_provenance(
70
+ grid_path,
71
+ operation="grid_build",
72
+ source=str(specification_path),
73
+ grid=grid,
74
+ parameters={"grid_spec": spec.to_dict()},
75
+ )
76
+
77
+ points_path = directory / "points.csv"
78
+ pd.DataFrame(
79
+ {
80
+ "station": ["P-01", "P-02", "P-03"],
81
+ "x": [10.0, 30.0, 70.0],
82
+ "y": [70.0, 50.0, 10.0],
83
+ "rainfall_mm": [12.0, 18.0, 9.0],
84
+ }
85
+ ).to_csv(points_path, index=False)
86
+ point_frame = load_points(points_path, source_crs="EPSG:3857")
87
+ point_output = directory / "rainfall.parquet"
88
+ point_result = align_points(
89
+ point_frame,
90
+ grid,
91
+ aggregations={"rainfall_mm": "mean"},
92
+ )
93
+ _write_aligned(
94
+ point_result,
95
+ point_output,
96
+ operation="point_alignment",
97
+ source=str(points_path),
98
+ grid=grid,
99
+ parameters={"source_crs": "EPSG:3857", "aggregation": {"rainfall_mm": "mean"}},
100
+ )
101
+
102
+ polygon_path = directory / "landuse.geojson"
103
+ polygons = gpd.GeoDataFrame(
104
+ {"landuse": ["park", "built"], "impervious": [0.1, 0.9]},
105
+ geometry=[box(0, 40, 40, 80), box(40, 0, 80, 40)],
106
+ crs="EPSG:3857",
107
+ )
108
+ polygons.to_file(polygon_path, driver="GeoJSON")
109
+ polygon_output = directory / "landuse.parquet"
110
+ polygon_result = align_polygons(
111
+ polygons,
112
+ grid,
113
+ category_column="landuse",
114
+ numeric_columns=("impervious",),
115
+ )
116
+ _write_aligned(
117
+ polygon_result,
118
+ polygon_output,
119
+ operation="vector_alignment",
120
+ source=str(polygon_path),
121
+ grid=grid,
122
+ parameters={
123
+ "source_crs": "EPSG:3857",
124
+ "category_column": "landuse",
125
+ "numeric_columns": ["impervious"],
126
+ },
127
+ )
128
+
129
+ raster_path = directory / "elevation.tif"
130
+ transform = Affine.translation(0, 80) @ Affine.scale(20, -20)
131
+ with rasterio.open(
132
+ raster_path,
133
+ "w",
134
+ driver="GTiff",
135
+ height=4,
136
+ width=4,
137
+ count=1,
138
+ dtype="float32",
139
+ crs="EPSG:3857",
140
+ transform=transform,
141
+ nodata=-9999.0,
142
+ ) as raster:
143
+ raster.write(np.arange(16, dtype="float32").reshape(4, 4), 1)
144
+ raster_output = directory / "elevation.parquet"
145
+ raster_result = align_raster(raster_path, grid, aggregation="mean")
146
+ _write_aligned(
147
+ raster_result,
148
+ raster_output,
149
+ operation="raster_alignment",
150
+ source=str(raster_path),
151
+ grid=grid,
152
+ parameters={
153
+ "source_crs": "EPSG:3857",
154
+ "aggregation": "mean",
155
+ "resampling": "nearest",
156
+ "band": 1,
157
+ },
158
+ )
159
+
160
+ joined = join_features(grid, point_result, polygon_result, raster_result)
161
+ joined_path = directory / "features.parquet"
162
+ _write_aligned(
163
+ joined,
164
+ joined_path,
165
+ operation="feature_join",
166
+ source=[str(point_output), str(polygon_output), str(raster_output)],
167
+ grid=grid,
168
+ parameters={"feature_datasets": 3},
169
+ )
170
+ report = validate_dataset(joined, grid=grid)
171
+ if report.exit_code:
172
+ raise DatasetError("synthetic demo output did not pass validation")
173
+ return report
gridforge/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ """Domain exceptions raised by GridForge."""
2
+
3
+
4
+ class GridForgeError(Exception):
5
+ """Base class for expected GridForge errors."""
6
+
7
+
8
+ class GridSpecError(GridForgeError, ValueError):
9
+ """A canonical grid specification is invalid."""
10
+
11
+
12
+ class DatasetError(GridForgeError, ValueError):
13
+ """An input or output dataset violates its declared contract."""
@@ -0,0 +1,6 @@
1
+ """Canonical grid specification and construction."""
2
+
3
+ from gridforge.grid.build import create_grid, get_grid_spec
4
+ from gridforge.grid.spec import GridSpec
5
+
6
+ __all__ = ["GridSpec", "create_grid", "get_grid_spec"]
@@ -0,0 +1,57 @@
1
+ """Build canonical cell geometries and stable per-cell identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import geopandas as gpd
6
+ from shapely.geometry import box
7
+
8
+ from gridforge.errors import DatasetError
9
+ from gridforge.grid.spec import GridSpec
10
+
11
+
12
+ def create_grid(spec: GridSpec) -> gpd.GeoDataFrame:
13
+ """Create every canonical cell intersecting the requested bounds."""
14
+ row_start, row_end, col_start, col_end = spec.index_extent
15
+ rows: list[dict[str, object]] = []
16
+ for row in range(row_start, row_end + 1):
17
+ top_decimal = spec.origin.y - row * spec.cell_size
18
+ bottom_decimal = top_decimal - spec.cell_size
19
+ top = float(top_decimal)
20
+ bottom = float(bottom_decimal)
21
+ for column in range(col_start, col_end + 1):
22
+ left_decimal = spec.origin.x + column * spec.cell_size
23
+ right_decimal = left_decimal + spec.cell_size
24
+ left = float(left_decimal)
25
+ right = float(right_decimal)
26
+ rows.append(
27
+ {
28
+ "grid_id": f"{row}:{column}",
29
+ "grid_fingerprint": spec.fingerprint,
30
+ "row": row,
31
+ "column": column,
32
+ "left": left,
33
+ "bottom": bottom,
34
+ "right": right,
35
+ "top": top,
36
+ "geometry": box(left, bottom, right, top),
37
+ }
38
+ )
39
+ grid = gpd.GeoDataFrame(rows, geometry="geometry", crs=spec.crs)
40
+ grid.attrs["gridforge_spec"] = spec.to_dict()
41
+ return grid
42
+
43
+
44
+ def get_grid_spec(grid: gpd.GeoDataFrame) -> GridSpec:
45
+ """Recover and verify the GridForge spec attached to a canonical grid."""
46
+ value = grid.attrs.get("gridforge_spec")
47
+ if value is None:
48
+ raise DatasetError("grid is missing GridForge specification metadata")
49
+ try:
50
+ spec = GridSpec.from_mapping(value)
51
+ except ValueError as exc:
52
+ raise DatasetError(f"grid specification metadata is invalid: {exc}") from exc
53
+ if grid.crs is None or not spec.crs.equals(grid.crs):
54
+ raise DatasetError("grid CRS does not match GridForge specification")
55
+ if "grid_fingerprint" not in grid or not grid["grid_fingerprint"].eq(spec.fingerprint).all():
56
+ raise DatasetError("grid fingerprint does not match GridForge specification")
57
+ return spec