wolfhece 2.2.30__py3-none-any.whl → 2.2.32__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.
wolfhece/analyze_poly.py CHANGED
@@ -4,10 +4,11 @@ import numpy as np
4
4
  from shapely.geometry import Point, LineString
5
5
  from typing import Literal
6
6
  import pandas as pd
7
+ from pathlib import Path
7
8
 
8
9
  from .PyTranslate import _
9
10
  from .drawing_obj import Element_To_Draw
10
- from .PyVertexvectors import Triangulation, vector,Zones, zone
11
+ from .PyVertexvectors import Triangulation, vector,Zones, zone, Polygon
11
12
  from .wolf_array import WolfArray, header_wolf
12
13
 
13
14
  class Array_analysis_onepolygon():
@@ -20,63 +21,119 @@ class Array_analysis_onepolygon():
20
21
  Plots of the values distribution can be generated using seaborn or plotly.
21
22
  """
22
23
 
23
- def __init__(self, wa:WolfArray, polygon:vector):
24
+ def __init__(self, wa:WolfArray, polygon:vector, buffer_size:float = 0.0):
24
25
 
25
26
  self._wa = wa
26
- self._polygon = polygon
27
+
28
+ if buffer_size > 0.0:
29
+ self._polygon = polygon.buffer(buffer_size, inplace=False)
30
+ elif buffer_size == 0.0:
31
+ self._polygon = polygon
32
+ else:
33
+ raise ValueError("Buffer size must be greater than or equal to 0.0.")
27
34
 
28
35
  self._selected_cells = None
29
36
  self._values = None
30
37
 
31
- def values(self, which:Literal['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Values']) -> pd.DataFrame | float:
38
+ @ property
39
+ def centroid(self) -> Point:
40
+ """ Get the centroid of the polygon as a Point object.
41
+
42
+ :return: Shapely Point object representing the centroid of the polygon
43
+ """
44
+ return self._polygon.centroid
45
+
46
+ def values(self, which:Literal['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Values', 'Area']) -> pd.DataFrame | float:
32
47
  """ Get the values as a pandas DataFrame
33
48
 
34
49
  :param which: Mean, Std, Median, Sum, Volume, Values
35
50
  """
36
51
 
37
- authrorized = ['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Values']
38
- if which not in authrorized:
39
- raise ValueError(f"Invalid value for 'which'. Must be one of {authrorized}.")
52
+ authorized = ['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Values', 'Area']
53
+ if which not in authorized:
54
+ raise ValueError(f"Invalid value for 'which'. Must be one of {authorized}.")
40
55
 
41
56
  if self._values is None:
42
57
  self.compute_values()
43
58
 
59
+ if which == 'Area' and 'Area' not in self._values:
60
+ self._add_area2velues()
61
+
44
62
  if self._values is None:
45
63
  raise ValueError("No values computed. Please call compute_values() first.")
46
64
 
47
65
  if which == 'Values':
48
- return pd.DataFrame(self._values[_(which)], columns=[which])
66
+ return pd.DataFrame(self._values[_(which)], columns=[self._polygon.myname])
49
67
  else:
50
68
  return self._values[which]
51
69
 
70
+ def as_vector(self, add_values:bool = True):
71
+ """ Return a copy of the polygon with the values as attributes. """
72
+
73
+ newvec = self._polygon.deepcopy()
74
+
75
+ if add_values:
76
+ if self._values is None:
77
+ self.compute_values()
78
+ self._add_area2velues()
79
+
80
+ if self._values is None:
81
+ raise ValueError("No values computed. Please call compute_values() first.")
82
+
83
+ for key, value in self._values.items():
84
+ newvec.add_value(key, value)
85
+
86
+ newvec.myname = self._polygon.myname
87
+ return newvec
88
+
52
89
  def select_cells(self, mode:Literal['polygon', 'buffer'] = 'polygon', **kwargs):
53
- """ Select the cells inside the polygon """
90
+ """Select the cells inside the polygon.
91
+
92
+ :param mode: 'polygon' or 'buffer'
93
+ :param kwargs: 'polygon' for polygon selection or 'buffer' for buffer size
94
+
95
+ For polygon selection, the polygon must be provided in kwargs or use the polygon set during initialization.
96
+ For buffer selection, the buffer size in meter must be provided in kwargs.
97
+ """
54
98
 
55
99
  if mode == 'polygon':
56
100
  if 'polygon' in kwargs:
57
101
  self._polygon = kwargs['polygon']
58
- self._select_cells_polygon(self._polygon)
59
- else:
102
+ elif self._polygon is None:
60
103
  raise ValueError("No polygon provided. Please provide a polygon to select cells.")
104
+ self._select_cells_polygon(self._polygon)
105
+
61
106
  elif mode == 'buffer':
62
- if 'buffer' in kwargs:
107
+ if 'buffer' in kwargs and self._polygon is not None:
63
108
  self._select_cells_buffer(kwargs['buffer'])
64
109
  else:
65
110
  raise ValueError("No buffer size provided. Please provide a buffer size to select cells.")
66
111
  else:
67
112
  raise ValueError("Invalid mode. Please use 'polygon' or 'buffer'.")
68
113
 
69
- def _select_cells_polygon(self, selection_poly:vector):
114
+ def _select_cells_polygon(self, selection_poly:vector = None):
70
115
  """ Select the cells inside the polygon """
71
116
 
72
- self._polygon = selection_poly
73
- self._selected_cells = self._wa.get_xy_inside_polygon(self._polygon)
117
+ if selection_poly is None:
118
+ if self._polygon is None:
119
+ raise ValueError("No polygon provided. Please provide a polygon to select cells.")
120
+ selection_poly = self._polygon
121
+ else:
122
+ self._polygon = selection_poly
123
+
124
+ self._selected_cells = self._wa.get_xy_inside_polygon(selection_poly)
74
125
 
75
126
  def _select_cells_buffer(self, buffer_size:float = 0.0):
76
127
  """ Select the cells inside the buffer of the polygon """
77
128
 
78
- self._polygon = self._polygon.buffer(buffer_size, inplace=False)
79
- self._selected_cells = self._wa.get_xy_inside_polygon(self._polygon)
129
+ if buffer_size > 0.0:
130
+ selection_poly = self._polygon.buffer(buffer_size, inplace=False)
131
+ elif buffer_size == 0.0:
132
+ selection_poly = self._polygon
133
+ else:
134
+ raise ValueError("Buffer size must be greater than or equal to 0.0.")
135
+
136
+ self._selected_cells = self._wa.get_xy_inside_polygon(selection_poly)
80
137
 
81
138
  def compute_values(self):
82
139
  """ Get the values of the array inside the polygon """
@@ -85,7 +142,48 @@ class Array_analysis_onepolygon():
85
142
  if self._polygon is None:
86
143
  raise ValueError("No polygon provided. Please provide a polygon to select cells.")
87
144
 
88
- self._values = self._wa.statistics(self._polygon)
145
+ self._values = self._wa.statistics(self._polygon)
146
+ else:
147
+ self._values = self._wa.statistics(self._selected_cells)
148
+
149
+ def _add_area2velues(self):
150
+ """ Add the area of the polygon to the values """
151
+
152
+ if self._selected_cells is None:
153
+ if self._polygon is None:
154
+ raise ValueError("No polygon provided. Please provide a polygon to select cells.")
155
+
156
+ self._values['Area'] = self._polygon.area
157
+ centroid = self._polygon.centroid
158
+ self._values['X'] = centroid.x
159
+ self._values['Y'] = centroid.y
160
+ else:
161
+ self._values['Area'] = len(self._selected_cells) * self._wa.dx * self._wa.dy
162
+ self._values['X'] = np.mean(self._selected_cells[:, 0])
163
+ self._values['Y'] = np.mean(self._selected_cells[:, 1])
164
+
165
+ @property
166
+ def n_selected_cells(self) -> int:
167
+ """ Get the number of selected cells """
168
+ if self._selected_cells is None:
169
+ return 0
170
+
171
+ return len(self._selected_cells)
172
+
173
+ def get_selection(self) -> np.ndarray:
174
+ """ Get the selected cells as a numpy array of coordinates.
175
+
176
+ :return: numpy array of shape (n, 2) with the coordinates of the selected cells
177
+ """
178
+ if self._selected_cells is None:
179
+ raise ValueError("No cells selected. Please call select_cells() first.")
180
+
181
+ return np.array(self._selected_cells)
182
+
183
+ def reset_selection(self):
184
+ """ Reset the selection of cells """
185
+ self._selected_cells = None
186
+ self._values = None
89
187
 
90
188
  def plot_values(self, show:bool = True, bins:int = 100,
91
189
  engine:Literal['seaborn', 'plotly'] = 'seaborn'):
@@ -153,6 +251,41 @@ class Array_analysis_onepolygon():
153
251
 
154
252
  return fig
155
253
 
254
+ @property
255
+ def has_values(self) -> bool:
256
+ """ Check if there useful values """
257
+ if self._values is None:
258
+ self.compute_values()
259
+
260
+ return len(self.values('Values')) > 0
261
+
262
+ @property
263
+ def has_strictly_positive_values(self) -> bool:
264
+ """ Check if there useful values """
265
+ if self._values is None:
266
+ self.compute_values()
267
+
268
+ return len(self.values('Values') > 0) > 0
269
+
270
+ def distribute_values(self, bins:list[float]):
271
+ """ Distribute the values in bins
272
+
273
+ :param bins: list of bin edges
274
+ :return: pandas DataFrame with the counts of values in each bin
275
+ """
276
+
277
+ if self._values is None:
278
+ self.compute_values()
279
+
280
+ if self._values is None:
281
+ raise ValueError("No values computed. Please call compute_values() first.")
282
+
283
+ values = self.values('Values')
284
+ if values is None or len(values) == 0:
285
+ raise ValueError("No values to distribute. Please compute values first.")
286
+
287
+ counts, __ = np.histogram(values, bins=bins)
288
+ return pd.DataFrame({'Bin Edges': bins[:-1], 'Counts': counts})
156
289
 
157
290
  class Array_analysis_polygons():
158
291
  """ Class for values analysis of an array based on a polygon.
@@ -164,21 +297,164 @@ class Array_analysis_polygons():
164
297
  Plots of the values distribution can be generated using seaborn or plotly.
165
298
  """
166
299
 
167
- def __init__(self, wa:WolfArray, polygons:zone):
300
+ def __init__(self, wa:WolfArray, polygons:zone, buffer_size:float = 0.0):
168
301
  """ Initialize the class with a WolfArray and a zone of polygons """
169
302
 
170
303
  self._wa = wa
171
- self._polygons = polygons
304
+ self._polygons = polygons # pointer to the original zone of polygons
305
+ self._check_names()
306
+
307
+ self._has_buffer = buffer_size > 0.0
308
+
309
+ self._zone = {polygon.myname: Array_analysis_onepolygon(self._wa, polygon, buffer_size) for polygon in self._polygons.myvectors if polygon.used}
310
+
311
+ self._active_categories = self.all_categories
312
+
313
+ def as_zone(self, add_values:bool = True) -> zone:
314
+ """ Convert the analysis to a zone of polygons """
315
+
316
+ ret_zone = zone(name=self._polygons.myname)
317
+ for name, poly in self._zone.items():
318
+ if name.split('___')[0] in self._active_categories:
319
+ if poly.has_values:
320
+ ret_zone.add_vector(poly.as_vector(add_values), forceparent=True)
172
321
 
173
- self._zone = {polygon.myname: Array_analysis_onepolygon(self._wa, polygon) for polygon in self._polygons.myvectors}
322
+ return ret_zone
174
323
 
175
- def __getitem__(self, key):
324
+ @property
325
+ def _areas(self) -> list[float]:
326
+ """ Get the areas of the polygons in the zone """
327
+ return [poly.area for poly in self.polygons.myvectors if poly.used]
328
+
329
+ @property
330
+ def all_categories(self) -> list[str]:
331
+ """ Get the name of the building categories from the Polygons """
332
+
333
+ return list(set([v.myname.split('___')[0] for v in self._polygons.myvectors if v.used]))
334
+
335
+ @property
336
+ def active_categories(self) -> list[str]:
337
+ """ Get the active categories for the analysis """
338
+ return self._active_categories
339
+
340
+ @active_categories.setter
341
+ def active_categories(self, categories:list[str]):
342
+ """ Set the active categories for the analysis
343
+
344
+ :param categories: list of categories to activate
345
+ """
346
+ if not categories:
347
+ raise ValueError("The list of categories must not be empty.")
348
+
349
+ all_categories = self.all_categories
350
+ for cat in categories:
351
+ if cat not in all_categories:
352
+ logging.debug(f"Category '{cat}' is not a valid category.")
353
+
354
+ self._active_categories = categories
355
+
356
+ def activate_category(self, category_name:str):
357
+ """ Activate a category for the analysis
358
+
359
+ :param category_name: name of the category to activate
360
+ """
361
+ if category_name not in self.all_categories:
362
+ raise ValueError(f"Category '{category_name}' is not a valid category. Available categories: {self.all_categories}")
363
+
364
+ if category_name not in self._active_categories:
365
+ self._active_categories.append(category_name)
366
+
367
+ def deactivate_category(self, category_name:str):
368
+ """ Deactivate a category for the analysis
369
+
370
+ :param category_name: name of the category to deactivate
371
+ """
372
+ if category_name not in self._active_categories:
373
+ raise ValueError(f"Category '{category_name}' is not active. Active categories: {self._active_categories}")
374
+
375
+ self._active_categories.remove(category_name)
376
+
377
+ def _check_names(self):
378
+ """ Check if the names of the polygons are unique """
379
+ names = [poly.myname for poly in self._polygons.myvectors if poly.used]
380
+ if len(names) != len(set(names)):
381
+ raise ValueError("Polygon names must be unique. Please rename the polygons in the zone.")
382
+
383
+ def __getitem__(self, key:str) -> Array_analysis_onepolygon:
176
384
  """ Get the polygon by name """
177
385
  if key in self._zone:
178
386
  return self._zone[key]
179
387
  else:
180
388
  raise KeyError(f"Polygon {key} not found in zone.")
181
389
 
390
+ def reset_selection(self):
391
+ """ Reset the selection of cells in all polygons """
392
+ for poly in self._zone.values():
393
+ poly.reset_selection()
394
+
395
+ def get_values(self) -> pd.DataFrame:
396
+ """ Get the values of all polygons in the zones as a pandas DataFrame.
397
+
398
+ One column per polygon with the values."""
399
+
400
+ lst = [pol.values('Values') for key, pol in self._zone.items() if pol.has_values and key.split('___')[0] in self._active_categories]
401
+ return pd.concat(lst, axis=1)
402
+
403
+ def get_geometries(self) -> pd.DataFrame:
404
+ """ Get the centroids of all polygons in the zone as a pandas DataFrame.
405
+
406
+ :return: pandas DataFrame with the centroids of the polygons
407
+ """
408
+ centroids = {key: {'Centroid' : poly.centroid, 'X': poly.centroid.x, 'Y' : poly.centroid.y, 'Geometry': poly._polygon.polygon} for key, poly in self._zone.items() if poly.has_values and key.split('___')[0] in self._active_categories}
409
+ return pd.DataFrame.from_dict(centroids, orient='index')
410
+
411
+ def get_geodataframe_with_values(self, epsg:int = 31370) -> 'gpd.GeoDataFrame':
412
+ """ Create a GeoDataFrame with the centroids and values of the polygons.
413
+
414
+ Values are added as a column named 'Values' as Numpy array."""
415
+
416
+ import geopandas as gpd
417
+
418
+ geom = self.get_geometries()
419
+ # Add values as numpy arrays to the DataFrame
420
+ geom['Values'] = None
421
+ geom['Values'] = geom['Values'].astype(object)
422
+
423
+ # Get values for each polygon and add them to the DataFrame
424
+ for key, poly in self._zone.items():
425
+ if poly.has_values and key.split('___')[0] in self._active_categories:
426
+ values = poly.values('Values')
427
+ geom.at[key, 'Values'] = values.to_numpy().ravel()
428
+
429
+ # Create a GeoDataFrame
430
+ gdf = gpd.GeoDataFrame(geom, geometry='Geometry', crs=f'EPSG:{epsg}')
431
+ return gdf
432
+
433
+ @property
434
+ def polygons(self) -> zone:
435
+ """ Get the zone of polygons """
436
+ if self._has_buffer:
437
+ # return a new zone with the polygons and their buffers
438
+ ret_zone = zone(name = self._polygons.myname)
439
+ for name, poly in self._zone.items():
440
+ if name.split('___')[0] in self._active_categories:
441
+ ret_zone.add_vector(poly._polygon)
442
+
443
+ return ret_zone
444
+ else:
445
+ # return the original zone of polygons
446
+ return self._polygons
447
+
448
+ @property
449
+ def keys(self) -> list[str]:
450
+ """ Get the names of the polygons in the zone """
451
+ return list(self._zone.keys())
452
+
453
+ def update_values(self):
454
+ """ Update the polygons values in the zone """
455
+ for poly in self._zone.values():
456
+ poly.compute_values()
457
+
182
458
  def plot_values(self, show:bool = True, bins:int = 100,
183
459
  engine:Literal['seaborn', 'plotly'] = 'seaborn'):
184
460
  """ Plot a histogram of the values """
@@ -190,13 +466,902 @@ class Array_analysis_polygons():
190
466
 
191
467
  def plot_values_seaborn(self, bins:int = 100, show:bool = True):
192
468
  """ Plot a histogram of the values """
193
- return {key: pol.plot_values_seaborn(bins=bins, show=show) for key, pol in self._zone.items()}
469
+ return {key: pol.plot_values_seaborn(bins=bins, show=show) for key, pol in self._zone.items() if key.split('___')[0] in self._active_categories}
194
470
 
195
471
  def plot_values_plotly(self, bins:int = 100, show:bool = True):
196
472
  """ Plot a histogram of the values """
197
473
 
198
- return {key: pol.plot_values_plotly(bins=bins, show=show) for key, pol in self._zone.items()}
474
+ return {key: pol.plot_values_plotly(bins=bins, show=show) for key, pol in self._zone.items() if key.split('___')[0] in self._active_categories}
475
+
476
+ def count_strictly_positive(self) -> int:
477
+ """ Count the number of polygons with values greater than zero """
478
+ nb = 0
479
+ for key, poly in self._zone.items():
480
+ if key.split('___')[0] not in self._active_categories:
481
+ continue
482
+ if poly.has_strictly_positive_values:
483
+ nb += 1
484
+ return nb
485
+
486
+ def values(self, which:Literal['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area']) -> pd.Series:
487
+ """ Get the values as a pandas DataFrame
488
+
489
+ :param which: Mean, Std, Median, Sum, Volume
490
+ :return: pandas DataFrame with the values for each polygon
491
+ """
492
+
493
+ authorized = ['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area']
494
+ if which not in authorized:
495
+ raise ValueError(f"Invalid value for 'which'. Must be one of {authorized}.")
496
+
497
+ values = {name: poly.values(which) for name, poly in self._zone.items() if poly.has_values and name.split('___')[0] in self._active_categories}
498
+
499
+ if not values:
500
+ raise ValueError("No values computed. Please compute values first.")
501
+
502
+ return pd.Series(values)
503
+
504
+ def distribute_polygons(self, bins:list[float], operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area']) -> pd.DataFrame:
505
+ """ Distribute the values of each polygon in bins
506
+
507
+ :param bins: list of bin edges
508
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
509
+ :return: pandas DataFrame with the counts of values in each bin for each polygon
510
+ """
511
+
512
+ values = np.asarray([poly.values(operator) for key, poly in self._zone.items() if poly.has_values and key.split('___')[0] in self._active_categories] )
513
+
514
+ if values.size == 0:
515
+ raise ValueError("No values to distribute. Please compute values first.")
516
+
517
+ counts, __ = np.histogram(values, bins=bins)
518
+ distribution = pd.DataFrame({'Bin Edges': bins[:-1], 'Counts': counts})
519
+
520
+ return distribution
521
+
522
+ def plot_distributed_values(self, bins:list[float],
523
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'],
524
+ show:bool = True,
525
+ engine:Literal['seaborn', 'plotly'] = 'seaborn'):
526
+ """ Plot the distribution of values in bins for each polygon
527
+
528
+ :param bins: list of bin edges
529
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
530
+ :param show: whether to show the plot
531
+ :param engine: 'seaborn' or 'plotly'
532
+ """
533
+
534
+ distribution = self.distribute_polygons(bins, operator)
535
+
536
+ if engine == 'seaborn':
537
+ fig, ax = self.plot_distribution_seaborn(distribution, show=show)
538
+ elif engine == 'plotly':
539
+ fig, ax = self.plot_distribution_plotly(distribution, show=show)
540
+
541
+ ax.set_title(f'Distribution of Values ({operator})')
542
+
543
+ return fig, ax
544
+
545
+ def plot_distribution_seaborn(self, distribution:pd.DataFrame, show:bool = True):
546
+ """ Plot the distribution of values in bins using seaborn
547
+
548
+ :param distribution: pandas DataFrame with the counts of values in each bin
549
+ :param show: whether to show the plot
550
+ """
551
+
552
+ import seaborn as sns
553
+ import matplotlib.pyplot as plt
554
+
555
+ fig, ax = plt.subplots()
556
+ sns.barplot(x='Bin Edges', y='Counts', data=distribution, ax=ax)
557
+
558
+ ax.set_xlabel('Bin Edges')
559
+ ax.set_ylabel('Counts')
560
+ ax.set_title('Distribution of Values')
561
+
562
+ if show:
563
+ plt.show()
564
+
565
+ return (fig, ax)
566
+
567
+ def plot_distribution_plotly(self, distribution:pd.DataFrame, show:bool = True):
568
+ """ Plot the distribution of values in bins using plotly
569
+
570
+ :param distribution: pandas DataFrame with the counts of values in each bin
571
+ :param show: whether to show the plot
572
+ """
573
+
574
+ import plotly.express as px
575
+
576
+ fig = px.bar(distribution, x='Bin Edges', y='Counts',
577
+ title='Distribution of Values')
578
+
579
+ fig.update_layout(xaxis_title='Bin Edges', yaxis_title='Counts')
580
+
581
+ if show:
582
+ fig.show(renderer='browser')
583
+
584
+ return fig
585
+
586
+ def clustering(self, n_clusters:int = 5, operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'] = 'Mean'):
587
+ """ Perform clustering on the polygons based on their values. """
588
+ from sklearn.cluster import KMeans
589
+ import geopandas as gpd
590
+
591
+ # Get the values of the polygons
592
+ values = self.values(operator)
593
+ geometries = self.get_geodataframe_with_values()
594
+
595
+ xy = geometries[['X', 'Y']].copy()
596
+
597
+ if values.empty:
598
+ raise ValueError("No values to cluster. Please compute values first.")
599
+
600
+ # Perform clustering
601
+ kmeans = KMeans(n_clusters=n_clusters)
602
+
603
+ kmeans.fit(xy[['X', 'Y']].values)
604
+ labels = kmeans.labels_
605
+ geometries['Cluster'] = labels
606
+
607
+ centroids = kmeans.cluster_centers_
608
+ cluster_centroids = [Point(xy) for xy in centroids]
609
+
610
+ # Find footprints of the clusters
611
+ footprints = []
612
+ for label in np.unique(labels):
613
+ geom_cluster = geometries[geometries['Cluster'] == label]
614
+ footprint = geom_cluster.geometry.unary_union.convex_hull
615
+ footprints.append(footprint)
616
+
617
+ return geometries, (cluster_centroids, footprints)
618
+
619
+
620
+ class Array_analysis_zones():
621
+ """ Class for values analysis of an array based on a Zones instance.
622
+
623
+ This class select values insides a zone of polygons and plot statistics of the values.
624
+ """
625
+
626
+ def __init__(self, wa:WolfArray, zones:Zones, buffer_size:float = 0.0):
627
+ """ Initialize the class with a Wolf Zones """
628
+
629
+ self._wa = wa
630
+ self._zones = zones
631
+
632
+ self._polygons = {zone.myname: Array_analysis_polygons(self._wa, zone, buffer_size) for zone in self._zones.myzones if zone.used}
633
+
634
+ def as_zones(self, add_values:bool = True) -> Zones:
635
+ """ Convert the analysis to a Zones instance """
636
+
637
+ newzones = Zones(idx=self._zones.idx)
638
+ for name, pol in self._polygons.items():
639
+ newzones.add_zone(pol.as_zone(add_values), forceparent=True)
640
+
641
+ return newzones
642
+
643
+
644
+ def __getitem__(self, key:str) -> Array_analysis_polygons:
645
+ """ Get the zone by name """
646
+ if key in self._polygons:
647
+ return self._polygons[key]
648
+ else:
649
+ raise KeyError(f"Zone {key} not found in zones.")
650
+
651
+ def reset_selection(self):
652
+ """ Reset the selection of cells in all polygons """
653
+ for poly in self._polygons.values():
654
+ poly.reset_selection()
655
+
656
+ @property
657
+ def keys(self) -> list[str]:
658
+ """ Get the names of the polygons in the zones """
659
+ return list(self._polygons.keys())
660
+
661
+ def update_values(self):
662
+ """ Update the polygons values in the zones """
663
+ for pol in self._polygons.values():
664
+ pol.update_values()
665
+
666
+ def get_values(self) -> dict[str, pd.DataFrame]:
667
+ """ Get the values of all polygons in the zones as a dictionary of pandas DataFrames """
668
+
669
+ return {key: pol.get_values() for key, pol in self._polygons.items()}
670
+
671
+ def plot_values(self, show:bool = True, bins:int = 100,
672
+ engine:Literal['seaborn', 'plotly'] = 'seaborn'):
673
+ """ Plot a histogram of the values """
674
+ if engine == 'seaborn':
675
+ return self.plot_values_seaborn(show=show, bins=bins)
676
+ elif engine == 'plotly':
677
+ return self.plot_values_plotly(show=show, bins=bins)
678
+
679
+ def plot_values_seaborn(self, bins:int = 100, show:bool = True):
680
+ """ Plot a histogram of the values """
681
+ return {key: pol.plot_values_seaborn(bins=bins, show=show) for key, pol in self._polygons.items()}
682
+
683
+ def plot_values_plotly(self, bins:int = 100, show:bool = True):
684
+ """ Plot a histogram of the values """
685
+ return {key: pol.plot_values_plotly(bins=bins, show=show) for key, pol in self._polygons.items()}
686
+
687
+ def distribute_zones(self, bins:list[float],
688
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area']) -> dict[str, pd.DataFrame]:
689
+ """ Distribute the values of each zone in bins
690
+
691
+ :param bins: list of bin edges
692
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
693
+ :return: pandas DataFrame with the counts of values in each bin for each zone
694
+ """
695
+
696
+ return {key: pol.distribute_polygons(bins, operator) for key, pol in self._polygons.items()}
697
+
698
+ def values(self, which:Literal['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area']) -> dict[str, pd.Series]:
699
+ """ Get the values as a dictionnary of pandas Series
700
+
701
+ :param which: Mean, Std, Median, Sum, Volume
702
+ :return: pandas DataFrame with the values for each polygon
703
+ """
704
+
705
+ authorized = ['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area']
706
+ if which not in authorized:
707
+ raise ValueError(f"Invalid value for 'which'. Must be one of {authorized}.")
708
+
709
+ values = {name: pol.values(which) for name, pol in self._polygons.items()}
710
+
711
+ if not values:
712
+ raise ValueError("No values computed. Please compute values first.")
713
+
714
+ return values
715
+
716
+ class Arrays_analysis_zones():
717
+ """
718
+ Class for analysis multiples arrays based on a Zones instance.
719
+ Each array must have the same shape.
720
+ """
721
+
722
+ def __init__(self, arrays:dict[str, WolfArray], zones:Zones, buffer_size:float = 0.0):
723
+ """ Initialize the class with a list of WolfArray and a Zones instance """
724
+ if not arrays:
725
+ raise ValueError("The list of arrays must not be empty.")
726
+
727
+ self._arrays = arrays
728
+ self._zones = zones
729
+ self._xlabel = 'Value'
730
+
731
+ # Check that all arrays have the same shape
732
+ ref = next(iter(arrays.values()))
733
+ for array in arrays.values():
734
+ if not array.is_like(ref):
735
+ raise ValueError("All arrays must have the same shape.")
736
+
737
+ self._polygons = {zone.myname: {key: Array_analysis_polygons(array, zone, buffer_size) for key, array in self._arrays.items()}
738
+ for zone in self._zones.myzones if zone.used }
739
+
740
+ self._active_categories = self.all_categories
741
+ self._active_arrays = self.all_arrays
742
+
743
+ def __getitem__(self, key:str | tuple) -> Array_analysis_polygons:
744
+ """ Get the zone by name """
745
+ if isinstance(key, tuple):
746
+ if len(key) != 2:
747
+ raise ValueError("Key must be a tuple of (zone_name, array_name).")
748
+ zone_name, array_name = key
749
+ if zone_name not in self._polygons or array_name not in self._polygons[zone_name]:
750
+ raise KeyError(f"Zone {zone_name} or array {array_name} not found in zones.")
751
+ return self._polygons[zone_name][array_name]
752
+
753
+ elif isinstance(key, str):
754
+ if len(self._polygons) == 1:
755
+ # If there is only one zone, return the first array in that zone
756
+ zone_name = next(iter(self._polygons))
757
+ if key in self._polygons[zone_name]:
758
+ return self._polygons[zone_name][key]
759
+ else:
760
+ raise KeyError(f"Array {key} not found in the only zone available.")
761
+ else:
762
+ if key in self._polygons:
763
+ return self._polygons[key]
764
+ else:
765
+ raise KeyError(f"Zone {key} not found in zones.")
766
+
767
+ def as_zones(self, add_values:bool = True) -> Zones:
768
+ """ Convert the analysis to a Zones instance """
769
+
770
+ newzones = Zones(idx=self._zones.idx)
771
+ for name, dct in self._polygons.items():
772
+ for array_name, pols in dct.items():
773
+ if array_name not in self._active_arrays:
774
+ continue
775
+
776
+ newzone = pols.as_zone(add_values)
777
+ newzone.myname = f"{array_name}"
778
+
779
+ newzones.add_zone(newzone, forceparent=True)
780
+
781
+ return newzones
782
+
783
+ @property
784
+ def _areas(self) -> dict[str, list[float]]:
785
+ """ Get the areas of the polygons in the zones """
786
+ return {polygons.myname: [poly.area for poly in polygons.myvectors if poly.used]
787
+ for polygons in self._zones.myzones}
788
+
789
+ @property
790
+ def all_categories(self) -> list[str]:
791
+ """ Get the name of the building categories from the Zones """
792
+
793
+ return sorted(list(set([v.myname.split('___')[0] for z in self._zones.myzones for v in z.myvectors])))
794
+
795
+ @property
796
+ def all_arrays(self) -> list[str]:
797
+ """ Get the names of all arrays """
798
+ return list(self._arrays.keys())
799
+
800
+ def activate_array(self, array_name:str):
801
+ """ Activate an array for the analysis
802
+
803
+ :param array_name: name of the array to activate
804
+ """
805
+ if array_name not in self.all_arrays:
806
+ raise ValueError(f"Array '{array_name}' is not a valid array. Available arrays: {self.all_arrays}")
807
+
808
+ if array_name not in self._active_arrays:
809
+ self._active_arrays.append(array_name)
810
+
811
+ def deactivate_array(self, array_name:str):
812
+ """ Deactivate an array for the analysis
813
+
814
+ :param array_name: name of the array to deactivate
815
+ """
816
+ if array_name not in self._active_arrays:
817
+ raise ValueError(f"Array '{array_name}' is not active. Active arrays: {self._active_arrays}")
818
+
819
+ self._active_arrays.remove(array_name)
820
+
821
+ def activate_category(self, category_name:str):
822
+ """ Activate a category for the analysis
823
+
824
+ :param category_name: name of the category to activate
825
+ """
826
+ if category_name not in self.all_categories:
827
+ raise ValueError(f"Category '{category_name}' is not a valid category. Available categories: {self.all_categories}")
828
+
829
+ if category_name not in self._active_categories:
830
+ self._active_categories.append(category_name)
831
+
832
+ for zone_name, dct in self._polygons.items():
833
+ for array_name, poly in dct.items():
834
+ poly.active_categories = self._active_categories
835
+
836
+ def deactivate_category(self, category_name:str):
837
+ """ Deactivate a category for the analysis
838
+
839
+ :param category_name: name of the category to deactivate
840
+ """
841
+ if category_name not in self._active_categories:
842
+ raise ValueError(f"Category '{category_name}' is not active. Active categories: {self._active_categories}")
843
+
844
+ self._active_categories.remove(category_name)
845
+
846
+ for zone_name, dct in self._polygons.items():
847
+ for array_name, poly in dct.items():
848
+ poly.active_categories = self._active_categories
849
+
850
+ @property
851
+ def active_arrays(self) -> list[str]:
852
+ """ Get the active arrays for the analysis """
853
+ return self._active_arrays
854
+
855
+ @active_arrays.setter
856
+ def active_arrays(self, arrays:list[str]):
857
+ """ Set the active arrays for the analysis
858
+
859
+ :param arrays: list of arrays to activate
860
+ """
861
+ if not arrays:
862
+ raise ValueError("The list of arrays must not be empty.")
863
+
864
+ all_arrays = self.all_arrays
865
+ for arr in arrays:
866
+ if arr not in all_arrays:
867
+ raise ValueError(f"Array '{arr}' is not a valid array. Available arrays: {all_arrays}")
868
+
869
+ self._active_arrays = arrays
870
+
871
+ @property
872
+ def active_categories(self) -> list[str]:
873
+ """ Get the active categories for the analysis """
874
+ return self._active_categories
875
+
876
+ @active_categories.setter
877
+ def active_categories(self, categories:list[str]):
878
+ """ Set the active categories for the analysis
879
+
880
+ :param categories: list of categories to activate
881
+ """
882
+ if not categories:
883
+ raise ValueError("The list of categories must not be empty.")
884
+
885
+ all_categories = self.all_categories
886
+ for cat in categories:
887
+ if cat not in all_categories:
888
+ raise ValueError(f"Category '{cat}' is not a valid category. Available categories: {all_categories}")
889
+
890
+ self._active_categories = categories
891
+
892
+ for zone_name, dct in self._polygons.items():
893
+ for array_name, poly in dct.items():
894
+ poly.active_categories = self._active_categories
895
+
896
+ def get_values(self) -> dict[str, dict[str, pd.DataFrame]]:
897
+ """ Get the values of all polygons in the zones as a dictionary of pandas DataFrames """
898
+
899
+ values = {}
900
+ for zone_name, polygons in self._polygons.items():
901
+ values[zone_name] = {}
902
+ for array_name, polygon in polygons.items():
903
+ values[zone_name][array_name] = polygon.get_values()
904
+
905
+ return values
906
+
907
+ def values(self, which:Literal['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area']) -> dict[str, dict[str, pd.Series]]:
908
+ """ Get the values of all polygons in the zones as a dictionary of pandas Series
909
+
910
+ :param which: Mean, Std, Median, Sum, Volume, Area
911
+ :return: dictionary with zone names as keys and dictionaries of array names and their values as values
912
+ """
913
+
914
+ authorized = ['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area']
915
+ if which not in authorized:
916
+ raise ValueError(f"Invalid value for 'which'. Must be one of {authorized}.")
917
+
918
+ values = {}
919
+ for zone_name, polygons in self._polygons.items():
920
+ values[zone_name] = {}
921
+ for array_name, polygon in polygons.items():
922
+ if array_name not in self._active_arrays:
923
+ continue
924
+ values[zone_name][array_name] = polygon.values(which)
925
+
926
+ return values
927
+
928
+ def update_values(self):
929
+ """ Update the polygons values in the zones for all arrays """
930
+ for polygons in self._polygons.values():
931
+ for polygon in polygons.values():
932
+ polygon.update_values()
933
+
934
+ def count_strictly_positive(self) -> dict[str, int]:
935
+ """ Count the number of polygons with values greater than zero for each array """
936
+ import concurrent.futures
937
+
938
+ counts = {}
939
+ for zone_name, polygons in self._polygons.items():
940
+ counts[zone_name] = {}
941
+ with concurrent.futures.ThreadPoolExecutor() as executor:
942
+ futures = {}
943
+ for array_name, polygon in polygons.items():
944
+ if array_name not in self._active_arrays:
945
+ continue
946
+ futures[array_name] = executor.submit(polygon.count_strictly_positive)
947
+ for array_name, future in futures.items():
948
+ counts[zone_name][array_name] = future.result()
949
+
950
+ return counts
951
+
952
+ def count_strictly_positive_as_df(self, merge_zones: bool = False) -> pd.DataFrame:
953
+ """ Count the number of polygons with strictly positive values for each array as a pandas DataFrame
954
+
955
+ :return: pandas DataFrame with the counts of strictly positive values for each array in each zone
956
+ """
957
+
958
+ counts = self.count_strictly_positive()
959
+
960
+ df = pd.DataFrame({'Zone': zone_name, 'Array': array_name, 'Count': count}
961
+ for zone_name, arrays in counts.items()
962
+ for array_name, count in arrays.items())
963
+ if merge_zones:
964
+ # Sum counts across zones for each array
965
+ df = df.groupby('Array', as_index=False).sum()
966
+ # remove the 'Zone' column
967
+ df = df.drop(columns=['Zone'])
968
+
969
+ return df
970
+
971
+ def distribute_zones(self, bins:list[float],
972
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area']) -> dict[str, dict[str, pd.DataFrame]]:
973
+ """ Distribute the values of each zone in bins for each array
974
+
975
+ :param bins: list of bin edges
976
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
977
+ :return: dictionary with zone names as keys and dictionaries of array names and their distributions as values
978
+ """
979
+
980
+ distributions = {}
981
+ for zone_name, polygons in self._polygons.items():
982
+ distributions[zone_name] = {}
983
+ for array_name, polygon in polygons.items():
984
+ if array_name not in self._active_arrays:
985
+ continue
986
+ distributions[zone_name][array_name] = polygon.distribute_polygons(bins, operator)
987
+
988
+ return distributions
989
+
990
+ def distribute_zones_as_df(self, bins:list[float],
991
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'],
992
+ merge_zones:bool = False) -> pd.DataFrame:
993
+ """ Distribute the values of each zone in bins for each array as a pandas DataFrame.
994
+
995
+ Date are tabulated in a DataFrame with columns 'Zone', 'Array', 'Bin Edges', 'Count'.
996
+ It is more convenient for plotting and analysis.
997
+
998
+ :param bins: list of bin edges
999
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
1000
+ :return: pandas DataFrame with the counts of values in each bin for each array in each zone
1001
+ """
1002
+
1003
+ distributions = self.distribute_zones(bins, operator)
1004
+ df = pd.DataFrame({'Zone': zone_name, 'Array': array, 'Bin Edges': bin, 'Count': count}
1005
+ for zone_name, arrays in distributions.items()
1006
+ for array, bins_counts in arrays.items()
1007
+ for bin, count in zip(bins_counts['Bin Edges'], bins_counts['Counts']))
1008
+ if merge_zones:
1009
+ # Sum counts across zones for each array
1010
+ df = df.groupby(['Array', 'Bin Edges'], as_index=False).sum()
1011
+ # remove the 'Zone' column
1012
+ df = df.drop(columns=['Zone'])
1013
+ return df
1014
+
1015
+ def _values_as_df(self, which:Literal['Mean', 'Std', 'Median', 'Sum', 'Volume', 'Area'],
1016
+ merge_zones:bool = False) -> pd.DataFrame:
1017
+ """ Get a full DataFrame with all arrays, zones and values for each polygon.
1018
+
1019
+ :param merge_zones: whether to merge the zones in the DataFrame
1020
+ :return: pandas DataFrame with the counts of strictly positive values for each array in each zone
1021
+ """
1022
+ dct = self.values(which)
1023
+
1024
+ df = pd.DataFrame({'Zone': zone_name, 'Array': array_name, 'Value': value}
1025
+ for zone_name, arrays in dct.items()
1026
+ for array_name, value in arrays.items()
1027
+ for value in value.values)
1028
+ if merge_zones:
1029
+ # remove the 'Zone' column
1030
+ df = df.drop(columns=['Zone'])
1031
+
1032
+ return df
1033
+
1034
+ def plot_count_strictly_positive(self, show:bool = True,
1035
+ engine:Literal['seaborn', 'plotly'] = 'seaborn',
1036
+ merge_zones:bool = False):
1037
+ """ Plot the count of strictly positive values for each array in each zone
1038
+
1039
+ :param show: whether to show the plot
1040
+ :param engine: 'seaborn' or 'plotly'
1041
+ """
1042
+
1043
+ if engine == 'seaborn':
1044
+ return self._plot_count_strictly_positive_seaborn(show=show, merge_zones=merge_zones)
1045
+ elif engine == 'plotly':
1046
+ return self.plot_count_strictly_positive_plotly(show=show, merge_zones=merge_zones)
1047
+
1048
+ def _plot_count_strictly_positive_seaborn(self, show:bool = True, merge_zones:bool = False):
1049
+ """ Plot the count of strictly positive values for each array in each zone using seaborn
1050
+
1051
+ :param counts: dictionary with zone names as keys, and dictionaries of array names and their counts as values
1052
+ :param show: whether to show the plot
1053
+ """
1054
+
1055
+ import seaborn as sns
1056
+ import matplotlib.pyplot as plt
1057
+
1058
+ df = self.count_strictly_positive_as_df(merge_zones=merge_zones)
1059
+
1060
+ fig, ax = plt.subplots()
1061
+
1062
+ if merge_zones:
1063
+ # If merging zones, we only have 'Array' and 'Count' columns
1064
+ sns.barplot(x='Array', y='Count', data=df, ax=ax)
1065
+ else:
1066
+ sns.barplot(x='Array', y='Count', hue='Zone', data=df, ax=ax)
1067
+
1068
+ ax.set_xlabel('Array')
1069
+ ax.set_ylabel('Count of strictly positive values')
1070
+ ax.set_title('Count of strictly positive values')
1071
+ plt.tight_layout()
1072
+
1073
+ if show:
1074
+ plt.show()
1075
+
1076
+ return (fig, ax)
1077
+
1078
+ def _plot_count_strictly_positive_plotly(self, show:bool = True, merge_zones:bool = False):
1079
+ """ Plot the count of strictly positive values for each array in each zone using plotly
1080
+
1081
+ :param counts: dictionary with zone names as keys, and dictionaries of array names and their counts as values
1082
+ :param show: whether to show the plot
1083
+ """
1084
+
1085
+ import plotly.express as px
1086
+
1087
+ df = self.count_strictly_positive_as_df(merge_zones=merge_zones)
1088
+
1089
+ if merge_zones:
1090
+ fig = px.bar(df, x='Array', y='Count', title='Count of strictly positive values',
1091
+ labels={'Count': 'Count of strictly positive values'})
1092
+ else:
1093
+ fig = px.bar(df, x='Array', y='Count', color='Zone',
1094
+ title='Count of strictly positive values',
1095
+ labels={'Count': 'Count of strictly positive values'})
1096
+
1097
+ fig.update_layout(xaxis_title='Array', yaxis_title='Count of strictly positive values')
1098
+
1099
+ if show:
1100
+ fig.show(renderer='browser')
1101
+
1102
+ return fig
1103
+
1104
+ def plot_distributed_values(self, bins:list[float]= [0., .3, 1.3, -1.],
1105
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'] = 'Median',
1106
+ show:bool = True,
1107
+ engine:Literal['seaborn', 'plotly'] = 'seaborn',
1108
+ merge_zones:bool = False):
1109
+ """ Plot the distribution of values in bins for each array in each zone or merged zones.
1110
+ :param bins: list of bin edges
1111
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
1112
+ :param show: whether to show the plot
1113
+ :param engine: 'seaborn' or 'plotly'
1114
+ :param merge_zones: whether to merge the zones in the plot
1115
+ """
1116
+
1117
+ if engine == 'seaborn':
1118
+ return self._plot_distributed_values_seaborn(bins, operator, show=show, merge_zones=merge_zones)
1119
+ elif engine == 'plotly':
1120
+ return self._plot_distributed_values_plotly(bins, operator, show=show, merge_zones=merge_zones)
1121
+ else:
1122
+ raise ValueError(f"Invalid engine '{engine}'. Must be 'seaborn' or 'plotly'.")
1123
+
1124
+ def _plot_distributed_values_seaborn(self, bins:list[float],
1125
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'],
1126
+ show:bool = True,
1127
+ merge_zones:bool = False):
1128
+ """ Plot the distribution of values in bins for each array in each zone using seaborn
1129
+
1130
+ :param bins: list of bin edges
1131
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
1132
+ :param show: whether to show the plot
1133
+ :param merge_zones: whether to merge the zones in the plot
1134
+ """
1135
+
1136
+ import seaborn as sns
1137
+ import matplotlib.pyplot as plt
1138
+
1139
+ df = self._values_as_df(operator, merge_zones=merge_zones)
1140
+
1141
+ if bins[-1] == -1:
1142
+ bins[-1] = df['Value'].max()
1143
+
1144
+ if merge_zones:
1145
+ fig, ax = plt.subplots()
1146
+ sns.histplot(df, x='Value', hue='Array', multiple='stack', bins = bins, ax=ax)
1147
+
1148
+ # set ticks
1149
+ ax.set_xticks(bins)
1150
+ ax.set_xticklabels([f"{b:.2f}" for b in bins])
1151
+ ax.set_xlabel(self._xlabel)
1152
+ if show:
1153
+ plt.show()
1154
+
1155
+ return (fig, ax)
1156
+ else:
1157
+ # 1 plot per zone
1158
+ figs, axs = [], []
1159
+ for i, zone_name in enumerate(self._polygons.keys()):
1160
+ fig, ax = plt.subplots()
1161
+ sns.histplot(df[df['Zone'] == zone_name],
1162
+ x='Value',
1163
+ hue='Array',
1164
+ multiple='stack', bins=bins, ax=ax)
1165
+
1166
+ ax.set_xlabel(self._xlabel)
1167
+ ax.set_ylabel('Counts')
1168
+ ax.set_title(f'Distribution of Values ({zone_name})')
1169
+
1170
+ # set ticks
1171
+ ax.set_xticks(bins)
1172
+ ax.set_xticklabels([f"{b:.2f}" for b in bins])
1173
+ fig.tight_layout()
1174
+
1175
+ figs.append(fig)
1176
+ axs.append(ax)
1177
+
1178
+ if show:
1179
+ plt.show()
1180
+
1181
+ return (figs, axs)
1182
+
1183
+ def _plot_distributed_values_plotly(self, bins:list[float],
1184
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'],
1185
+ show:bool = True,
1186
+ merge_zones:bool = False):
1187
+ """ Plot the distribution of values in bins for each array in each zone using plotly
1188
+
1189
+ :param bins: list of bin edges
1190
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
1191
+ :param show: whether to show the plot
1192
+ :param merge_zones: whether to merge the zones in the plot
1193
+ """
1194
+
1195
+ import plotly.graph_objects as go
1196
+
1197
+ logging.warning("In plotly engine, the bins will be ignored and replaced by 10 automatic bins.")
1198
+
1199
+ df = self._values_as_df(operator, merge_zones=merge_zones)
1200
+
1201
+ if bins[-1] == -1:
1202
+ bins[-1] = df['Value'].max()
1203
+
1204
+ if merge_zones:
1205
+ fig = go.Figure()
1206
+ for array_name in df['Array'].unique():
1207
+ fig.add_trace(go.Histogram(x=df[df['Array'] == array_name]['Value'],
1208
+ name=array_name,
1209
+ histnorm='',
1210
+ xbins=dict(start=bins[0], end=bins[-1], size=(bins[-1] - bins[0]) / 10)))
1211
+ else:
1212
+ fig = go.Figure()
1213
+ for zone_name in df['Zone'].unique():
1214
+ for array_name in df['Array'].unique():
1215
+ fig.add_trace(go.Histogram(x=df[(df['Zone'] == zone_name) & (df['Array'] == array_name)]['Value'],
1216
+ name=f"{zone_name} - {array_name}",
1217
+ histnorm='',
1218
+ xbins=dict(start=bins[0], end=bins[-1], size=(bins[-1] - bins[0]) / 10)))
1219
+
1220
+ if show:
1221
+ fig.show(renderer='browser')
1222
+
1223
+ return fig
1224
+
1225
+ def _plot_distributed_areas_seaborn(self, bins:list[float],
1226
+ operator:Literal['Mean', 'Median', 'Sum', 'Volume', 'Area'],
1227
+ show:bool = True,
1228
+ merge_zones:bool = False):
1229
+ """ Plot the distribution of values in bins for each array in each zone using seaborn
1230
+
1231
+ :param bins: list of bin edges
1232
+ :param operator: 'Mean', 'Median', 'Sum', 'Volume', 'Area'
1233
+ :param show: whether to show the plot
1234
+ :param merge_zones: whether to merge the zones in the plot
1235
+ """
1236
+
1237
+ import seaborn as sns
1238
+ import matplotlib.pyplot as plt
1239
+
1240
+ df = self._values_as_df(operator, merge_zones=merge_zones)
1241
+ df_area = self._values_as_df('Area', merge_zones=merge_zones)
1242
+
1243
+ # # Multiply the values by the area to get the weighted distribution
1244
+ # df['Value'] = df['Value'] * df_area['Value']
1245
+
1246
+ if bins[-1] == -1:
1247
+ if df['Value'].max() > bins[-2]:
1248
+ bins[-1] = df['Value'].max()
1249
+ else:
1250
+ bins[-1] = bins[-2] + (bins[-2] - bins[-3])
1251
+
1252
+ if merge_zones:
1253
+ fig, ax = plt.subplots()
1254
+ sns.histplot(df, x='Value', hue='Array',
1255
+ multiple='stack', bins = bins, ax=ax,
1256
+ weights= df_area['Value'])
1257
+
1258
+ # set ticks
1259
+ ax.set_xticks(bins)
1260
+ ax.set_xticklabels([f"{b:.2f}" for b in bins])
1261
+ ax.set_xlabel(self._xlabel)
1262
+ ax.set_ylabel('Area [m²]')
1263
+ if show:
1264
+ plt.show()
1265
+
1266
+ return (fig, ax)
1267
+ else:
1268
+ # 1 plot per zone
1269
+ figs, axs = [], []
1270
+ for i, zone_name in enumerate(self._polygons.keys()):
1271
+ fig, ax = plt.subplots()
1272
+ sns.histplot(df[df['Zone'] == zone_name],
1273
+ x='Value',
1274
+ hue='Array',
1275
+ multiple='stack', bins=bins, ax=ax,
1276
+ weights=df_area[df_area['Zone'] == zone_name]['Value'])
1277
+
1278
+ ax.set_xlabel(self._xlabel)
1279
+ ax.set_ylabel('Area [m²]')
1280
+ ax.set_title(f'Distribution of Values ({zone_name})')
1281
+
1282
+ # set ticks
1283
+ ax.set_xticks(bins)
1284
+ ax.set_xticklabels([f"{b:.2f}" for b in bins])
1285
+ fig.tight_layout()
1286
+
1287
+ figs.append(fig)
1288
+ axs.append(ax)
1289
+
1290
+ if show:
1291
+ plt.show()
1292
+
1293
+ return (figs, axs)
1294
+
1295
+ class Building_Waterdepth_analysis(Arrays_analysis_zones):
1296
+ """ Class for water depth analysis of multiple arrays based on a Zones instance.
1297
+
1298
+ This class is designed to analyze water depth data from multiple arrays and zones.
1299
+ It inherits from Arrays_analysis_zones and provides additional methods specific to water depth analysis.
1300
+ """
1301
+
1302
+ def __init__(self, arrays:dict[str, WolfArray],
1303
+ zones:Zones | Path | str,
1304
+ buffer_size:float = 0.0,
1305
+ merge_zones:bool = False,
1306
+ thershold_area:float = 0.0):
1307
+ """ Initialize the class with a list of WolfArray and a Zones instance.
1308
+
1309
+ :param arrays: dictionary of WolfArray instances to analyze
1310
+ :param zones: Zones instance or path to a zones file
1311
+ :param buffer_size: size of the buffer around the zones (default is 0.0)
1312
+ :param merge_zones: whether to merge all zones into a single zone (default is False)
1313
+ :param thershold_area: minimum area of the polygon to consider (default is 0.0)
1314
+ :raises ValueError: if the arrays are empty or have different shapes
1315
+ :raises FileNotFoundError: if the zones file does not exist
1316
+ """
1317
+
1318
+ if isinstance(zones, (Path, str)):
1319
+
1320
+ zones = Path(zones)
1321
+ if not zones.exists():
1322
+ raise FileNotFoundError(f"Zones file {zones} does not exist.")
1323
+
1324
+ [xmin, xmax], [ymin, ymax] = arrays[next(iter(arrays))].get_bounds()
1325
+ logging.info(f"Using bounds from the first array: {xmin}, {xmax}, {ymin}, {ymax}")
1326
+ bbox = Polygon([(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)])
1327
+ logging.info(f"Creating zones from {zones} with bounding box {bbox}")
1328
+ zones = Zones(zones, bbox=bbox)
1329
+ logging.info(f"Zones loaded from {zones}")
1330
+
1331
+ if merge_zones:
1332
+ # copy all vectors in an unique zone
1333
+ newz = Zones(idx='all')
1334
+ merged_zone = zone(name='all')
1335
+ newz.add_zone(merged_zone, forceparent= True)
1336
+
1337
+ for z in zones.myzones:
1338
+ if z.used:
1339
+ merged_zone.myvectors.extend(z.myvectors)
1340
+
1341
+ # Rename vectors adding the index position inside the zone
1342
+ for i, v in enumerate(merged_zone.myvectors):
1343
+ v.myname = f"{v.myname}___{i}"
1344
+
1345
+ zones = newz
1346
+
1347
+ for z in zones.myzones:
1348
+ if z.used:
1349
+ for v in z.myvectors:
1350
+ if v.area < thershold_area:
1351
+ logging.dbg(f"Polygon {v.myname} has an area of {v.area} which is below the threshold of {thershold_area}. It will be ignored.")
1352
+ v.used = False
1353
+
1354
+ super().__init__(arrays, zones, buffer_size)
1355
+
1356
+ self._xlabel = _('Water Depth [m]')
1357
+
1358
+ def plot_distributed_areas(self, bins = [0, 0.3, 1.3, -1],
1359
+ operator = 'Median',
1360
+ show = True,
1361
+ engine = 'seaborn',
1362
+ merge_zones = False):
199
1363
 
1364
+ return super()._plot_distributed_areas_seaborn(bins, operator, show=show, merge_zones=merge_zones)
200
1365
  class Slope_analysis:
201
1366
  """ Class for slope analysis of in an array based on a trace vector.
202
1367