lsapy 0.1.0.dev0__tar.gz → 0.1.0.dev1__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.
- lsapy-0.1.0.dev1/CHANGELOG.rst +27 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/PKG-INFO +1 -1
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/pyproject.toml +51 -2
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/__init__.py +2 -2
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/criteria.py +21 -18
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/functions.py +35 -34
- lsapy-0.1.0.dev1/src/lsapy/lsa.py +389 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/statistics.py +51 -43
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/utils.py +8 -12
- lsapy-0.1.0.dev0/src/lsapy/lsa.py +0 -331
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/LICENSE +0 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/README.md +0 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/docs/notebooks/introduction.ipynb +0 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/environment.yml +0 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/data/climate_sample.nc +0 -0
- {lsapy-0.1.0.dev0 → lsapy-0.1.0.dev1}/src/lsapy/data/soil_sample.nc +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
=========
|
|
2
|
+
Changelog
|
|
3
|
+
=========
|
|
4
|
+
|
|
5
|
+
v0.1.0-dev1 (2025-05-16)
|
|
6
|
+
------------------------
|
|
7
|
+
Contributor to this version: Baptiste Hamon (@baptistehamon).
|
|
8
|
+
|
|
9
|
+
New features
|
|
10
|
+
^^^^^^^^^^^^
|
|
11
|
+
* Add ruff configuration to the project.
|
|
12
|
+
|
|
13
|
+
Bug fixes
|
|
14
|
+
^^^^^^^^^
|
|
15
|
+
* Fix the fit of `MembershipSuitFunction` returning the wrong best fit (issue `#1 <https://github.com/baptistehamon/lsapy/issues/1>`_, PR `#5 <https://github.com/baptistehamon/lsapy/pull/5>`_)
|
|
16
|
+
|
|
17
|
+
v0.1.0-dev0 (2025-03-12)
|
|
18
|
+
------------------------
|
|
19
|
+
Contributor to this version: Baptiste Hamon (@baptistehamon).
|
|
20
|
+
|
|
21
|
+
* First release on PyPI.
|
|
22
|
+
|
|
23
|
+
New features
|
|
24
|
+
^^^^^^^^^^^^
|
|
25
|
+
* ``SuitabilityFunction`` to define the function used for suitability computation.
|
|
26
|
+
* ``SuitabilityCriteria`` to define criteria to consider in the LSA
|
|
27
|
+
* ``LandSuitability`` to conduct LSA.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lsapy
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.dev1
|
|
4
4
|
Summary: A Python package for Land Suitability Analysis.
|
|
5
5
|
Keywords: LSAPy,Land Suitability Analysis,Spatial Analysis,GIS,Geography
|
|
6
6
|
Author-email: Baptiste Hamon <baptiste.hamon@pg.canterbury.ac.nz>
|
|
@@ -30,7 +30,7 @@ Homepage = "https://github.com/baptistehamon/lsapy"
|
|
|
30
30
|
Issues = "https://github.com/baptistehamon/lsapy/issues"
|
|
31
31
|
|
|
32
32
|
[tool.bumpversion]
|
|
33
|
-
current_version = "0.1.0-
|
|
33
|
+
current_version = "0.1.0-dev1"
|
|
34
34
|
commit = true
|
|
35
35
|
commit_args = "--signoff"
|
|
36
36
|
tag = false
|
|
@@ -66,4 +66,53 @@ exclude = [
|
|
|
66
66
|
"**/__pycache__/**",
|
|
67
67
|
"**/*.py[co]",
|
|
68
68
|
".*"
|
|
69
|
-
]
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
line-length = 120
|
|
73
|
+
output-format = "grouped"
|
|
74
|
+
|
|
75
|
+
[tool.ruff.format]
|
|
76
|
+
docstring-code-format = true
|
|
77
|
+
line-ending = "lf"
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint]
|
|
80
|
+
preview=true
|
|
81
|
+
select = [ # default values
|
|
82
|
+
"B", # bugbear
|
|
83
|
+
"C90", # McCabe complexity
|
|
84
|
+
"D", # docstring
|
|
85
|
+
"E", # pycodestyle errors
|
|
86
|
+
"F", # pyflakes
|
|
87
|
+
"I", # imports
|
|
88
|
+
"N", # naming
|
|
89
|
+
"PL", # pylint
|
|
90
|
+
"S", # bandit
|
|
91
|
+
"UP", # pyupgrade
|
|
92
|
+
"W", # pycodestyle warnings
|
|
93
|
+
]
|
|
94
|
+
ignore = [
|
|
95
|
+
"D400", # end-in-period
|
|
96
|
+
"D401", # non-imperative-mood
|
|
97
|
+
"PLR6201", # literal-membership
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint.per-file-ignores]
|
|
101
|
+
"src/lsapy/**/__init__.py" = ["F401", "F403"]
|
|
102
|
+
|
|
103
|
+
[tool.ruff.lint.isort]
|
|
104
|
+
case-sensitive = true
|
|
105
|
+
known-first-party = ["lsapy"]
|
|
106
|
+
|
|
107
|
+
[tool.ruff.lint.mccabe]
|
|
108
|
+
max-complexity = 20
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint.pycodestyle]
|
|
111
|
+
max-line-length = 120
|
|
112
|
+
|
|
113
|
+
[tool.ruff.lint.pydocstyle]
|
|
114
|
+
convention = "numpy"
|
|
115
|
+
|
|
116
|
+
[tool.ruff.lint.pylint]
|
|
117
|
+
max-args = 15
|
|
118
|
+
max-branches = 20
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""A Python package for Land Suitability Analysis."""
|
|
2
2
|
|
|
3
|
-
from lsapy.lsa import LandSuitability
|
|
4
3
|
from lsapy.criteria import SuitabilityCriteria
|
|
5
4
|
from lsapy.functions import *
|
|
5
|
+
from lsapy.lsa import LandSuitability
|
|
6
6
|
|
|
7
7
|
__author__ = "Baptiste Hamon"
|
|
8
8
|
__email__ = "baptiste.hamon@pg.canterbury.ac.nz"
|
|
9
|
-
__version__ = "0.1.0-
|
|
9
|
+
__version__ = "0.1.0-dev1"
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
"""Suitability Criteria definition."""
|
|
2
2
|
|
|
3
|
-
from typing import Optional, Union, Any
|
|
4
3
|
import xarray as xr
|
|
5
4
|
|
|
6
|
-
from lsapy.functions import
|
|
5
|
+
from lsapy.functions import DiscreteSuitFunction, MembershipSuitFunction, SuitabilityFunction
|
|
7
6
|
|
|
8
7
|
__all__ = ["SuitabilityCriteria"]
|
|
9
8
|
|
|
9
|
+
|
|
10
10
|
class SuitabilityCriteria:
|
|
11
11
|
def __init__(
|
|
12
12
|
self,
|
|
13
13
|
name: str,
|
|
14
14
|
indicator: xr.Dataset | xr.DataArray,
|
|
15
|
-
func:
|
|
16
|
-
weight:
|
|
17
|
-
category:
|
|
18
|
-
long_name:
|
|
19
|
-
description:
|
|
15
|
+
func: SuitabilityFunction | MembershipSuitFunction | DiscreteSuitFunction,
|
|
16
|
+
weight: int | float | None = 1,
|
|
17
|
+
category: str | None = None,
|
|
18
|
+
long_name: str | None = None,
|
|
19
|
+
description: str | None = None
|
|
20
20
|
|
|
21
21
|
) -> None:
|
|
22
22
|
self.name = name
|
|
@@ -27,7 +27,7 @@ class SuitabilityCriteria:
|
|
|
27
27
|
self.long_name = long_name
|
|
28
28
|
self.description = description
|
|
29
29
|
self._from_indicator = _get_indicator_description(indicator)
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
def __repr__(self) -> str:
|
|
32
32
|
attrs = []
|
|
33
33
|
attrs.append(f"name='{self.name}'")
|
|
@@ -41,17 +41,20 @@ class SuitabilityCriteria:
|
|
|
41
41
|
if self.description is not None:
|
|
42
42
|
attrs.append(f"description='{self.description}'")
|
|
43
43
|
return f"{self.__class__.__name__}({', '.join(attrs) if attrs else ''})"
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
|
|
46
45
|
def compute(self) -> xr.DataArray:
|
|
47
|
-
if self.func.func_method == 'discrete':
|
|
48
|
-
sc
|
|
46
|
+
if self.func.func_method == 'discrete': # need to vectorize the discrete function
|
|
47
|
+
sc: xr.DataArray = xr.apply_ufunc(self.func.map, self.indicator).rename(self.name)
|
|
49
48
|
else:
|
|
50
|
-
sc
|
|
51
|
-
return sc.assign_attrs(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
sc: xr.DataArray = self.func.map(self.indicator).rename(self.name)
|
|
50
|
+
return sc.assign_attrs(
|
|
51
|
+
dict({k: v for k, v in self.attrs.items() if k not in ['name', 'func_method', 'from_indicator']},
|
|
52
|
+
**{
|
|
53
|
+
'history': f"func_method: {self.func}; from_indicator: [{self._from_indicator}]",
|
|
54
|
+
'compute': 'done'
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
|
|
55
58
|
@property
|
|
56
59
|
def attrs(self):
|
|
57
60
|
return {k: v for k, v in {
|
|
@@ -69,4 +72,4 @@ def _get_indicator_description(indicator: xr.Dataset | xr.DataArray) -> str:
|
|
|
69
72
|
if indicator.attrs != {}:
|
|
70
73
|
return f"name: {indicator.name}; " + "; ".join([f"{k}: {v}" for k, v in indicator.attrs.items()])
|
|
71
74
|
else:
|
|
72
|
-
return f"name: {indicator.name}"
|
|
75
|
+
return f"name: {indicator.name}"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""Suitability Functions definitions."""
|
|
2
2
|
|
|
3
|
-
from typing import Optional, Callable, Union, Any
|
|
4
3
|
import warnings
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
5
7
|
import matplotlib.pyplot as plt
|
|
6
8
|
import numpy as np
|
|
7
|
-
import xarray as xr
|
|
8
9
|
from scipy.optimize import curve_fit
|
|
9
10
|
|
|
10
|
-
|
|
11
11
|
__all__ = [
|
|
12
12
|
"SuitabilityFunction",
|
|
13
13
|
"MembershipSuitFunction",
|
|
@@ -19,42 +19,42 @@ class SuitabilityFunction:
|
|
|
19
19
|
|
|
20
20
|
def __init__(
|
|
21
21
|
self,
|
|
22
|
-
func:
|
|
23
|
-
func_method:
|
|
24
|
-
func_params:
|
|
22
|
+
func: Callable | None = None,
|
|
23
|
+
func_method: str | None = None,
|
|
24
|
+
func_params: dict[str, Any] = None
|
|
25
25
|
):
|
|
26
26
|
if func_params is not None:
|
|
27
27
|
if func is None and func_method is None:
|
|
28
28
|
raise ValueError("If `func_params` is provided, `func` or `func_method` must also be provided.")
|
|
29
|
+
else:
|
|
30
|
+
func_params = {}
|
|
29
31
|
|
|
30
32
|
self.func = func
|
|
31
33
|
self.func_method = func_method
|
|
32
34
|
self.func_params = func_params
|
|
33
35
|
if func is None and func_method is not None:
|
|
34
36
|
self.func = _get_function_from_name(func_method)
|
|
35
|
-
|
|
36
37
|
|
|
37
38
|
def __repr__(self):
|
|
38
|
-
return f"{self.__class__.__name__}(
|
|
39
|
-
|
|
39
|
+
return (f"{self.__class__.__name__}("
|
|
40
|
+
f"func={self.func.__name__}, "
|
|
41
|
+
f"func_method='{self.func_method}', "
|
|
42
|
+
f"func_params={self.func_params})")
|
|
40
43
|
|
|
41
44
|
def __call__(self, x):
|
|
42
45
|
if self.func is None:
|
|
43
46
|
raise ValueError("No function has been provided.")
|
|
44
47
|
return self.func(x, **self.func_params)
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
|
|
47
49
|
def map(self, x):
|
|
48
50
|
return self(x)
|
|
49
|
-
|
|
50
51
|
|
|
51
52
|
def plot(self, x) -> None:
|
|
52
53
|
plt.plot(x, self(x))
|
|
53
54
|
|
|
54
|
-
|
|
55
55
|
@property
|
|
56
56
|
def attrs(self):
|
|
57
|
-
if self.func_method
|
|
57
|
+
if self.func_method is None and self.func_params is None:
|
|
58
58
|
return {}
|
|
59
59
|
return {k: v for k, v in {
|
|
60
60
|
'func_method': self.func_method,
|
|
@@ -71,16 +71,17 @@ class MembershipSuitFunction(SuitabilityFunction):
|
|
|
71
71
|
|
|
72
72
|
def __init__(
|
|
73
73
|
self,
|
|
74
|
-
func:
|
|
75
|
-
func_method:
|
|
76
|
-
func_params:
|
|
74
|
+
func: Callable | None = None,
|
|
75
|
+
func_method: str | None = None,
|
|
76
|
+
func_params: dict[str, int | float] | None = None
|
|
77
77
|
):
|
|
78
78
|
super().__init__(func, func_method, func_params)
|
|
79
79
|
|
|
80
|
-
|
|
81
80
|
@staticmethod
|
|
82
|
-
def fit(x, y
|
|
83
|
-
|
|
81
|
+
def fit(x, y=None, methods: str | list[str] = 'all', plot: bool = False):
|
|
82
|
+
if y is None:
|
|
83
|
+
y = [0, .25, .5, .75, 1]
|
|
84
|
+
return _fit_mbs_functions(x, np.array(y), methods, plot)
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
def _prepare_for_fitting(methods: str | list[str] = 'all'):
|
|
@@ -92,16 +93,16 @@ def _prepare_for_fitting(methods: str | list[str] = 'all'):
|
|
|
92
93
|
elif isinstance(methods, list) or isinstance(methods, str):
|
|
93
94
|
if isinstance(methods, str):
|
|
94
95
|
methods = [methods]
|
|
95
|
-
|
|
96
|
+
|
|
96
97
|
_methods = []
|
|
97
98
|
for method in methods:
|
|
98
99
|
if method in _types:
|
|
99
100
|
[_methods.append(m) for m in equations[method.replace('_like', '')].keys()]
|
|
100
101
|
else:
|
|
101
|
-
try
|
|
102
|
+
try:
|
|
102
103
|
_get_function_from_name(method)
|
|
103
104
|
_methods.append(method)
|
|
104
|
-
except :
|
|
105
|
+
except Exception:
|
|
105
106
|
_skipped.append(method)
|
|
106
107
|
warnings.warn(f"`{method}` not found in equations. Skipped.", stacklevel=2)
|
|
107
108
|
methods = _methods
|
|
@@ -125,7 +126,7 @@ def _get_function_p0(method: str, x: np.ndarray) -> list[float]:
|
|
|
125
126
|
|
|
126
127
|
|
|
127
128
|
def _fit_mbs_functions(x, y, methods: str | list[str] = 'all', plot: bool = False):
|
|
128
|
-
|
|
129
|
+
|
|
129
130
|
skipped = []
|
|
130
131
|
methods, _skipped = _prepare_for_fitting(methods)
|
|
131
132
|
skipped.extend(_skipped)
|
|
@@ -133,22 +134,22 @@ def _fit_mbs_functions(x, y, methods: str | list[str] = 'all', plot: bool = Fals
|
|
|
133
134
|
if len(methods) == 0:
|
|
134
135
|
print(f"Skipped fitting for the following methods: {', '.join(skipped)}.")
|
|
135
136
|
raise ValueError("No methods to fit.")
|
|
136
|
-
else
|
|
137
|
+
else:
|
|
137
138
|
x_ = np.linspace(min(x), max(x), 100)
|
|
138
139
|
rms_errors = []
|
|
139
140
|
f_params = []
|
|
140
141
|
for method in methods:
|
|
141
|
-
try
|
|
142
|
+
try:
|
|
142
143
|
f = _get_function_from_name(method)
|
|
143
144
|
p0 = _get_function_p0(method, x)
|
|
144
145
|
popt, _ = curve_fit(f, x, y, p0=p0, maxfev=15000)
|
|
145
146
|
y_ = f(x_, *popt)
|
|
146
147
|
f_params.append(popt)
|
|
147
|
-
rmse = _rms_error(y,f(x, *popt))
|
|
148
|
+
rmse = _rms_error(y, f(x, *popt))
|
|
148
149
|
rms_errors.append(rmse)
|
|
149
150
|
if plot:
|
|
150
151
|
plt.plot(x_, y_, label=method + f' (RMSE={rmse:.2f})')
|
|
151
|
-
except:
|
|
152
|
+
except Exception:
|
|
152
153
|
skipped.append(method)
|
|
153
154
|
warnings.warn(f"Failed to fit `{method}`. Skipped.", stacklevel=2)
|
|
154
155
|
if plot:
|
|
@@ -158,7 +159,7 @@ def _fit_mbs_functions(x, y, methods: str | list[str] = 'all', plot: bool = Fals
|
|
|
158
159
|
|
|
159
160
|
if len(skipped) > 0:
|
|
160
161
|
print(f"Skipped fitting for the following methods: {', '.join(skipped)}.")
|
|
161
|
-
f_best, p_best = _get_best_fit(methods, rms_errors, f_params)
|
|
162
|
+
f_best, p_best = _get_best_fit([m for m in methods if m not in skipped], rms_errors, f_params)
|
|
162
163
|
return _get_function_from_name(f_best), p_best
|
|
163
164
|
|
|
164
165
|
|
|
@@ -170,7 +171,7 @@ class DiscreteSuitFunction(SuitabilityFunction):
|
|
|
170
171
|
|
|
171
172
|
def __init__(
|
|
172
173
|
self,
|
|
173
|
-
func_params:
|
|
174
|
+
func_params: dict[str, int | float] | None = None
|
|
174
175
|
):
|
|
175
176
|
self.func = discrete
|
|
176
177
|
self.func_method = 'discrete'
|
|
@@ -181,7 +182,7 @@ class DiscreteSuitFunction(SuitabilityFunction):
|
|
|
181
182
|
# ---------------------------- Utility functions ----------------------------- #
|
|
182
183
|
# ---------------------------------------------------------------------------- #
|
|
183
184
|
|
|
184
|
-
equations
|
|
185
|
+
equations: dict[str, dict] = {}
|
|
185
186
|
|
|
186
187
|
|
|
187
188
|
def _get_function_from_name(name: str) -> callable:
|
|
@@ -199,7 +200,7 @@ def equation(type: str):
|
|
|
199
200
|
----------
|
|
200
201
|
type : str
|
|
201
202
|
The type of equation to register.
|
|
202
|
-
|
|
203
|
+
|
|
203
204
|
Returns
|
|
204
205
|
-------
|
|
205
206
|
decorator
|
|
@@ -216,13 +217,13 @@ def equation(type: str):
|
|
|
216
217
|
|
|
217
218
|
|
|
218
219
|
@equation('discrete')
|
|
219
|
-
def discrete(x, rules: dict[str|int, int|float]) -> float:
|
|
220
|
+
def discrete(x, rules: dict[str | int, int | float]) -> float:
|
|
220
221
|
return np.vectorize(rules.get, otypes=[np.float32])(x, np.nan)
|
|
221
222
|
|
|
222
223
|
|
|
223
224
|
@equation('sigmoid')
|
|
224
225
|
def logistic(x, a, b):
|
|
225
|
-
return 1 / (1 + np.exp(-a*(x - b)))
|
|
226
|
+
return 1 / (1 + np.exp(-a * (x - b)))
|
|
226
227
|
|
|
227
228
|
|
|
228
229
|
@equation('sigmoid')
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Land Suitability definition."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import xarray as xr
|
|
9
|
+
from shapely.geometry import mapping
|
|
10
|
+
|
|
11
|
+
from lsapy.criteria import SuitabilityCriteria
|
|
12
|
+
from lsapy.statistics import spatial_statistics_summary, statistics_summary
|
|
13
|
+
|
|
14
|
+
__all__ = ["LandSuitability"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LandSuitability:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
name: str,
|
|
21
|
+
criteria: dict[str, SuitabilityCriteria],
|
|
22
|
+
short_name: str | None = None,
|
|
23
|
+
long_name: str | None = None,
|
|
24
|
+
description: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.name = name
|
|
27
|
+
self.criteria = criteria
|
|
28
|
+
self.short_name = short_name
|
|
29
|
+
self.long_name = long_name
|
|
30
|
+
self.description = description
|
|
31
|
+
|
|
32
|
+
self._sort_criteria_by_weight() # important if suitability as limited factor
|
|
33
|
+
self._criteria_name_list = [sc.name for sc in self.criteria.values()]
|
|
34
|
+
self._category_list = list(set([sc.category for sc in self.criteria.values()]))
|
|
35
|
+
self._get_params_by_category()
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
if hasattr(self, "data"):
|
|
39
|
+
return self.data.__repr__()
|
|
40
|
+
else:
|
|
41
|
+
attrs = []
|
|
42
|
+
for k, v in self.attrs.items():
|
|
43
|
+
if isinstance(v, str):
|
|
44
|
+
v_ = f"'{v}'"
|
|
45
|
+
else:
|
|
46
|
+
v_ = v
|
|
47
|
+
attrs.append(f"{k}={v_}")
|
|
48
|
+
return f"{self.__class__.__name__}({', '.join(attrs) if attrs else ''})"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def attrs(self):
|
|
52
|
+
return {
|
|
53
|
+
k: v
|
|
54
|
+
for k, v in {
|
|
55
|
+
"name": self.name,
|
|
56
|
+
"criteria": self._criteria_name_list,
|
|
57
|
+
"short_name": self.short_name,
|
|
58
|
+
"long_name": self.long_name,
|
|
59
|
+
"description": self.description,
|
|
60
|
+
}.items()
|
|
61
|
+
if v is not None
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# def __getitem__(self, key: str) -> SuitabilityCriteria:
|
|
65
|
+
# return self.criteria[key]
|
|
66
|
+
|
|
67
|
+
def compute_criteria_suitability(self, inplace: bool | None = False) -> None | xr.Dataset:
|
|
68
|
+
sc_list = []
|
|
69
|
+
for _, sc in self.criteria.items():
|
|
70
|
+
print(f"Computing {sc.name}...")
|
|
71
|
+
if sc.indicator.attrs.get("compute", "") == "done":
|
|
72
|
+
sc_list.append(sc.indicator.rename(sc.name))
|
|
73
|
+
else:
|
|
74
|
+
sc_list.append(sc.compute())
|
|
75
|
+
ls = xr.merge(sc_list, compat="override", combine_attrs="drop")
|
|
76
|
+
for sc in sc_list:
|
|
77
|
+
ls[sc.name].attrs = sc.attrs
|
|
78
|
+
ls.attrs = self.attrs
|
|
79
|
+
if inplace:
|
|
80
|
+
self.data = ls
|
|
81
|
+
else:
|
|
82
|
+
return ls
|
|
83
|
+
|
|
84
|
+
def compute_category_suitability(
|
|
85
|
+
self,
|
|
86
|
+
method: str,
|
|
87
|
+
keep_criteria: bool | None = False,
|
|
88
|
+
inplace: bool | None = False,
|
|
89
|
+
limit_var: bool | None = False,
|
|
90
|
+
) -> xr.Dataset:
|
|
91
|
+
if not hasattr(self, "data"):
|
|
92
|
+
ds = self.compute_criteria_suitability()
|
|
93
|
+
else:
|
|
94
|
+
ds = self.data
|
|
95
|
+
|
|
96
|
+
out = []
|
|
97
|
+
out_attrs = []
|
|
98
|
+
for category in self._category_list:
|
|
99
|
+
print(f"Computing {category}...")
|
|
100
|
+
sc_list = [sc for sc in self.criteria.values() if sc.category == category]
|
|
101
|
+
res = _aggregate_vars(
|
|
102
|
+
ds[self._criteria_by_category[category]],
|
|
103
|
+
method=method,
|
|
104
|
+
weights=[sc.weight for sc in sc_list],
|
|
105
|
+
limit_var=limit_var,
|
|
106
|
+
)
|
|
107
|
+
if isinstance(res, xr.Dataset):
|
|
108
|
+
res = res.rename({"limiting_factor": f"{category}", "limiting_var": f"{category}_var"})
|
|
109
|
+
else:
|
|
110
|
+
res = res.rename(f"{category}_suitability")
|
|
111
|
+
res.attrs.update({"long_name": f"{category.capitalize()} Suitability"})
|
|
112
|
+
out.append(res)
|
|
113
|
+
out_attrs.append(res.attrs)
|
|
114
|
+
out = xr.merge(out, compat="override", combine_attrs="drop")
|
|
115
|
+
if keep_criteria:
|
|
116
|
+
out = xr.merge([ds, out], compat="override", combine_attrs="drop")
|
|
117
|
+
for sc in ds.data_vars: # add attributes of criteria
|
|
118
|
+
out[sc].attrs = ds[sc].attrs
|
|
119
|
+
for i, category in enumerate(self._category_list): # add attributes of category suitability
|
|
120
|
+
out[f"{category}_suitability"].attrs = out_attrs[i]
|
|
121
|
+
|
|
122
|
+
out.attrs.update(self.attrs)
|
|
123
|
+
if inplace:
|
|
124
|
+
self.data = out
|
|
125
|
+
else:
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def compute_suitability(
|
|
129
|
+
self,
|
|
130
|
+
method: str | dict[str, str] = "mean",
|
|
131
|
+
by_category: bool | None = False,
|
|
132
|
+
keep_all: bool | None = False,
|
|
133
|
+
inplace: bool | None = False,
|
|
134
|
+
limit_var: bool | None = False,
|
|
135
|
+
) -> xr.Dataset:
|
|
136
|
+
if isinstance(method, str):
|
|
137
|
+
cat_method, suit_method = method, method
|
|
138
|
+
elif isinstance(method, dict):
|
|
139
|
+
cat_method = method.get("category", "mean")
|
|
140
|
+
suit_method = method.get("overall", "mean")
|
|
141
|
+
else:
|
|
142
|
+
raise ValueError("Method must be a string or a dictionary.")
|
|
143
|
+
|
|
144
|
+
if not hasattr(self, "data"):
|
|
145
|
+
if by_category:
|
|
146
|
+
ds = self.compute_category_suitability(
|
|
147
|
+
method=cat_method, keep_criteria=True, inplace=False, limit_var=limit_var
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
ds = self.compute_criteria_suitability(inplace=False)
|
|
151
|
+
else:
|
|
152
|
+
ds = self.data
|
|
153
|
+
|
|
154
|
+
if by_category:
|
|
155
|
+
weights = [self.weights_by_category[category] for category in self._category_list]
|
|
156
|
+
on_vars = [f"{category}_suitability" for category in self._category_list]
|
|
157
|
+
else:
|
|
158
|
+
weights = [sc.weight for sc in self.criteria.values()]
|
|
159
|
+
on_vars = self._criteria_name_list
|
|
160
|
+
|
|
161
|
+
print("Computing suitability...")
|
|
162
|
+
out = _aggregate_vars(ds[on_vars], method=suit_method, weights=weights, limit_var=limit_var).rename(
|
|
163
|
+
"suitability"
|
|
164
|
+
)
|
|
165
|
+
out.attrs.update({"long_name": "Suitability"})
|
|
166
|
+
out_attrs = out.attrs
|
|
167
|
+
|
|
168
|
+
if keep_all:
|
|
169
|
+
out = xr.merge([ds, out], compat="override", combine_attrs="drop")
|
|
170
|
+
for sc in ds.data_vars: # add attributes of criteria
|
|
171
|
+
out[sc].attrs = ds[sc].attrs
|
|
172
|
+
out["suitability"].attrs = out_attrs
|
|
173
|
+
|
|
174
|
+
out.attrs.update(self.attrs)
|
|
175
|
+
if inplace:
|
|
176
|
+
self.data = out
|
|
177
|
+
else:
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
def _sort_criteria_by_weight(self) -> dict[str, SuitabilityCriteria]:
|
|
181
|
+
self.criteria = dict(sorted(self.criteria.items(), key=lambda item: item[1].weight, reverse=True))
|
|
182
|
+
|
|
183
|
+
def _get_params_by_category(self):
|
|
184
|
+
self._get_criteria_by_category()
|
|
185
|
+
self._get_weights_by_category()
|
|
186
|
+
|
|
187
|
+
def _get_criteria_by_category(self) -> dict[str, list[str]]:
|
|
188
|
+
self._criteria_by_category = {category: [] for category in self._category_list}
|
|
189
|
+
for sc in self.criteria.values():
|
|
190
|
+
self._criteria_by_category[sc.category].append(sc.name)
|
|
191
|
+
|
|
192
|
+
def _get_weights_by_category(self) -> dict[str, float | int]:
|
|
193
|
+
self.weights_by_category = {category: [] for category in self._category_list}
|
|
194
|
+
for category in self._category_list:
|
|
195
|
+
self.weights_by_category[category] = sum(
|
|
196
|
+
[sc.weight for sc in self.criteria.values() if sc.category == category]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def mask(
|
|
200
|
+
self,
|
|
201
|
+
mask: xr.DataArray | gpd.GeoDataFrame,
|
|
202
|
+
inplace: bool | None = False,
|
|
203
|
+
spatial_dims: tuple[str, str] | None = None,
|
|
204
|
+
crs: str | None = None,
|
|
205
|
+
invert: bool = False,
|
|
206
|
+
**kwargs,
|
|
207
|
+
) -> xr.DataArray | xr.Dataset:
|
|
208
|
+
if not hasattr(self, "data"):
|
|
209
|
+
raise ValueError("Suitability must be computed first.")
|
|
210
|
+
|
|
211
|
+
if inplace:
|
|
212
|
+
self.data = _mask_data(self.data, mask, spatial_dims=spatial_dims, crs=crs, invert=invert, **kwargs)
|
|
213
|
+
else:
|
|
214
|
+
return _mask_data(self.data, mask, spatial_dims=spatial_dims, crs=crs, invert=invert, **kwargs)
|
|
215
|
+
|
|
216
|
+
def statistics(
|
|
217
|
+
self,
|
|
218
|
+
on_vars: list | None = None,
|
|
219
|
+
on_dims: list | None = None,
|
|
220
|
+
on_dim_values: dict[str, Any] | None = None,
|
|
221
|
+
bins: list | np.ndarray | None = None,
|
|
222
|
+
bins_labels: list | None = None,
|
|
223
|
+
all_bins: bool | None = False,
|
|
224
|
+
cell_area: tuple[float | str, str] | None = None,
|
|
225
|
+
dropna: bool | None = False,
|
|
226
|
+
**kwargs,
|
|
227
|
+
) -> pd.DataFrame:
|
|
228
|
+
if not hasattr(self, "data"):
|
|
229
|
+
raise ValueError("Suitability must be computed first.")
|
|
230
|
+
|
|
231
|
+
return statistics_summary(
|
|
232
|
+
self.data,
|
|
233
|
+
on_vars=on_vars,
|
|
234
|
+
on_dims=on_dims,
|
|
235
|
+
on_dim_values=on_dim_values,
|
|
236
|
+
bins=bins,
|
|
237
|
+
bins_labels=bins_labels,
|
|
238
|
+
all_bins=all_bins,
|
|
239
|
+
cell_area=cell_area,
|
|
240
|
+
dropna=dropna**kwargs,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def spatial_statistics(
|
|
244
|
+
self,
|
|
245
|
+
areas: gpd.GeoDataFrame,
|
|
246
|
+
name: str | None = "area",
|
|
247
|
+
on_vars: list | None = None,
|
|
248
|
+
on_dims: list | None = None,
|
|
249
|
+
on_dim_values: dict[str, Any] | None = None,
|
|
250
|
+
bins: np.ndarray | None = None,
|
|
251
|
+
all_bins: bool | None = False,
|
|
252
|
+
cell_area: tuple[float | str, str] | None = None,
|
|
253
|
+
mask_kwargs: dict = None,
|
|
254
|
+
stats_kwargs: dict = None,
|
|
255
|
+
) -> pd.DataFrame:
|
|
256
|
+
if not hasattr(self, "data"):
|
|
257
|
+
raise ValueError("Suitability must be computed first.")
|
|
258
|
+
|
|
259
|
+
return spatial_statistics_summary(
|
|
260
|
+
self.data,
|
|
261
|
+
areas,
|
|
262
|
+
name=name,
|
|
263
|
+
on_vars=on_vars,
|
|
264
|
+
on_dims=on_dims,
|
|
265
|
+
on_dim_values=on_dim_values,
|
|
266
|
+
bins=bins,
|
|
267
|
+
all_bins=all_bins,
|
|
268
|
+
cell_area=cell_area,
|
|
269
|
+
mask_kwargs=mask_kwargs,
|
|
270
|
+
stats_kwargs=stats_kwargs,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _mask_data(
|
|
275
|
+
data: xr.DataArray | xr.Dataset,
|
|
276
|
+
mask: xr.DataArray | gpd.GeoDataFrame,
|
|
277
|
+
spatial_dims: tuple[str, str] | None = None,
|
|
278
|
+
crs: str | None = None,
|
|
279
|
+
invert: bool = False,
|
|
280
|
+
**kwargs,
|
|
281
|
+
) -> xr.DataArray | xr.Dataset:
|
|
282
|
+
if isinstance(mask, gpd.GeoDataFrame):
|
|
283
|
+
mask = mask.to_crs(crs)
|
|
284
|
+
data = data.rio.set_spatial_dims(*spatial_dims).rio.write_crs(crs)
|
|
285
|
+
return data.rio.clip(mask.geometry.apply(mapping), invert=invert, **kwargs)
|
|
286
|
+
elif isinstance(mask, xr.DataArray):
|
|
287
|
+
if invert:
|
|
288
|
+
mask = ~mask
|
|
289
|
+
return data.where(mask, **kwargs)
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError("mask must be a GeoDataFrame or DataArray")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
####################################################################################################
|
|
295
|
+
# VARIABLES AGGREGATION FUNCTIONS
|
|
296
|
+
####################################################################################################
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def vars_weighted_mean(ds: xr.Dataset, vars=None, weights=None) -> xr.DataArray:
|
|
300
|
+
if vars is None:
|
|
301
|
+
vars = list(ds.data_vars)
|
|
302
|
+
if weights is None:
|
|
303
|
+
weights = np.ones(len(vars))
|
|
304
|
+
|
|
305
|
+
s = sum([ds[v] * w for v, w in zip(vars, weights, strict=False)])
|
|
306
|
+
da: xr.DataArray = s / sum(weights)
|
|
307
|
+
return da.assign_attrs(
|
|
308
|
+
{
|
|
309
|
+
"method": "Weighted Mean",
|
|
310
|
+
"descritpion": (
|
|
311
|
+
"Weighted Mean of variables: "
|
|
312
|
+
f"{', '.join([f'{v} ({w})' for v, w in zip(vars, weights, strict=False)])}."
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
).rename("weighted_mean")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def vars_mean(ds: xr.Dataset, vars=None) -> xr.DataArray:
|
|
319
|
+
if vars is None:
|
|
320
|
+
vars = list(ds.data_vars)
|
|
321
|
+
da = vars_weighted_mean(ds, vars=vars)
|
|
322
|
+
return da.assign_attrs({"method": "Mean", "description": f"Mean of variables: {', '.join(vars)}."}).rename("mean")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def vars_weighted_geomean(ds: xr.Dataset, vars=None, weights=None) -> xr.DataArray:
|
|
326
|
+
if vars is None:
|
|
327
|
+
vars = list(ds.data_vars)
|
|
328
|
+
if weights is None:
|
|
329
|
+
weights = np.ones(len(vars))
|
|
330
|
+
|
|
331
|
+
s = sum([np.log(ds[v]) * w for v, w in zip(vars, weights, strict=False)])
|
|
332
|
+
da: xr.DataArray = np.exp(s / sum(weights))
|
|
333
|
+
return da.assign_attrs(
|
|
334
|
+
{
|
|
335
|
+
"method": "Weighted Geometric Mean",
|
|
336
|
+
"description": (
|
|
337
|
+
"Weighted Geometric Mean of variables: "
|
|
338
|
+
f"{', '.join([f'{v} ({w})' for v, w in zip(vars, weights, strict=False)])}."
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
).rename("weighted_geometric_mean")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def vars_geomean(ds: xr.Dataset, vars=None) -> xr.DataArray:
|
|
345
|
+
if vars is None:
|
|
346
|
+
vars = list(ds.data_vars)
|
|
347
|
+
da = vars_weighted_geomean(ds, vars=vars)
|
|
348
|
+
return da.assign_attrs(
|
|
349
|
+
{"method": "Geometric Mean", "description": f"Geometric Mean of variables: {', '.join(vars)}."}
|
|
350
|
+
).rename("geometric_mean")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def limiting_factor(ds: xr.Dataset, vars=None, limiting_var: bool | None = True) -> xr.DataArray | xr.Dataset:
|
|
354
|
+
if vars is None:
|
|
355
|
+
vars = list(ds.data_vars)
|
|
356
|
+
|
|
357
|
+
da = ds[vars].to_array()
|
|
358
|
+
mask = da.notnull().all(dim="variable")
|
|
359
|
+
|
|
360
|
+
lim = da.min(dim="variable", skipna=True).where(mask).rename("limiting_factor")
|
|
361
|
+
lim = lim.assign_attrs(
|
|
362
|
+
{"method": "Limiting Factor", "description": f"Value of limiting factor among variables: {', '.join(vars)}."}
|
|
363
|
+
)
|
|
364
|
+
if limiting_var:
|
|
365
|
+
lim_var = da.fillna(9999).argmin(dim="variable", skipna=True).where(mask).rename("limiting_var")
|
|
366
|
+
lim_var.attrs = {
|
|
367
|
+
"method": "Limiting Factor",
|
|
368
|
+
"description": f"Limiting factor among: {', '.join(vars)}.",
|
|
369
|
+
"legend": {f"{i}": v for i, v in enumerate(vars)},
|
|
370
|
+
}
|
|
371
|
+
return xr.merge([lim, lim_var])
|
|
372
|
+
return lim.to_dataset()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _aggregate_vars(
|
|
376
|
+
ds: xr.Dataset, method: str = "mean", vars=None, weights=None, **kwargs
|
|
377
|
+
) -> xr.DataArray | xr.Dataset:
|
|
378
|
+
if method.lower() == "mean":
|
|
379
|
+
return vars_mean(ds, vars=vars)
|
|
380
|
+
elif method.lower() == "weighted_mean":
|
|
381
|
+
return vars_weighted_mean(ds, vars=vars, weights=weights)
|
|
382
|
+
elif method.lower() == "geomean":
|
|
383
|
+
return vars_geomean(ds, vars=vars)
|
|
384
|
+
elif method.lower() == "weighted_geomean":
|
|
385
|
+
return vars_weighted_geomean(ds, vars=vars, weights=weights)
|
|
386
|
+
elif method.lower() == "limit_factor":
|
|
387
|
+
return limiting_factor(ds, vars=vars, **kwargs)
|
|
388
|
+
else:
|
|
389
|
+
raise ValueError(f"Method '{method}' not recognized.")
|
|
@@ -1,67 +1,70 @@
|
|
|
1
|
-
|
|
1
|
+
"""Statistics Module"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
2
6
|
import numpy as np
|
|
3
7
|
import pandas as pd
|
|
4
|
-
import geopandas as gpd
|
|
5
|
-
import xarray as xr
|
|
6
8
|
import regionmask
|
|
9
|
+
import xarray as xr
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
def statistics_summary(
|
|
10
13
|
data: xr.DataArray | xr.Dataset,
|
|
11
|
-
on_vars:
|
|
12
|
-
on_dims:
|
|
13
|
-
on_dim_values:
|
|
14
|
-
bins
|
|
15
|
-
bins_labels
|
|
16
|
-
all_bins
|
|
17
|
-
cell_area
|
|
18
|
-
dropna:
|
|
14
|
+
on_vars: list | None = None,
|
|
15
|
+
on_dims: list | None = None,
|
|
16
|
+
on_dim_values: dict[str, Any] | None = None,
|
|
17
|
+
bins: list | np.ndarray | None = None,
|
|
18
|
+
bins_labels: list | None = None,
|
|
19
|
+
all_bins: bool | None = False,
|
|
20
|
+
cell_area: tuple[float | str, str] | None = None,
|
|
21
|
+
dropna: bool | None = False,
|
|
19
22
|
**kwargs) -> pd.DataFrame:
|
|
20
|
-
|
|
23
|
+
|
|
21
24
|
def _correct_lowest_cut_interal(x: pd.Series) -> pd.Series:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if bins is not None and bins_labels is not None and (len(bins)-1 != len(bins_labels)):
|
|
25
|
+
first_cat = x.cat.categories[0]
|
|
26
|
+
lf = first_cat.left + (abs(first_cat.left) - bins[0])
|
|
27
|
+
return x.cat.rename_categories({first_cat: pd.Interval(lf, first_cat.right, closed='both')})
|
|
28
|
+
|
|
29
|
+
if bins is not None and bins_labels is not None and (len(bins) - 1 != len(bins_labels)):
|
|
28
30
|
raise ValueError('bins and bins_labels must have the same length')
|
|
29
|
-
|
|
31
|
+
|
|
30
32
|
if on_dim_values is not None:
|
|
31
33
|
for dim, value in on_dim_values.items():
|
|
32
34
|
data = data.sel({dim: value})
|
|
33
|
-
|
|
35
|
+
|
|
34
36
|
if on_vars is None:
|
|
35
37
|
on_vars = list(data.data_vars)
|
|
36
38
|
data = data[on_vars]
|
|
37
39
|
if on_dims is None:
|
|
38
40
|
on_dims = list(data.dims)
|
|
39
|
-
on_dims = [d for d in on_dims if d not in ['lat', 'lon', 'x', 'y']]
|
|
41
|
+
on_dims = [d for d in on_dims if d not in ['lat', 'lon', 'x', 'y']] # remove spatial dims
|
|
40
42
|
if cell_area:
|
|
41
43
|
cell_area, cell_unit = cell_area
|
|
42
44
|
|
|
43
|
-
|
|
44
45
|
df = data.to_dataframe().reset_index()
|
|
45
46
|
if len(on_dims) > 0:
|
|
46
47
|
df = df.drop(columns=[c for c in data.coords if c not in on_dims])
|
|
47
|
-
|
|
48
|
+
|
|
48
49
|
df = df.melt(id_vars=on_dims)
|
|
49
50
|
_dims = ['variable'] + on_dims
|
|
50
51
|
|
|
51
52
|
if bins is not None:
|
|
52
53
|
df['bin'] = pd.cut(df['value'], bins=pd.Index(bins), **kwargs)
|
|
53
|
-
if 'include_lowest' in kwargs and kwargs['include_lowest']
|
|
54
|
+
if 'include_lowest' in kwargs and kwargs['include_lowest']:
|
|
54
55
|
df['bin'] = _correct_lowest_cut_interal(df['bin'])
|
|
55
56
|
|
|
56
57
|
if bins_labels is not None:
|
|
57
|
-
lab_mapping = dict(zip(df['bin'].cat.categories.astype(str), bins_labels))
|
|
58
|
+
lab_mapping = dict(zip(df['bin'].cat.categories.astype(str), bins_labels, strict=False))
|
|
58
59
|
_dims.append('bin')
|
|
59
60
|
if all_bins:
|
|
60
|
-
all_bins_inter = pd.Interval(
|
|
61
|
+
all_bins_inter = pd.Interval(
|
|
62
|
+
df['bin'].cat.categories[0].left, df['bin'].cat.categories[-1].right, closed='both'
|
|
63
|
+
)
|
|
61
64
|
df_ = df.drop(columns=['bin']).assign(bin=all_bins_inter)
|
|
62
65
|
df_.loc[df['value'].isnull(), 'bin'] = np.nan
|
|
63
66
|
if bins_labels is not None:
|
|
64
|
-
lab_mapping.update({str(all_bins_inter)
|
|
67
|
+
lab_mapping.update({str(all_bins_inter): 'all'})
|
|
65
68
|
df = pd.concat([df, df_])
|
|
66
69
|
df['bin'] = df['bin'].astype(str)
|
|
67
70
|
|
|
@@ -73,7 +76,7 @@ def statistics_summary(
|
|
|
73
76
|
|
|
74
77
|
if cell_area:
|
|
75
78
|
df_out[f'area_{cell_unit}'] = df_out['count'] * cell_area
|
|
76
|
-
|
|
79
|
+
|
|
77
80
|
if dropna:
|
|
78
81
|
return df_out.dropna()
|
|
79
82
|
return df_out
|
|
@@ -81,19 +84,24 @@ def statistics_summary(
|
|
|
81
84
|
|
|
82
85
|
def spatial_statistics_summary(
|
|
83
86
|
data: xr.DataArray | xr.Dataset,
|
|
84
|
-
areas
|
|
85
|
-
name
|
|
86
|
-
on_vars:
|
|
87
|
-
on_dims:
|
|
88
|
-
on_dim_values:
|
|
89
|
-
bins
|
|
90
|
-
bins_labels
|
|
91
|
-
all_bins
|
|
92
|
-
cell_area
|
|
93
|
-
dropna:
|
|
94
|
-
mask_kwargs
|
|
95
|
-
stats_kwargs: dict =
|
|
96
|
-
|
|
87
|
+
areas: gpd.GeoDataFrame,
|
|
88
|
+
name: str | None = 'area',
|
|
89
|
+
on_vars: list | None = None,
|
|
90
|
+
on_dims: list | None = None,
|
|
91
|
+
on_dim_values: dict[str, Any] | None = None,
|
|
92
|
+
bins: np.ndarray | None = None,
|
|
93
|
+
bins_labels: list | None = None,
|
|
94
|
+
all_bins: bool | None = False,
|
|
95
|
+
cell_area: tuple[float | str, str] | None = None,
|
|
96
|
+
dropna: bool | None = False,
|
|
97
|
+
mask_kwargs: dict = None,
|
|
98
|
+
stats_kwargs: dict = None) -> pd.DataFrame:
|
|
99
|
+
|
|
100
|
+
if mask_kwargs is None:
|
|
101
|
+
mask_kwargs = {}
|
|
102
|
+
if stats_kwargs is None:
|
|
103
|
+
stats_kwargs = {}
|
|
104
|
+
|
|
97
105
|
regions = regionmask.from_geopandas(areas, name=name, **mask_kwargs)
|
|
98
106
|
mask = regions.mask_3D(data)
|
|
99
107
|
|
|
@@ -102,8 +110,8 @@ def spatial_statistics_summary(
|
|
|
102
110
|
df = statistics_summary(
|
|
103
111
|
data.where(mask.sel(region=r)), on_vars=on_vars, on_dims=on_dims, on_dim_values=on_dim_values,
|
|
104
112
|
bins=bins, bins_labels=bins_labels, all_bins=all_bins,
|
|
105
|
-
cell_area=cell_area
|
|
113
|
+
cell_area=cell_area, dropna=dropna, **stats_kwargs
|
|
106
114
|
)
|
|
107
115
|
df.insert(0, name, regions[r].name)
|
|
108
116
|
out.append(df)
|
|
109
|
-
return pd.concat(out)
|
|
117
|
+
return pd.concat(out)
|
|
@@ -1,28 +1,24 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Miscellaneous Utilities
|
|
3
|
-
===============================
|
|
4
|
-
|
|
5
|
-
...
|
|
6
|
-
"""
|
|
1
|
+
"""Miscellaneous Utilities"""
|
|
7
2
|
|
|
8
3
|
from pathlib import Path
|
|
9
4
|
|
|
10
5
|
import xarray as xr
|
|
11
6
|
|
|
7
|
+
|
|
12
8
|
def _load_data(name: str) -> xr.Dataset:
|
|
13
9
|
"""Load a sample dataset for testing or example purposes."""
|
|
14
|
-
if name.lower() not in [
|
|
10
|
+
if name.lower() not in ["soil", "climate"]:
|
|
15
11
|
raise ValueError(f"Invalid data name: {name}. Must be one of 'soil' or 'climate'.")
|
|
16
|
-
|
|
17
|
-
data_path = Path(__file__).parent /
|
|
18
|
-
return xr.open_dataset(data_path / f
|
|
12
|
+
|
|
13
|
+
data_path = Path(__file__).parent / "data"
|
|
14
|
+
return xr.open_dataset(data_path / f"{name.lower()}_sample.nc")
|
|
19
15
|
|
|
20
16
|
|
|
21
17
|
def load_soil_data() -> xr.Dataset:
|
|
22
18
|
"""Load soil dataset for testing or example purposes."""
|
|
23
|
-
return _load_data(
|
|
19
|
+
return _load_data("soil")
|
|
24
20
|
|
|
25
21
|
|
|
26
22
|
def load_climate_data() -> xr.Dataset:
|
|
27
23
|
"""Load climate dataset for testing or example purposes."""
|
|
28
|
-
return _load_data(
|
|
24
|
+
return _load_data("climate")
|
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
"""Land Suitability definition."""
|
|
2
|
-
|
|
3
|
-
from typing import Optional, Any, Union
|
|
4
|
-
import xarray as xr
|
|
5
|
-
import numpy as np
|
|
6
|
-
import pandas as pd
|
|
7
|
-
import geopandas as gpd
|
|
8
|
-
from shapely.geometry import mapping
|
|
9
|
-
import rioxarray
|
|
10
|
-
import regionmask
|
|
11
|
-
|
|
12
|
-
from lsapy.criteria import SuitabilityCriteria
|
|
13
|
-
from lsapy.statistics import statistics_summary, spatial_statistics_summary
|
|
14
|
-
|
|
15
|
-
__all__ = ["LandSuitability"]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class LandSuitability:
|
|
19
|
-
|
|
20
|
-
def __init__(
|
|
21
|
-
self,
|
|
22
|
-
name: str,
|
|
23
|
-
criteria: dict[str, SuitabilityCriteria],
|
|
24
|
-
short_name: Optional[str] = None,
|
|
25
|
-
long_name: Optional[str] = None,
|
|
26
|
-
description: Optional[str] = None,
|
|
27
|
-
) -> None:
|
|
28
|
-
self.name = name
|
|
29
|
-
self.criteria = criteria
|
|
30
|
-
self.short_name = short_name
|
|
31
|
-
self.long_name = long_name
|
|
32
|
-
self.description = description
|
|
33
|
-
|
|
34
|
-
self._sort_criteria_by_weight() # important if suitability as limited factor: return most weighted criteria if equals
|
|
35
|
-
self._criteria_name_list = [sc.name for sc in self.criteria.values()]
|
|
36
|
-
self._category_list = list(set([sc.category for sc in self.criteria.values()]))
|
|
37
|
-
self._get_params_by_category()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def __repr__(self) -> str:
|
|
41
|
-
if hasattr(self, 'data'):
|
|
42
|
-
return self.data.__repr__()
|
|
43
|
-
else:
|
|
44
|
-
attrs = []
|
|
45
|
-
for k, v in self.attrs.items():
|
|
46
|
-
if isinstance(v, str):
|
|
47
|
-
v = f"'{v}'"
|
|
48
|
-
attrs.append(f"{k}={v}")
|
|
49
|
-
return f"{self.__class__.__name__}({', '.join(attrs) if attrs else ''})"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def attrs(self):
|
|
54
|
-
return {k: v for k, v in {
|
|
55
|
-
'name': self.name,
|
|
56
|
-
'criteria': self._criteria_name_list,
|
|
57
|
-
'short_name': self.short_name,
|
|
58
|
-
'long_name': self.long_name,
|
|
59
|
-
'description': self.description,
|
|
60
|
-
}.items() if v is not None}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# def __getitem__(self, key: str) -> SuitabilityCriteria:
|
|
64
|
-
# return self.criteria[key]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def compute_criteria_suitability(self, inplace: Optional[bool] = False) -> None | xr.Dataset:
|
|
68
|
-
sc_list = []
|
|
69
|
-
for _, sc in self.criteria.items():
|
|
70
|
-
print(f'Computing {sc.name}...')
|
|
71
|
-
if sc.indicator.attrs.get('compute', '') == 'done':
|
|
72
|
-
sc_list.append(sc.indicator.rename(sc.name))
|
|
73
|
-
else:
|
|
74
|
-
sc_list.append(sc.compute())
|
|
75
|
-
ls = xr.merge(sc_list, compat='override', combine_attrs='drop')
|
|
76
|
-
for sc in sc_list:
|
|
77
|
-
ls[sc.name].attrs = sc.attrs
|
|
78
|
-
ls.attrs = self.attrs
|
|
79
|
-
if inplace:
|
|
80
|
-
self.data = ls
|
|
81
|
-
else:
|
|
82
|
-
return ls
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def compute_category_suitability(
|
|
86
|
-
self, method: str,
|
|
87
|
-
keep_criteria: Optional[bool] = False,
|
|
88
|
-
inplace: Optional[bool] = False,
|
|
89
|
-
limit_var: Optional[bool] = False,
|
|
90
|
-
) -> xr.Dataset:
|
|
91
|
-
if not hasattr(self, 'data'):
|
|
92
|
-
ds = self.compute_criteria_suitability()
|
|
93
|
-
else:
|
|
94
|
-
ds = self.data
|
|
95
|
-
|
|
96
|
-
out = []
|
|
97
|
-
out_attrs = []
|
|
98
|
-
for category in self._category_list:
|
|
99
|
-
print(f'Computing {category}...')
|
|
100
|
-
sc_list = [sc for sc in self.criteria.values() if sc.category == category]
|
|
101
|
-
res = _aggregate_vars(ds[self._criteria_by_category[category]], method=method,
|
|
102
|
-
weights=[sc.weight for sc in sc_list], limit_var=limit_var)
|
|
103
|
-
if isinstance(res, xr.Dataset):
|
|
104
|
-
res = res.rename({'limiting_factor': f'{category}', 'limiting_var': f'{category}_var'})
|
|
105
|
-
else:
|
|
106
|
-
res = res.rename(f'{category}_suitability')
|
|
107
|
-
res.attrs.update({'long_name': f'{category.capitalize()} Suitability'})
|
|
108
|
-
out.append(res)
|
|
109
|
-
out_attrs.append(res.attrs)
|
|
110
|
-
out = xr.merge(out, compat='override', combine_attrs='drop')
|
|
111
|
-
if keep_criteria:
|
|
112
|
-
out = xr.merge([ds, out], compat='override', combine_attrs='drop')
|
|
113
|
-
for sc in ds.data_vars: # add attributes of criteria
|
|
114
|
-
out[sc].attrs = ds[sc].attrs
|
|
115
|
-
for i, category in enumerate(self._category_list): # add attributes of category suitability
|
|
116
|
-
out[f'{category}_suitability'].attrs = out_attrs[i]
|
|
117
|
-
|
|
118
|
-
out.attrs.update(self.attrs)
|
|
119
|
-
if inplace:
|
|
120
|
-
self.data = out
|
|
121
|
-
else:
|
|
122
|
-
return out
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def compute_suitability(self,
|
|
126
|
-
method: Union[str, dict[str,str]] = 'mean',
|
|
127
|
-
by_category: Optional[bool] = False,
|
|
128
|
-
keep_all: Optional[bool] = False,
|
|
129
|
-
inplace: Optional[bool] = False,
|
|
130
|
-
limit_var: Optional[bool] = False
|
|
131
|
-
) -> xr.Dataset:
|
|
132
|
-
if isinstance(method, str):
|
|
133
|
-
cat_method, suit_method = method, method
|
|
134
|
-
elif isinstance(method, dict):
|
|
135
|
-
cat_method = method.get('category', 'mean')
|
|
136
|
-
suit_method = method.get('overall', 'mean')
|
|
137
|
-
else:
|
|
138
|
-
raise ValueError("Method must be a string or a dictionary.")
|
|
139
|
-
|
|
140
|
-
if not hasattr(self, 'data'):
|
|
141
|
-
if by_category:
|
|
142
|
-
ds = self.compute_category_suitability(method=cat_method, keep_criteria=True, inplace=False, limit_var=limit_var)
|
|
143
|
-
else:
|
|
144
|
-
ds = self.compute_criteria_suitability(inplace=False)
|
|
145
|
-
else:
|
|
146
|
-
ds = self.data
|
|
147
|
-
|
|
148
|
-
if by_category:
|
|
149
|
-
weights = [self.weights_by_category[category] for category in self._category_list]
|
|
150
|
-
on_vars = [f'{category}_suitability' for category in self._category_list]
|
|
151
|
-
else :
|
|
152
|
-
weights = [sc.weight for sc in self.criteria.values()]
|
|
153
|
-
on_vars = self._criteria_name_list
|
|
154
|
-
|
|
155
|
-
print(f'Computing suitability...')
|
|
156
|
-
out = _aggregate_vars(ds[on_vars], method=suit_method, weights=weights, limit_var=limit_var).rename('suitability')
|
|
157
|
-
out.attrs.update({'long_name': 'Suitability'})
|
|
158
|
-
out_attrs = out.attrs
|
|
159
|
-
|
|
160
|
-
if keep_all:
|
|
161
|
-
out = xr.merge([ds, out], compat='override', combine_attrs='drop')
|
|
162
|
-
for sc in ds.data_vars: # add attributes of criteria
|
|
163
|
-
out[sc].attrs = ds[sc].attrs
|
|
164
|
-
out['suitability'].attrs = out_attrs
|
|
165
|
-
|
|
166
|
-
out.attrs.update(self.attrs)
|
|
167
|
-
if inplace:
|
|
168
|
-
self.data = out
|
|
169
|
-
else:
|
|
170
|
-
return out
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _sort_criteria_by_weight(self) -> dict[str, SuitabilityCriteria]:
|
|
174
|
-
self.criteria = dict(sorted(self.criteria.items(), key=lambda item: item[1].weight, reverse=True))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _get_params_by_category(self):
|
|
178
|
-
self._get_criteria_by_category()
|
|
179
|
-
self._get_weights_by_category()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _get_criteria_by_category(self) -> dict[str, list[str]]:
|
|
183
|
-
self._criteria_by_category = {category: [] for category in self._category_list}
|
|
184
|
-
for sc in self.criteria.values():
|
|
185
|
-
self._criteria_by_category[sc.category].append(sc.name)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _get_weights_by_category(self) -> dict[str, float | int]:
|
|
189
|
-
self.weights_by_category = {category: [] for category in self._category_list}
|
|
190
|
-
for category in self._category_list:
|
|
191
|
-
self.weights_by_category[category] = sum([sc.weight for sc in self.criteria.values() if sc.category == category])
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def mask(self, mask : Union[xr.DataArray, gpd.GeoDataFrame], inplace : Optional[bool] = False,
|
|
195
|
-
spatial_dims : Optional[tuple[str, str]] = None, crs : Optional[str] = None, invert : bool = False, **kwargs) -> xr.DataArray | xr.Dataset:
|
|
196
|
-
|
|
197
|
-
if not hasattr(self, 'data'):
|
|
198
|
-
raise ValueError("Suitability must be computed first.")
|
|
199
|
-
|
|
200
|
-
if inplace:
|
|
201
|
-
self.data = _mask_data(self.data, mask, spatial_dims=spatial_dims, crs=crs, invert=invert, **kwargs)
|
|
202
|
-
else:
|
|
203
|
-
return _mask_data(self.data, mask, spatial_dims=spatial_dims, crs=crs, invert=invert, **kwargs)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def statistics(
|
|
207
|
-
self,
|
|
208
|
-
on_vars: Optional[list] = None,
|
|
209
|
-
on_dims: Optional[list] = None,
|
|
210
|
-
on_dim_values: Optional[dict[str, Any]] = None,
|
|
211
|
-
bins : Optional[Union[list, np.ndarray]] = None,
|
|
212
|
-
bins_labels : Optional[list] = None,
|
|
213
|
-
all_bins : Optional[bool] = False,
|
|
214
|
-
cell_area : Optional[tuple[Union[float, str], str]] = None,
|
|
215
|
-
dropna: Optional[bool] = False,
|
|
216
|
-
**kwargs) -> pd.DataFrame:
|
|
217
|
-
|
|
218
|
-
if not hasattr(self, 'data'):
|
|
219
|
-
raise ValueError("Suitability must be computed first.")
|
|
220
|
-
|
|
221
|
-
return statistics_summary(self.data, on_vars=on_vars, on_dims=on_dims, on_dim_values=on_dim_values,
|
|
222
|
-
bins=bins, bins_labels=bins_labels, all_bins=all_bins,
|
|
223
|
-
cell_area=cell_area, dropna=dropna **kwargs)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def spatial_statistics(
|
|
227
|
-
self,
|
|
228
|
-
areas : gpd.GeoDataFrame,
|
|
229
|
-
name : Optional[str] = 'area',
|
|
230
|
-
on_vars: Optional[list] = None,
|
|
231
|
-
on_dims: Optional[list] = None,
|
|
232
|
-
on_dim_values: Optional[dict[str, Any]] = None,
|
|
233
|
-
bins : Optional[np.ndarray] = None,
|
|
234
|
-
all_bins : Optional[bool] = False,
|
|
235
|
-
cell_area : Optional[tuple[Union[float, str], str]] = None,
|
|
236
|
-
mask_kwargs : dict = {},
|
|
237
|
-
stats_kwargs: dict = {}) -> pd.DataFrame:
|
|
238
|
-
|
|
239
|
-
if not hasattr(self, 'data'):
|
|
240
|
-
raise ValueError("Suitability must be computed first.")
|
|
241
|
-
|
|
242
|
-
return spatial_statistics_summary(self.data, areas, name=name, on_vars=on_vars, on_dims=on_dims, on_dim_values=on_dim_values,
|
|
243
|
-
bins=bins, all_bins=all_bins, cell_area=cell_area, mask_kwargs=mask_kwargs, stats_kwargs=stats_kwargs)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _mask_data(data : xr.DataArray | xr.Dataset, mask : Union[xr.DataArray, gpd.GeoDataFrame],
|
|
247
|
-
spatial_dims : Optional[tuple[str, str]] = None,
|
|
248
|
-
crs : Optional[str] = None, invert : bool = False, **kwargs) -> xr.DataArray | xr.Dataset:
|
|
249
|
-
|
|
250
|
-
if isinstance(mask, gpd.GeoDataFrame):
|
|
251
|
-
mask = mask.to_crs(crs)
|
|
252
|
-
data = data.rio.set_spatial_dims(*spatial_dims).rio.write_crs(crs)
|
|
253
|
-
return data.rio.clip(mask.geometry.apply(mapping), invert=invert, **kwargs)
|
|
254
|
-
elif isinstance(mask, xr.DataArray):
|
|
255
|
-
if invert:
|
|
256
|
-
mask = ~mask
|
|
257
|
-
return data.where(mask, **kwargs)
|
|
258
|
-
else:
|
|
259
|
-
raise ValueError('mask must be a GeoDataFrame or DataArray')
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
####################################################################################################
|
|
263
|
-
# VARIABLES AGGREGATION FUNCTIONS
|
|
264
|
-
####################################################################################################
|
|
265
|
-
|
|
266
|
-
def vars_weighted_mean(ds: xr.Dataset, vars = None, weights = None) -> xr.DataArray:
|
|
267
|
-
if vars is None:
|
|
268
|
-
vars = list(ds.data_vars)
|
|
269
|
-
if weights is None:
|
|
270
|
-
weights = np.ones(len(vars))
|
|
271
|
-
|
|
272
|
-
s = sum([ds[v] * w for v, w in zip(vars, weights)])
|
|
273
|
-
da: xr.DataArray = s / sum(weights)
|
|
274
|
-
return da.assign_attrs({
|
|
275
|
-
'method': 'Weighted Mean',
|
|
276
|
-
'description': f"Weighted Mean of variables: {', '.join([f'{v} ({w})' for v, w in zip(vars, weights)])}."
|
|
277
|
-
}).rename('weighted_mean')
|
|
278
|
-
|
|
279
|
-
def vars_mean(ds: xr.Dataset, vars = None) -> xr.DataArray:
|
|
280
|
-
if vars is None: vars = list(ds.data_vars)
|
|
281
|
-
da = vars_weighted_mean(ds, vars=vars)
|
|
282
|
-
return da.assign_attrs({'method': 'Mean', 'description': f"Mean of variables: {', '.join(vars)}."}).rename('mean')
|
|
283
|
-
|
|
284
|
-
def vars_weighted_geomean(ds: xr.Dataset, vars = None, weights = None) -> xr.DataArray:
|
|
285
|
-
if vars is None:
|
|
286
|
-
vars = list(ds.data_vars)
|
|
287
|
-
if weights is None:
|
|
288
|
-
weights = np.ones(len(vars))
|
|
289
|
-
|
|
290
|
-
s = sum([np.log(ds[v]) * w for v, w in zip(vars, weights)])
|
|
291
|
-
da : xr.DataArray = np.exp(s / sum(weights))
|
|
292
|
-
return da.assign_attrs({
|
|
293
|
-
'method': 'Weighted Geometric Mean',
|
|
294
|
-
'description': f"Weighted Geometric Mean of variables: {', '.join([f'{v} ({w})' for v, w in zip(vars, weights)])}."
|
|
295
|
-
}).rename('weighted_geometric_mean')
|
|
296
|
-
|
|
297
|
-
def vars_geomean(ds: xr.Dataset, vars = None) -> xr.DataArray:
|
|
298
|
-
if vars is None: vars = list(ds.data_vars)
|
|
299
|
-
da = vars_weighted_geomean(ds, vars=vars)
|
|
300
|
-
return da.assign_attrs({'method': 'Geometric Mean', 'description': f"Geometric Mean of variables: {', '.join(vars)}."}).rename('geometric_mean')
|
|
301
|
-
|
|
302
|
-
def limiting_factor(ds: xr.Dataset, vars = None, limiting_var: Optional[bool] = True) -> xr.DataArray | xr.Dataset:
|
|
303
|
-
if vars is None:
|
|
304
|
-
vars = list(ds.data_vars)
|
|
305
|
-
|
|
306
|
-
da = ds[vars].to_array()
|
|
307
|
-
mask = da.notnull().all(dim='variable')
|
|
308
|
-
|
|
309
|
-
lim = da.min(dim='variable', skipna=True).where(mask).rename('limiting_factor')
|
|
310
|
-
lim = lim.assign_attrs({'method': 'Limiting Factor', 'description': f"Value of limiting factor among variables: {', '.join(vars)}."})
|
|
311
|
-
if limiting_var:
|
|
312
|
-
lim_var = da.fillna(9999).argmin(dim='variable', skipna=True).where(mask).rename('limiting_var')
|
|
313
|
-
lim_var.attrs = {'method': 'Limiting Factor',
|
|
314
|
-
'description': f"Limiting factor among: {', '.join(vars)}.",
|
|
315
|
-
'legend': {f'{i}': v for i, v in enumerate(vars)}}
|
|
316
|
-
return xr.merge([lim, lim_var])
|
|
317
|
-
return lim.to_dataset()
|
|
318
|
-
|
|
319
|
-
def _aggregate_vars(ds : xr.Dataset, method: str = 'mean', vars = None, weights = None, **kwargs) -> xr.DataArray | xr.Dataset:
|
|
320
|
-
if method.lower() == 'mean':
|
|
321
|
-
return vars_mean(ds, vars=vars)
|
|
322
|
-
elif method.lower() == 'weighted_mean':
|
|
323
|
-
return vars_weighted_mean(ds, vars=vars, weights=weights)
|
|
324
|
-
elif method.lower() == 'geomean':
|
|
325
|
-
return vars_geomean(ds, vars=vars)
|
|
326
|
-
elif method.lower() == 'weighted_geomean':
|
|
327
|
-
return vars_weighted_geomean(ds, vars=vars, weights=weights)
|
|
328
|
-
elif method.lower() == 'limit_factor':
|
|
329
|
-
return limiting_factor(ds, vars=vars, **kwargs)
|
|
330
|
-
else:
|
|
331
|
-
raise ValueError(f"Method '{method}' not recognized.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|