pygeodata 0.0.1__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.1/LICENSE +21 -0
- pygeodata-0.0.1/PKG-INFO +35 -0
- pygeodata-0.0.1/README.md +17 -0
- pygeodata-0.0.1/pygeodata/__init__.py +0 -0
- pygeodata-0.0.1/pygeodata/config.py +37 -0
- pygeodata-0.0.1/pygeodata/drivers/__init__.py +4 -0
- pygeodata-0.0.1/pygeodata/drivers/xarray.py +88 -0
- pygeodata-0.0.1/pygeodata/load.py +12 -0
- pygeodata-0.0.1/pygeodata/loader.py +68 -0
- pygeodata-0.0.1/pygeodata/options.py +46 -0
- pygeodata-0.0.1/pygeodata/paths.py +34 -0
- pygeodata-0.0.1/pygeodata/processors/__init__.py +4 -0
- pygeodata-0.0.1/pygeodata/processors/rasterizer.py +91 -0
- pygeodata-0.0.1/pygeodata/processors/reprojection.py +124 -0
- pygeodata-0.0.1/pygeodata/types.py +45 -0
- pygeodata-0.0.1/pygeodata/utils.py +5 -0
- pygeodata-0.0.1/pygeodata.egg-info/PKG-INFO +35 -0
- pygeodata-0.0.1/pygeodata.egg-info/SOURCES.txt +23 -0
- pygeodata-0.0.1/pygeodata.egg-info/dependency_links.txt +1 -0
- pygeodata-0.0.1/pygeodata.egg-info/requires.txt +7 -0
- pygeodata-0.0.1/pygeodata.egg-info/top_level.txt +1 -0
- pygeodata-0.0.1/pyproject.toml +30 -0
- pygeodata-0.0.1/setup.cfg +4 -0
- pygeodata-0.0.1/tests/test_config.py +29 -0
- pygeodata-0.0.1/tests/test_loader.py +52 -0
pygeodata-0.0.1/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.1/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygeodata
|
|
3
|
+
Version: 0.0.1
|
|
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.12
|
|
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
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pygeodata
|
|
20
|
+
|
|
21
|
+
**Lightweight framework for geospatial data access and processing**
|
|
22
|
+
|
|
23
|
+
`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.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- Unified access to raster and vector datasets
|
|
28
|
+
- CRS-aware reprojection and alignment utilities
|
|
29
|
+
- Clean, composable API designed for scientific workflows
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pygeodata
|
|
35
|
+
```
|
|
@@ -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
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Iterator, Optional
|
|
5
|
+
|
|
6
|
+
from pygeodata.types import SpatialSpec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Config:
|
|
11
|
+
path_data_processed: Path = Path('data_processed')
|
|
12
|
+
num_threads: int = 1
|
|
13
|
+
warp_mem_limit: int = 0 # GDAL default, indicates 64 MB
|
|
14
|
+
spec: Optional[SpatialSpec] = None
|
|
15
|
+
|
|
16
|
+
def update(self, **kwargs):
|
|
17
|
+
for key, value in kwargs.items():
|
|
18
|
+
if not hasattr(self, key):
|
|
19
|
+
raise ValueError(f'Invalid config key: {key}')
|
|
20
|
+
setattr(self, key, value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CONFIG = Config()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_config() -> Config:
|
|
27
|
+
return CONFIG
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def set_config(**overrides: Any) -> Iterator[Config]:
|
|
32
|
+
old_values = asdict(CONFIG)
|
|
33
|
+
CONFIG.update(**overrides)
|
|
34
|
+
try:
|
|
35
|
+
yield CONFIG
|
|
36
|
+
finally:
|
|
37
|
+
CONFIG.update(**old_values)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import rasterio as rio
|
|
7
|
+
import rioxarray as rxr
|
|
8
|
+
import xarray as xr
|
|
9
|
+
from rioxarray.exceptions import TooManyDimensions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RioXArrayDriver:
|
|
14
|
+
"""Load a raster file using rioxarray.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
parse_coordinates : bool, optional
|
|
19
|
+
Whether to parse coordinates
|
|
20
|
+
mask_and_scale : bool, optional
|
|
21
|
+
Whether to mask and scale. By default, this parameter is None, which causes it to be inferred from the data
|
|
22
|
+
type. Floating data will default to True, integer data to False.
|
|
23
|
+
decode_times : bool, optional
|
|
24
|
+
Whether to decode times, by default False
|
|
25
|
+
cache : bool, optional
|
|
26
|
+
Whether to cache, by default False
|
|
27
|
+
open_kw : dict, optional
|
|
28
|
+
Additional keyword arguments to pass to rioxarray.open_rasterio
|
|
29
|
+
flatten : bool, optional
|
|
30
|
+
By default 2D rasters will be returned as 3D, with a band dimension of size 1. If True, which is the
|
|
31
|
+
default, this dimension is removed.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
parse_coordinates: bool = True
|
|
35
|
+
mask_and_scale: Optional[bool] = None # if None, inferred from data -> float yes, int no
|
|
36
|
+
decode_times: bool = False
|
|
37
|
+
cache: bool = False
|
|
38
|
+
open_kw: dict = field(default_factory=dict)
|
|
39
|
+
flatten: bool = True
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _subdatasets(path: Path) -> list[str]:
|
|
43
|
+
with rio.open(path) as fp:
|
|
44
|
+
return fp.subdatasets
|
|
45
|
+
|
|
46
|
+
def _has_subdatasets(self, path: Path) -> bool:
|
|
47
|
+
return len(self._subdatasets(path)) > 0
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _infer_mask_and_scale(path: Path) -> bool:
|
|
51
|
+
with rio.open(path) as fp:
|
|
52
|
+
return np.issubdtype(fp.dtypes[0], np.floating)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _assert_exists(path: Path) -> None:
|
|
56
|
+
with rio.open(path):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def load(self, path: str | Path) -> xr.DataArray:
|
|
60
|
+
path = Path(path)
|
|
61
|
+
|
|
62
|
+
self._assert_exists(path)
|
|
63
|
+
|
|
64
|
+
if self._has_subdatasets(path):
|
|
65
|
+
subs_str = '\n'.join(self._subdatasets(path))
|
|
66
|
+
raise TooManyDimensions(f'Multiple variables found in {path}. Use one of the subdatasets:\n{subs_str}')
|
|
67
|
+
|
|
68
|
+
mask_and_scale = self.mask_and_scale if self.mask_and_scale is not None else self._infer_mask_and_scale(path)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
da = rxr.open_rasterio(
|
|
72
|
+
path,
|
|
73
|
+
parse_coordinates=self.parse_coordinates,
|
|
74
|
+
decode_times=self.decode_times,
|
|
75
|
+
cache=self.cache,
|
|
76
|
+
mask_and_scale=mask_and_scale,
|
|
77
|
+
**self.open_kw,
|
|
78
|
+
)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise type(e)(f'Failed to load {path}: {e}') from e
|
|
81
|
+
|
|
82
|
+
if isinstance(da, xr.Dataset):
|
|
83
|
+
raise TooManyDimensions(f'Dataset found in {path}. Use one of the subdatasets.')
|
|
84
|
+
|
|
85
|
+
if self.flatten and 'band' in da.dims and da.sizes['band'] == 1:
|
|
86
|
+
da = da.isel(band=0).drop_vars('band')
|
|
87
|
+
|
|
88
|
+
return da
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
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_data(loader: DataLoader, spec: Optional[SpatialSpec] = 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.load(spec)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pygeodata.config import get_config
|
|
7
|
+
from pygeodata.paths import generate_path
|
|
8
|
+
from pygeodata.types import SpatialSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataLoader(ABC):
|
|
12
|
+
@property
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def processor(self):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def driver(self):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def class_name(self) -> str:
|
|
24
|
+
return self.__class__.__name__.replace('Loader', '')
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
# Handle acronym → word transitions (e.g. XMLHTTPRequest → XML_Http_Request)
|
|
29
|
+
s1 = re.sub('([A-Z]+)([A-Z][a-z])', r'\1_\2', self.class_name)
|
|
30
|
+
# Handle normal camelCase → camel_Case
|
|
31
|
+
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
|
32
|
+
return s2.lower()
|
|
33
|
+
|
|
34
|
+
def get_params(self) -> dict[str, Any]:
|
|
35
|
+
params = {}
|
|
36
|
+
for key in self.__dict__:
|
|
37
|
+
if key in ('name', 'class_name'):
|
|
38
|
+
continue
|
|
39
|
+
params.update({key: self.__dict__[key]})
|
|
40
|
+
return params
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
params = self.get_params()
|
|
44
|
+
parts = [f'{k}={v}' for k, v in sorted(params.items())]
|
|
45
|
+
return f'{self.class_name}(' + ', '.join(parts) + ')'
|
|
46
|
+
|
|
47
|
+
def get_processed_path(self, spec: SpatialSpec) -> Path:
|
|
48
|
+
path = generate_path(
|
|
49
|
+
spec=spec,
|
|
50
|
+
name=self.class_name,
|
|
51
|
+
filename=self.name,
|
|
52
|
+
base_dir=get_config().path_data_processed,
|
|
53
|
+
**self.get_params(),
|
|
54
|
+
)
|
|
55
|
+
path.parent.mkdir(exist_ok=True, parents=True)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
def is_processed(self, spec: SpatialSpec) -> bool:
|
|
59
|
+
p = self.get_processed_path(spec)
|
|
60
|
+
return p.exists() or p.is_symlink()
|
|
61
|
+
|
|
62
|
+
def process(self, spec: SpatialSpec) -> None:
|
|
63
|
+
if not self.is_processed(spec):
|
|
64
|
+
self.processor(self.get_processed_path(spec), spec)
|
|
65
|
+
|
|
66
|
+
def load(self, spec: SpatialSpec) -> Any:
|
|
67
|
+
self.process(spec)
|
|
68
|
+
self.driver.load(self.get_processed_path(spec))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class RasterioProfile:
|
|
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: Optional[str] = None
|
|
34
|
+
compress_level: Optional[int] = None
|
|
35
|
+
tiled: Optional[bool] = None
|
|
36
|
+
blockxsize: Optional[int] = None
|
|
37
|
+
blockysize: Optional[int] = None
|
|
38
|
+
interleave: Optional[str] = None
|
|
39
|
+
photometric: Optional[str] = None
|
|
40
|
+
predictor: Optional[int] = None
|
|
41
|
+
bigtiff: Optional[bool | str] = None
|
|
42
|
+
sparse_ok: Optional[bool] = None
|
|
43
|
+
num_threads: Optional[int | str] = None
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pygeodata.types import SpatialSpec
|
|
6
|
+
from pygeodata.utils import transform_to_str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_path(
|
|
10
|
+
spec: SpatialSpec,
|
|
11
|
+
filename: str,
|
|
12
|
+
name: Optional[str] = None,
|
|
13
|
+
base_dir: str | Path = "data_processed",
|
|
14
|
+
ext: str = "tif",
|
|
15
|
+
**kwargs,
|
|
16
|
+
) -> Path:
|
|
17
|
+
"""Function that converts a path of the data to the processed data."""
|
|
18
|
+
base_dir = Path(base_dir)
|
|
19
|
+
|
|
20
|
+
p = []
|
|
21
|
+
if name is not None:
|
|
22
|
+
p.append(name)
|
|
23
|
+
|
|
24
|
+
for key in sorted(kwargs.keys()):
|
|
25
|
+
p.append(f"{key}={kwargs[key]}")
|
|
26
|
+
|
|
27
|
+
return Path(
|
|
28
|
+
base_dir,
|
|
29
|
+
f"{re.sub(r'[^\w\-]', '_', spec.crs.to_string())}",
|
|
30
|
+
transform_to_str(spec.transform),
|
|
31
|
+
f"{spec.shape[0]}-{spec.shape[1]}",
|
|
32
|
+
*p,
|
|
33
|
+
f"{filename}.{ext}",
|
|
34
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import numpy as np
|
|
7
|
+
import rasterio
|
|
8
|
+
from rasterio.features import rasterize
|
|
9
|
+
|
|
10
|
+
from pygeodata.options import RasterioProfile
|
|
11
|
+
from pygeodata.types import SpatialSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Rasterizer:
|
|
16
|
+
"""
|
|
17
|
+
Rasterize a vector dataset to a single-band raster.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
path : Path
|
|
22
|
+
Path to the vector dataset (e.g., shapefile, GeoPackage).
|
|
23
|
+
column : str
|
|
24
|
+
Name of the column to use as raster values (must be numeric).
|
|
25
|
+
load_df_func : Callable[[str | Path, SpatialSpec], gpd.GeoDataFrame], optional
|
|
26
|
+
Function to load the vector data. By default, reads with geopandas and reprojects to `spec.crs`.
|
|
27
|
+
all_touched : bool, default=True
|
|
28
|
+
Whether to burn all pixels touched by geometries.
|
|
29
|
+
dtype : Optional[np.dtype]
|
|
30
|
+
Data type for raster. Defaults to the dtype of `column`.
|
|
31
|
+
fill_value : Optional[float]
|
|
32
|
+
Nodata value for raster. Defaults to `_NODATA_DTYPE_MAP` based on dtype.
|
|
33
|
+
rasterize_kw : dict
|
|
34
|
+
Additional keyword arguments passed to `rasterio.features.rasterize`.
|
|
35
|
+
profile : Optional[RasterioProfile]
|
|
36
|
+
Optional raster creation profile (compression, tiling, etc.).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
path: Path
|
|
40
|
+
column: str = 'index'
|
|
41
|
+
load_df_func: Callable[[str | Path, SpatialSpec], gpd.GeoDataFrame] = field(
|
|
42
|
+
default_factory=lambda: lambda path, spec: gpd.read_file(path).to_crs(spec.crs).reset_index()
|
|
43
|
+
)
|
|
44
|
+
all_touched: bool = True
|
|
45
|
+
dtype: Optional[np.dtype] = None
|
|
46
|
+
fill_value: Optional[float] = None
|
|
47
|
+
rasterize_kw: Dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
profile: Optional[RasterioProfile] = field(default_factory=RasterioProfile)
|
|
49
|
+
|
|
50
|
+
def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None:
|
|
51
|
+
df = self.load_df_func(self.path, spec)
|
|
52
|
+
|
|
53
|
+
if df.crs != spec.crs:
|
|
54
|
+
raise ValueError(f'GeoDataFrame CRS ({df.crs}) does not match target spec CRS ({spec.crs}).')
|
|
55
|
+
|
|
56
|
+
if self.column not in df.columns:
|
|
57
|
+
raise ValueError(f"Column '{self.column}' not found in GeoDataFrame.")
|
|
58
|
+
|
|
59
|
+
dtype = self.dtype or df[self.column].dtype
|
|
60
|
+
|
|
61
|
+
if not np.issubdtype(dtype, np.number):
|
|
62
|
+
raise TypeError(f"Column '{self.column}' must be numeric, got {dtype}.")
|
|
63
|
+
|
|
64
|
+
fill_value = self.fill_value or (np.nan if np.issubdtype(dtype, np.floating) else 0)
|
|
65
|
+
|
|
66
|
+
if fill_value in df[self.column]:
|
|
67
|
+
raise ValueError(f'Fill value {fill_value} is present in the data. Overwrite with a different value.')
|
|
68
|
+
|
|
69
|
+
raster = rasterize(
|
|
70
|
+
((row.geometry, row[self.column]) for i, row in df.iterrows()),
|
|
71
|
+
out_shape=spec.shape,
|
|
72
|
+
transform=spec.transform,
|
|
73
|
+
fill=fill_value,
|
|
74
|
+
all_touched=self.all_touched,
|
|
75
|
+
dtype=dtype,
|
|
76
|
+
**self.rasterize_kw,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
with rasterio.open(
|
|
80
|
+
dst_path,
|
|
81
|
+
'w',
|
|
82
|
+
driver='GTiff',
|
|
83
|
+
height=spec.shape[0],
|
|
84
|
+
width=spec.shape[1],
|
|
85
|
+
count=1,
|
|
86
|
+
dtype=dtype,
|
|
87
|
+
crs=spec.crs,
|
|
88
|
+
transform=spec.transform,
|
|
89
|
+
**self.profile.to_dict(),
|
|
90
|
+
) as dst:
|
|
91
|
+
dst.write(raster, 1)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import rasterio as rio
|
|
9
|
+
import rasterio.warp
|
|
10
|
+
from numpy.typing import DTypeLike
|
|
11
|
+
from rasterio import CRS, RasterioIOError
|
|
12
|
+
from rasterio.enums import Resampling
|
|
13
|
+
|
|
14
|
+
from pygeodata.config import get_config
|
|
15
|
+
from pygeodata.options import RasterioProfile
|
|
16
|
+
from pygeodata.types import SpatialSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Reprojector:
|
|
21
|
+
"""Reprojects raster data to GeoTIFF format.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
src_path : str | Path
|
|
26
|
+
Path to source raster file
|
|
27
|
+
bands : int | Sequence[int], optional
|
|
28
|
+
Band indices to reproject (1-indexed). If None, reprojects all bands
|
|
29
|
+
src_crs : CRS, optional
|
|
30
|
+
Override source CRS if not defined in file
|
|
31
|
+
resampling : Resampling, default=Resampling.nearest
|
|
32
|
+
Resampling method
|
|
33
|
+
dst_dtype : DTypeLike, optional
|
|
34
|
+
Output data type. If None, uses source dtype
|
|
35
|
+
dst_nodata : float, optional
|
|
36
|
+
Output nodata value. If None, uses source nodata
|
|
37
|
+
profile : RasterioProfile, optional
|
|
38
|
+
GeoTIFF creation profile options. If None, uses defaults
|
|
39
|
+
warp_kw : dict, optional
|
|
40
|
+
Additional keyword arguments for rasterio.warp.reproject
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
src_path: str | Path
|
|
44
|
+
src_crs: Optional[CRS] = None
|
|
45
|
+
bands: Optional[int | Sequence[int]] = None
|
|
46
|
+
resampling: Resampling = Resampling.nearest
|
|
47
|
+
dst_dtype: Optional[DTypeLike] = None
|
|
48
|
+
dst_nodata: Optional[float] = None
|
|
49
|
+
warp_kw: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
warp_mem_limit: Optional[int] = None
|
|
51
|
+
num_threads: Optional[int] = None
|
|
52
|
+
profile: RasterioProfile = field(default_factory=RasterioProfile)
|
|
53
|
+
|
|
54
|
+
def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None:
|
|
55
|
+
"""Reproject raster to specified spatial configuration.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
dst_path : str | Path
|
|
60
|
+
Destination file path
|
|
61
|
+
spec : SpatialSpec
|
|
62
|
+
Target spatial specification (CRS, transform, shape)
|
|
63
|
+
"""
|
|
64
|
+
dst_path = Path(dst_path)
|
|
65
|
+
|
|
66
|
+
if dst_path.exists():
|
|
67
|
+
raise FileExistsError(f'Destination already exists: {dst_path}')
|
|
68
|
+
|
|
69
|
+
print(f'Reprojecting: {self.src_path} -> {dst_path}')
|
|
70
|
+
|
|
71
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
72
|
+
temp_path = Path(tmpdir) / f'~{"_".join(dst_path.parts[1:])}'
|
|
73
|
+
|
|
74
|
+
with rio.open(self.src_path) as src:
|
|
75
|
+
if len(src.subdatasets) > 1:
|
|
76
|
+
sub_str = '\n'.join(src.subdatasets)
|
|
77
|
+
raise RasterioIOError(
|
|
78
|
+
f'Cannot reproject multi-variable dataset: {self.src_path}\nSubdatasets:\n{sub_str}'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
src_crs = src.crs if src.crs is not None else self.src_crs
|
|
82
|
+
|
|
83
|
+
if src_crs is None:
|
|
84
|
+
raise ValueError(f'Cannot determine CRS for {self.src_path}. Provide src_crs parameter.')
|
|
85
|
+
|
|
86
|
+
src_dtype = src.dtypes[0]
|
|
87
|
+
src_transform = src.transform
|
|
88
|
+
|
|
89
|
+
src_nodata = src.nodata or (np.nan if np.issubdtype(src_dtype, np.floating) else 0)
|
|
90
|
+
|
|
91
|
+
src_bands = src.indexes if self.bands is None else self.bands
|
|
92
|
+
|
|
93
|
+
dst_dtype = src_dtype if self.dst_dtype is None else self.dst_dtype
|
|
94
|
+
dst_nodata = src_nodata if self.dst_nodata is None else self.dst_nodata
|
|
95
|
+
|
|
96
|
+
rio_profile = {
|
|
97
|
+
'driver': 'GTiff',
|
|
98
|
+
'height': spec.shape[0],
|
|
99
|
+
'width': spec.shape[1],
|
|
100
|
+
'dtype': dst_dtype,
|
|
101
|
+
'nodata': dst_nodata,
|
|
102
|
+
'count': len(src_bands),
|
|
103
|
+
'crs': spec.crs,
|
|
104
|
+
'transform': spec.transform,
|
|
105
|
+
**self.profile.to_dict(),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
with rio.open(temp_path, 'w', **rio_profile) as dst:
|
|
109
|
+
rasterio.warp.reproject(
|
|
110
|
+
source=rio.band(src, src_bands),
|
|
111
|
+
destination=rio.band(dst, dst.indexes),
|
|
112
|
+
src_crs=src_crs,
|
|
113
|
+
dst_crs=spec.crs,
|
|
114
|
+
src_transform=src_transform,
|
|
115
|
+
dst_transform=spec.transform,
|
|
116
|
+
src_nodata=src_nodata,
|
|
117
|
+
dst_nodata=dst_nodata,
|
|
118
|
+
resampling=self.resampling,
|
|
119
|
+
warp_mem_limit=self.warp_mem_limit or get_config().warp_mem_limit,
|
|
120
|
+
num_threads=self.num_threads or get_config().num_threads,
|
|
121
|
+
**self.warp_kw,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
shutil.move(temp_path, dst_path)
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
type RasterShape = tuple[int, int]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SpatialSpec:
|
|
14
|
+
crs: CRS
|
|
15
|
+
transform: Affine
|
|
16
|
+
shape: RasterShape
|
|
17
|
+
|
|
18
|
+
def resolution(self) -> tuple[int, int]:
|
|
19
|
+
return (abs(self.transform.a), abs(self.transform.e))
|
|
20
|
+
|
|
21
|
+
def bounds(self) -> BoundingBox:
|
|
22
|
+
height, width = self.shape
|
|
23
|
+
x0, y0 = self.transform * (0, 0)
|
|
24
|
+
x1, y1 = self.transform * (width, height)
|
|
25
|
+
return BoundingBox(min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
transform_str = (
|
|
29
|
+
f'Affine('
|
|
30
|
+
f'{self.transform.a:.2f}, '
|
|
31
|
+
f'{self.transform.b:.2f}, '
|
|
32
|
+
f'{self.transform.c:.2f}, '
|
|
33
|
+
f'{self.transform.d:.2f}, '
|
|
34
|
+
f'{self.transform.e:.2f}, '
|
|
35
|
+
f'{self.transform.f:.2f})'
|
|
36
|
+
)
|
|
37
|
+
return f'SpatialSpec(crs={self.crs.to_string()}, transform={transform_str}, shape={self.shape})'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Processor(Protocol):
|
|
41
|
+
def __call__(self, dst_path: str | Path, spec: SpatialSpec) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Driver(Protocol):
|
|
45
|
+
def load(self, path: str | Path) -> Any: ...
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygeodata
|
|
3
|
+
Version: 0.0.1
|
|
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.12
|
|
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
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pygeodata
|
|
20
|
+
|
|
21
|
+
**Lightweight framework for geospatial data access and processing**
|
|
22
|
+
|
|
23
|
+
`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.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- Unified access to raster and vector datasets
|
|
28
|
+
- CRS-aware reprojection and alignment utilities
|
|
29
|
+
- Clean, composable API designed for scientific workflows
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pygeodata
|
|
35
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pygeodata/__init__.py
|
|
5
|
+
pygeodata/config.py
|
|
6
|
+
pygeodata/load.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/xarray.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
|
+
version = "0.0.1"
|
|
8
|
+
description = "Lightweight framework for geospatial data access and processing"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Jasper Roebroek", email = "roebroek.jasper@gmail.com" }
|
|
11
|
+
]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.12"
|
|
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"]
|
|
@@ -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,52 @@
|
|
|
1
|
+
from pygeodata.config import set_config
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_data_entry_initialization(sample_loader_class):
|
|
5
|
+
"""Test DataEntry initialization with default values."""
|
|
6
|
+
loader = sample_loader_class()
|
|
7
|
+
assert loader.name == 'sample'
|
|
8
|
+
assert hasattr(loader, 'processor')
|
|
9
|
+
assert hasattr(loader, 'driver')
|
|
10
|
+
assert loader.class_name == 'Sample'
|
|
11
|
+
assert loader.get_params() == {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_get_processed_path(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
15
|
+
loader = sample_loader_class()
|
|
16
|
+
with set_config(path_data_processed=tmp_path):
|
|
17
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
18
|
+
|
|
19
|
+
assert path.parent.exists()
|
|
20
|
+
assert path.suffix == '.tif'
|
|
21
|
+
assert str(tmp_path) in str(path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_is_processed_false(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
25
|
+
"""Test is_processed when file doesn't exist."""
|
|
26
|
+
with set_config(path_data_processed=tmp_path):
|
|
27
|
+
assert not sample_loader_class().is_processed(sample_spatial_spec)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_is_processed_true(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
31
|
+
"""Test is_processed when file exists."""
|
|
32
|
+
loader = sample_loader_class()
|
|
33
|
+
with set_config(path_data_processed=tmp_path):
|
|
34
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
path.touch()
|
|
37
|
+
|
|
38
|
+
assert loader.is_processed(sample_spatial_spec)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_process_creates_file(sample_loader_class, sample_spatial_spec, tmp_path):
|
|
42
|
+
"""Test that process creates the expected output file."""
|
|
43
|
+
loader = sample_loader_class()
|
|
44
|
+
with set_config(path_data_processed=tmp_path):
|
|
45
|
+
loader.process(sample_spatial_spec)
|
|
46
|
+
path = loader.get_processed_path(sample_spatial_spec)
|
|
47
|
+
assert path.exists()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_load_with_params(sample_loader_class_complex, sample_spatial_spec):
|
|
51
|
+
"""Test that parameters are included in the generated path."""
|
|
52
|
+
assert set(sample_loader_class_complex(10, 10).get_params()) == {'time', 'resolution'}
|