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
18 changes: 18 additions & 0 deletions .geodebug.toml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# GeoDebug project policy.
profile = "default"
fail_on = "error"

[rules]
disable = []

# Explicit severity overrides take precedence over the selected profile.
[rules.severity]
# GEO101 = "error"

# Suppressions require an explanation. An optional expiry date keeps temporary
# exceptions from becoming permanent policy.
# [[suppress]]
# rule = "GEO103"
# path = "legacy/*.geojson"
# reason = "Known upstream coordinate convention"
# expires = 2027-01-01
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Run dataset checks:

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

Compare two datasets:
Expand All @@ -47,29 +47,68 @@ Compare two datasets:
geodebug compare dem-a.tif dem-b.tif
```

Check an operation before execution:

```bash
geodebug preflight roads.geojson --operation buffer --distance 500
geodebug preflight parcels.geojson --operation area
```

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`.
The default path is metadata-first. Full geometry or raster scans are opt-in with `--deep`.

## Project policy

GeoDebug searches the current directory and its parents for `.geodebug.toml`. An explicit
config path can be supplied with `--config`.

```toml
profile = "default"
fail_on = "error"

[rules]
disable = []

[rules.severity]
GEO101 = "error"

[[suppress]]
rule = "GEO103"
path = "legacy/*.geojson"
reason = "Known upstream coordinate convention"
expires = 2027-01-01
```

Profiles do not change rule truth values. `strict` promotes warnings to errors;
`exploratory` demotes warnings to notes. Explicit severity overrides take precedence.
Suppressions require a reason and may expire.

## Built-in rules

The v0.1 usable core currently includes:
The current v0.1 rule set includes:

- `GEO101` — missing CRS
- `GEO103` — geographic coordinate out of range
- `GEO105` — projected data outside CRS area of use
- `GEO201` — invalid geometry
- `GEO301` — invalid or singular raster transform
- `GEO304` — NoData value conflicts with the validity mask
- `GEO402` — no spatial overlap
- `GEO404` — raster grid misalignment
- `GEO501` — metric buffer on a geographic CRS
- `GEO502` — planar area on a geographic CRS
- `GEO503` — planar distance or length on a geographic CRS

Inspect rule contracts with:

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

## Architecture
Expand All @@ -94,3 +133,6 @@ ruff check .
mypy src/geodebug
pytest
```

Golden rule cases live in `tests/golden/cases.toml`. Each case declares both expected
diagnostics and rules that must not be reported.
12 changes: 10 additions & 2 deletions src/geodebug/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
from geodebug.api import check, compare, evaluate, inspect
from geodebug.api import check, compare, evaluate, inspect, preflight
from geodebug.models.report import Report
from geodebug.version import __version__

__all__ = ["Report", "__version__", "check", "compare", "evaluate", "inspect"]
__all__ = [
"Report",
"__version__",
"check",
"compare",
"evaluate",
"inspect",
"preflight",
]
50 changes: 48 additions & 2 deletions src/geodebug/adapters/rasterio.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
RASTER_DTYPES,
RASTER_HEIGHT,
RASTER_NODATA,
RASTER_NODATA_COLLISION_BANDS,
RASTER_RESOLUTION,
RASTER_TRANSFORM,
RASTER_WIDTH,
SPATIAL_BOUNDS,
)
from geodebug.models.enums import SubjectKind
from geodebug.models.enums import FactState, SubjectKind
from geodebug.models.facts import FactProvenance, FactRecord, FactStore
from geodebug.models.subjects import DatasetSnapshot, SubjectRef

Expand All @@ -35,7 +36,6 @@ def supports(self, target: Any) -> int:
return 90 if Path(target).suffix.casefold() in self._suffixes else 0

def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot:
del options
try:
import rasterio
except ImportError as exc:
Expand Down Expand Up @@ -97,6 +97,13 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot:
),
)
)
records.append(
_nodata_collision_fact(
dataset,
deep=options.deep,
provenance=provenance,
)
)
except Exception as exc:
raise AdapterError(f"unable to inspect raster dataset: {path}") from exc

Expand All @@ -110,3 +117,42 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot:
),
facts=FactStore(records),
)


def _nodata_collision_fact(
dataset: Any,
*,
deep: bool,
provenance: FactProvenance,
) -> FactRecord:
nodata = dataset.nodata
if nodata is None:
return FactRecord(
key=RASTER_NODATA_COLLISION_BANDS,
state=FactState.NOT_APPLICABLE,
provenance=provenance,
)
if not deep:
return FactRecord.unknown(
RASTER_NODATA_COLLISION_BANDS,
provenance=provenance,
)

collisions: list[int] = []
for band_index in range(1, dataset.count + 1):
found = False
for _, window in dataset.block_windows(band_index):
values = dataset.read(band_index, window=window)
valid_mask = dataset.dataset_mask(window=window) != 0
nodata_values = values != values if nodata != nodata else values == nodata
if (nodata_values & valid_mask).any():
found = True
break
if found:
collisions.append(band_index)

return FactRecord.known(
RASTER_NODATA_COLLISION_BANDS,
tuple(collisions),
provenance=FactProvenance(origin="rasterio", method="deep.nodata-mask-scan"),
)
22 changes: 22 additions & 0 deletions src/geodebug/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from geodebug.adapters.base import InspectOptions
Expand All @@ -9,6 +10,7 @@
from geodebug.engine.policy import Policy
from geodebug.facts.relations import build_relation_facts
from geodebug.models.context import EvaluationContext
from geodebug.models.operations import OperationContext
from geodebug.models.report import Report
from geodebug.models.subjects import DatasetSnapshot

Expand Down Expand Up @@ -50,6 +52,26 @@ def compare(
return evaluate(context, policy=policy)


def preflight(
target: Any,
*,
operation: str,
parameters: Mapping[str, Any] | None = None,
deep: bool = False,
policy: Policy | None = None,
) -> Report:
"""Evaluate operation-aware diagnostics before an operation is executed."""
snapshot = inspect(target, deep=deep)
context = EvaluationContext(
subjects=(snapshot,),
operation=OperationContext(
name=operation,
parameters=parameters or {},
),
)
return evaluate(context, policy=policy)


def evaluate(context: EvaluationContext, *, policy: Policy | None = None) -> Report:
"""Evaluate built-in rules against an already-normalized context."""
return Evaluator(build_default_registry(), policy=policy).evaluate(context)
Loading
Loading