Skip to content
Merged

sync #153

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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
raster_map_frames
testes

chart_frames/
map_frames/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.6.1] — 2026-06-12

### Fixed
- GeoTIFF write with mixed-dtype `band_spec` (e.g. int32 categorical +
float32 continuous) silently truncated float bands to the first band's
dtype. Bands are now promoted to the common NumPy result type
(`np.result_type`) before writing.

### Added
- Test suites for `dissmodel.io` (utils, dispatch, storage, raster, vector,
convert, xarray) and `dissmodel.visualization` (chart, map, env detection).
Coverage: 55% → 79% (319 → 441 tests).

### Notes
- GeoTIFFs containing mixed-dtype bands saved with v0.6.0 may have truncated
float bands. Re-exporting those files is recommended.

---

## [0.6.0] — 2026-06-11

### Breaking Changes
Expand Down
16 changes: 16 additions & 0 deletions dissmodel/io/raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ def save_geotiff(
Affine geotransform. Overrides meta["transform"].
compress : str
Compression algorithm. Default: "deflate".

Notes
-----
GeoTIFF stores a single dtype per file. When band dtypes differ
(e.g. an int32 categorical band alongside a float32 continuous band),
all bands are promoted to their common NumPy result type
(``np.result_type``) before writing.
"""
if not HAS_RASTERIO:
raise ImportError("rasterio is required — pip install rasterio")
Expand Down Expand Up @@ -212,6 +219,15 @@ def _write_geotiff(
if transform is None:
transform = rasterio.transform.from_bounds(0, 0, cols, rows, cols, rows)

# GeoTIFF requires a single dtype for all bands. When bands have mixed
# dtypes (e.g. int32 categorical + float32 continuous), promote every
# band to the common NumPy result type instead of silently truncating
# to the first band's dtype.
dtypes = {arr.dtype for arr in arrays}
if len(dtypes) > 1:
common = np.result_type(*dtypes)
arrays = [arr.astype(common) for arr in arrays]

with rasterio.open(
path, "w",
driver = "GTiff",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "dissmodel"
version = "0.6.0"
version = "0.6.1"
description = "Discrete Spatial Modeling framework for raster and vector simulations"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.10"
Expand Down
47 changes: 47 additions & 0 deletions tests/executor/test_utils_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
tests/executor/test_utils_config.py
====================================
Tests for dissmodel.executor.utils — default_output_uri — and
dissmodel.executor.config — Settings.
"""
from __future__ import annotations

from dissmodel.executor.utils import default_output_uri
from dissmodel.executor.config import Settings, settings
from dissmodel.io import _storage


class TestDefaultOutputUri:

def teardown_method(self):
_storage.set_default_client(None)

def test_s3_uri_when_minio_reachable(self):
_storage.set_default_client(object()) # any non-None client
uri = default_output_uri("exp-123", "tif")
assert uri == "s3://dissmodel-outputs/experiments/exp-123/output.tif"

def test_local_fallback_when_minio_unreachable(self, monkeypatch):
_storage.set_default_client(None)

def boom():
raise RuntimeError("no MinIO")

monkeypatch.setattr(
"dissmodel.io._storage.get_default_client", boom
)
uri = default_output_uri("exp-123", "gpkg")
assert uri == "./outputs/exp-123/output.gpkg"


class TestSettings:

def test_default_output_base(self):
assert Settings().default_output_base == "./outputs"

def test_module_level_singleton_exists(self):
assert isinstance(settings, Settings)

def test_env_var_overrides_default(self, monkeypatch):
monkeypatch.setenv("DEFAULT_OUTPUT_BASE", "/data/runs")
assert Settings().default_output_base == "/data/runs"
157 changes: 157 additions & 0 deletions tests/io/test_convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""
tests/io/test_convert.py
=========================
Tests for dissmodel.io.convert — vector_to_raster_backend.

Covers: GeoDataFrame and file-path sources, attrs as list and dict,
mask band, nodata_value sentinel, CRS validation/reprojection,
error paths, and the deprecated shapefile_to_raster_backend alias.
"""
from __future__ import annotations

import numpy as np
import pytest

gpd = pytest.importorskip("geopandas")
pytest.importorskip("rasterio")

from shapely.geometry import Polygon # noqa: E402

from dissmodel.io.convert import ( # noqa: E402
vector_to_raster_backend,
shapefile_to_raster_backend,
)


@pytest.fixture
def gdf():
"""Two unit squares side by side covering x∈[0,2], y∈[0,1]."""
geoms = [
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
Polygon([(1, 0), (2, 0), (2, 1), (1, 1)]),
]
return gpd.GeoDataFrame(
{"uso": [1, 2], "alt": [0.25, 0.75]},
geometry=geoms,
crs="EPSG:31984",
)


class TestVectorToRasterBackend:

def test_grid_dimensions_follow_bounds_and_resolution(self, gdf):
b = vector_to_raster_backend(gdf, resolution=0.5, attrs=["uso"])
# bounds 2x1, resolution 0.5 → 4 cols × 2 rows
assert b.shape == (2, 4)

def test_integer_column_rasterized_as_int32(self, gdf):
b = vector_to_raster_backend(gdf, resolution=0.5, attrs=["uso"])
arr = b.arrays["uso"]
assert arr.dtype == np.int32
# Left half burned with 1, right half with 2
assert np.all(arr[:, :2] == 1)
assert np.all(arr[:, 2:] == 2)

def test_float_column_rasterized_as_float32(self, gdf):
b = vector_to_raster_backend(gdf, resolution=0.5, attrs=["alt"])
arr = b.arrays["alt"]
assert arr.dtype == np.float32
np.testing.assert_allclose(arr[:, :2], 0.25)
np.testing.assert_allclose(arr[:, 2:], 0.75)

def test_mask_band_added_by_default(self, gdf):
b = vector_to_raster_backend(gdf, resolution=0.5, attrs=["uso"])
assert "mask" in b.arrays
assert np.all(b.arrays["mask"] == 1.0) # fully covered extent

def test_add_mask_false_omits_band(self, gdf):
b = vector_to_raster_backend(
gdf, resolution=0.5, attrs=["uso"], add_mask=False
)
assert "mask" not in b.arrays

def test_attrs_dict_with_per_column_defaults(self, gdf):
b = vector_to_raster_backend(
gdf, resolution=0.5, attrs={"uso": -1, "alt": -9999.0}
)
assert set(b.arrays) >= {"uso", "alt"}

def test_nodata_value_sentinel_outside_coverage(self):
# Two squares at opposite corners of a 2x2 extent — the other two
# corner cells of the grid fall outside any geometry.
gdf2 = gpd.GeoDataFrame(
{"uso": [5, 7]},
geometry=[
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
Polygon([(1.9, 1.9), (2, 1.9), (2, 2), (1.9, 2)]),
],
crs="EPSG:31984",
)
b = vector_to_raster_backend(
gdf2, resolution=1.0, attrs=["uso"], nodata_value=-1
)
arr = b.arrays["uso"]
mask = b.arrays["mask"].astype(bool)
assert np.all(arr[~mask] == -1) # sentinel outside coverage
assert b.nodata_value == -1

def test_source_gdf_is_not_mutated(self, gdf):
original_crs = gdf.crs
vector_to_raster_backend(gdf, resolution=0.5, attrs=["uso"], crs="EPSG:4326")
assert gdf.crs == original_crs

def test_reprojection_applied_when_crs_given(self, gdf):
b = vector_to_raster_backend(
gdf, resolution=0.00001, attrs=["uso"], crs="EPSG:4326"
)
assert b.crs is not None
assert "4326" in str(b.crs)

def test_file_path_source(self, gdf, tmp_path):
path = tmp_path / "grid.gpkg"
gdf.to_file(str(path), driver="GPKG")
b = vector_to_raster_backend(str(path), resolution=0.5, attrs=["uso"])
assert b.shape == (2, 4)

# ── error paths ───────────────────────────────────────────────────────────

def test_missing_file_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
vector_to_raster_backend(
str(tmp_path / "missing.shp"), resolution=1, attrs=["uso"]
)

def test_empty_attrs_raises(self, gdf):
with pytest.raises(ValueError, match="must not be empty"):
vector_to_raster_backend(gdf, resolution=0.5, attrs=[])

def test_missing_column_raises(self, gdf):
with pytest.raises(ValueError, match="not found"):
vector_to_raster_backend(gdf, resolution=0.5, attrs=["inexistente"])

def test_no_crs_anywhere_raises(self, gdf):
# Build a GeoDataFrame without CRS from scratch — overriding
# .crs on an existing one is deprecated by pandas/geopandas.
naked = gpd.GeoDataFrame(
gdf.drop(columns="geometry"),
geometry=list(gdf.geometry), # raw shapely geoms — no CRS
)
assert naked.crs is None
with pytest.raises(ValueError, match="no CRS"):
vector_to_raster_backend(naked, resolution=0.5, attrs=["uso"])

def test_gdf_without_crs_but_explicit_crs_ok(self, gdf):
naked = gdf.copy()
naked = naked.set_crs("EPSG:31984", allow_override=True)
b = vector_to_raster_backend(
naked, resolution=0.5, attrs=["uso"], crs="EPSG:31984"
)
assert b.shape == (2, 4)


class TestDeprecatedAlias:

def test_shapefile_to_raster_backend_warns(self, gdf):
with pytest.warns(FutureWarning, match="deprecated"):
b = shapefile_to_raster_backend(gdf, resolution=0.5, attrs=["uso"])
assert b.shape == (2, 4)
Loading
Loading