lsapy 0.1.0.dev0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """A Python package for Land Suitability Analysis."""
2
+
3
+ from lsapy.lsa import LandSuitability
4
+ from lsapy.criteria import SuitabilityCriteria
5
+ from lsapy.functions import *
6
+
7
+ __author__ = "Baptiste Hamon"
8
+ __email__ = "baptiste.hamon@pg.canterbury.ac.nz"
9
+ __version__ = "0.1.0-dev0"
lsapy/criteria.py ADDED
@@ -0,0 +1,72 @@
1
+ """Suitability Criteria definition."""
2
+
3
+ from typing import Optional, Union, Any
4
+ import xarray as xr
5
+
6
+ from lsapy.functions import SuitabilityFunction, MembershipSuitFunction, DiscreteSuitFunction
7
+
8
+ __all__ = ["SuitabilityCriteria"]
9
+
10
+ class SuitabilityCriteria:
11
+ def __init__(
12
+ self,
13
+ name: str,
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
20
+
21
+ ) -> None:
22
+ self.name = name
23
+ self.indicator = indicator
24
+ self.func = func
25
+ self.weight = weight
26
+ self.category = category
27
+ self.long_name = long_name
28
+ self.description = description
29
+ self._from_indicator = _get_indicator_description(indicator)
30
+
31
+ def __repr__(self) -> str:
32
+ attrs = []
33
+ attrs.append(f"name='{self.name}'")
34
+ attrs.append(f"indicator={self.indicator.name}")
35
+ attrs.append(f"func={self.func}")
36
+ attrs.append(f"weight={self.weight}")
37
+ if self.category is not None:
38
+ attrs.append(f"category='{self.category}'")
39
+ if self.long_name is not None:
40
+ attrs.append(f"long_name='{self.long_name}'")
41
+ if self.description is not None:
42
+ attrs.append(f"description='{self.description}'")
43
+ return f"{self.__class__.__name__}({', '.join(attrs) if attrs else ''})"
44
+
45
+
46
+ 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)
49
+ 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
+
55
+ @property
56
+ def attrs(self):
57
+ return {k: v for k, v in {
58
+ 'name': self.name,
59
+ 'weight': self.weight,
60
+ 'category': self.category,
61
+ 'long_name': self.long_name,
62
+ 'description': self.description,
63
+ 'func_method': self.func,
64
+ 'from_indicator': self._from_indicator
65
+ }.items() if v is not None}
66
+
67
+
68
+ def _get_indicator_description(indicator: xr.Dataset | xr.DataArray) -> str:
69
+ if indicator.attrs != {}:
70
+ return f"name: {indicator.name}; " + "; ".join([f"{k}: {v}" for k, v in indicator.attrs.items()])
71
+ else:
72
+ return f"name: {indicator.name}"
Binary file
Binary file
lsapy/functions.py ADDED
@@ -0,0 +1,266 @@
1
+ """Suitability Functions definitions."""
2
+
3
+ from typing import Optional, Callable, Union, Any
4
+ import warnings
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ import xarray as xr
8
+ from scipy.optimize import curve_fit
9
+
10
+
11
+ __all__ = [
12
+ "SuitabilityFunction",
13
+ "MembershipSuitFunction",
14
+ "DiscreteSuitFunction"
15
+ ]
16
+
17
+
18
+ class SuitabilityFunction:
19
+
20
+ def __init__(
21
+ self,
22
+ func: Optional[Callable] = None,
23
+ func_method: Optional[str] = None,
24
+ func_params: Union[dict[str, Any]] = {}
25
+ ):
26
+ if func_params is not None:
27
+ if func is None and func_method is None:
28
+ raise ValueError("If `func_params` is provided, `func` or `func_method` must also be provided.")
29
+
30
+ self.func = func
31
+ self.func_method = func_method
32
+ self.func_params = func_params
33
+ if func is None and func_method is not None:
34
+ self.func = _get_function_from_name(func_method)
35
+
36
+
37
+ def __repr__(self):
38
+ return f"{self.__class__.__name__}(func={self.func.__name__}, func_method='{self.func_method}', func_params={self.func_params})"
39
+
40
+
41
+ def __call__(self, x):
42
+ if self.func is None:
43
+ raise ValueError("No function has been provided.")
44
+ return self.func(x, **self.func_params)
45
+
46
+
47
+ def map(self, x):
48
+ return self(x)
49
+
50
+
51
+ def plot(self, x) -> None:
52
+ plt.plot(x, self(x))
53
+
54
+
55
+ @property
56
+ def attrs(self):
57
+ if self.func_method == None and self.func_params == None:
58
+ return {}
59
+ return {k: v for k, v in {
60
+ 'func_method': self.func_method,
61
+ 'func_params': self.func_params
62
+ }.items() if v is not None}
63
+
64
+
65
+ # ---------------------------------------------------------------------------- #
66
+ # ------------------------ Membership functions ------------------------------ #
67
+ # ---------------------------------------------------------------------------- #
68
+
69
+
70
+ class MembershipSuitFunction(SuitabilityFunction):
71
+
72
+ def __init__(
73
+ self,
74
+ func: Optional[Callable] = None,
75
+ func_method: Optional[str] = None,
76
+ func_params: Optional[dict[str, int | float]] = None
77
+ ):
78
+ super().__init__(func, func_method, func_params)
79
+
80
+
81
+ @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)
84
+
85
+
86
+ def _prepare_for_fitting(methods: str | list[str] = 'all'):
87
+ _types = ['sigmoid_like', 'gaussian_like']
88
+ _skipped = []
89
+
90
+ if methods == 'all':
91
+ methods = [f for t in _types for f in equations[t.replace('_like', '')]]
92
+ elif isinstance(methods, list) or isinstance(methods, str):
93
+ if isinstance(methods, str):
94
+ methods = [methods]
95
+
96
+ _methods = []
97
+ for method in methods:
98
+ if method in _types:
99
+ [_methods.append(m) for m in equations[method.replace('_like', '')].keys()]
100
+ else:
101
+ try :
102
+ _get_function_from_name(method)
103
+ _methods.append(method)
104
+ except :
105
+ _skipped.append(method)
106
+ warnings.warn(f"`{method}` not found in equations. Skipped.", stacklevel=2)
107
+ methods = _methods
108
+ for m in ['sigmoid', 'vetharaniam2024_eq8']:
109
+ if m in methods:
110
+ methods.remove(m)
111
+ _skipped.append(m)
112
+ if m == 'sigmoid':
113
+ warnings.warn("No parameters to determine for `sigmoid`. Skipped.", stacklevel=2)
114
+ if m == 'vetharaniam2024_eq8':
115
+ warnings.warn("Fitting method does not support `vetharaniam2024_eq8`. Skipped.", stacklevel=2)
116
+ return methods, _skipped
117
+
118
+
119
+ def _get_function_p0(method: str, x: np.ndarray) -> list[float]:
120
+ if method in equations['sigmoid']:
121
+ return [1, np.median(x)]
122
+ if method in equations['gaussian']:
123
+ return [1, np.median(x), 1]
124
+ return []
125
+
126
+
127
+ def _fit_mbs_functions(x, y, methods: str | list[str] = 'all', plot: bool = False):
128
+
129
+ skipped = []
130
+ methods, _skipped = _prepare_for_fitting(methods)
131
+ skipped.extend(_skipped)
132
+
133
+ if len(methods) == 0:
134
+ print(f"Skipped fitting for the following methods: {', '.join(skipped)}.")
135
+ raise ValueError("No methods to fit.")
136
+ else :
137
+ x_ = np.linspace(min(x), max(x), 100)
138
+ rms_errors = []
139
+ f_params = []
140
+ for method in methods:
141
+ try :
142
+ f = _get_function_from_name(method)
143
+ p0 = _get_function_p0(method, x)
144
+ popt, _ = curve_fit(f, x, y, p0=p0, maxfev=15000)
145
+ y_ = f(x_, *popt)
146
+ f_params.append(popt)
147
+ rmse = _rms_error(y,f(x, *popt))
148
+ rms_errors.append(rmse)
149
+ if plot:
150
+ plt.plot(x_, y_, label=method + f' (RMSE={rmse:.2f})')
151
+ except:
152
+ skipped.append(method)
153
+ warnings.warn(f"Failed to fit `{method}`. Skipped.", stacklevel=2)
154
+ if plot:
155
+ plt.scatter(x, y, c='r')
156
+ plt.legend()
157
+ plt.show()
158
+
159
+ if len(skipped) > 0:
160
+ print(f"Skipped fitting for the following methods: {', '.join(skipped)}.")
161
+ f_best, p_best = _get_best_fit(methods, rms_errors, f_params)
162
+ return _get_function_from_name(f_best), p_best
163
+
164
+
165
+ # ---------------------------------------------------------------------------- #
166
+ # --------------------------- Discrete functions ----------------------------- #
167
+ # ---------------------------------------------------------------------------- #
168
+
169
+ class DiscreteSuitFunction(SuitabilityFunction):
170
+
171
+ def __init__(
172
+ self,
173
+ func_params: Optional[dict[str, int | float]] = None
174
+ ):
175
+ self.func = discrete
176
+ self.func_method = 'discrete'
177
+ self.func_params = func_params
178
+
179
+
180
+ # ---------------------------------------------------------------------------- #
181
+ # ---------------------------- Utility functions ----------------------------- #
182
+ # ---------------------------------------------------------------------------- #
183
+
184
+ equations : dict[str, dict] = {}
185
+
186
+
187
+ def _get_function_from_name(name: str) -> callable:
188
+ for _type, funcs in equations.items():
189
+ if name in funcs:
190
+ return funcs[name]
191
+ raise ValueError(f"Equation `{name}` not implemented.")
192
+
193
+
194
+ def equation(type: str):
195
+ """
196
+ Register an equation in the `equations` mapping under the specified type.
197
+
198
+ Parameters
199
+ ----------
200
+ type : str
201
+ The type of equation to register.
202
+
203
+ Returns
204
+ -------
205
+ decorator
206
+ The decorator function.
207
+ """
208
+
209
+ def decorator(func: callable):
210
+ if type not in equations:
211
+ equations[type] = {}
212
+
213
+ equations[type].update({func.__name__: func})
214
+ return func
215
+ return decorator
216
+
217
+
218
+ @equation('discrete')
219
+ def discrete(x, rules: dict[str|int, int|float]) -> float:
220
+ return np.vectorize(rules.get, otypes=[np.float32])(x, np.nan)
221
+
222
+
223
+ @equation('sigmoid')
224
+ def logistic(x, a, b):
225
+ return 1 / (1 + np.exp(-a*(x - b)))
226
+
227
+
228
+ @equation('sigmoid')
229
+ def sigmoid(x):
230
+ return logistic(x, 1, 0)
231
+
232
+
233
+ @equation('sigmoid')
234
+ def vetharaniam2022_eq3(x, a, b):
235
+ return np.exp(a * (x - b)) / (1 + np.exp(a * (x - b)))
236
+
237
+
238
+ @equation('sigmoid')
239
+ def vetharaniam2022_eq5(x, a, b):
240
+ return 1 / (1 + np.exp(a * (np.sqrt(x) - np.sqrt(b))))
241
+
242
+
243
+ @equation('gaussian')
244
+ def vetharaniam2024_eq8(x, a, b, c):
245
+ return np.exp(-a * np.power(x - b, c))
246
+
247
+
248
+ @equation('gaussian')
249
+ def vetharaniam2024_eq10(x, a, b, c):
250
+ return 2 / (1 + np.exp(a * np.power(np.power(x, c) - np.power(b, c), 2)))
251
+
252
+
253
+ def _rms_error(y_true, y_pred):
254
+ diff = abs(y_true - y_pred)
255
+ return np.sqrt(np.mean(diff**2))
256
+
257
+
258
+ def _get_best_fit(methods, rmse, params, verbose=True):
259
+ best_fit = np.nanargmin(rmse)
260
+ if verbose:
261
+ print(f"""
262
+ Best fit: {methods[best_fit]}
263
+ RMSE: {rmse[best_fit]:.5f}
264
+ Params: a={params[best_fit][0]}, b={params[best_fit][1]}
265
+ """)
266
+ return methods[best_fit], params[best_fit]
lsapy/lsa.py ADDED
@@ -0,0 +1,331 @@
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.")
lsapy/statistics.py ADDED
@@ -0,0 +1,109 @@
1
+ from typing import Any, Optional, Union
2
+ import numpy as np
3
+ import pandas as pd
4
+ import geopandas as gpd
5
+ import xarray as xr
6
+ import regionmask
7
+
8
+
9
+ def statistics_summary(
10
+ 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,
19
+ **kwargs) -> pd.DataFrame:
20
+
21
+ 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)):
28
+ raise ValueError('bins and bins_labels must have the same length')
29
+
30
+ if on_dim_values is not None:
31
+ for dim, value in on_dim_values.items():
32
+ data = data.sel({dim: value})
33
+
34
+ if on_vars is None:
35
+ on_vars = list(data.data_vars)
36
+ data = data[on_vars]
37
+ if on_dims is None:
38
+ 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
40
+ if cell_area:
41
+ cell_area, cell_unit = cell_area
42
+
43
+
44
+ df = data.to_dataframe().reset_index()
45
+ if len(on_dims) > 0:
46
+ df = df.drop(columns=[c for c in data.coords if c not in on_dims])
47
+
48
+ df = df.melt(id_vars=on_dims)
49
+ _dims = ['variable'] + on_dims
50
+
51
+ if bins is not None:
52
+ df['bin'] = pd.cut(df['value'], bins=pd.Index(bins), **kwargs)
53
+ if 'include_lowest' in kwargs and kwargs['include_lowest'] == True:
54
+ df['bin'] = _correct_lowest_cut_interal(df['bin'])
55
+
56
+ if bins_labels is not None:
57
+ lab_mapping = dict(zip(df['bin'].cat.categories.astype(str), bins_labels))
58
+ _dims.append('bin')
59
+ if all_bins:
60
+ all_bins_inter = pd.Interval(df['bin'].cat.categories[0].left, df['bin'].cat.categories[-1].right, closed='both')
61
+ df_ = df.drop(columns=['bin']).assign(bin=all_bins_inter)
62
+ df_.loc[df['value'].isnull(), 'bin'] = np.nan
63
+ if bins_labels is not None:
64
+ lab_mapping.update({str(all_bins_inter) : 'all'})
65
+ df = pd.concat([df, df_])
66
+ df['bin'] = df['bin'].astype(str)
67
+
68
+ df_out = df.groupby(_dims, observed=False).describe().droplevel(0, axis=1).reset_index()
69
+
70
+ if bins_labels is not None:
71
+ bin_idx = np.where(df_out.columns == 'bin')[0][0]
72
+ df_out.insert(bin_idx + 1, 'bin_label', df_out['bin'].map(lab_mapping).values)
73
+
74
+ if cell_area:
75
+ df_out[f'area_{cell_unit}'] = df_out['count'] * cell_area
76
+
77
+ if dropna:
78
+ return df_out.dropna()
79
+ return df_out
80
+
81
+
82
+ def spatial_statistics_summary(
83
+ 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
+
97
+ regions = regionmask.from_geopandas(areas, name=name, **mask_kwargs)
98
+ mask = regions.mask_3D(data)
99
+
100
+ out = []
101
+ for r in mask.region.values:
102
+ df = statistics_summary(
103
+ data.where(mask.sel(region=r)), on_vars=on_vars, on_dims=on_dims, on_dim_values=on_dim_values,
104
+ bins=bins, bins_labels=bins_labels, all_bins=all_bins,
105
+ cell_area=cell_area ,dropna=dropna, **stats_kwargs
106
+ )
107
+ df.insert(0, name, regions[r].name)
108
+ out.append(df)
109
+ return pd.concat(out)
lsapy/utils.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Miscellaneous Utilities
3
+ ===============================
4
+
5
+ ...
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import xarray as xr
11
+
12
+ def _load_data(name: str) -> xr.Dataset:
13
+ """Load a sample dataset for testing or example purposes."""
14
+ if name.lower() not in ['soil', 'climate']:
15
+ 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')
19
+
20
+
21
+ def load_soil_data() -> xr.Dataset:
22
+ """Load soil dataset for testing or example purposes."""
23
+ return _load_data('soil')
24
+
25
+
26
+ def load_climate_data() -> xr.Dataset:
27
+ """Load climate dataset for testing or example purposes."""
28
+ return _load_data('climate')
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: lsapy
3
+ Version: 0.1.0.dev0
4
+ Summary: A Python package for Land Suitability Analysis.
5
+ Keywords: LSAPy,Land Suitability Analysis,Spatial Analysis,GIS,Geography
6
+ Author-email: Baptiste Hamon <baptiste.hamon@pg.canterbury.ac.nz>
7
+ Maintainer-email: Baptiste Hamon <baptiste.hamon@pg.canterbury.ac.nz>
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Natural Language :: English
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Topic :: Scientific/Engineering :: GIS
17
+ License-File: LICENSE
18
+ Project-URL: Homepage, https://github.com/baptistehamon/lsapy
19
+ Project-URL: Issues, https://github.com/baptistehamon/lsapy/issues
20
+
21
+ # LSAPy: Land Suitability Analysis for Python
22
+
23
+ `lsapy` stands for Land Suitability Analysis (LSA) for Python. The package has been designed to perfom spatial and temporal LSA operating on [xarray](https://docs.xarray.dev/en/stable/) `DataArray` and `Dataset`.
24
+
25
+
26
+ ## Features
27
+ - TODO
@@ -0,0 +1,12 @@
1
+ lsapy/__init__.py,sha256=MXu8EIXhOrPg7Aba-9nrQCU51QTRAWNKxDapzYcnzFs,284
2
+ lsapy/criteria.py,sha256=f2piZaEecKsLBvIH4JhLNXDNfNKNXUp70r23oeHErcQ,2971
3
+ lsapy/functions.py,sha256=WYfpUREOsrkv-70wme_zL_HArM_AI2b5YxU8vtee5NI,8382
4
+ lsapy/lsa.py,sha256=sBx96OpTDeaSrcX9s8IB2k91CNl0WUIW9hVM6oP-Hqg,14339
5
+ lsapy/statistics.py,sha256=62O57pqF1Pth4s1D0MbQEyobnxDGZ2bNatC2uVFnHH8,4269
6
+ lsapy/utils.py,sha256=eezLr5l0gqqgFEglzsSnbwx7JqOlVeKK3G63qllAJrg,776
7
+ lsapy/data/climate_sample.nc,sha256=ng2o7BxKLgzQWIl5fdiGWvdCQSeHeOO5-X3ciyD72Xo,52687793
8
+ lsapy/data/soil_sample.nc,sha256=y7NpYV9VZKXwiYExynA0vF1KAn5rhsWqzwGJ4tFE-TQ,2535031
9
+ lsapy-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=6kbiFSfobTZ7beWiKnHpN902HgBx-Jzgcme0SvKqhKY,1091
10
+ lsapy-0.1.0.dev0.dist-info/WHEEL,sha256=_2ozNFCLWc93bK4WKHCO-eDUENDlo-dgc9cU3qokYO4,82
11
+ lsapy-0.1.0.dev0.dist-info/METADATA,sha256=Vr0peYa--97JnnQKHtAsNb8SdreyYCs7espEr4XSemg,1145
12
+ lsapy-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.11.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2018 The Python Packaging Authority
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.