Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ jobs:
- name: Tests
run: pytest --cov=geodebug --cov-report=term-missing

integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install geospatial extras
run: python -m pip install -e '.[dev,all]'
- name: Integration tests
run: pytest tests/integration

smoke:
strategy:
matrix:
Expand Down
86 changes: 65 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,93 @@ Deterministic diagnostics for geospatial data and workflows.

**Find the geographic bug, not just the code bug.**

GeoDebug is an early-stage GeoGeekLab project for turning geospatial correctness rules into explicit, testable diagnostics. The core is intentionally independent of GIS file formats and language models: adapters produce normalized facts, rules evaluate spatial invariants, and reporters expose stable diagnostics.
GeoDebug turns geospatial correctness rules into explicit, testable diagnostics. Adapters
observe datasets, rules evaluate spatial invariants, and the canonical JSON report provides a
stable integration boundary for CLI, CI, and future agent tooling.

## Status
## Install

The repository is in kernel development. The current vertical slice includes the fact model, rule engine, canonical report schema, CLI rule inspection, and three sentinel rules:
Core GeoJSON support:

```bash
pip install geodebug
```

Optional format support:

```bash
pip install "geodebug[vector]" # GPKG, Shapefile, FlatGeobuf
pip install "geodebug[raster]" # GeoTIFF, COG, VRT
pip install "geodebug[parquet]" # GeoParquet
pip install "geodebug[geopandas]" # in-memory GeoDataFrame
pip install "geodebug[all]"
```

## Usage

Inspect normalized facts without diagnostics:

```bash
geodebug inspect roads.geojson
```

Run dataset checks:

```bash
geodebug check roads.geojson
geodebug check roads.geojson --deep
```

Compare two datasets:

```bash
geodebug compare dem-a.tif dem-b.tif
```

Emit the canonical JSON report:

```bash
geodebug check roads.geojson --format json
```

The default path is metadata-first. Full vector geometry scans are opt-in with `--deep`.

## Built-in rules

The v0.1 usable core currently includes:

- `GEO101` — missing CRS
- `GEO201` — invalid geometry
- `GEO402` — no spatial overlap
- `GEO404` — raster grid misalignment
- `GEO501` — metric buffer on a geographic CRS

File-format adapters and user-facing `check` / `compare` workflows are the next milestone.
Inspect rule contracts with:

```bash
geodebug rules list
geodebug rules show GEO404
```

## Architecture

```text
adapter -> facts -> rules -> diagnostics -> report
```

Rules never read files directly and adapters never emit diagnostics.
Rules never read files directly and adapters never emit diagnostics. Unknown evidence remains
`UNKNOWN`; it is never silently treated as a pass.

See [`docs/architecture.md`](docs/architecture.md) for the kernel contracts.

## Development

```bash
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
pip install -e '.[dev,all]'

ruff check .
mypy src/geodebug
pytest
```

Inspect the registered rules:

```bash
geodebug rules list
geodebug rules show GEO501
```

Print the canonical report JSON Schema:

```bash
geodebug schema
```

See [`docs/architecture.md`](docs/architecture.md) for the kernel contracts.
18 changes: 17 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,30 @@ build-backend = "hatchling.build"

[project]
name = "geodebug"
version = "0.0.1.dev0"
version = "0.1.0.dev0"
description = "Deterministic diagnostics for geospatial data and workflows."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GeoGeekLab" }]
dependencies = [
"pydantic>=2.9,<3",
"pyproj>=3.6,<4",
"rich>=13.9,<15",
"shapely>=2.0,<3",
"typer>=0.15,<1",
]

[project.optional-dependencies]
vector = ["geopandas>=1.0,<2", "pyogrio>=0.10,<1"]
raster = ["rasterio>=1.4,<2"]
parquet = ["pyarrow>=17,<30"]
geopandas = ["geopandas>=1.0,<2"]
all = [
"geopandas>=1.0,<2",
"pyarrow>=17,<30",
"pyogrio>=0.10,<1",
"rasterio>=1.4,<2",
]
dev = [
"jsonschema>=4.23,<5",
"mypy>=1.13,<2",
Expand Down Expand Up @@ -49,6 +61,10 @@ python_version = "3.12"
strict = true
packages = ["geodebug"]

[[tool.mypy.overrides]]
module = ["geopandas.*", "pyarrow.*", "pyogrio.*", "rasterio.*", "shapely.*"]
ignore_missing_imports = true

[tool.coverage.run]
source = ["geodebug"]
branch = true
Expand Down
4 changes: 2 additions & 2 deletions src/geodebug/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from geodebug.api import evaluate
from geodebug.api import check, compare, evaluate, inspect
from geodebug.models.report import Report
from geodebug.version import __version__

__all__ = ["Report", "__version__", "evaluate"]
__all__ = ["Report", "__version__", "check", "compare", "evaluate", "inspect"]
Empty file.
36 changes: 36 additions & 0 deletions src/geodebug/adapters/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol

from geodebug.models.subjects import DatasetSnapshot


@dataclass(frozen=True, slots=True)
class InspectOptions:
deep: bool = False


class AdapterError(RuntimeError):
pass


class AdapterDependencyError(AdapterError):
pass


class UnsupportedTargetError(AdapterError):
pass


class Adapter(Protocol):
name: str

def supports(self, target: Any) -> int: ...

def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: ...


def path_subject_id(path: Path) -> str:
return f"file:{path.as_posix()}"
16 changes: 16 additions & 0 deletions src/geodebug/adapters/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from geodebug.adapters.geojson import GeoJSONAdapter
from geodebug.adapters.geopandas import GeoPandasAdapter
from geodebug.adapters.parquet import GeoParquetAdapter
from geodebug.adapters.pyogrio import PyogrioAdapter
from geodebug.adapters.rasterio import RasterioAdapter
from geodebug.adapters.registry import AdapterRegistry


def build_default_adapter_registry() -> AdapterRegistry:
registry = AdapterRegistry()
registry.register(GeoPandasAdapter())
registry.register(GeoJSONAdapter())
registry.register(GeoParquetAdapter())
registry.register(RasterioAdapter())
registry.register(PyogrioAdapter())
return registry
124 changes: 124 additions & 0 deletions src/geodebug/adapters/geojson.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry

from geodebug.adapters.base import AdapterError, InspectOptions, path_subject_id
from geodebug.facts.crs import build_crs_facts
from geodebug.facts.keys import (
SPATIAL_BOUNDS,
VECTOR_EMPTY_GEOMETRY_COUNT,
VECTOR_GEOMETRY_COUNT,
VECTOR_GEOMETRY_TYPES,
VECTOR_INVALID_GEOMETRY_COUNT,
)
from geodebug.models.enums import SubjectKind
from geodebug.models.facts import FactProvenance, FactRecord, FactStore
from geodebug.models.subjects import DatasetSnapshot, SubjectRef


class GeoJSONAdapter:
name = "geojson"

def supports(self, target: Any) -> int:
if not isinstance(target, (str, Path)):
return 0
suffix = Path(target).suffix.casefold()
return 100 if suffix in {".geojson", ".json"} else 0

def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot:
path = Path(target)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise AdapterError(f"unable to read GeoJSON: {path}") from exc

geometries = _geometries(payload)
provenance = FactProvenance(origin=self.name, method="geojson")
records = list(build_crs_facts("OGC:CRS84", origin=self.name, method="rfc7946"))
records.extend(_geometry_facts(geometries, provenance=provenance, deep=options.deep))
bounds = _total_bounds(geometries)
if bounds is None:
records.append(FactRecord.unknown(SPATIAL_BOUNDS, provenance=provenance))
else:
records.append(FactRecord.known(SPATIAL_BOUNDS, bounds, provenance=provenance))

return DatasetSnapshot(
subject=SubjectRef(
id=path_subject_id(path),
kind=SubjectKind.VECTOR,
uri=path.as_posix(),
label=path.name,
adapter=self.name,
),
facts=FactStore(records),
)


def _geometries(payload: Any) -> list[BaseGeometry]:
if not isinstance(payload, dict):
raise AdapterError("GeoJSON root must be an object")

object_type = payload.get("type")
raw: list[Any]
if object_type == "FeatureCollection":
features = payload.get("features")
if not isinstance(features, list):
raise AdapterError("GeoJSON FeatureCollection must contain a features array")
raw = [feature.get("geometry") for feature in features if isinstance(feature, dict)]
elif object_type == "Feature":
raw = [payload.get("geometry")]
else:
raw = [payload]

geometries: list[BaseGeometry] = []
for item in raw:
if item is None:
continue
try:
geometries.append(shape(item))
except (AttributeError, TypeError, ValueError) as exc:
raise AdapterError("GeoJSON contains an invalid geometry object") from exc
return geometries


def _geometry_facts(
geometries: list[BaseGeometry],
*,
provenance: FactProvenance,
deep: bool,
) -> tuple[FactRecord, ...]:
count = len(geometries)
empty_count = sum(geometry.is_empty for geometry in geometries)
geometry_types = tuple(sorted({geometry.geom_type for geometry in geometries}))
records = [
FactRecord.known(VECTOR_GEOMETRY_COUNT, count, provenance=provenance),
FactRecord.known(VECTOR_EMPTY_GEOMETRY_COUNT, empty_count, provenance=provenance),
FactRecord.known(VECTOR_GEOMETRY_TYPES, geometry_types, provenance=provenance),
]
if deep:
records.append(
FactRecord.known(
VECTOR_INVALID_GEOMETRY_COUNT,
sum(not geometry.is_valid for geometry in geometries),
provenance=provenance,
)
)
else:
records.append(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance))
return tuple(records)


def _total_bounds(geometries: list[BaseGeometry]) -> tuple[float, float, float, float] | None:
non_empty = [geometry for geometry in geometries if not geometry.is_empty]
if not non_empty:
return None
xmin = min(geometry.bounds[0] for geometry in non_empty)
ymin = min(geometry.bounds[1] for geometry in non_empty)
xmax = max(geometry.bounds[2] for geometry in non_empty)
ymax = max(geometry.bounds[3] for geometry in non_empty)
return (float(xmin), float(ymin), float(xmax), float(ymax))
Loading
Loading