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
6 changes: 5 additions & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ authors:
given-names: "Sérgio Souza"
orcid: "https://orcid.org/0000-0002-0232-4549"
title: "DisSCube: Declarative Spatial Layer for Dynamic Models"
version: 0.2.0
abstract: "An open-source, declarative engine for constructing cellular spatial data cubes for dynamic modeling, spatial simulation, and environmental analysis."
version: 0.3.0
date-released: "2026-09-29"
url: "https://github.com/DisSModel/disscube"
repository-code: "https://github.com/DisSModel/disscube"
license: MIT
Expand All @@ -14,4 +16,6 @@ keywords:
- "geospatial"
- "land use and land cover change"
- "brazil data cube"
- "cellular automata"
- "reproducibility"
- "fair-software"
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
include LICENSE
include README.md
recursive-include disscube/data *
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,19 +234,17 @@ data/derived/{grid_id}/{partition}/{spec_hash}/{variable_name}.zarr

```
disscube/
├── client/ CubeClient — public entry point
├── models/ GridSpec, SpatialSource, SpatialDerivation, Variable…
├── derivation.py Declarative Derivation (front end over SpatialDerivation)
├── client.py CubeClient — public entry point
├── models/ GridSpec, SpatialSource, SpatialDerivation, Variable, Derivation…
├── operators/ Operators as classes (self-registered via __init_subclass__)
│ ├── base.py Operator ABC + OPERATOR_REGISTRY
│ ├── zonal.py mean, sum, majority, percentage, attribute, presence…
│ └── proximity.py distance, min_distance, count
├── pipeline/ Stages: Normalizer → GridAligner → Aggregator → Writer
├── pipeline/ Pipeline execution & planning (schema, runner) + internal stages
├── catalog/ CatalogStore (Protocol) + SQLite and JSON implementations
├── storage/ AssetStore (fsspec — local and S3)
├── storage.py AssetStore (fsspec — local and S3)
├── api/ Experimental HTTP API (optional `api` extra)
├── config/ Pipeline files (TOML): schema, planning, running
├── cli.py `disscube validate` / `disscube run`
├── cli.py `disscube validate` / `disscube run` / `disscube export`
├── sources/ Adapters that bring external data in as SpatialSources,
│ │ each with a checksum and a provenance.json sidecar
│ ├── _raster.py Window2D, windowed reads, composites, mosaics, register_raster
Expand All @@ -255,8 +253,7 @@ disscube/
│ ├── mapbiomas.py MapBiomas annual land-cover maps (Collection 11, 10 m series)
│ ├── prodes.py PRODES deforestation (download + cache, legend from the .qml)
│ └── classified.py any classified map with its legend, e.g. from SITS
└── utils/ Grids (grids.py), BDC tile geometry (bdc_importer.py),
checksums (files.py)
└── utils.py Checksums (sha256_file) and BDC tile importer (import_bdc_grids)
```

## Adding a new operator
Expand Down Expand Up @@ -319,6 +316,20 @@ The `purity_threshold` field on `Derivation` is included in `spec_hash` but is n
**STAC: reading only**
`disscube.sources.bdc` reads Brazil Data Cube cubes through their STAC catalog (search, windowed reads, per-tile composites, mosaics) and writes local GeoTIFFs that are registered as ordinary sources. Derived variables are not published back as STAC, and the `valid_from`/`valid_until` and `bbox` fields on `Derivation` only follow STAC naming conventions.

## Citation

If you use DisSCube in your research, dynamic modeling, or spatial data pipelines, please cite it using the metadata from [`CITATION.cff`](CITATION.cff) or the following BibTeX entry:

```bibtex
@software{costa_disscube_2026,
author = {Costa, S{\'e}rgio Souza},
title = {{DisSCube: Declarative Spatial Layer for Dynamic Models}},
year = {2026},
version = {0.3.0},
url = {https://github.com/DisSModel/disscube}
}
```

## License

DisSCube is part of the DisSModel ecosystem and is released under the MIT License. See [LICENSE](LICENSE) for details.
20 changes: 17 additions & 3 deletions disscube/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
from disscube.client import CubeClient
from disscube.derivation import Derivation
from disscube.models import DerivedVariable, GridSpec, SpatialDerivation, SpatialSource, Variable
from disscube.models import (
Derivation,
DerivedVariable,
GridSpec,
SpatialDerivation,
SpatialSource,
Variable,
)

__all__ = ["CubeClient", "Derivation", "DerivedVariable", "GridSpec", "SpatialDerivation", "SpatialSource", "Variable"]
__all__ = [
"CubeClient",
"Derivation",
"DerivedVariable",
"GridSpec",
"SpatialDerivation",
"SpatialSource",
"Variable",
]
11 changes: 7 additions & 4 deletions disscube/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@
"The DisSCube HTTP API requires the optional 'api' extra: pip install \"disscube[api]\""
) from exc

import os

from disscube.client import CubeClient
from disscube.models import DerivedVariable, GridSpec, SpatialDerivation, SpatialSource

from . import config
DEFAULT_CATALOG_PATH = os.getenv("DISSCUBE_CATALOG", "./catalog.db")
DEFAULT_STORE_PATH = os.getenv("DISSCUBE_STORE", "./data/")


def get_cube(request: Request) -> CubeClient:
Expand All @@ -48,16 +51,16 @@ def create_app(catalog_path: str | None = None, store_path: str | None = None) -
"""
Build the API application.

Parameters default to ``config.CATALOG_PATH`` / ``config.STORE_PATH``.
Parameters default to ``DEFAULT_CATALOG_PATH`` / ``DEFAULT_STORE_PATH``.
The ``CubeClient`` is created in the application lifespan, so building
the app has no side effects on disk.
"""

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.cube = CubeClient(
catalog_path or config.CATALOG_PATH,
store_path or config.STORE_PATH,
catalog_path or DEFAULT_CATALOG_PATH,
store_path or DEFAULT_STORE_PATH,
)
yield

Expand Down
4 changes: 0 additions & 4 deletions disscube/api/config.py

This file was deleted.

84 changes: 70 additions & 14 deletions disscube/cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""
Command line: run DisSCube pipeline files.

disscube validate pipeline.toml
disscube validate pipeline.toml [--json]
disscube fetch pipeline.toml
disscube run pipeline.toml [--workspace DIR] [--output OUT.tif] [-v]
disscube export pipeline.toml --output OUT.tif [--workspace DIR] [--variables ...] [-v]
disscube run pipeline.toml [--workspace DIR] [--output OUT.tif] [--dry-run] [--json] [-v]
disscube export pipeline.toml --output OUT.tif [--workspace DIR] [--variables ...] [--json] [-v]
"""

from __future__ import annotations
Expand All @@ -20,6 +20,7 @@ def main(argv: list[str] | None = None) -> int:

p_val = sub.add_parser("validate", help="check a pipeline file without fetching anything")
p_val.add_argument("file")
p_val.add_argument("--json", action="store_true", help="output validation result as JSON")

p_fetch = sub.add_parser("fetch", help="download and verify remote file sources declared in the pipeline")
p_fetch.add_argument("file")
Expand All @@ -29,27 +30,31 @@ def main(argv: list[str] | None = None) -> int:
p_run.add_argument("--workspace", help="output folder (default: the file's 'workspace', "
"else a folder named after the file)")
p_run.add_argument("--output", "-o", help="export derived variables to a multi-band GeoTIFF")
p_run.add_argument("--dry-run", action="store_true", help="simulate plan execution without downloading or computing")
p_run.add_argument("--json", action="store_true", help="output execution report as JSON")
p_run.add_argument("-v", "--verbose", action="store_true", help="log each step")

p_exp = sub.add_parser("export", help="export derived variables from an existing data cube to GeoTIFF")
p_exp.add_argument("file", help="pipeline TOML file")
p_exp.add_argument("--output", "-o", required=True, help="output GeoTIFF file path (e.g. data/cellspace.tif)")
p_exp.add_argument("--workspace", help="workspace folder (default: data/cube or from pipeline)")
p_exp.add_argument("--variables", nargs="*", help="specific variables to export (default: all derived)")
p_exp.add_argument("--json", action="store_true", help="output export result as JSON")
p_exp.add_argument("-v", "--verbose", action="store_true", help="log each step")

args = parser.parse_args(argv)
from disscube.config import plan, run
from disscube.config.runner import PipelineError
from disscube.pipeline import PipelineError, plan, run

logging.basicConfig(level=logging.INFO if getattr(args, "verbose", False) else logging.WARNING,
format="%(levelname)s %(name)s: %(message)s")
is_json = getattr(args, "json", False)
try:
p = plan(args.file)
print(p.summary())
if not is_json:
print(p.summary())
if args.command == "fetch":
from disscube.config.runner import _fetch_file_source, _local_file, _raw_cache_dir, _resolve
from disscube.config.schema import FileSource
from disscube.pipeline.runner import _fetch_file_source, _local_file, _raw_cache_dir, _resolve
from disscube.pipeline.schema import FileSource
raw = _raw_cache_dir()
raw.mkdir(parents=True, exist_ok=True)
fetched = 0
Expand All @@ -63,18 +68,69 @@ def main(argv: list[str] | None = None) -> int:
print(f"Fetch completed: {fetched} remote sources verified.")
return 0
if args.command == "validate":
print("OK")
if is_json:
import json
print(json.dumps({
"status": "ok",
"file": str(p.file.path),
"name": p.file.config.name,
"grid": p.grid.name if p.grid else None,
"sources": [s.id for s in p.sources],
"derives": [d.target for d in p.derives],
}, indent=2))
else:
print("OK")
return 0
if getattr(args, "dry_run", False):
if is_json:
import json
print(json.dumps({
"status": "ok",
"dry_run": True,
"file": str(p.file.path),
"grid": p.grid.name if p.grid else None,
"sources": [{"id": s.id, "type": s.config.type} for s in p.sources],
"derives": [{"target": d.target, "source": d.source, "operator": d.operator} for d in p.derives],
}, indent=2))
else:
print(f"[dry-run] Plan is valid. Would process {len(p.sources)} sources and derive {len(p.derives)} variables.")
return 0
if args.command == "export":
from disscube.config.runner import export_cube
from disscube.pipeline import export_cube
exp = export_cube(p, output=args.output, workspace=args.workspace, variables=args.variables)
print(f"workspace : {exp.workspace}")
print(f"exported : {len(exp.variables)} variables on {exp.grid_id}")
print(f"output : {exp.output}")
if is_json:
import json
print(json.dumps({
"status": "ok",
"workspace": str(exp.workspace),
"grid_id": exp.grid_id,
"variables": exp.variables,
"output": str(exp.output),
}, indent=2))
else:
print(f"workspace : {exp.workspace}")
print(f"exported : {len(exp.variables)} variables on {exp.grid_id}")
print(f"output : {exp.output}")
return 0
report = run(p, workspace=args.workspace, export_geotiff=getattr(args, "output", None))
if is_json:
import json
print(json.dumps({
"status": "ok",
"workspace": str(report.workspace),
"grid_id": report.grid_id,
"sources": report.sources,
"derived": report.derived,
"exported": str(report.exported) if report.exported else None,
"record": str(report.record),
}, indent=2, default=str))
return 0
except PipelineError as exc:
print(f"error: {exc}", file=sys.stderr)
if is_json:
import json
print(json.dumps({"status": "error", "error": str(exc)}, indent=2), file=sys.stderr)
else:
print(f"error: {exc}", file=sys.stderr)
return 2
print(f"workspace : {report.workspace}")
print(f"derived : {len(report.derived)} products on {report.grid_id}")
Expand Down
13 changes: 10 additions & 3 deletions disscube/client/cube_client.py → disscube/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import os
import sys
from typing import TYPE_CHECKING

import numpy as np
Expand All @@ -21,9 +22,9 @@
# circular import; dissmodel is only needed by to_lucc_data().
from dissmodel.geo.raster.backend import RasterBackend

from disscube.derivation import Derivation
from disscube.models import Derivation

log = logging.getLogger(__name__)
log = logging.getLogger("disscube.client.cube_client")


class CubeClient:
Expand Down Expand Up @@ -198,7 +199,7 @@ def _exists(d: DerivedVariable) -> bool:
return ok

temporal = [d for d in matches if d.times and _exists(d)]
static = [d for d in matches if not d.times and _exists(d)]
static = [d for d in matches if not d.times and _exists(d)]

if temporal:
# Stack temporal slices along time axis sorted by first time value
Expand Down Expand Up @@ -363,3 +364,9 @@ def to_data_source(self, derived_id: str) -> dict:
"checksum": derived.content_hash,
"type": "local" if derived.asset_url.startswith("/") else "s3"
}


# Backward-compatibility alias for legacy code importing disscube.client.cube_client
sys.modules[f"{__name__}.cube_client"] = sys.modules[__name__]

__all__ = ["CubeClient"]
3 changes: 0 additions & 3 deletions disscube/client/__init__.py

This file was deleted.

15 changes: 0 additions & 15 deletions disscube/config/__init__.py

This file was deleted.

20 changes: 18 additions & 2 deletions disscube/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
from .grid import GridAnchor, GridSpec, SpatialRelation
from .derivation import Derivation
from .grid import (
BDC_CRS,
BRAZIL_BBOX,
SIMULATION_GRIDS,
GridAnchor,
GridSpec,
SpatialRelation,
register_local_grid,
register_simulation_grids,
)
from .variable import DerivedVariable, SpatialDerivation, SpatialSource, Variable

__all__ = [
"BDC_CRS",
"BRAZIL_BBOX",
"SIMULATION_GRIDS",
"Derivation",
"DerivedVariable",
"GridAnchor",
"GridSpec",
"SpatialDerivation",
"SpatialRelation",
"SpatialSource",
"Variable"
"Variable",
"register_local_grid",
"register_simulation_grids",
]
Loading
Loading