wolfhece 2.2.37__py3-none-any.whl → 2.2.39__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.
Files changed (53) hide show
  1. wolfhece/Coordinates_operations.py +5 -0
  2. wolfhece/GraphNotebook.py +72 -1
  3. wolfhece/GraphProfile.py +1 -1
  4. wolfhece/MulticriteriAnalysis.py +1579 -0
  5. wolfhece/PandasGrid.py +62 -1
  6. wolfhece/PyCrosssections.py +194 -43
  7. wolfhece/PyDraw.py +891 -73
  8. wolfhece/PyGui.py +913 -72
  9. wolfhece/PyGuiHydrology.py +528 -74
  10. wolfhece/PyPalette.py +26 -4
  11. wolfhece/PyParams.py +33 -0
  12. wolfhece/PyPictures.py +2 -2
  13. wolfhece/PyVertex.py +32 -0
  14. wolfhece/PyVertexvectors.py +147 -75
  15. wolfhece/PyWMS.py +52 -36
  16. wolfhece/acceptability/acceptability.py +15 -8
  17. wolfhece/acceptability/acceptability_gui.py +507 -360
  18. wolfhece/acceptability/func.py +80 -183
  19. wolfhece/apps/version.py +1 -1
  20. wolfhece/compare_series.py +480 -0
  21. wolfhece/drawing_obj.py +12 -1
  22. wolfhece/hydrology/Catchment.py +228 -162
  23. wolfhece/hydrology/Internal_variables.py +43 -2
  24. wolfhece/hydrology/Models_characteristics.py +69 -67
  25. wolfhece/hydrology/Optimisation.py +893 -182
  26. wolfhece/hydrology/PyWatershed.py +267 -165
  27. wolfhece/hydrology/SubBasin.py +185 -140
  28. wolfhece/hydrology/climate_data.py +334 -0
  29. wolfhece/hydrology/constant.py +11 -0
  30. wolfhece/hydrology/cst_exchanges.py +76 -1
  31. wolfhece/hydrology/forcedexchanges.py +413 -49
  32. wolfhece/hydrology/hyetograms.py +2095 -0
  33. wolfhece/hydrology/read.py +65 -5
  34. wolfhece/hydrometry/kiwis.py +42 -26
  35. wolfhece/hydrometry/kiwis_gui.py +7 -2
  36. wolfhece/insyde_be/INBE_func.py +746 -0
  37. wolfhece/insyde_be/INBE_gui.py +1776 -0
  38. wolfhece/insyde_be/__init__.py +3 -0
  39. wolfhece/interpolating_raster.py +366 -0
  40. wolfhece/irm_alaro.py +1457 -0
  41. wolfhece/irm_qdf.py +889 -57
  42. wolfhece/lifewatch.py +6 -3
  43. wolfhece/picc.py +124 -8
  44. wolfhece/pyLandUseFlanders.py +146 -0
  45. wolfhece/pydownloader.py +2 -1
  46. wolfhece/pywalous.py +225 -31
  47. wolfhece/toolshydrology_dll.py +149 -0
  48. wolfhece/wolf_array.py +63 -25
  49. {wolfhece-2.2.37.dist-info → wolfhece-2.2.39.dist-info}/METADATA +3 -1
  50. {wolfhece-2.2.37.dist-info → wolfhece-2.2.39.dist-info}/RECORD +53 -42
  51. {wolfhece-2.2.37.dist-info → wolfhece-2.2.39.dist-info}/WHEEL +0 -0
  52. {wolfhece-2.2.37.dist-info → wolfhece-2.2.39.dist-info}/entry_points.txt +0 -0
  53. {wolfhece-2.2.37.dist-info → wolfhece-2.2.39.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1579 @@
1
+ """
2
+ This module contains the objects needed to perform
3
+ a multi-criteria analysis for fish passage
4
+ on spatially distributed hydrodynamic data.
5
+
6
+ The module tests are located in the folder:
7
+ tests/test_MulticriteriAnalysis.py file,
8
+ in HECEPython repository.
9
+
10
+ Authors: Utashi Ciraane,
11
+ HECE, University of Liège, Belgium.
12
+ """
13
+ # **************************************************************
14
+ # Libraries used throughout the module
15
+ # **************************************************************
16
+
17
+ # To stay inside the HECEPython repository
18
+ # ----------------------------------------
19
+
20
+ # Standard libraries
21
+ # -------------------
22
+ import logging
23
+ import numpy as np
24
+ import os
25
+
26
+ # Cherry-picked objects from standard libraries
27
+ # ---------------------------------------------
28
+ from enum import Enum
29
+ from pathlib import Path
30
+ from tqdm import tqdm
31
+ from typing import Literal, Union
32
+
33
+ # Cherry-picked objects from wolfhece
34
+ # ------------------------------------
35
+ from wolfhece.wolf_array import WolfArray, header_wolf
36
+
37
+ ## **************************************************************
38
+ # Constants Used
39
+ # **************************************************************
40
+ class Operator(Enum):
41
+ """
42
+ Mathematical operators used
43
+ in the module.
44
+ """
45
+ AVERAGE = "average"
46
+ BETWEEN = "between" # Between two limits. The limits are included.
47
+ BETWEEN_STRICT = "strictly between" # Between two limits. The limits are not included.
48
+ EQUAL = "equal" # Equal to a value.
49
+ INFERIOR = "inferior" # Less than a limit. The limit is not included.
50
+ INFERIOR_OR_EQUAL = "inferior or equal" # Less than or equal to a limit. The limit is included.
51
+ OUTSIDE = "outside" # Outside two limits. The limits are included.
52
+ OUTSIDE_STRICT = "strictly outside" # Outside two limits. The limits are not included.
53
+ PERCENTAGE = "percentage"
54
+ PRODUCT = "product"
55
+ SUM = "sum"
56
+ SUPERIOR = "superior" # Greater than a limit. The limit is not included.
57
+ SUPERIOR_OR_EQUAL = "superior or equal" # Greater than or equal to a limit. The limit is included.
58
+ THRESHOLD = "threshold"
59
+ WEIGHTED_SUM = "weighted sum"
60
+
61
+ class Format(Enum):
62
+ """
63
+ Types of data formats used in the module.
64
+ """
65
+ FLOAT32 = np.float32 # The float32 data type used in the module.
66
+ FLOAT64 = np.float64 # The float64 data type used in the module.
67
+ INT32 = np.int32 # The int32 data type used in the module.
68
+ INT64 = np.int64 # The int64 data type used in the module.
69
+
70
+ class Status(Enum):
71
+ """
72
+ Status of objects in the module
73
+ """
74
+ PROGRESS_BAR_DISABLE = False # Enable or disable the tqdm progress bars.
75
+
76
+
77
+ class Constant(Enum):
78
+ """
79
+ This class contains the constants used throughout the module.
80
+ """
81
+ EPSG = 31370
82
+ ONE = 1
83
+ ZERO = 0
84
+ CONDITION = "condition"
85
+
86
+
87
+ ## **************************************************************
88
+ # Classes or Objects used to perform the multi-criteria analysis
89
+ ## **************************************************************
90
+ class Variable:
91
+ """
92
+ This class is used to store variable (data: array)
93
+ that will be used in the multi-criteria analysis.
94
+
95
+ The types of variable allowed are:
96
+ - np.ma.MaskedArray: A numpy masked array.
97
+ - np.ndarray: A numpy array.
98
+ - WolfArray: A WolfArray object from the wolfhece library.
99
+ """
100
+ def __init__(self,
101
+ variable: np.ma.MaskedArray| np.ndarray| WolfArray,
102
+ dtype : Union[np.dtype, str] = Format.FLOAT32.value) :
103
+ """
104
+ Initialize the variable with a WolfArray or a numpy array.
105
+ if a type dtype is not provided, it defaults to np.float32.
106
+
107
+ :param variable: A WolfArray or a numpy array.
108
+ ::type variable: np.ma.MaskedArray | np.ndarray | WolfArray
109
+ :param dtype: The data type of the variable, defaults to np.float32.
110
+ ::type dtype: Union[np.dtype, str]
111
+
112
+ """
113
+ self._variable: np.ma.MaskedArray = None
114
+ self._dtype = dtype
115
+ self.variable = variable
116
+
117
+
118
+
119
+ @property
120
+ def variable(self) -> np.ma.MaskedArray:
121
+ """
122
+ Return the variable.
123
+
124
+ :return: The variable as a numpy masked array.
125
+ """
126
+ return self._variable
127
+
128
+ @variable.setter
129
+ def variable(self, value: np.ma.MaskedArray| np.ndarray| WolfArray) :
130
+ """
131
+ Set the variable and converts it to a numpy masked array.
132
+
133
+ :param value: A WolfArray or a numpy masked array.
134
+ """
135
+ assert isinstance(value, (np.ma.MaskedArray, np.ndarray, WolfArray)), \
136
+ "The variable must be a numpy masked array, a numpy array or a WolfArray."
137
+ if isinstance(value, WolfArray):
138
+ if np.ma.is_masked(value.array):
139
+ value = value.array
140
+ else:
141
+ # If the WolfArray is not masked, create a masked array with no mask.
142
+ mask = np.zeros(value.array.shape, dtype=bool)
143
+ value = np.ma.masked_array(value.array, mask=mask)
144
+
145
+ elif isinstance(value, np.ndarray):
146
+ mask =np.zeros(value.shape, dtype=bool)
147
+ value = np.ma.masked_array(value, mask=mask)
148
+ self._variable = value.astype(self._dtype, copy=True)
149
+
150
+ @property
151
+ def dtype(self) -> type:
152
+ """
153
+ Return the data type of the variable.
154
+
155
+ :return: The data type of the variable.
156
+ """
157
+ return self._variable.dtype
158
+
159
+ @dtype.setter
160
+ def dtype(self, value: Union[type, str]) -> None:
161
+ """
162
+ Set the data type of the variable.
163
+
164
+ :param value: The data type to set.
165
+ """
166
+ assert isinstance(value, (type, str)), \
167
+ "The data type must be a numpy dtype or a string representing a numpy dtype."
168
+ # if isinstance(value, str):
169
+ # # value = type(value)
170
+
171
+ self._variable = self._variable.astype(value, copy=True)
172
+ self._dtype = value
173
+
174
+ class Criteria:
175
+ """
176
+ This class is used to define a criteria included in the multi-criteria analysis
177
+
178
+ It contains a threshold and a condition:.
179
+ - superior,
180
+ - inferior,
181
+ - equal,
182
+ - superior or equal,
183
+ - inferior or equal,
184
+ - between,
185
+ - strictly between,
186
+ - outside,
187
+ - strictly outside.
188
+
189
+ The condition defines how the threshold is applied to select
190
+ values in the variable true (1) or False (0).
191
+ """
192
+ def __init__(self,
193
+ threshold: Union[float, int, tuple],
194
+ condition: Literal[Operator.SUPERIOR.value,
195
+ Operator.INFERIOR.value,
196
+ Operator.SUPERIOR_OR_EQUAL.value,
197
+ Operator.INFERIOR_OR_EQUAL.value,
198
+ Operator.BETWEEN.value,
199
+ Operator.BETWEEN_STRICT.value,
200
+ Operator.OUTSIDE.value,
201
+ Operator.OUTSIDE_STRICT.value
202
+ ] = Operator.SUPERIOR_OR_EQUAL.value) -> dict:
203
+ """
204
+ Return a criteria with a threshold and a condition.
205
+
206
+ :param threshold: The threshold value for the criteria.
207
+ :type threshold: float| int|tuple
208
+ :param condition: The condition to select the data around the threshold.
209
+ :type condition: str
210
+ :return: a dictionary containing 2 elements, the threshold and the condition.
211
+ :rtype: dict
212
+ """
213
+
214
+ self._available_conditions = [Operator.SUPERIOR.value,
215
+ Operator.INFERIOR.value,
216
+ Operator.EQUAL.value,
217
+ Operator.SUPERIOR_OR_EQUAL.value,
218
+ Operator.INFERIOR_OR_EQUAL.value,
219
+ Operator.BETWEEN.value,
220
+ Operator.BETWEEN_STRICT.value,
221
+ Operator.OUTSIDE.value,
222
+ Operator.OUTSIDE_STRICT.value
223
+ ] # add NOT in method too.
224
+ self._needs_tuple = (Operator.BETWEEN.value,
225
+ Operator.BETWEEN_STRICT.value,
226
+ Operator.OUTSIDE.value,
227
+ Operator.OUTSIDE_STRICT.value)
228
+ self._criteria: dict = {}
229
+
230
+ self.threshold = threshold
231
+ self.condition = condition
232
+
233
+ @property
234
+ def criteria(self) -> dict:
235
+ """
236
+ Return the criteria.
237
+
238
+ :return: The criteria as a dictionary.
239
+ """
240
+ return self._criteria
241
+
242
+ @criteria.setter
243
+ def criteria(self,
244
+ criteria : dict) -> None:
245
+ """
246
+ Set the criteria.
247
+
248
+ :param value: The criteria as a dictionary.
249
+ """
250
+ assert isinstance(criteria, dict), f"The criteria must be a dictionary, not {type(criteria)}."
251
+ assert Operator.THRESHOLD.value in criteria,\
252
+ f"The threshold key must be {Operator.THRESHOLD.value } in the criteria dictionary."
253
+ assert Constant.CONDITION.value in criteria,\
254
+ f"The condition key must be {Constant.CONDITION.value} in the criteria dictionary."
255
+ self.threshold = criteria[Operator.THRESHOLD.value]
256
+ self.condition = criteria[Constant.CONDITION.value]
257
+
258
+ @property
259
+ def threshold(self) -> float:
260
+ """
261
+ Get the threshold of the criteria.
262
+
263
+ :return: The threshold as a float.
264
+ """
265
+ return self._criteria[Operator.THRESHOLD.value]
266
+
267
+ @threshold.setter
268
+ def threshold(self, value: float) -> None:
269
+ """
270
+ Set the threshold of the criteria.
271
+
272
+ :param value: The threshold to set.
273
+ """
274
+ assert isinstance(value, (float, int, tuple)),\
275
+ f"The threshold must be a float or an int or a tuple of length equals to Two, not a {type(value)} ."
276
+ if isinstance(value, tuple):
277
+ assert len(value) == 2, "The threshold tuple must have exactly two elements."
278
+ assert value[0] <= value[1], "The first element of the threshold tuple must be less than the second element."
279
+ if Constant.CONDITION.value in self._criteria:
280
+ if self.condition not in (self._needs_tuple):
281
+ value = self._tuple_to_number(value)
282
+ logging.warning(f"The condition '{self.condition}' does not require a tuple for the threshold. "
283
+ f"The threshold will be set to : {value} which i the first value of the tuple.")
284
+
285
+ elif isinstance(value, (float, int)):
286
+ if Constant.CONDITION.value in self._criteria:
287
+ if self.condition in self._needs_tuple:
288
+ logging.warning(f"The condition '{self.condition}' requires a tuple for the threshold. "
289
+ f"The threshold will be set to a tuple with the value {value} as both elements.")
290
+ # Convert single number to tuple
291
+ value = self._number_to_tuple(value)
292
+ assert isinstance(self._criteria, dict), f"The criteria type must be a dictionary, not {type(self._criteria)}."
293
+ self._criteria[Operator.THRESHOLD.value] = value
294
+
295
+ @property
296
+ def condition(self) -> str:
297
+ """
298
+ Get the condition.
299
+
300
+ :return: The condition as a string.
301
+ """
302
+ return self._criteria[Constant.CONDITION.value]
303
+
304
+ @condition.setter
305
+ def condition(self, value: Literal[Operator.SUPERIOR.value,
306
+ Operator.INFERIOR.value,
307
+ Operator.EQUAL.value,
308
+ Operator.SUPERIOR_OR_EQUAL.value,
309
+ Operator.INFERIOR_OR_EQUAL.value,
310
+ Operator.BETWEEN.value,
311
+ Operator.BETWEEN_STRICT.value
312
+ ]) -> None:
313
+ """
314
+ Set the condition.
315
+
316
+ :param value: The condition to set.
317
+ """
318
+ assert isinstance(value, str), f"The condition must be a string, not a {type(value)}."
319
+ assert value in self._available_conditions,\
320
+ f"The criteria must be one of the following: {self._available_conditions}."
321
+ self._criteria[Constant.CONDITION.value] = value
322
+ # If the condition is a range condition (between, strictly between, outside, strictly outside),
323
+ # we need to convert the threshold to a tuple if it is not already.
324
+ if value in self._needs_tuple:
325
+ if isinstance(self.threshold, (int, float)):
326
+ # If the threshold is a single number, convert it to a tuple
327
+ self.threshold = self._number_to_tuple(self.threshold)
328
+ else:
329
+ # If the condition does not require a tuple, ensure the threshold is a single number
330
+ if isinstance(self.threshold, tuple):
331
+ self.threshold = self._tuple_to_number(self.threshold)
332
+
333
+ def _number_to_tuple(self, value: Union[float, int]) -> tuple:
334
+ """
335
+ Convert a number to a tuple of two elements.
336
+
337
+ :param value: The number to convert.
338
+ :return: A tuple with the number as both elements.
339
+ """
340
+ assert isinstance(value, (float, int)), f"The value must be a float or an int, not {type(value)}."
341
+ return (value, value)
342
+
343
+ def _tuple_to_number(self, value: tuple) -> float:
344
+ """
345
+ Convert a tuple of two elements to a number.
346
+
347
+ :param value: The tuple to convert.
348
+ :return: The first element of the tuple as a float.
349
+ """
350
+ assert isinstance(value, tuple), f"The value must be a tuple, not {type(value)}."
351
+ assert len(value) == 2, "The tuple must have exactly two elements."
352
+ return value[0]
353
+
354
+ class Score:
355
+ """
356
+ This class scores a Variable based on the given criteria (threshold & condition).
357
+
358
+ The score is a np.MaskedArray with the same shape as the given variable.
359
+
360
+ The score contains boolean values indicating whether the variable meets the criteria (1) or (0).
361
+ If the variable is masked, the score is also masked.
362
+
363
+ Binary notation was selected to allow mathematical operations.
364
+ """
365
+ def __init__(self,
366
+ variable: Variable,
367
+ criteria: Criteria,
368
+ dtype = Format.INT32.value
369
+ ) -> None:
370
+ """
371
+ Initialize the score with a variable and a criteria.
372
+
373
+ :param variable: The variable (array) to score.
374
+ :type variable: Variable
375
+ :param criteria: The criteria to use for scoring (threshold and condition).
376
+ :type criteria: Criteria
377
+ :param dtype: The data type of the score, defaults to np.int32.
378
+ :type dtype: Union[np.dtype, str]
379
+ """
380
+ assert isinstance(variable, Variable), f"The variable must be an instance of Variable, not {type(variable)}."
381
+ assert isinstance(criteria, Criteria), f"The criteria must be an instance of Criteria, not {type(criteria)}."
382
+ self._variable = None
383
+ self.variable = variable
384
+ self._criteria = None
385
+ self.criteria = criteria
386
+ self._score: np.ma.MaskedArray = None
387
+ self._dtype = None
388
+ self.dtype = dtype
389
+
390
+ @property
391
+ def variable(self) -> Variable:
392
+ """
393
+ Get the variable.
394
+
395
+ :return: The variable as a Variable object.
396
+ """
397
+ return self._variable
398
+
399
+ @variable.setter
400
+ def variable(self, value: Variable) -> None:
401
+ """
402
+ Set the variable.
403
+
404
+ :param value: The variable to set.
405
+ """
406
+ assert isinstance(value, Variable), f"The variable must be an instance of Variable, not {type(value)}."
407
+ self._variable = value
408
+
409
+ @property
410
+ def criteria(self) -> Criteria:
411
+ """
412
+ Get the criteria.
413
+
414
+ :return: The criteria as a Criteria object.
415
+ """
416
+ return self._criteria
417
+
418
+ @criteria.setter
419
+ def criteria(self, value: Criteria) -> None:
420
+ """
421
+ Set the criteria.
422
+
423
+ :param value: The criteria to set.
424
+ """
425
+ assert isinstance(value, Criteria), f"The criteria must be an instance of Criteria, not {type(value)}."
426
+ self._criteria = value
427
+
428
+ @property
429
+ def score(self) -> np.ma.MaskedArray:
430
+ """
431
+ Get the score.
432
+
433
+ :return: The score as a numpy masked array.
434
+ """
435
+ self.score = self._compute_score()
436
+ return self._score
437
+
438
+ @score.setter
439
+ def score(self, value: np.ma.MaskedArray) -> None:
440
+ """
441
+ Set the score.
442
+
443
+ :param value: The score to set.
444
+ """
445
+ assert isinstance(value, np.ma.MaskedArray), f"The score must be a numpy masked array, not {type(value)}."
446
+ assert value.shape == self.variable.variable.shape, \
447
+ f"The score must have the same shape as the variable, not {value.shape}."
448
+ self._score = value.astype(self._dtype, copy=True)
449
+
450
+ @property
451
+ def dtype(self) -> type:
452
+ """
453
+ Get the data type of the score.
454
+
455
+ :return: The data type of the score.
456
+ """
457
+ return self._dtype
458
+
459
+ @dtype.setter
460
+ def dtype(self, value: Union[type, str]) -> None:
461
+ """
462
+ Set the data type of the score.
463
+
464
+ :param value: The data type to set.
465
+ """
466
+ assert isinstance(value, (type, str)), f"The data type must be a numpy dtype or a string representing a numpy dtype, not {type(value)}."
467
+ assert np.issubdtype(value, np.integer), \
468
+ f"The data type must be an integer type, not {value}."
469
+ self._dtype = value
470
+ if self._score is not None:
471
+ self._score = self._score.astype(value, copy=True)
472
+
473
+ def _compute_score(self):
474
+ """
475
+ Compute the score based on the variable and criteria.
476
+
477
+ :return: The score as a numpy masked array.
478
+ """
479
+ assert self._variable is not None, "Variable is not set."
480
+ assert self._criteria is not None, "Criteria is not set."
481
+ assert isinstance(self._variable.variable, np.ma.MaskedArray), \
482
+ f"The variable must be a numpy masked array, not {type(self._variable.variable)}"
483
+ score:np.ma.MaskedArray = self.variable.variable.copy()
484
+ score = score.astype(Format.INT32.value, copy=True) # Convert to int32 for scoring
485
+
486
+ if self.criteria.condition == Operator.SUPERIOR.value:
487
+ score[self.variable.variable <= self.criteria.threshold] = Constant.ZERO.value
488
+ score[self.variable.variable > self.criteria.threshold] = Constant.ONE.value
489
+
490
+ elif self.criteria.condition == Operator.INFERIOR.value:
491
+ score[self.variable.variable >= self.criteria.threshold] = Constant.ZERO.value
492
+ score[self.variable.variable < self.criteria.threshold] = Constant.ONE.value
493
+
494
+ elif self.criteria.condition == Operator.EQUAL.value:
495
+ score[self.variable.variable != self.criteria.threshold] = Constant.ZERO.value
496
+ score[self.variable.variable == self.criteria.threshold] = Constant.ONE.value
497
+
498
+ elif self.criteria.condition == Operator.SUPERIOR_OR_EQUAL.value:
499
+ score[self.variable.variable < self.criteria.threshold] = Constant.ZERO.value
500
+ score[self.variable.variable >= self.criteria.threshold] = Constant.ONE.value
501
+
502
+ elif self.criteria.condition == Operator.INFERIOR_OR_EQUAL.value:
503
+ score[self.variable.variable > self.criteria.threshold] = Constant.ZERO.value
504
+ score[self.variable.variable <= self.criteria.threshold] = Constant.ONE.value
505
+
506
+ elif self.criteria.condition == Operator.BETWEEN.value:
507
+ assert isinstance(self.criteria.threshold, tuple), \
508
+ "The threshold for the 'between' condition must be a tuple of two elements."
509
+ score[(self.variable.variable < self.criteria.threshold[0]) | \
510
+ (self.variable.variable > self.criteria.threshold[1])] = Constant.ZERO.value
511
+ score[(self.variable.variable >= self.criteria.threshold[0]) & \
512
+ (self.variable.variable <= self.criteria.threshold[1])] = Constant.ONE.value
513
+
514
+ elif self.criteria.condition == Operator.BETWEEN_STRICT.value:
515
+ assert isinstance(self.criteria.threshold, tuple), \
516
+ "The threshold for the 'strictly between' condition must be a tuple of two elements."
517
+ score[(self.variable.variable <= self.criteria.threshold[0]) | \
518
+ (self.variable.variable >= self.criteria.threshold[1])] = Constant.ZERO.value
519
+ score[(self.variable.variable > self.criteria.threshold[0]) & \
520
+ (self.variable.variable < self.criteria.threshold[1])] = Constant.ONE.value
521
+
522
+ elif self.criteria.condition == Operator.OUTSIDE.value:
523
+ assert isinstance(self.criteria.threshold, tuple), \
524
+ "The threshold for the 'outside' condition must be a tuple of two elements."
525
+ score[(self.variable.variable > self.criteria.threshold[0]) & \
526
+ (self.variable.variable < self.criteria.threshold[1])] = Constant.ZERO.value
527
+ score[(self.variable.variable <= self.criteria.threshold[0]) | \
528
+ (self.variable.variable >= self.criteria.threshold[1])] = Constant.ONE.value
529
+
530
+ elif self.criteria.condition == Operator.OUTSIDE_STRICT.value:
531
+ assert isinstance(self.criteria.threshold, tuple), \
532
+ "The threshold for the 'strictly outside' condition must be a tuple of two elements."
533
+ score[(self.variable.variable >= self.criteria.threshold[0]) | \
534
+ (self.variable.variable <= self.criteria.threshold[1])] = Constant.ZERO.value
535
+ score[(self.variable.variable < self.criteria.threshold[0]) | \
536
+ (self.variable.variable > self.criteria.threshold[1])] = Constant.ONE.value
537
+
538
+ else:
539
+ raise ValueError(f"Unknown condition: {self._criteria.condition}. "
540
+ f"Available conditions are: {self._criteria._available_conditions}")
541
+
542
+ return score.astype(self.dtype, copy=True)
543
+
544
+ class Scores:
545
+ """
546
+ This class is used to store multiple scores from different criteria as dictionary.
547
+
548
+ It contains a dictionary of scores,
549
+ where the keys are the score names and the values are the Score objects.
550
+ """
551
+ def __init__(self, scores:dict[str, Score]) -> None:
552
+ """
553
+ Initialize the Scores object.
554
+ """
555
+ self._scores: dict[str, Score] = None
556
+ self.scores = scores
557
+
558
+ @property
559
+ def scores(self) -> dict[str, Score]:
560
+ """
561
+ Get the scores.
562
+
563
+ :return: The scores as a dictionary of Score objects.
564
+ """
565
+ return self._scores
566
+
567
+ @scores.setter
568
+ def scores(self, value: dict[str, Score]) -> None:
569
+ """
570
+ Set the scores.
571
+
572
+ :param value: The scores to set.
573
+ """
574
+ assert isinstance(value, dict), f"The scores must be a dictionary, not {type(value)}."
575
+ assert all(isinstance(key, str) for key in value.keys()), \
576
+ "All keys in the scores dictionary must be strings."
577
+ assert all(isinstance(score, Score) for score in value.values()), \
578
+ "All values in the scores dictionary must be Score objects."
579
+ assert all(score.variable.variable.shape == next(iter(value.values())).variable.variable.shape \
580
+ for score in value.values()), "All scores must have the same shape."
581
+ self._scores = value
582
+
583
+ @property
584
+ def number(self) -> int:
585
+ """
586
+ Get the number of scores.
587
+
588
+ :return: The number of scores in the dictionary.
589
+ """
590
+ return len(self._scores) if self._scores is not None else 0
591
+
592
+ def add_score(self, name: str, score: Score) -> None:
593
+ """
594
+ Add a score to the scores dictionary.
595
+
596
+ :param name: The name of the score.
597
+ :param score: The Score object to add.
598
+ """
599
+ assert isinstance(name, str), f"The name must be a string, not {type(name)}."
600
+ assert isinstance(score, Score), f"The score must be an instance of Score, not {type(score)}."
601
+ if self._scores is None:
602
+ self._scores = {}
603
+ self._scores[name] = score
604
+
605
+ def get_score(self, name: str) -> Score:
606
+ """
607
+ Get a score by its name.
608
+
609
+ :param name: The name of the score to get.
610
+ :return: The Score object with the given name.
611
+ """
612
+ try:
613
+ return self._scores[name]
614
+ except KeyError:
615
+ logging.error(f"The score with name: '{name}' does not exist.")
616
+ raise KeyError(f"The score with name '{name}' does not exist.")
617
+
618
+ def remove_score(self, name: str) -> None:
619
+ """
620
+ Remove a score by its name.
621
+
622
+ :param name: The name of the score to remove.
623
+ """
624
+ try:
625
+ del self._scores[name]
626
+ except KeyError:
627
+ logging.error(f"The score with name:'{name}' does not exist.")
628
+ raise KeyError(f"The score with name '{name}' does not exist.")
629
+
630
+ class Operations:
631
+ """
632
+ This class is used to perform mathematical operations on scores.
633
+
634
+ N.B.: if needed operations from other libraries can be added here.
635
+ for instance **pymcdm, scikit-mcda, etc.**
636
+ """
637
+
638
+ def __init__(self,
639
+ scores: Scores,
640
+ int_type:str = Format.INT32.value,
641
+ float_type:str = Format.FLOAT32.value,
642
+ weight: dict = None
643
+ ) -> None:
644
+ """
645
+ Initialize the Operations object with a Scores object.
646
+
647
+ :param scores: The Scores object to perform operations on.
648
+ :type scores: Scores
649
+ :param int_type: The integer data type to use in the operations if integers are expected,
650
+ defaults to np.int32.
651
+ :type int_type: str
652
+ :param float_type: The float data type to use in the operations if floats are expected
653
+ , defaults to np.float32.
654
+ :type float_type: str
655
+ :param weight: A dictionary of weights for scores, defaults to None.
656
+ :type weight: dict
657
+
658
+ :raises AssertionError: If the scores is not an instance of Scores.
659
+ """
660
+ assert isinstance(scores, Scores),\
661
+ f"The scores must be an instance of Scores, not {type(scores)}."
662
+ self._scores = None
663
+ self._int_type = None
664
+ self._float_type = None
665
+ self._weight = None
666
+ self.scores = scores
667
+ self.int_type = int_type
668
+ self.float_type = float_type
669
+ self.weight = weight
670
+ self._available_operations = [Operator.SUM.value,
671
+ Operator.PRODUCT.value,
672
+ Operator.WEIGHTED_SUM.value,
673
+ Operator.AVERAGE.value]
674
+
675
+ @property
676
+ def scores(self) -> Scores:
677
+ """
678
+ Get the Scores object.
679
+
680
+ :return: The Scores object.
681
+ """
682
+ return self._scores
683
+
684
+ @scores.setter
685
+ def scores(self, value: Scores) -> None:
686
+ """
687
+ Set the Scores object.
688
+
689
+ :param value: The Scores object to set.
690
+ """
691
+ assert isinstance(value, Scores),\
692
+ f"The scores must be an instance of Scores, not {type(value)}."
693
+ self._scores = value
694
+
695
+ @property
696
+ def int_type(self) -> str:
697
+ """
698
+ Get the integer data type used in the operations.
699
+
700
+ :return: The integer data type as a string.
701
+ """
702
+ return self._int_type
703
+ @int_type.setter
704
+ def int_type(self, value: Union[str,type]) -> None:
705
+ """
706
+ Set the integer data type used in the operations.
707
+
708
+ :param value: The integer data type to set.
709
+ """
710
+ assert isinstance(value, (str,type)), f"The integer type must be a string, not {type(value)}."
711
+ self._int_type = value
712
+
713
+ @property
714
+ def float_type(self) -> str:
715
+ """
716
+ Get the float data type used in the operations.
717
+
718
+ :return: The float data type as a string.
719
+ """
720
+ return self._float_type
721
+
722
+ @float_type.setter
723
+ def float_type(self, value: Union[str,type]) -> None:
724
+ """
725
+ Set the float data type used in the operations.
726
+
727
+ :param value: The float data type to set.
728
+ """
729
+ assert isinstance(value, (str,type)), f"The float type must be a string, not {type(value)}."
730
+ self._float_type = value
731
+
732
+ @property
733
+ def weight(self) -> dict:
734
+ """
735
+ Get the weight dictionary.
736
+
737
+ :return: The weight dictionary.
738
+ """
739
+ return self._weight
740
+
741
+ @weight.setter
742
+ def weight(self, value: dict) -> None:
743
+ """
744
+ Set the weight dictionary.
745
+
746
+ :param value: The weight dictionary to set.
747
+ """
748
+ if value is None:
749
+ if self.scores.number == 0:
750
+ logging.warning("No scores to set weight for. Setting weight to None.")
751
+ self._weight = None
752
+ return
753
+ elif self.scores.number > 0:
754
+ logging.warning("No weight provided. Setting weight to equal distribution.")
755
+ value = {key: 1 for key in self.scores.scores.keys()}
756
+ else:
757
+ assert isinstance(value, dict), f"The weight must be a dictionary, not {type(value)}."
758
+ assert len(value) == self.scores.number,\
759
+ "The weight dictionary must have the same number of keys as the scores dictionary."
760
+ assert all(key in self.scores.scores.keys() for key in value.keys()),\
761
+ "All keys in the weight dictionary must be present in the scores dictionary."
762
+ self._weight = value
763
+
764
+ def sum(self) -> np.ma.MaskedArray:
765
+ """
766
+ Sum all scores in the Scores object.
767
+
768
+ :return: The sum of all scores as a numpy masked array.
769
+ """
770
+ if self.scores.number == 0:
771
+ logging.warning("No scores to sum.")
772
+ return np.ma.masked_array([], mask=True)
773
+ score_sum = np.ma.masked_array(np.zeros_like(next(iter(self.scores.scores.values())).score),
774
+ dtype=Format.INT32.value)
775
+
776
+ for score in self.scores.scores.values():
777
+ score_sum += score.score
778
+
779
+ return score_sum
780
+
781
+ def product(self) -> np.ma.MaskedArray:
782
+ """
783
+ Calculate the product of all scores in the Scores object.
784
+
785
+ :return: The product of all scores as a numpy masked array.
786
+ """
787
+ if self.scores.number == 0:
788
+ logging.warning("No scores to multiply.")
789
+ return np.ma.masked_array([], mask=True)
790
+
791
+ score_product = np.ma.masked_array(np.ones_like(next(iter(self.scores.scores.values())).score),
792
+ dtype=Format.INT32.value)
793
+
794
+ for score in self.scores.scores.values():
795
+ score_product *= score.score
796
+
797
+ return score_product
798
+
799
+
800
+ def weighted_sum(self) -> np.ma.MaskedArray:
801
+ """
802
+ Calculate the weighted sum of all scores in the Scores object.
803
+
804
+ :return: The weighted sum of all scores as a numpy masked array.
805
+ """
806
+ if self.scores.number == 0:
807
+ logging.warning("No scores to calculate weighted sum.")
808
+ return np.ma.masked_array([], mask=True)
809
+
810
+ score_sum = np.ma.masked_array(np.zeros_like(next(iter(self.scores.scores.values())).score),
811
+ dtype=self.float_type) # FIXME Find a better method to apply this
812
+
813
+ for key, score in self.scores.scores.items():
814
+ if key in self.weight:
815
+ array = (score.score * self.weight[key]).astype(self.float_type, copy=True)
816
+ score_sum = score_sum.astype(self.float_type, copy=True)
817
+ score_sum += array
818
+
819
+ else:
820
+ raise KeyError(f"No weight provided for score '{key}'. Using default weight of 1.")
821
+
822
+ return score_sum
823
+
824
+ def average(self) -> np.ma.MaskedArray:
825
+ """
826
+ Calculate the average of all scores in the Scores object.
827
+
828
+ :return: The average of all scores as a numpy masked array.
829
+ """
830
+ if self.scores.number == 0:
831
+ logging.warning("No scores to average.")
832
+ return np.ma.masked_array([], mask=True)
833
+
834
+ score_sum = self.sum()
835
+ average: np.ma.MaskedArray = (score_sum / self.scores.number)
836
+ average = average.astype(self.float_type)
837
+
838
+ return average
839
+
840
+ def percentage(self) -> np.ma.MaskedArray:
841
+ """
842
+ Calculate the percentage of each score in the Scores object.
843
+
844
+ :return: The percentage of each score as a numpy masked array.
845
+ """
846
+ return self.average() * 100
847
+
848
+ def apply_operation(self,
849
+ operation: Literal[Operator.SUM.value,
850
+ Operator.PRODUCT.value,
851
+ Operator.WEIGHTED_SUM.value,
852
+ Operator.AVERAGE.value,
853
+ Operator.PERCENTAGE.value
854
+ ]) -> np.ma.MaskedArray:
855
+ """
856
+ Select an operation to perform on the scores.
857
+
858
+ :param operation: The operation to perform.
859
+ :return: The result of the operation as a numpy masked array.
860
+ """
861
+ # if operation.lower() not in self._available_operations():
862
+ if operation == Operator.SUM.value:
863
+ return self.sum()
864
+ elif operation == Operator.PRODUCT.value:
865
+ return self.product()
866
+ elif operation == Operator.WEIGHTED_SUM.value:
867
+ return self.weighted_sum()
868
+ elif operation == Operator.AVERAGE.value:
869
+ return self.average()
870
+ elif operation == Operator.PERCENTAGE.value:
871
+ return self.percentage()
872
+ else:
873
+ raise ValueError(f"Unknown operation: {operation}. "
874
+ f"Available operations are: {self._available_operations()}")
875
+
876
+ class Results:
877
+ """
878
+ This class is used to collect results of the Operations module.
879
+
880
+ It allows:
881
+ - to define the mold (a WolfArray) which serve as the geo-spatial extent computed results,
882
+ - to define the method used to compute scores,
883
+ - to get and write the results.
884
+ """
885
+ def __init__(self,
886
+ operations: Operations,
887
+ mold: WolfArray = None,
888
+ method: Literal[Operator.SUM.value,
889
+ Operator.PRODUCT.value,
890
+ Operator.WEIGHTED_SUM.value,
891
+ Operator.AVERAGE.value,
892
+ Operator.PERCENTAGE.value
893
+ ] = Operator.SUM.value
894
+ ) -> None:
895
+
896
+ """
897
+ Initialize the Results object.
898
+
899
+ :param operations: The Operations object to perform operations on.
900
+ :type operations: Operations
901
+ :param mold: The mold used for the results, defaults to None.
902
+ :type mold: WolfArray
903
+ :param method: The method used to compute the scores, defaults sum.
904
+ :type method: str,
905
+
906
+ """
907
+ self._operations = None
908
+ self._mold = None
909
+ self._method = None
910
+ self.operations = operations
911
+ self.mold = mold
912
+ self.method = method
913
+
914
+ @property
915
+ def operations(self) -> Operations:
916
+ """
917
+ Get the Operations object.
918
+
919
+ :return: The Operations object.
920
+ """
921
+ return self._operations
922
+
923
+ @operations.setter
924
+ def operations(self, value: Operations) -> None:
925
+ """
926
+ Set the Operations object.
927
+
928
+ :param value: The Operations object to set.
929
+ """
930
+ assert isinstance(value, Operations),\
931
+ f"The operations must be an instance of Operations, not {type(value)}."
932
+ self._operations = value
933
+
934
+ @property
935
+ def mold(self) -> WolfArray:
936
+ """
937
+ Get the mold used for the results.
938
+
939
+ :return: The mold as a WolfArray.
940
+ """
941
+
942
+ return self._mold
943
+
944
+ @mold.setter
945
+ def mold(self, value: WolfArray) -> None:
946
+ """
947
+ Set the mold used for the results.
948
+ :param value: The mold to set.
949
+ """
950
+ if value is not None:
951
+ assert isinstance(value, WolfArray), f"The mold must be a WolfArray, not {type(value)}."
952
+ for key in self.operations.scores.scores.keys():
953
+ if value.shape != self.operations.scores.get_score(key).score.shape:
954
+ raise ValueError(f"The mold shape {value.shape} does not match the score shape {self.operations.scores.get_score(key).score.shape}.")
955
+ self._mold = value
956
+
957
+ @property
958
+ def header(self) -> header_wolf:
959
+ """
960
+ Get the header of the mold.
961
+
962
+ :return: The header of the mold as a header_wolf object.
963
+ """
964
+ if self.mold is None:
965
+ if self.operations is not None:
966
+ sample = self.operations.scores.get_score(next(iter(self.operations.scores.scores.keys()))).score
967
+ nbx = sample.shape[0]
968
+ nby = sample.shape[1]
969
+ return self.create_header_wolf(nbx=nbx,
970
+ nby=nby,
971
+ dx= Constant.ONE.value,
972
+ dy= Constant.ONE.value)
973
+ else:
974
+ return self.mold.get_header()
975
+
976
+ @property
977
+ def method(self) -> str:
978
+ """
979
+ Get the method used for the results.
980
+
981
+ :return: The method as a string.
982
+ """
983
+ return self._method
984
+
985
+ @method.setter
986
+ def method(self, value: Literal[Operator.SUM.value,
987
+ Operator.PRODUCT.value,
988
+ Operator.WEIGHTED_SUM.value,
989
+ Operator.AVERAGE.value,
990
+ Operator.PERCENTAGE.value
991
+ ]) -> None:
992
+ """
993
+ Set the method used for the results.
994
+ :param value: The method to set.
995
+
996
+ """
997
+ assert value in self.operations._available_operations,\
998
+ f"The method must be one of the following: {self.operations._available_operations}."
999
+ self._method = value
1000
+
1001
+ @property
1002
+ def as_numpy_array(self) -> np.ma.MaskedArray:
1003
+ return self.operations.apply_operation(self.method)
1004
+
1005
+ @property
1006
+ def as_WolfArray(self) -> WolfArray:
1007
+ """
1008
+ Get the results as a WolfArray.
1009
+
1010
+ :return: The results as a WolfArray.
1011
+ """
1012
+ return self.as_results(self.as_numpy_array)
1013
+
1014
+ #------------
1015
+ # Methods
1016
+ #------------
1017
+
1018
+ def create_header_wolf(self,
1019
+ origx:float = None,
1020
+ origy: float =None ,
1021
+ origz: float = None,
1022
+ dx: float = None,
1023
+ dy: float = None,
1024
+ dz: float = None,
1025
+ nbx: int = None,
1026
+ nby: int = None,
1027
+ nbz: int = None):
1028
+ """
1029
+ Create a header_wolf object.
1030
+ The header_wolf object is used to describe the spatial
1031
+ characteristics of the array.
1032
+
1033
+
1034
+ :param origx: The x-coordinate of the origin (in 2D - lower left corner),
1035
+ defaults to None.
1036
+ :type origx: float, optional
1037
+ :param origy: The y-coordinate of the origin (in 2D - lower left corner),
1038
+ defaults to None.
1039
+ :type origy: float, optional
1040
+ :param origz: The z-coordinate of the origin, defaults to None.
1041
+ :type origz: float, optional
1042
+ :param dx: The x-spacing (discretization in the x direction), defaults to None.
1043
+ :type dx: float, optional
1044
+ :param dy: The y-spacing (discretization in the x direction), defaults to None.
1045
+ :type dy: float, optional
1046
+ :param dz: The z-spacing, defaults to None.
1047
+ :type dz: float, optional
1048
+ :param nbx: The number of columns, defaults to None.
1049
+ :type nbx: int, optional
1050
+ :param nby: The number of rows, defaults to None.
1051
+ :type nby: int, optional
1052
+ :param nbz: The number of layers, defaults to None.
1053
+ :type nbz: int, optional
1054
+ :return: A header_wolf object with the given parameters.
1055
+ :rtype: header_wolf
1056
+ """
1057
+ header = header_wolf()
1058
+ if origx is not None:
1059
+ header.origx = origx
1060
+ if origy is not None:
1061
+ header.origy = origy
1062
+ if origz is not None:
1063
+ header.origz = origz
1064
+ if dx is not None:
1065
+ header.dx = dx
1066
+ if dy is not None:
1067
+ header.dy = dy
1068
+ if dz is not None:
1069
+ header.dz = dz
1070
+ if nbx is not None:
1071
+ header.nbx = nbx
1072
+ if nby is not None:
1073
+ header.nby = nby
1074
+ if nbz is not None:
1075
+ header.nbz = nbz
1076
+ return header
1077
+
1078
+ def create_WolfArray(self,
1079
+ dtype = None) -> WolfArray:
1080
+ """
1081
+ Create an empty WolfArray with the given name, and header.
1082
+
1083
+ :param dtype: The data type of the WolfArray, defaults to np.float32.
1084
+ :type dtype: Union[type, str]
1085
+ :return: An empty WolfArray with the given name and header.
1086
+ :rtype: WolfArray
1087
+ """
1088
+ array = WolfArray()
1089
+ array.init_from_header(self.header, dtype=dtype)
1090
+ return array
1091
+
1092
+ def as_results(self,
1093
+ results: np.ma.MaskedArray,
1094
+ EPSG: int = 31370,
1095
+ dtype: Union[type, str] =None,
1096
+ write_to: Union [Path,str] = None
1097
+ ) -> WolfArray:
1098
+ """
1099
+ Write the results to a WolfArray file.
1100
+
1101
+ :param output_path: The path to write the results to.
1102
+ :param name: The name of the results file.
1103
+ :param header: The header for the WolfArray.
1104
+ :param dtype: The data type of the WolfArray.
1105
+ """
1106
+ if dtype is None:
1107
+ dtype = results.dtype
1108
+ array = self.create_WolfArray(dtype=dtype)
1109
+ array.array = results
1110
+ array.array.mask = results.mask
1111
+ array.nullvalue = -99999
1112
+ if write_to is not None:
1113
+ assert isinstance(write_to, (str, Path)), \
1114
+ f"The output path must be a string or a Path object, not {type(write_to)}."
1115
+ # if not os.path.exists(write_to):
1116
+ # os.makedirs(write_to)
1117
+ array.write_all(write_to, EPSG=EPSG)
1118
+ return array
1119
+
1120
+ class Input:
1121
+ """
1122
+ This class stores the inputs of the MultiCriteriaAnalysis.
1123
+
1124
+ Also, it ensures that the inputs are valid and consistent
1125
+ to feed the MulticriteriAnalysis tool.
1126
+ """
1127
+ def __init__(self,
1128
+ name: str,
1129
+ array: np.ma.MaskedArray| np.ndarray| WolfArray,
1130
+ condition: Literal[Operator.SUPERIOR.value,
1131
+ Operator.INFERIOR.value,
1132
+ Operator.EQUAL.value,
1133
+ Operator.SUPERIOR_OR_EQUAL.value,
1134
+ Operator.INFERIOR_OR_EQUAL.value] = Operator.SUPERIOR_OR_EQUAL.value,
1135
+ threshold: Union[float, int] = 0.0,
1136
+ ) -> None:
1137
+ """
1138
+ Initialize the Input object.
1139
+
1140
+ :param name: The name of the variable.
1141
+ :type name: str
1142
+ :param array: The array (matrix) containing the variable (observations).
1143
+ :type array: np.ma.MaskedArray, np.ndarray, or WolfArray
1144
+ :param condition: The condition (criteria) used to discriminate values inside the variable,
1145
+ defaults to 'superior or equal'.
1146
+ :type condition: str, optional
1147
+ :param threshold: The threshold used to select values inside the variable,
1148
+ defaults to 0.0.
1149
+ :type threshold: Union[float, int], optional
1150
+ """
1151
+ self._name = None
1152
+ self._array = None
1153
+ self._condition = None
1154
+ self._threshold = None
1155
+ self._score = None
1156
+ self.name = name
1157
+ self.array = array
1158
+ self.condition = condition
1159
+ self.threshold = threshold
1160
+
1161
+ @property
1162
+ def name(self) -> str:
1163
+ """
1164
+ Get the name of the input.
1165
+
1166
+ :return: The name of the input.
1167
+ """
1168
+ return self._name
1169
+
1170
+ @name.setter
1171
+ def name(self, value: str) -> None:
1172
+ """
1173
+ Set the name of the input.
1174
+
1175
+ :param value: The name to set.
1176
+ """
1177
+ assert isinstance(value, str), f"The name must be a string, not {type(value)}."
1178
+ self._name = value
1179
+
1180
+ @property
1181
+ def array(self) -> Union[np.ma.MaskedArray, np.ndarray, WolfArray]:
1182
+ """
1183
+ Get the array of the input.
1184
+
1185
+ :return: The array as a numpy masked array.
1186
+ """
1187
+ return self._array
1188
+
1189
+ @array.setter
1190
+ def array(self, value: Union[np.ma.MaskedArray, np.ndarray, WolfArray
1191
+ ]) -> None:
1192
+ """
1193
+ Set the array of the input.
1194
+
1195
+ :param value: The array to set.
1196
+ """
1197
+ assert isinstance(value, (np.ma.MaskedArray, np.ndarray, WolfArray)), \
1198
+ f"The array must be a numpy masked array, a numpy array or a WolfArray, not {type(value)}."
1199
+ self._array = value
1200
+
1201
+ @property
1202
+ def condition(self) -> Literal[Operator.SUPERIOR.value,
1203
+ Operator.INFERIOR.value,
1204
+ Operator.EQUAL.value,
1205
+ Operator.SUPERIOR_OR_EQUAL.value,
1206
+ Operator.INFERIOR_OR_EQUAL.value]:
1207
+ """
1208
+ Get the condition of the input.
1209
+
1210
+ :return: The condition as a string.
1211
+ """
1212
+ return self._condition
1213
+
1214
+ @condition.setter
1215
+ def condition(self, value: Literal[Operator.SUPERIOR.value,
1216
+ Operator.INFERIOR.value,
1217
+ Operator.EQUAL.value,
1218
+ Operator.SUPERIOR_OR_EQUAL.value,
1219
+ Operator.INFERIOR_OR_EQUAL.value]) -> None:
1220
+ """
1221
+ Set the condition of the input.
1222
+
1223
+ :param value: The condition to set.
1224
+ """
1225
+ assert isinstance(value, str), f"The condition must be a string, not {type(value)}."
1226
+ self._condition = value
1227
+
1228
+ @property
1229
+ def threshold(self) -> Union[float, int]:
1230
+ """
1231
+ Get the threshold of the input.
1232
+
1233
+ :return: The threshold as a float or an int.
1234
+ """
1235
+ return self._threshold
1236
+
1237
+ @threshold.setter
1238
+ def threshold(self, value: Union[float, int, tuple, list, np.ndarray]) -> None:
1239
+ """
1240
+ Set the threshold of the input.
1241
+
1242
+ :param value: The threshold to set.
1243
+ FIXME make it fair for list as well.
1244
+ """
1245
+ assert isinstance(value, (float, int, tuple, list, np.ndarray)),\
1246
+ f"The threshold must be a float or an int or a tuple, not {type(value)}."
1247
+ if isinstance(value, (tuple, list)):
1248
+ assert len(value) == 2, "The threshold must be a tuple or a list with exactly two elements."
1249
+ assert all(isinstance(item, (float, int)) for item in value), \
1250
+ f"All elements in the threshold tuple or list must be floats or ints.\
1251
+ not {[type(item) for item in value]}."
1252
+ value = tuple(value) # Ensure it's a tuple
1253
+ elif isinstance(value, np.ndarray):
1254
+ assert value.ndim == 1, "The threshold must be a 1D numpy"
1255
+ assert len(value) == 2, "The threshold must be a 1D numpy array with exactly two elements."
1256
+ # val1 = value[0]
1257
+ if np.issubdtype(value.dtype, np.integer):
1258
+ value = (int(value[0]), int(value[1]))
1259
+ elif np.issubdtype(value.dtype,np.floating):
1260
+ value = (float(value[0]), float(value[1]))
1261
+ # value = (float(value[0]), float(value[1])) # Convert to tuple
1262
+ self._threshold = value
1263
+
1264
+ @property
1265
+ def score(self) -> Score:
1266
+ """
1267
+ Get the score of the input.
1268
+
1269
+ :return: The score as a Score object.
1270
+ """
1271
+ if self._score is None:
1272
+ self._score = Score(variable= Variable(variable = self.array),
1273
+ criteria= Criteria(threshold=self.threshold, condition=self.condition, ))
1274
+ return self._score
1275
+
1276
+
1277
+ @score.setter
1278
+ def score(self, value: Score) -> None:
1279
+ """
1280
+ Set the score of the input.
1281
+
1282
+ :param value: The score to set.
1283
+ """
1284
+ assert isinstance(value, Score), f"The score must be an instance of Score, not {type(value)}."
1285
+ self._score = value
1286
+
1287
+ class MulticriteriAnalysis:
1288
+ """
1289
+ This class performs a multi-criteria analysis based
1290
+ on a set of inputs (variables and criteria).
1291
+
1292
+ The inputs are:
1293
+ - name: The name of the variable (observations).
1294
+ - array: The array (matrix) containing the variable (observations).
1295
+ - condition: The condition (criteria) used to distinguish between good and bad variables.
1296
+ Basically, the condition is used to obtain a binary score (0 or 1) for each value in the array.
1297
+ - Threshold: The value(s) used as limits for the condition.
1298
+ """
1299
+ def __init__(self,
1300
+ inputs: Union[list[Input],tuple[Input],Input],
1301
+ method:Literal[Operator.SUM.value,
1302
+ Operator.AVERAGE.value,
1303
+ Operator.PERCENTAGE.value] = Operator.PERCENTAGE.value,
1304
+ dtype: Union[type, str] = Format.FLOAT32.value,
1305
+ mold: WolfArray = None,
1306
+ write_to: Union[Path, str] = None,
1307
+ EPSG: int = None
1308
+ ) -> None:
1309
+ """
1310
+ Initialize the MultiCriteriaAnalysis
1311
+
1312
+ :param inputs: The inputs to the MultiCriteriaAnalysis.
1313
+ :type inputs: Union[list[Input], tuple[Input], Input]
1314
+ :param method: The method used for the MultiCriteriaAnalysis,
1315
+ defaults to 'percentage'.
1316
+ :type method: Literal[constant_value(Constant.SUM),
1317
+ constant_value(Constant.AVERAGE),
1318
+ constant_value(Constant.PERCENTAGE)], optional
1319
+ :param dtype: The data type used for the MultiCriteriaAnalysis,
1320
+ defaults to np.float32.
1321
+ :type dtype: Union[type, str], optional
1322
+ :param mold: The mold used for the MultiCriteriaAnalysis,
1323
+ defaults to None.
1324
+ :type mold: WolfArray, optional
1325
+ :param write_to: The path where the results will be written,
1326
+ defaults to None.
1327
+ :type write_to: Union[Path, str], optional
1328
+ :param EPSG: The EPSG code for the coordinate reference system,
1329
+ defaults to None.
1330
+ :type EPSG: int, optional
1331
+ """
1332
+ self._inputs: list[Input] = None
1333
+ self._method: str = None
1334
+ self._dtype: Union[type, str] = None
1335
+ self._mold: WolfArray = None
1336
+ self._path: Union[Path, str] = None
1337
+ self._EPSG: int = None
1338
+ self.inputs = inputs
1339
+ self.method = method
1340
+ self.dtype = dtype
1341
+ self.mold = mold
1342
+ self.path = write_to
1343
+ self.EPSG = EPSG
1344
+
1345
+ # ************************
1346
+ # Preprocessing the inputs
1347
+ # ************************
1348
+
1349
+ @property
1350
+ def inputs(self) -> list[Input]:
1351
+ """
1352
+ Get the inputs of the MultiCriteriaAnalysis.
1353
+
1354
+ :return: The inputs as a list of Input objects.
1355
+ """
1356
+ return self._inputs
1357
+
1358
+ @inputs.setter
1359
+ def inputs(self, value: Union[list[Input],tuple[Input],Input]) -> None:
1360
+ """
1361
+ Set the inputs of the MultiCriteriaAnalysis.
1362
+
1363
+ :param value: The inputs to set.
1364
+ """
1365
+ assert isinstance(value, (list, tuple, Input)), \
1366
+ f"The inputs must be a list or a tuple of Input objects, not {type(value)}."
1367
+ if isinstance(value, (list, tuple)):
1368
+ assert len(value) > 0, "The inputs list must not be empty."
1369
+ assert all(isinstance(item, Input) for item in value), \
1370
+ "All items in the inputs list must be Input objects."
1371
+ if isinstance(value, tuple):
1372
+ value = list(value)
1373
+ elif isinstance(value, Input):
1374
+ value = [value]
1375
+
1376
+ self._inputs = value
1377
+
1378
+ @property
1379
+ def number_of_inputs(self) -> int:
1380
+ """
1381
+ Get the number of inputs.
1382
+
1383
+ :return: The number of inputs.
1384
+ """
1385
+ return len(self.inputs) if self.inputs is not None else 0
1386
+
1387
+ @property
1388
+ def method(self) -> Literal[Operator.SUM.value,
1389
+ Operator.AVERAGE.value,
1390
+ Operator.PERCENTAGE.value]:
1391
+ """
1392
+ Get the method used for the MultiCriteriaAnalysis.
1393
+
1394
+ :return: The method as a string.
1395
+ """
1396
+ return self._method
1397
+
1398
+ @method.setter
1399
+ def method(self, value: Literal[Operator.SUM.value,
1400
+ Operator.AVERAGE.value,
1401
+ Operator.PERCENTAGE.value]) -> None:
1402
+ """
1403
+ Set the method used for the MultiCriteriaAnalysis.
1404
+
1405
+ :param value: The method to set.
1406
+ """
1407
+ assert isinstance(value, str), f"The method must be a string, not {type(value)}."
1408
+ self._method = value
1409
+
1410
+ @property
1411
+ def dtype(self) -> Union[type, str]:
1412
+ """
1413
+ Get the data type used for the MultiCriteriaAnalysis.
1414
+
1415
+ :return: The data type as a string or a numpy dtype.
1416
+ """
1417
+ return self._dtype
1418
+
1419
+ @dtype.setter
1420
+ def dtype(self, value: Union[type, str]) -> None:
1421
+ """
1422
+ Set the data type used for the MultiCriteriaAnalysis.
1423
+
1424
+ :param value: The data type to set.
1425
+ """
1426
+ if value is None:
1427
+ value = Format.FLOAT32.value # Default data type
1428
+ else:
1429
+ assert isinstance(value, (type, str)),\
1430
+ f"The data type must be a numpy dtype or a string representing a numpy dtype, not {type(value)}."
1431
+ self._dtype = value
1432
+
1433
+ @property
1434
+ def mold(self) -> WolfArray:
1435
+ """
1436
+ Get the mold used for the MultiCriteriaAnalysis.
1437
+
1438
+ :return: The mold as a WolfArray.
1439
+ """
1440
+ return self._mold
1441
+
1442
+ @mold.setter
1443
+ def mold(self, value: WolfArray) -> None:
1444
+ """
1445
+ Set the mold used for the MultiCriteriaAnalysis.
1446
+
1447
+ :param value: The mold to set.
1448
+
1449
+ FIXME: add update mold from inputs.
1450
+ """
1451
+ if value is not None:
1452
+ assert isinstance(value, WolfArray), f"The mold must be a WolfArray, not {type(value)}."
1453
+ for input_ in self.inputs:
1454
+ if value.shape != input_.array.shape:
1455
+ raise ValueError(f"The mold shape {value.shape} does not match the input shape {input_.array.shape}.")
1456
+ self._mold = value
1457
+
1458
+ @property
1459
+ def path(self) -> Union[Path, str]:
1460
+ """
1461
+ Get the path where the results will be written.
1462
+
1463
+ :return: The path as a string or a Path object.
1464
+ """
1465
+ return self._path
1466
+
1467
+ @path.setter
1468
+ def path(self, value: Union[Path, str]) -> None:
1469
+ """
1470
+ Set the path where the results will be written.
1471
+
1472
+ :param value: The path to set.
1473
+ """
1474
+ if value is None:
1475
+ self._path = None
1476
+ else:
1477
+ assert isinstance(value, (Path, str)),\
1478
+ f"The path must be a string or a Path object, not {type(value)}."
1479
+ self._path = value
1480
+
1481
+ @property
1482
+ def scores(self) -> Scores:
1483
+ """
1484
+ Get the scores for each input.
1485
+
1486
+ :return: The scores as a Scores object.
1487
+ """
1488
+ scores = {}
1489
+ for input_ in self.inputs:
1490
+ scores[input_.name] = input_.score
1491
+
1492
+ return Scores(scores)
1493
+
1494
+ @property
1495
+ def operations(self) -> Operations:
1496
+ """
1497
+ Get the operations to be performed on the scores.
1498
+
1499
+ :return: The Operations object.
1500
+ """
1501
+ return Operations(scores=self.scores, int_type=self.dtype, float_type=self.dtype)
1502
+
1503
+ @property
1504
+ def EPSG(self) -> int:
1505
+ """
1506
+ Get the EPSG code for the results.
1507
+
1508
+ :return: The EPSG code as an integer.
1509
+ """
1510
+ return self._EPSG
1511
+
1512
+ @EPSG.setter
1513
+ def EPSG(self, value: int) -> None:
1514
+ """
1515
+ Set the EPSG code for the results.
1516
+
1517
+ :param value: The EPSG code to set.
1518
+ """
1519
+ if value is None:
1520
+ self._EPSG = Constant.EPSG.value # Default EPSG code
1521
+ else:
1522
+ assert isinstance(value, int), f"The EPSG code must be an integer, not {type(value)}."
1523
+ self._EPSG = value
1524
+
1525
+ # *************************************
1526
+ # Performing the MultiCriteria Analysis
1527
+ # *************************************
1528
+
1529
+ @property
1530
+ def results(self) -> WolfArray:
1531
+ """
1532
+ Perform the MultiCriteria Analysis on the inputs.
1533
+
1534
+ :return: The results of the analysis as a Results object.
1535
+ """
1536
+ if self.number_of_inputs == 0:
1537
+ raise ValueError("No inputs provided for the MultiCriteria Analysis.")
1538
+ assert self.number_of_inputs >= 2, "At least two inputs are required for the MultiCriteria Analysis."
1539
+ assert self.method in self.operations._available_operations,\
1540
+ f"The method '{self.method}' is not available.\
1541
+ Available methods are: {self.operations._available_operations}"
1542
+ # Create results object
1543
+ results = Results(operations=self.operations, mold=self.mold, method=self.method)
1544
+ return results.as_WolfArray
1545
+
1546
+ def write_result(self) -> WolfArray:
1547
+ """
1548
+ Write the results to a WolfArray file.
1549
+
1550
+ :return: The results as a WolfArray.
1551
+ """
1552
+ if self.path is None:
1553
+ raise ValueError("No path provided for writing the results.")
1554
+ return self.results.write_all(self.path, self.EPSG)
1555
+
1556
+
1557
+
1558
+
1559
+
1560
+
1561
+
1562
+
1563
+
1564
+
1565
+
1566
+
1567
+
1568
+
1569
+
1570
+
1571
+
1572
+
1573
+
1574
+
1575
+
1576
+
1577
+
1578
+
1579
+