pygeodata 0.0.0__tar.gz
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.
- pygeodata-0.0.0/LICENSE +21 -0
- pygeodata-0.0.0/PKG-INFO +36 -0
- pygeodata-0.0.0/README.md +17 -0
- pygeodata-0.0.0/pygeodata/__init__.py +12 -0
- pygeodata-0.0.0/pygeodata/base.py +21 -0
- pygeodata-0.0.0/pygeodata/config.py +40 -0
- pygeodata-0.0.0/pygeodata/drivers/__init__.py +3 -0
- pygeodata-0.0.0/pygeodata/drivers/rioxarray.py +92 -0
- pygeodata-0.0.0/pygeodata/loader.py +90 -0
- pygeodata-0.0.0/pygeodata/options.py +45 -0
- pygeodata-0.0.0/pygeodata/paths.py +33 -0
- pygeodata-0.0.0/pygeodata/processors/__init__.py +4 -0
- pygeodata-0.0.0/pygeodata/processors/rasterizer.py +100 -0
- pygeodata-0.0.0/pygeodata/processors/reprojection.py +168 -0
- pygeodata-0.0.0/pygeodata/types.py +54 -0
- pygeodata-0.0.0/pygeodata/utils.py +5 -0
- pygeodata-0.0.0/pygeodata.egg-info/PKG-INFO +36 -0
- pygeodata-0.0.0/pygeodata.egg-info/SOURCES.txt +23 -0
- pygeodata-0.0.0/pygeodata.egg-info/dependency_links.txt +1 -0
- pygeodata-0.0.0/pygeodata.egg-info/requires.txt +8 -0
- pygeodata-0.0.0/pygeodata.egg-info/top_level.txt +1 -0
- pygeodata-0.0.0/pyproject.toml +30 -0
- pygeodata-0.0.0/setup.cfg +4 -0
- pygeodata-0.0.0/tests/test_config.py +29 -0
- pygeodata-0.0.0/tests/test_loader.py +282 -0
pygeodata-0.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Jasper Roebroek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pygeodata-0.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygeodata
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Lightweight framework for geospatial data access and processing
|
|
5
|
+
Author-email: Jasper Roebroek <roebroek.jasper@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: rioxarray
|
|
12
|
+
Requires-Dist: geopandas
|
|
13
|
+
Requires-Dist: dask
|
|
14
|
+
Requires-Dist: zarr
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# pygeodata
|
|
21
|
+
|
|
22
|
+
**Lightweight framework for geospatial data access and processing**
|
|
23
|
+
|
|
24
|
+
`pygeodata` provides simple, composable tools for working with geospatial raster and vector data. It aims to make spatial data handling consistent, efficient, and compatible with large-scale and cloud-based workflows.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Unified access to raster and vector datasets
|
|
29
|
+
- CRS-aware reprojection and alignment utilities
|
|
30
|
+
- Clean, composable API designed for scientific workflows
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pygeodata
|
|
36
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# pygeodata
|
|
2
|
+
|
|
3
|
+
**Lightweight framework for geospatial data access and processing**
|
|
4
|
+
|
|
5
|
+
`pygeodata` provides simple, composable tools for working with geospatial raster and vector data. It aims to make spatial data handling consistent, efficient, and compatible with large-scale and cloud-based workflows.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Unified access to raster and vector datasets
|
|
10
|
+
- CRS-aware reprojection and alignment utilities
|
|
11
|
+
- Clean, composable API designed for scientific workflows
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install pygeodata
|
|
17
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pygeodata.config import get_config
|
|
4
|
+
from pygeodata.loader import DataLoader
|
|
5
|
+
from pygeodata.types import SpatialSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load(loader: DataLoader, spec: SpatialSpec | None = None) -> Any:
|
|
9
|
+
spec = spec or get_config().spec
|
|
10
|
+
if spec is None:
|
|
11
|
+
raise ValueError('No spatial specification (spec) provided or present in config')
|
|
12
|
+
return loader(spec)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def process(loader: DataLoader, spec: SpatialSpec | None = None) -> None:
|
|
16
|
+
spec = spec or get_config().spec
|
|
17
|
+
if spec is None:
|
|
18
|
+
raise ValueError('No spatial specification (spec) provided or present in config')
|
|
19
|
+
if loader.is_processed(spec):
|
|
20
|
+
return
|
|
21
|
+
loader.process(spec)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from dataclasses import dataclass, field, fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pygeodata.options import RasterCreationOptions
|
|
8
|
+
from pygeodata.types import SpatialSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Config:
|
|
13
|
+
path_data_processed: Path = Path('data_processed')
|
|
14
|
+
num_threads: int = 1
|
|
15
|
+
warp_mem_limit: int = 0 # GDAL default, indicates 64 MB
|
|
16
|
+
spec: SpatialSpec | None = None
|
|
17
|
+
raster_creation_options: RasterCreationOptions = field(default_factory=RasterCreationOptions)
|
|
18
|
+
|
|
19
|
+
def update(self, **kwargs):
|
|
20
|
+
for key, value in kwargs.items():
|
|
21
|
+
if not hasattr(self, key):
|
|
22
|
+
raise ValueError(f'Invalid config key: {key}')
|
|
23
|
+
setattr(self, key, value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CONFIG = Config()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_config() -> Config:
|
|
30
|
+
return CONFIG
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def set_config(**overrides: Any) -> Iterator[Config]:
|
|
35
|
+
old_values = {k.name: getattr(CONFIG, k.name) for k in fields(CONFIG)}
|
|
36
|
+
CONFIG.update(**overrides)
|
|
37
|
+
try:
|
|
38
|
+
yield CONFIG
|
|
39
|
+
finally:
|
|
40
|
+
CONFIG.update(**old_values)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import rasterio as rio
|
|
6
|
+
import rioxarray as rxr
|
|
7
|
+
import xarray as xr
|
|
8
|
+
from rioxarray.exceptions import TooManyDimensions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RioXArrayDriver:
|
|
13
|
+
"""Load a raster file using rioxarray.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
parse_coordinates : bool, optional
|
|
18
|
+
Whether to parse coordinates
|
|
19
|
+
mask_and_scale : bool, optional
|
|
20
|
+
Whether to mask and scale
|
|
21
|
+
decode_times : bool, optional
|
|
22
|
+
Whether to decode times, by default False
|
|
23
|
+
cache : bool, optional
|
|
24
|
+
Whether to cache, by default False
|
|
25
|
+
flatten : bool, optional
|
|
26
|
+
By default 2D rasters will be returned as 3D, with a band dimension of size 1. If True, which is the
|
|
27
|
+
default, this dimension is removed.
|
|
28
|
+
boolean : bool, optional
|
|
29
|
+
Whether to convert the data to boolean, by default False
|
|
30
|
+
open_kw : dict, optional
|
|
31
|
+
Additional keyword arguments to pass to rioxarray.open_rasterio
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
parse_coordinates: bool = True
|
|
35
|
+
mask_and_scale: bool = True
|
|
36
|
+
decode_times: bool = False
|
|
37
|
+
cache: bool = False
|
|
38
|
+
flatten: bool = True
|
|
39
|
+
boolean: bool = False
|
|
40
|
+
open_kw: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _subdatasets(path: Path) -> list[str]:
|
|
44
|
+
with rio.open(path) as fp:
|
|
45
|
+
return fp.subdatasets
|
|
46
|
+
|
|
47
|
+
def _has_subdatasets(self, path: Path) -> bool:
|
|
48
|
+
return len(self._subdatasets(path)) > 0
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _infer_mask_and_scale(path: Path) -> bool:
|
|
52
|
+
with rio.open(path) as fp:
|
|
53
|
+
return np.issubdtype(fp.dtypes[0], np.floating)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _assert_exists(path: Path) -> None:
|
|
57
|
+
with rio.open(path):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def __call__(self, path: str | Path) -> xr.DataArray:
|
|
61
|
+
path = Path(path)
|
|
62
|
+
|
|
63
|
+
self._assert_exists(path)
|
|
64
|
+
|
|
65
|
+
if self._has_subdatasets(path):
|
|
66
|
+
subs_str = '\n'.join(self._subdatasets(path))
|
|
67
|
+
raise TooManyDimensions(f'Multiple variables found in {path}. Use one of the subdatasets:\n{subs_str}')
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
da = rxr.open_rasterio(
|
|
71
|
+
path,
|
|
72
|
+
parse_coordinates=self.parse_coordinates,
|
|
73
|
+
decode_times=self.decode_times,
|
|
74
|
+
cache=self.cache,
|
|
75
|
+
mask_and_scale=self.mask_and_scale,
|
|
76
|
+
**self.open_kw,
|
|
77
|
+
)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise type(e)(f'Failed to load {path}: {e}') from e
|
|
80
|
+
|
|
81
|
+
if isinstance(da, xr.Dataset):
|
|
82
|
+
raise TooManyDimensions(f'Dataset found in {path}. Use one of the subdatasets.')
|
|
83
|
+
|
|
84
|
+
if self.flatten and 'band' in da.dims and da.sizes['band'] == 1:
|
|
85
|
+
da = da.isel(band=0).drop_vars('band')
|
|
86
|
+
|
|
87
|
+
if self.boolean:
|
|
88
|
+
da = da.astype(bool)
|
|
89
|
+
|
|
90
|
+
return da
|
|
91
|
+
|
|
92
|
+
default_ext = 'tif'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pygeodata.config import get_config
|
|
6
|
+
from pygeodata.paths import generate_path
|
|
7
|
+
from pygeodata.types import Driver, Processor, SpatialSpec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DataLoader:
|
|
11
|
+
@property
|
|
12
|
+
def processor(self) -> Processor:
|
|
13
|
+
raise NotImplementedError(f'{self}: Either processor or driver must be implemented')
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def driver(self) -> Driver:
|
|
17
|
+
try:
|
|
18
|
+
processor = self.processor
|
|
19
|
+
except NotImplementedError:
|
|
20
|
+
raise NotImplementedError(f'{self}: Either processor or driver must be implemented')
|
|
21
|
+
|
|
22
|
+
if not hasattr(processor, 'default_driver'):
|
|
23
|
+
raise AttributeError(f'Processor {processor} lacks default_driver and no driver is set')
|
|
24
|
+
return getattr(processor, 'default_driver')
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def class_name(self) -> str:
|
|
28
|
+
return self.__class__.__name__.split('.')[-1].replace('Loader', '')
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
# Handle acronym → word transitions (e.g. XMLHTTPRequest → XML_Http_Request)
|
|
33
|
+
s1 = re.sub('([A-Z]+)([A-Z][a-z])', r'\1_\2', self.class_name)
|
|
34
|
+
# Handle normal camelCase → camel_Case
|
|
35
|
+
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
|
36
|
+
return s2.lower()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ext(self) -> str:
|
|
40
|
+
try:
|
|
41
|
+
ext = getattr(self.processor, 'ext', None)
|
|
42
|
+
except NotImplementedError:
|
|
43
|
+
ext = None
|
|
44
|
+
if ext is None:
|
|
45
|
+
ext = self.driver.default_ext
|
|
46
|
+
return ext
|
|
47
|
+
|
|
48
|
+
def get_params(self) -> dict[str, Any]:
|
|
49
|
+
params = {}
|
|
50
|
+
for key in self.__dict__:
|
|
51
|
+
if key in ('name', 'class_name', 'processor', 'driver', 'process', 'load'):
|
|
52
|
+
continue
|
|
53
|
+
if key.startswith('_'):
|
|
54
|
+
continue
|
|
55
|
+
params.update({key: self.__dict__[key]})
|
|
56
|
+
return params
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
params = self.get_params()
|
|
60
|
+
parts = [f'{k}={v!r}' for k, v in sorted(params.items())]
|
|
61
|
+
return f'{self.class_name}({", ".join(parts)})'
|
|
62
|
+
|
|
63
|
+
def get_processed_path(self, spec: SpatialSpec, ext: str | None = None) -> Path:
|
|
64
|
+
ext = ext or self.ext
|
|
65
|
+
|
|
66
|
+
path = generate_path(
|
|
67
|
+
spec=spec,
|
|
68
|
+
name=self.class_name,
|
|
69
|
+
filename=self.name,
|
|
70
|
+
base_dir=get_config().path_data_processed,
|
|
71
|
+
ext=ext,
|
|
72
|
+
**self.get_params(),
|
|
73
|
+
)
|
|
74
|
+
path.parent.mkdir(exist_ok=True, parents=True)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
def is_processed(self, spec: SpatialSpec) -> bool:
|
|
78
|
+
p = self.get_processed_path(spec)
|
|
79
|
+
return p.exists()
|
|
80
|
+
|
|
81
|
+
def process(self, spec: SpatialSpec) -> None:
|
|
82
|
+
self.processor(self.get_processed_path(spec), spec)
|
|
83
|
+
|
|
84
|
+
def load(self, spec: SpatialSpec) -> Any:
|
|
85
|
+
return self.driver(self.get_processed_path(spec))
|
|
86
|
+
|
|
87
|
+
def __call__(self, spec: SpatialSpec) -> Any:
|
|
88
|
+
if not self.is_processed(spec):
|
|
89
|
+
self.process(spec)
|
|
90
|
+
return self.load(spec)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class RasterCreationOptions:
|
|
7
|
+
"""Rasterio creation profile options.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
compress : str, optional
|
|
12
|
+
Compression algorithm ('lzw', 'deflate', 'zstd', 'lzma', 'jpeg', 'webp')
|
|
13
|
+
compress_level : int, optional
|
|
14
|
+
Compression level (1-9 for deflate, 1-12 for zstd)
|
|
15
|
+
tiled : bool, default=False
|
|
16
|
+
Whether to create a tiled raster (False for striped layout)
|
|
17
|
+
blockxsize : int, optional
|
|
18
|
+
Tile width (defaults to 256 if tiled=True, must be multiple of 16)
|
|
19
|
+
blockysize : int, optional
|
|
20
|
+
Tile height (defaults to 256 if tiled=True, must be multiple of 16)
|
|
21
|
+
interleave : str, optional
|
|
22
|
+
Band interleave ('pixel', 'band', 'line')
|
|
23
|
+
photometric : str, optional
|
|
24
|
+
Photometric interpretation ('minisblack', 'rgb', 'ycbcr')
|
|
25
|
+
predictor : int, optional
|
|
26
|
+
Predictor for compression (1=none, 2=horizontal, 3=floating point)
|
|
27
|
+
bigtiff : bool | str, optional
|
|
28
|
+
Create BigTIFF file ('yes', 'no', 'if_needed', 'if_safer')
|
|
29
|
+
sparse_ok : bool, optional
|
|
30
|
+
Allow sparse files
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
compress: str | None = None
|
|
34
|
+
compress_level: int | None = None
|
|
35
|
+
tiled: bool | None = None
|
|
36
|
+
blockxsize: int | None = None
|
|
37
|
+
blockysize: int | None = None
|
|
38
|
+
interleave: str | None = None
|
|
39
|
+
photometric: str | None = None
|
|
40
|
+
predictor: int | None = None
|
|
41
|
+
bigtiff: bool | str | None = None
|
|
42
|
+
sparse_ok: bool | None = None
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pygeodata.types import SpatialSpec
|
|
5
|
+
from pygeodata.utils import transform_to_str
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_path(
|
|
9
|
+
spec: SpatialSpec,
|
|
10
|
+
base_dir: str | Path,
|
|
11
|
+
filename: str,
|
|
12
|
+
ext: str,
|
|
13
|
+
name: str | None = None,
|
|
14
|
+
**kwargs,
|
|
15
|
+
) -> Path:
|
|
16
|
+
"""Function that converts a path of the data to the processed data."""
|
|
17
|
+
base_dir = Path(base_dir)
|
|
18
|
+
|
|
19
|
+
p = []
|
|
20
|
+
if name is not None:
|
|
21
|
+
p.append(name)
|
|
22
|
+
|
|
23
|
+
for key in sorted(kwargs.keys()):
|
|
24
|
+
p.append(f'{key}={kwargs[key]}')
|
|
25
|
+
|
|
26
|
+
return Path(
|
|
27
|
+
base_dir,
|
|
28
|
+
re.sub(r'[^\w\-]', '_', spec.crs.to_string()),
|
|
29
|
+
transform_to_str(spec.transform),
|
|
30
|
+
f'{spec.shape[0]}-{spec.shape[1]}',
|
|
31
|
+
*p,
|
|
32
|
+
f'{filename}.{ext}',
|
|
33
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import geopandas as gpd
|
|
7
|
+
import numpy as np
|
|
8
|
+
import rasterio
|
|
9
|
+
from rasterio.features import rasterize
|
|
10
|
+
|
|
11
|
+
from pygeodata.config import get_config
|
|
12
|
+
from pygeodata.drivers import RioXArrayDriver
|
|
13
|
+
from pygeodata.options import RasterCreationOptions
|
|
14
|
+
from pygeodata.types import SpatialSpec
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Rasterizer:
|
|
19
|
+
"""
|
|
20
|
+
Rasterize a vector dataset to a single-band raster.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
path : Path
|
|
25
|
+
Path to the vector dataset (e.g., shapefile, GeoPackage).
|
|
26
|
+
column : str
|
|
27
|
+
Name of the column to use as raster values (must be numeric).
|
|
28
|
+
load_df_func : Callable[[str | Path, SpatialSpec], gpd.GeoDataFrame], optional
|
|
29
|
+
Function to load the vector data. By default, reads with geopandas and reprojects to `spec.crs`.
|
|
30
|
+
all_touched : bool, default=True
|
|
31
|
+
Whether to burn all pixels touched by geometries.
|
|
32
|
+
dtype : np.dtype, optional
|
|
33
|
+
Data type for raster. Defaults to the dtype of `column`.
|
|
34
|
+
fill_value : float, optional
|
|
35
|
+
Nodata value for raster. Defaults to `_NODATA_DTYPE_MAP` based on dtype.
|
|
36
|
+
rasterize_kw : dict, optional
|
|
37
|
+
Additional keyword arguments passed to `rasterio.features.rasterize`.
|
|
38
|
+
raster_creation_options : RasterCreationOptions, optional
|
|
39
|
+
Optional raster creation profile (compression, tiling, etc.).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
path: Path
|
|
43
|
+
column: str = 'index'
|
|
44
|
+
load_df_func: Callable[[str | Path, SpatialSpec], gpd.GeoDataFrame] = field(
|
|
45
|
+
default_factory=lambda: lambda path, spec: gpd.read_file(path).to_crs(spec.crs).reset_index(),
|
|
46
|
+
)
|
|
47
|
+
all_touched: bool = True
|
|
48
|
+
dtype: np.dtype | None = None
|
|
49
|
+
fill_value: float | None = None
|
|
50
|
+
rasterize_kw: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
raster_creation_options: RasterCreationOptions | None = None
|
|
52
|
+
|
|
53
|
+
def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None:
|
|
54
|
+
df = self.load_df_func(self.path, spec)
|
|
55
|
+
|
|
56
|
+
if df.crs != spec.crs:
|
|
57
|
+
raise ValueError(f'GeoDataFrame CRS ({df.crs}) does not match target spec CRS ({spec.crs}).')
|
|
58
|
+
|
|
59
|
+
if self.column not in df.columns:
|
|
60
|
+
raise ValueError(f"Column '{self.column}' not found in GeoDataFrame.")
|
|
61
|
+
|
|
62
|
+
dtype = self.dtype if self.dtype is not None else df[self.column].dtype
|
|
63
|
+
|
|
64
|
+
if not np.issubdtype(dtype, np.number):
|
|
65
|
+
raise TypeError(f"Column '{self.column}' must be numeric, got {dtype}.")
|
|
66
|
+
|
|
67
|
+
default_fill_value = np.nan if np.issubdtype(dtype, np.floating) else 0
|
|
68
|
+
fill_value = self.fill_value if self.fill_value is not None else default_fill_value
|
|
69
|
+
|
|
70
|
+
if fill_value in df[self.column]:
|
|
71
|
+
raise ValueError(f'Fill value {fill_value} is present in the data. Overwrite with a different value.')
|
|
72
|
+
|
|
73
|
+
raster = rasterize(
|
|
74
|
+
((row.geometry, row[self.column]) for i, row in df.iterrows()),
|
|
75
|
+
out_shape=spec.shape,
|
|
76
|
+
transform=spec.transform,
|
|
77
|
+
fill=fill_value,
|
|
78
|
+
all_touched=self.all_touched,
|
|
79
|
+
dtype=dtype,
|
|
80
|
+
**self.rasterize_kw,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
raster_creation_options = self.raster_creation_options or get_config().raster_creation_options
|
|
84
|
+
|
|
85
|
+
with rasterio.open(
|
|
86
|
+
dst_path,
|
|
87
|
+
'w',
|
|
88
|
+
driver='GTiff',
|
|
89
|
+
height=spec.shape[0],
|
|
90
|
+
width=spec.shape[1],
|
|
91
|
+
count=1,
|
|
92
|
+
dtype=dtype,
|
|
93
|
+
crs=spec.crs,
|
|
94
|
+
transform=spec.transform,
|
|
95
|
+
**raster_creation_options.to_dict(),
|
|
96
|
+
) as dst:
|
|
97
|
+
dst.write(raster, 1)
|
|
98
|
+
|
|
99
|
+
default_driver = RioXArrayDriver()
|
|
100
|
+
ext = 'tif'
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from numbers import Number
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import rasterio as rio
|
|
11
|
+
import rasterio.warp
|
|
12
|
+
from numpy.typing import DTypeLike
|
|
13
|
+
from rasterio import CRS, RasterioIOError
|
|
14
|
+
from rasterio.enums import Resampling
|
|
15
|
+
|
|
16
|
+
from pygeodata.config import get_config
|
|
17
|
+
from pygeodata.drivers import RioXArrayDriver
|
|
18
|
+
from pygeodata.options import RasterCreationOptions
|
|
19
|
+
from pygeodata.types import SpatialSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Reprojector:
|
|
24
|
+
"""Reprojects raster data to GeoTIFF format.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
src_path : str | Path
|
|
29
|
+
Path to source raster file
|
|
30
|
+
bands : int or tuple of int, optional
|
|
31
|
+
Band indices to reproject (1-indexed). If None, reprojects all bands
|
|
32
|
+
src_crs : CRS, optional
|
|
33
|
+
Override source CRS if not defined in file
|
|
34
|
+
resampling : Resampling, default=Resampling.nearest
|
|
35
|
+
Resampling method
|
|
36
|
+
dst_dtype : DTypeLike, optional
|
|
37
|
+
Output data type. If None, uses source dtype
|
|
38
|
+
dst_nodata : float, optional
|
|
39
|
+
Output nodata value. If None, uses source nodata
|
|
40
|
+
nbits : int, optional
|
|
41
|
+
Number of bits per pixel
|
|
42
|
+
warp_kw : dict, optional
|
|
43
|
+
Additional keyword arguments for rasterio.warp.reproject
|
|
44
|
+
scales : float or sequence of floats, optional
|
|
45
|
+
Scale factor for each band
|
|
46
|
+
offsets : float or sequence of floats, optional
|
|
47
|
+
Offset for each band
|
|
48
|
+
raster_creation_options : RasterCreationOptions, optional
|
|
49
|
+
GeoTIFF creation profile options. If None, uses defaults
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
src_path: str | Path
|
|
53
|
+
src_crs: CRS | None = None
|
|
54
|
+
bands: int | Sequence[int] | None = None
|
|
55
|
+
resampling: Resampling = Resampling.nearest
|
|
56
|
+
dst_dtype: DTypeLike | None = None
|
|
57
|
+
dst_nodata: float | None = None
|
|
58
|
+
nbits: int | None = None
|
|
59
|
+
warp_kw: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
warp_mem_limit: int | None = None
|
|
61
|
+
num_threads: int | None = None
|
|
62
|
+
scales: float | Sequence[float] | None = None
|
|
63
|
+
offsets: float | Sequence[float] | None = None
|
|
64
|
+
raster_creation_options: RasterCreationOptions | None = None
|
|
65
|
+
|
|
66
|
+
def __post_init__(self):
|
|
67
|
+
if self.dst_dtype == np.bool_:
|
|
68
|
+
self.dst_dtype = 'uint8'
|
|
69
|
+
self.nbits = 1
|
|
70
|
+
|
|
71
|
+
def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None:
|
|
72
|
+
"""Reproject raster to specified spatial configuration.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
dst_path : str | Path
|
|
77
|
+
Destination file path
|
|
78
|
+
spec : SpatialSpec
|
|
79
|
+
Target spatial specification (CRS, transform, shape)
|
|
80
|
+
"""
|
|
81
|
+
dst_path = Path(dst_path)
|
|
82
|
+
|
|
83
|
+
if dst_path.exists():
|
|
84
|
+
raise FileExistsError(f'Destination already exists: {dst_path}')
|
|
85
|
+
|
|
86
|
+
print(f'Reprojecting: {self.src_path} -> {dst_path}')
|
|
87
|
+
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
89
|
+
temp_path = Path(tmpdir) / f'~{"_".join(dst_path.parts[1:])}'
|
|
90
|
+
|
|
91
|
+
with rio.open(self.src_path) as src:
|
|
92
|
+
if len(src.subdatasets) > 1:
|
|
93
|
+
sub_str = '\n'.join(src.subdatasets)
|
|
94
|
+
raise RasterioIOError(
|
|
95
|
+
f'Cannot reproject multi-variable dataset: {self.src_path}\nSubdatasets:\n{sub_str}',
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
src_crs = src.crs if src.crs is not None else self.src_crs
|
|
99
|
+
|
|
100
|
+
if src_crs is None:
|
|
101
|
+
raise ValueError(f'Cannot determine CRS for {self.src_path}. Provide src_crs parameter.')
|
|
102
|
+
|
|
103
|
+
src_dtype = src.dtypes[0]
|
|
104
|
+
src_transform = src.transform
|
|
105
|
+
|
|
106
|
+
src_nodata = src.nodata
|
|
107
|
+
if src_nodata is None and np.issubdtype(src_dtype, np.floating):
|
|
108
|
+
src_nodata = np.nan
|
|
109
|
+
|
|
110
|
+
src_bands = src.indexes if self.bands is None else self.bands
|
|
111
|
+
if isinstance(src_bands, Number):
|
|
112
|
+
src_bands = (src_bands,)
|
|
113
|
+
count = len(src_bands)
|
|
114
|
+
|
|
115
|
+
dst_dtype = src_dtype if self.dst_dtype is None else self.dst_dtype
|
|
116
|
+
dst_nodata = src_nodata if self.dst_nodata is None else self.dst_nodata
|
|
117
|
+
|
|
118
|
+
raster_creation_options = self.raster_creation_options or get_config().raster_creation_options
|
|
119
|
+
|
|
120
|
+
rio_profile = {
|
|
121
|
+
'driver': 'GTiff',
|
|
122
|
+
'height': spec.shape[0],
|
|
123
|
+
'width': spec.shape[1],
|
|
124
|
+
'dtype': dst_dtype,
|
|
125
|
+
'nodata': dst_nodata,
|
|
126
|
+
'count': count,
|
|
127
|
+
'crs': spec.crs,
|
|
128
|
+
'transform': spec.transform,
|
|
129
|
+
**raster_creation_options.to_dict(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if self.nbits is not None:
|
|
133
|
+
rio_profile['nbits'] = self.nbits
|
|
134
|
+
|
|
135
|
+
warp_mem_limit = self.warp_mem_limit if self.warp_mem_limit is not None else get_config().warp_mem_limit
|
|
136
|
+
num_threads = self.num_threads if self.num_threads is not None else get_config().num_threads
|
|
137
|
+
|
|
138
|
+
with rio.open(temp_path, 'w', **rio_profile) as dst:
|
|
139
|
+
rasterio.warp.reproject(
|
|
140
|
+
source=rio.band(src, src_bands),
|
|
141
|
+
destination=rio.band(dst, dst.indexes),
|
|
142
|
+
src_crs=src_crs,
|
|
143
|
+
dst_crs=spec.crs,
|
|
144
|
+
src_transform=src_transform,
|
|
145
|
+
dst_transform=spec.transform,
|
|
146
|
+
src_nodata=src_nodata,
|
|
147
|
+
dst_nodata=dst_nodata,
|
|
148
|
+
resampling=self.resampling,
|
|
149
|
+
warp_mem_limit=warp_mem_limit,
|
|
150
|
+
num_threads=num_threads,
|
|
151
|
+
**self.warp_kw,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
scales = self.scales if self.scales is not None else tuple(src.scales[i - 1] for i in src_bands)
|
|
155
|
+
offsets = self.offsets if self.offsets is not None else tuple(src.offsets[i - 1] for i in src_bands)
|
|
156
|
+
|
|
157
|
+
if scales is not None:
|
|
158
|
+
scales = scales if isinstance(scales, Sequence) else [scales] * dst.count
|
|
159
|
+
dst._set_all_scales(scales)
|
|
160
|
+
|
|
161
|
+
if offsets is not None:
|
|
162
|
+
offsets = offsets if isinstance(offsets, Sequence) else [offsets] * dst.count
|
|
163
|
+
dst._set_all_offsets(offsets)
|
|
164
|
+
|
|
165
|
+
shutil.move(temp_path, dst_path)
|
|
166
|
+
|
|
167
|
+
default_driver = RioXArrayDriver()
|
|
168
|
+
ext = 'tif'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from affine import Affine
|
|
6
|
+
from pyproj import CRS
|
|
7
|
+
from rasterio.coords import BoundingBox
|
|
8
|
+
|
|
9
|
+
RasterShape = tuple[int, int]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SpatialSpec:
|
|
14
|
+
crs: CRS
|
|
15
|
+
transform: Affine
|
|
16
|
+
shape: RasterShape
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def resolution(self) -> tuple[int, int]:
|
|
20
|
+
return (abs(self.transform.a), abs(self.transform.e))
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def bounds(self) -> BoundingBox:
|
|
24
|
+
height, width = self.shape
|
|
25
|
+
x0, y0 = self.transform * (0, 0)
|
|
26
|
+
x1, y1 = self.transform * (width, height)
|
|
27
|
+
return BoundingBox(min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def extent(self) -> tuple[float, float, float, float]:
|
|
31
|
+
bounds = self.bounds
|
|
32
|
+
return (bounds.left, bounds.right, bounds.bottom, bounds.top)
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
transform_str = (
|
|
36
|
+
f'Affine('
|
|
37
|
+
f'{self.transform.a:.2f}, '
|
|
38
|
+
f'{self.transform.b:.2f}, '
|
|
39
|
+
f'{self.transform.c:.2f}, '
|
|
40
|
+
f'{self.transform.d:.2f}, '
|
|
41
|
+
f'{self.transform.e:.2f}, '
|
|
42
|
+
f'{self.transform.f:.2f})'
|
|
43
|
+
)
|
|
44
|
+
return f'SpatialSpec(crs={self.crs.to_string()}, transform={transform_str}, shape={self.shape})'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Processor(Protocol):
|
|
48
|
+
def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Driver(Protocol):
|
|
52
|
+
def __call__(self, path: str | Path) -> Any: ...
|
|
53
|
+
|
|
54
|
+
default_ext: str
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygeodata
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Lightweight framework for geospatial data access and processing
|
|
5
|
+
Author-email: Jasper Roebroek <roebroek.jasper@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: rioxarray
|
|
12
|
+
Requires-Dist: geopandas
|
|
13
|
+
Requires-Dist: dask
|
|
14
|
+
Requires-Dist: zarr
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# pygeodata
|
|
21
|
+
|
|
22
|
+
**Lightweight framework for geospatial data access and processing**
|
|
23
|
+
|
|
24
|
+
`pygeodata` provides simple, composable tools for working with geospatial raster and vector data. It aims to make spatial data handling consistent, efficient, and compatible with large-scale and cloud-based workflows.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Unified access to raster and vector datasets
|
|
29
|
+
- CRS-aware reprojection and alignment utilities
|
|
30
|
+
- Clean, composable API designed for scientific workflows
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pygeodata
|
|
36
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pygeodata/__init__.py
|
|
5
|
+
pygeodata/base.py
|
|
6
|
+
pygeodata/config.py
|
|
7
|
+
pygeodata/loader.py
|
|
8
|
+
pygeodata/options.py
|
|
9
|
+
pygeodata/paths.py
|
|
10
|
+
pygeodata/types.py
|
|
11
|
+
pygeodata/utils.py
|
|
12
|
+
pygeodata.egg-info/PKG-INFO
|
|
13
|
+
pygeodata.egg-info/SOURCES.txt
|
|
14
|
+
pygeodata.egg-info/dependency_links.txt
|
|
15
|
+
pygeodata.egg-info/requires.txt
|
|
16
|
+
pygeodata.egg-info/top_level.txt
|
|
17
|
+
pygeodata/drivers/__init__.py
|
|
18
|
+
pygeodata/drivers/rioxarray.py
|
|
19
|
+
pygeodata/processors/__init__.py
|
|
20
|
+
pygeodata/processors/rasterizer.py
|
|
21
|
+
pygeodata/processors/reprojection.py
|
|
22
|
+
tests/test_config.py
|
|
23
|
+
tests/test_loader.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pygeodata
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pygeodata"
|
|
7
|
+
description = "Lightweight framework for geospatial data access and processing"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Jasper Roebroek", email = "roebroek.jasper@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
dynamic = ["version"]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = []
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3"
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"rioxarray",
|
|
21
|
+
"geopandas",
|
|
22
|
+
"dask",
|
|
23
|
+
"zarr"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
include = ["pygeodata*"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest", "pytest-mock"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pygeodata.config import Config, get_config, set_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_default_config():
|
|
9
|
+
cfg = get_config()
|
|
10
|
+
assert cfg.path_data_processed == Path('data_processed')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_temporary_override():
|
|
14
|
+
original = get_config().path_data_processed
|
|
15
|
+
with set_config(path_data_processed=Path('/tmp')) as cfg:
|
|
16
|
+
assert cfg.path_data_processed == Path('/tmp')
|
|
17
|
+
assert get_config().path_data_processed == original
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_multiple_overrides():
|
|
21
|
+
with set_config(path_data_processed=Path('/tmp')) as cfg:
|
|
22
|
+
assert isinstance(cfg, Config)
|
|
23
|
+
assert cfg.path_data_processed == Path('/tmp')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_config_invalid_key():
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
with set_config(invalid_key='value'):
|
|
29
|
+
pass
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from dataclasses import asdict
|
|
2
|
+
|
|
3
|
+
from pygeodata import load
|
|
4
|
+
from pygeodata.config import set_config
|
|
5
|
+
from pygeodata.loader import DataLoader
|
|
6
|
+
from pygeodata.types import SpatialSpec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_loader_initialization(sample_loader_class):
|
|
10
|
+
"""Test DataEntry initialization with default values."""
|
|
11
|
+
loader = sample_loader_class()
|
|
12
|
+
assert loader.name == 'sample'
|
|
13
|
+
assert hasattr(loader, 'processor')
|
|
14
|
+
assert hasattr(loader, 'driver')
|
|
15
|
+
assert loader.class_name == 'Sample'
|
|
16
|
+
assert loader.get_params() == {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_loader_get_processed_path(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
20
|
+
loader = sample_loader_class()
|
|
21
|
+
with set_config(path_data_processed=tmp_path):
|
|
22
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
23
|
+
|
|
24
|
+
assert path.parent.exists()
|
|
25
|
+
assert path.suffix == '.tif'
|
|
26
|
+
assert str(tmp_path) in str(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_is_processed_false(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
30
|
+
"""Test is_processed when file doesn't exist."""
|
|
31
|
+
with set_config(path_data_processed=tmp_path):
|
|
32
|
+
assert not sample_loader_class().is_processed(sample_spatial_spec)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_is_processed_true(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
36
|
+
"""Test is_processed when file exists."""
|
|
37
|
+
loader = sample_loader_class()
|
|
38
|
+
with set_config(path_data_processed=tmp_path):
|
|
39
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.touch()
|
|
42
|
+
|
|
43
|
+
assert loader.is_processed(sample_spatial_spec)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_process_creates_file(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
47
|
+
"""Test that process creates the expected output file."""
|
|
48
|
+
loader = sample_loader_class()
|
|
49
|
+
with set_config(path_data_processed=tmp_path):
|
|
50
|
+
loader.process(sample_spatial_spec)
|
|
51
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
52
|
+
assert path.exists()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_load_with_params(sample_loader_class_complex, sample_spatial_spec):
|
|
56
|
+
"""Test that parameters are included in the generated path."""
|
|
57
|
+
assert set(sample_loader_class_complex(10, 10).get_params()) == {'time', 'resolution'}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_loader_repr(sample_loader_class):
|
|
61
|
+
"""Test string representation of loader."""
|
|
62
|
+
loader = sample_loader_class()
|
|
63
|
+
repr_str = repr(loader)
|
|
64
|
+
assert repr_str.startswith('Sample(')
|
|
65
|
+
assert repr_str.endswith(')')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_loader_repr_with_params(sample_loader_class_complex):
|
|
69
|
+
"""Test string representation includes parameters sorted alphabetically."""
|
|
70
|
+
loader = sample_loader_class_complex(time=10, resolution=5)
|
|
71
|
+
repr_str = repr(loader)
|
|
72
|
+
assert 'ComplexSample(' in repr_str
|
|
73
|
+
assert 'resolution=5' in repr_str
|
|
74
|
+
assert 'time=10' in repr_str
|
|
75
|
+
assert repr_str.index('resolution') < repr_str.index('time')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_name_conversion_simple(sample_loader_class):
|
|
79
|
+
"""Test class name to snake_case conversion for simple names."""
|
|
80
|
+
loader = sample_loader_class()
|
|
81
|
+
assert loader.name == 'sample'
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_name_conversion_camel_case():
|
|
85
|
+
"""Test class name conversion handles camelCase properly."""
|
|
86
|
+
|
|
87
|
+
class MyDataLoader(DataLoader):
|
|
88
|
+
@property
|
|
89
|
+
def processor(self):
|
|
90
|
+
return lambda path, spec: None
|
|
91
|
+
|
|
92
|
+
loader = MyDataLoader()
|
|
93
|
+
assert loader.name == 'my_data'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_name_conversion_acronyms():
|
|
97
|
+
"""Test class name conversion handles acronyms properly."""
|
|
98
|
+
|
|
99
|
+
class XMLHTTPLoader(DataLoader):
|
|
100
|
+
@property
|
|
101
|
+
def processor(self):
|
|
102
|
+
return lambda path, spec: None
|
|
103
|
+
|
|
104
|
+
loader = XMLHTTPLoader()
|
|
105
|
+
assert loader.name == 'xmlhttp'
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_name_conversion_mixed():
|
|
109
|
+
"""Test class name conversion handles mixed acronyms and camelCase."""
|
|
110
|
+
|
|
111
|
+
class USGSElevationLoader(DataLoader):
|
|
112
|
+
@property
|
|
113
|
+
def processor(self):
|
|
114
|
+
return lambda path, spec: None
|
|
115
|
+
|
|
116
|
+
loader = USGSElevationLoader()
|
|
117
|
+
assert loader.name == 'usgs_elevation'
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_get_params_excludes_private_attributes(sample_loader_class_complex):
|
|
121
|
+
"""Test that get_params excludes private attributes."""
|
|
122
|
+
loader = sample_loader_class_complex(time=10, resolution=5)
|
|
123
|
+
loader._private_attr = 'should_not_appear'
|
|
124
|
+
params = loader.get_params()
|
|
125
|
+
assert '_private_attr' not in params
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_get_params_multiple_params(sample_loader_class_complex):
|
|
129
|
+
"""Test get_params returns all instance attributes."""
|
|
130
|
+
loader = sample_loader_class_complex(time=15, resolution=20)
|
|
131
|
+
params = loader.get_params()
|
|
132
|
+
assert params['time'] == 15
|
|
133
|
+
assert params['resolution'] == 20
|
|
134
|
+
assert len(params) == 2
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_process_skips_if_already_processed(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
138
|
+
"""Test that process doesn't reprocess if file already exists."""
|
|
139
|
+
loader = sample_loader_class()
|
|
140
|
+
with set_config(path_data_processed=tmp_path):
|
|
141
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
path.touch()
|
|
144
|
+
|
|
145
|
+
loader.is_processed(sample_spatial_spec)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_process_calls_processor_if_not_processed(sample_loader_class, sample_spatial_spec, tmp_path, mocker):
|
|
149
|
+
"""Test that process calls processor when file doesn't exist."""
|
|
150
|
+
loader = sample_loader_class()
|
|
151
|
+
with set_config(path_data_processed=tmp_path):
|
|
152
|
+
# Mock the processor
|
|
153
|
+
mock_processor = mocker.MagicMock()
|
|
154
|
+
mocker.patch.object(loader, 'processor', mock_processor)
|
|
155
|
+
|
|
156
|
+
loader.process(sample_spatial_spec)
|
|
157
|
+
|
|
158
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
159
|
+
mock_processor.assert_called_once_with(path, sample_spatial_spec)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_load_returns_data(sample_loader_class, sample_spatial_spec, tmp_path, mocker):
|
|
163
|
+
"""Test that load returns data from driver."""
|
|
164
|
+
loader = sample_loader_class()
|
|
165
|
+
expected_data = {'test': 'data'}
|
|
166
|
+
|
|
167
|
+
with set_config(path_data_processed=tmp_path):
|
|
168
|
+
# Mock driver.load to return test data
|
|
169
|
+
mock_load = mocker.MagicMock(return_value=expected_data)
|
|
170
|
+
mocker.patch.object(loader, 'load', mock_load)
|
|
171
|
+
|
|
172
|
+
result = loader.load(sample_spatial_spec)
|
|
173
|
+
|
|
174
|
+
assert result == expected_data
|
|
175
|
+
mock_load.assert_called_once()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_load_processes_before_loading(sample_loader_class, sample_spatial_spec, tmp_path, mocker):
|
|
179
|
+
"""Test that load calls process before loading data."""
|
|
180
|
+
loader = sample_loader_class()
|
|
181
|
+
|
|
182
|
+
with set_config(path_data_processed=tmp_path):
|
|
183
|
+
mock_process = mocker.patch.object(loader, 'process')
|
|
184
|
+
mock_load = mocker.patch.object(loader, 'load', return_value=None)
|
|
185
|
+
|
|
186
|
+
loader(sample_spatial_spec)
|
|
187
|
+
|
|
188
|
+
# Ensure process is called before load
|
|
189
|
+
mock_process.assert_called_once_with(sample_spatial_spec)
|
|
190
|
+
mock_load.assert_called_once()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_is_processed_with_valid_symlink(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
194
|
+
"""Test is_processed returns True for valid symlink."""
|
|
195
|
+
loader = sample_loader_class()
|
|
196
|
+
with set_config(path_data_processed=tmp_path):
|
|
197
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
198
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
|
|
200
|
+
# Create a target file and symlink to it
|
|
201
|
+
target = tmp_path / 'target.tif'
|
|
202
|
+
target.touch()
|
|
203
|
+
path.symlink_to(target)
|
|
204
|
+
|
|
205
|
+
assert loader.is_processed(sample_spatial_spec)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_is_processed_with_broken_symlink(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
209
|
+
"""Test is_processed behavior with broken symlink."""
|
|
210
|
+
loader = sample_loader_class()
|
|
211
|
+
with set_config(path_data_processed=tmp_path):
|
|
212
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
213
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
# Create a symlink to non-existent target
|
|
216
|
+
target = tmp_path / 'nonexistent.tif'
|
|
217
|
+
path.symlink_to(target)
|
|
218
|
+
|
|
219
|
+
assert not loader.is_processed(sample_spatial_spec)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_driver_fallback_to_processor_default(sample_loader_class):
|
|
223
|
+
"""Test that driver property falls back to processor's default_driver."""
|
|
224
|
+
loader = sample_loader_class()
|
|
225
|
+
driver = loader.driver
|
|
226
|
+
assert driver is not None
|
|
227
|
+
assert hasattr(driver, '__call__')
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_get_processed_path_creates_parent_directory(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
231
|
+
"""Test that get_processed_path creates parent directories."""
|
|
232
|
+
loader = sample_loader_class()
|
|
233
|
+
with set_config(path_data_processed=tmp_path):
|
|
234
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
235
|
+
# Parent should be created by get_processed_path
|
|
236
|
+
assert path.parent.exists()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_multiple_specs_different_paths(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
240
|
+
"""Test that different spatial specs generate different paths."""
|
|
241
|
+
loader = sample_loader_class()
|
|
242
|
+
|
|
243
|
+
# Create a second spec with different properties
|
|
244
|
+
spec2 = SpatialSpec(**asdict(sample_spatial_spec))
|
|
245
|
+
spec2.shape = (2, 3)
|
|
246
|
+
|
|
247
|
+
with set_config(path_data_processed=tmp_path):
|
|
248
|
+
path1 = loader.get_processed_path(sample_spatial_spec)
|
|
249
|
+
path2 = loader.get_processed_path(spec2)
|
|
250
|
+
|
|
251
|
+
assert path1 != path2
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_same_spec_same_path(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
255
|
+
"""Test that same spec always generates the same path."""
|
|
256
|
+
loader = sample_loader_class()
|
|
257
|
+
|
|
258
|
+
with set_config(path_data_processed=tmp_path):
|
|
259
|
+
path1 = loader.get_processed_path(sample_spatial_spec)
|
|
260
|
+
path2 = loader.get_processed_path(sample_spatial_spec)
|
|
261
|
+
|
|
262
|
+
assert path1 == path2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_different_loader_instances_same_behavior(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
266
|
+
"""Test that different instances of the same loader class behave identically."""
|
|
267
|
+
loader1 = sample_loader_class()
|
|
268
|
+
loader2 = sample_loader_class()
|
|
269
|
+
|
|
270
|
+
with set_config(path_data_processed=tmp_path):
|
|
271
|
+
path1 = loader1.get_processed_path(sample_spatial_spec)
|
|
272
|
+
path2 = loader2.get_processed_path(sample_spatial_spec)
|
|
273
|
+
|
|
274
|
+
assert path1 == path2
|
|
275
|
+
assert loader1.name == loader2.name
|
|
276
|
+
assert loader1.class_name == loader2.class_name
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_load(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
280
|
+
with set_config(path_data_processed=tmp_path):
|
|
281
|
+
data = load(sample_loader_class(), spec=sample_spatial_spec)
|
|
282
|
+
assert data.rio.crs == sample_spatial_spec.crs
|