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.
@@ -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.dev0
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-dev0"
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-dev0"
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 SuitabilityFunction, MembershipSuitFunction, DiscreteSuitFunction
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: Union[SuitabilityFunction, MembershipSuitFunction, DiscreteSuitFunction],
16
- weight: Optional[Union[int,float]] = 1,
17
- category: Optional[str] = None,
18
- long_name: Optional[str] = None,
19
- description: Optional[str] = None
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': # need to vectorize the discrete function
48
- sc : xr.DataArray = xr.apply_ufunc(self.func.map, self.indicator).rename(self.name)
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 : xr.DataArray = self.func.map(self.indicator).rename(self.name)
51
- return sc.assign_attrs(dict({k: v for k, v in self.attrs.items() if k not in ['name', 'func_method', 'from_indicator']},
52
- **{'history': f"func_method: {self.func}; from_indicator: [{self._from_indicator}]", 'compute': 'done'}))
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: Optional[Callable] = None,
23
- func_method: Optional[str] = None,
24
- func_params: Union[dict[str, Any]] = {}
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__}(func={self.func.__name__}, func_method='{self.func_method}', func_params={self.func_params})"
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 == None and self.func_params == None:
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: Optional[Callable] = None,
75
- func_method: Optional[str] = None,
76
- func_params: Optional[dict[str, int | float]] = None
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 = np.array([0,.25,.5,.75,1]), methods: str | list[str] = 'all', plot: bool = False):
83
- return _fit_mbs_functions(x, y, methods, plot)
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: Optional[dict[str, int | float]] = None
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 : dict[str, dict] = {}
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
- from typing import Any, Optional, Union
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: Optional[list] = None,
12
- on_dims: Optional[list] = None,
13
- on_dim_values: Optional[dict[str, Any]] = None,
14
- bins : Optional[Union[list, np.ndarray]] = None,
15
- bins_labels : Optional[list] = None,
16
- all_bins : Optional[bool] = False,
17
- cell_area : Optional[tuple[Union[float, str], str]] = None,
18
- dropna: Optional[bool] = False,
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
- first_cat = x.cat.categories[0]
23
- lf = first_cat.left + (abs(first_cat.left) - bins[0])
24
- return x.cat.rename_categories({first_cat: pd.Interval(lf, first_cat.right, closed='both')})
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']] # remove spatial dims
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'] == True:
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(df['bin'].cat.categories[0].left, df['bin'].cat.categories[-1].right, closed='both')
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) : 'all'})
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 : gpd.GeoDataFrame,
85
- name : Optional[str] = 'area',
86
- on_vars: Optional[list] = None,
87
- on_dims: Optional[list] = None,
88
- on_dim_values: Optional[dict[str, Any]] = None,
89
- bins : Optional[np.ndarray] = None,
90
- bins_labels : Optional[list] = None,
91
- all_bins : Optional[bool] = False,
92
- cell_area : Optional[tuple[Union[float, str], str]] = None,
93
- dropna: Optional[bool] = False,
94
- mask_kwargs : dict = {},
95
- stats_kwargs: dict = {}) -> pd.DataFrame:
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 ,dropna=dropna, **stats_kwargs
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 ['soil', 'climate']:
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 / 'data'
18
- return xr.open_dataset(data_path / f'{name.lower()}_sample.nc')
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('soil')
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('climate')
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