pyelq 1.1.3__py3-none-any.whl → 1.2.0__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 (36) hide show
  1. pyelq/__init__.py +1 -0
  2. pyelq/component/__init__.py +1 -0
  3. pyelq/component/background.py +19 -13
  4. pyelq/component/component.py +2 -1
  5. pyelq/component/error_model.py +2 -1
  6. pyelq/component/offset.py +2 -1
  7. pyelq/component/source_model.py +78 -29
  8. pyelq/coordinate_system.py +1 -0
  9. pyelq/data_access/__init__.py +1 -0
  10. pyelq/data_access/data_access.py +1 -1
  11. pyelq/dispersion_model/__init__.py +4 -3
  12. pyelq/dispersion_model/dispersion_model.py +202 -0
  13. pyelq/dispersion_model/finite_volume.py +1084 -0
  14. pyelq/dispersion_model/gaussian_plume.py +8 -189
  15. pyelq/dispersion_model/site_layout.py +97 -0
  16. pyelq/dlm.py +11 -15
  17. pyelq/gas_species.py +1 -0
  18. pyelq/meteorology/__init__.py +6 -0
  19. pyelq/{meteorology.py → meteorology/meteorology.py} +388 -387
  20. pyelq/meteorology/meteorology_windfield.py +180 -0
  21. pyelq/model.py +2 -1
  22. pyelq/plotting/__init__.py +1 -0
  23. pyelq/plotting/plot.py +16 -25
  24. pyelq/preprocessing.py +98 -38
  25. pyelq/sensor/__init__.py +1 -0
  26. pyelq/sensor/sensor.py +70 -5
  27. pyelq/source_map.py +1 -0
  28. pyelq/support_functions/__init__.py +1 -0
  29. pyelq/support_functions/post_processing.py +1 -0
  30. pyelq/support_functions/spatio_temporal_interpolation.py +1 -0
  31. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info}/METADATA +45 -44
  32. pyelq-1.2.0.dist-info/RECORD +37 -0
  33. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info}/WHEEL +1 -1
  34. pyelq-1.1.3.dist-info/RECORD +0 -32
  35. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSE.md +0 -0
  36. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSES/Apache-2.0.txt +0 -0
@@ -1,387 +1,388 @@
1
- # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
- #
3
- # SPDX-License-Identifier: Apache-2.0
4
-
5
- # -*- coding: utf-8 -*-
6
- """Meteorology module.
7
-
8
- The superclass for the meteorology classes
9
-
10
- """
11
- import warnings
12
- from dataclasses import dataclass, field
13
-
14
- import numpy as np
15
- import pandas as pd
16
- import plotly.express as px
17
- import plotly.graph_objects as go
18
- from pandas.arrays import DatetimeArray
19
-
20
- from pyelq.coordinate_system import Coordinate
21
- from pyelq.sensor.sensor import SensorGroup
22
-
23
-
24
- @dataclass
25
- class Meteorology:
26
- """Defines the properties and methods of the meteorology class.
27
-
28
- Sizes of all attributes should match.
29
-
30
- Attributes:
31
- wind_speed (np.ndarray, optional): Wind speed [m/s]
32
- wind_direction (np.ndarray, optional): Meteorological wind direction (from) [deg], see
33
- https://confluence.ecmwf.int/pages/viewpage.action?pageId=133262398
34
- u_component (np.ndarray, optional): u component of wind [m/s] in the easterly direction
35
- v_component (np.ndarray, optional): v component of wind [m/s] in the northerly direction
36
- w_component (np.ndarray, optional): w component of wind [m/s] in the vertical direction
37
- wind_turbulence_horizontal (np.ndarray, optional): Parameter of the wind stability in
38
- horizontal direction [deg]
39
- wind_turbulence_vertical (np.ndarray, optional): Parameter of the wind stability in
40
- vertical direction [deg]
41
- pressure (np.ndarray, optional): Pressure [kPa]
42
- temperature (np.ndarray, optional): Temperature [K]
43
- atmospheric_boundary_layer (np.ndarray, optional): Atmospheric boundary layer [m]
44
- surface_albedo (np.ndarray, optional): Surface reflectance parameter [unitless]
45
- time (pandas.arrays.DatetimeArray, optional): Array containing time values associated with the
46
- meteorological observation
47
- location: (Coordinate, optional): Coordinate object specifying the meteorological observation locations
48
- label (str, optional): String label for object
49
-
50
- """
51
-
52
- wind_speed: np.ndarray = field(init=False, default=None)
53
- wind_direction: np.ndarray = field(init=False, default=None)
54
- u_component: np.ndarray = field(init=False, default=None)
55
- v_component: np.ndarray = field(init=False, default=None)
56
- w_component: np.ndarray = field(init=False, default=None)
57
- wind_turbulence_horizontal: np.ndarray = field(init=False, default=None)
58
- wind_turbulence_vertical: np.ndarray = field(init=False, default=None)
59
- pressure: np.ndarray = field(init=False, default=None)
60
- temperature: np.ndarray = field(init=False, default=None)
61
- atmospheric_boundary_layer: np.ndarray = field(init=False, default=None)
62
- surface_albedo: np.ndarray = field(init=False, default=None)
63
- time: DatetimeArray = field(init=False, default=None)
64
- location: Coordinate = field(init=False, default=None)
65
- label: str = field(init=False)
66
-
67
- @property
68
- def nof_observations(self) -> int:
69
- """Number of observations."""
70
- if self.location is None:
71
- return 0
72
- return self.location.nof_observations
73
-
74
- def calculate_wind_speed_from_uv(self) -> None:
75
- """Calculate wind speed.
76
-
77
- Calculate the wind speed from u and v components. Result gets stored in the wind_speed attribute
78
-
79
- """
80
- self.wind_speed = np.sqrt(self.u_component**2 + self.v_component**2)
81
-
82
- def calculate_wind_direction_from_uv(self) -> None:
83
- """Calculate wind direction: meteorological convention 0 is wind from the North.
84
-
85
- Calculate the wind direction from u and v components. Result gets stored in the wind_direction attribute
86
- See: https://confluence.ecmwf.int/pages/viewpage.action?pageId=133262398
87
-
88
- """
89
- self.wind_direction = (270 - 180 / np.pi * np.arctan2(self.v_component, self.u_component)) % 360
90
-
91
- def calculate_uv_from_wind_speed_direction(self) -> None:
92
- """Calculate u and v components from wind speed and direction.
93
-
94
- Results get stored in the u_component and v_component attributes.
95
- See: https://confluence.ecmwf.int/pages/viewpage.action?pageId=133262398
96
-
97
- """
98
- self.u_component = -1 * self.wind_speed * np.sin(self.wind_direction * (np.pi / 180))
99
- self.v_component = -1 * self.wind_speed * np.cos(self.wind_direction * (np.pi / 180))
100
-
101
- def calculate_wind_turbulence_horizontal(self, window: str) -> None:
102
- """Calculate the horizontal wind turbulence values from the wind direction attribute.
103
-
104
- Wind turbulence values are calculated as the circular standard deviation of wind direction
105
- (https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.circstd.html).
106
- The implementation here is equivalent to using the circstd function from scipy.stats as an apply
107
- function on a rolling window. However, using the rolling mean on sin and cos speeds up
108
- the calculation by a factor of 100.
109
-
110
- Outputted values are calculated at the center of the window and at least 3 observations are required in a
111
- window for the calculation. If the window contains less values the result will be np.nan.
112
- The result of the calculation will be stored as the wind_turbulence_horizontal attribute.
113
-
114
- Args:
115
- window (str): The size of the window in which values are aggregated specified as an offset alias:
116
- https://pandas.pydata.org/docs/user_guide/timeseries.html#timeseries-offset-aliases
117
-
118
- """
119
- data_series = pd.Series(data=self.wind_direction, index=self.time)
120
- sin_rolling = (np.sin(data_series * np.pi / 180)).rolling(window=window, center=True, min_periods=3).mean()
121
- cos_rolling = (np.cos(data_series * np.pi / 180)).rolling(window=window, center=True, min_periods=3).mean()
122
- aggregated_data = np.sqrt(-2 * np.log((sin_rolling**2 + cos_rolling**2) ** 0.5)) * 180 / np.pi
123
- self.wind_turbulence_horizontal = aggregated_data.values
124
-
125
- def plot_polar_hist(self, nof_sectors: int = 16, nof_divisions: int = 5, template: object = None) -> go.Figure():
126
- """Plots a histogram of wind speed and wind direction in polar Coordinates.
127
-
128
- Args:
129
- nof_sectors (int, optional): The number of wind direction sectors into which the data is binned.
130
- nof_divisions (int, optional): The number of wind speed divisions into which the data is binned.
131
- template (object): A layout template which can be applied to the plot. Defaults to None.
132
-
133
- Returns:
134
- fig (go.Figure): A plotly go figure containing the trace of the rose plot.
135
-
136
- """
137
- sector_half_width = 0.5 * (360 / nof_sectors)
138
- wind_direction_bin_edges = np.linspace(-sector_half_width, 360 - sector_half_width, nof_sectors + 1)
139
- wind_speed_bin_edges = np.linspace(np.min(self.wind_speed), np.max(self.wind_speed), nof_divisions)
140
-
141
- dataframe = pd.DataFrame()
142
- dataframe["wind_direction"] = [x - 360 if x > (360 - sector_half_width) else x for x in self.wind_direction]
143
- dataframe["wind_speed"] = self.wind_speed
144
-
145
- dataframe["sector"] = pd.cut(dataframe["wind_direction"], wind_direction_bin_edges, include_lowest=True)
146
- if np.allclose(wind_speed_bin_edges[0], wind_speed_bin_edges):
147
- dataframe["speed"] = wind_speed_bin_edges[0]
148
- else:
149
- dataframe["speed"] = pd.cut(dataframe["wind_speed"], wind_speed_bin_edges, include_lowest=True)
150
-
151
- dataframe = dataframe.groupby(["sector", "speed"], observed=False).count()
152
- dataframe = dataframe.rename(columns={"wind_speed": "count"}).drop(columns=["wind_direction"])
153
- dataframe["%"] = dataframe["count"] / dataframe["count"].sum()
154
-
155
- dataframe = dataframe.reset_index()
156
- dataframe["theta"] = dataframe.apply(lambda x: x["sector"].mid, axis=1)
157
-
158
- fig = px.bar_polar(
159
- dataframe,
160
- r="%",
161
- theta="theta",
162
- color="speed",
163
- direction="clockwise",
164
- start_angle=90,
165
- color_discrete_sequence=px.colors.sequential.Sunset_r,
166
- )
167
-
168
- ticktext = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
169
- polar_dict = {
170
- "radialaxis": {"tickangle": 90},
171
- "radialaxis_angle": 90,
172
- "angularaxis": {
173
- "tickmode": "array",
174
- "ticktext": ticktext,
175
- "tickvals": list(np.linspace(0, 360 - (360 / 8), 8)),
176
- },
177
- }
178
- fig.add_annotation(
179
- x=1,
180
- y=1,
181
- yref="paper",
182
- xref="paper",
183
- xanchor="right",
184
- yanchor="top",
185
- align="left",
186
- font={"size": 18, "color": "#000000"},
187
- showarrow=False,
188
- borderwidth=2,
189
- borderpad=10,
190
- bgcolor="#ffffff",
191
- bordercolor="#000000",
192
- opacity=0.8,
193
- text="<b>Radial Axis:</b> Proportion<br>of wind measurements<br>in a given direction.",
194
- )
195
-
196
- fig.update_layout(polar=polar_dict)
197
- fig.update_layout(template=template)
198
- fig.update_layout(title="Distribution of Wind Speeds and Directions")
199
-
200
- return fig
201
-
202
- def plot_polar_scatter(self, fig: go.Figure, sensor_object: SensorGroup, template: object = None) -> go.Figure():
203
- """Plots a scatter plot of concentration with respect to wind direction in polar Coordinates.
204
-
205
- This function implements the polar scatter functionality for a (single) Meteorology object. Assuming the all
206
- Sensors in the SensorGroup are consistent with the Meteorology object.
207
-
208
- Note we do plot the sensors which do not contain any values when present in the SensorGroup to keep consistency
209
- in plot colors.
210
-
211
- Args:
212
- fig (go.Figure): A plotly figure onto which traces can be drawn.
213
- sensor_object (SensorGroup): SensorGroup object which contains the concentration information
214
- template (object): A layout template which can be applied to the plot. Defaults to None.
215
-
216
- Returns:
217
- fig (go.Figure): A plotly go figure containing the trace of the rose plot.
218
-
219
- """
220
- max_concentration = 0
221
-
222
- for i, (sensor_key, sensor) in enumerate(sensor_object.items()):
223
- if sensor.concentration.shape != self.wind_direction.shape:
224
- warnings.warn(
225
- f"Concentration values for sensor {sensor_key} are of shape "
226
- + f"{sensor.concentration.shape}, but self.wind_direction has shape "
227
- + f"{self.wind_direction.shape}. It will not be plotted on the polar scatter plot."
228
- )
229
- else:
230
- theta = self.wind_direction
231
- color_idx = i % len(sensor_object.color_map)
232
-
233
- fig.add_trace(
234
- go.Scatterpolar(
235
- r=sensor.concentration,
236
- theta=theta,
237
- mode="markers",
238
- name=sensor_key,
239
- marker={"color": sensor_object.color_map[color_idx]},
240
- )
241
- )
242
- if sensor.concentration.size > 0:
243
- max_concentration = np.maximum(np.nanmax(sensor.concentration), max_concentration)
244
-
245
- fig = set_plot_polar_scatter_layout(max_concentration=max_concentration, fig=fig, template=template)
246
-
247
- return fig
248
-
249
-
250
- @dataclass
251
- class MeteorologyGroup(dict):
252
- """A dictionary containing multiple Meteorology objects.
253
-
254
- This class is used when we want to define/store a collection of meteorology objects consistent with an associated
255
- SensorGroup which can then be used in further processing, e.g. Gaussian plume coupling computation.
256
-
257
- """
258
-
259
- @property
260
- def nof_objects(self) -> int:
261
- """Int: Number of meteorology objects contained in the MeteorologyGroup."""
262
- return len(self)
263
-
264
- def add_object(self, met_object: Meteorology):
265
- """Add an object to the MeteorologyGroup."""
266
- self[met_object.label] = met_object
267
-
268
- def calculate_uv_from_wind_speed_direction(self):
269
- """Calculate the u and v components for each member of the group."""
270
- for met in self.values():
271
- met.calculate_uv_from_wind_speed_direction()
272
-
273
- def calculate_wind_direction_from_uv(self):
274
- """Calculate wind direction from the u and v components for each member of the group."""
275
- for met in self.values():
276
- met.calculate_wind_direction_from_uv()
277
-
278
- def calculate_wind_speed_from_uv(self):
279
- """Calculate wind speed from the u and v components for each member of the group."""
280
- for met in self.values():
281
- met.calculate_wind_speed_from_uv()
282
-
283
- def plot_polar_scatter(self, fig: go.Figure, sensor_object: SensorGroup, template: object = None) -> go.Figure():
284
- """Plots a scatter plot of concentration with respect to wind direction in polar coordinates.
285
-
286
- This function implements the polar scatter functionality for a MeteorologyGroup object. It assumes each object
287
- in the SensorGroup has an associated Meteorology object in the MeteorologyGroup.
288
-
289
- Note we do plot the sensors which do not contain any values when present in the SensorGroup to keep consistency
290
- in plot colors.
291
-
292
- Args:
293
- fig (go.Figure): A plotly figure onto which traces can be drawn.
294
- sensor_object (SensorGroup): SensorGroup object which contains the concentration information
295
- template (object): A layout template which can be applied to the plot. Defaults to None.
296
-
297
- Returns:
298
- fig (go.Figure): A plotly go figure containing the trace of the rose plot.
299
-
300
- Raises
301
- ValueError: When there is a sensor key which is not present in the MeteorologyGroup.
302
-
303
- """
304
- max_concentration = 0
305
-
306
- for i, (sensor_key, sensor) in enumerate(sensor_object.items()):
307
- if sensor_key not in self.keys():
308
- raise ValueError(f"Key {sensor_key} not found in MeteorologyGroup.")
309
- temp_met_object = self[sensor_key]
310
- if sensor.concentration.shape != temp_met_object.wind_direction.shape:
311
- warnings.warn(
312
- f"Concentration values for sensor {sensor_key} are of shape "
313
- + f"{sensor.concentration.shape}, but wind_direction values for meteorology object {sensor_key} "
314
- f"has shape {temp_met_object.wind_direction.shape}. It will not be plotted on the polar scatter "
315
- f"plot."
316
- )
317
- else:
318
- theta = temp_met_object.wind_direction
319
- color_idx = i % len(sensor_object.color_map)
320
-
321
- fig.add_trace(
322
- go.Scatterpolar(
323
- r=sensor.concentration,
324
- theta=theta,
325
- mode="markers",
326
- name=sensor_key,
327
- marker={"color": sensor_object.color_map[color_idx]},
328
- )
329
- )
330
-
331
- if sensor.concentration.size > 0:
332
- max_concentration = np.maximum(np.nanmax(sensor.concentration), max_concentration)
333
-
334
- fig = set_plot_polar_scatter_layout(max_concentration=max_concentration, fig=fig, template=template)
335
-
336
- return fig
337
-
338
-
339
- def set_plot_polar_scatter_layout(max_concentration: float, fig: go.Figure(), template: object) -> go.Figure:
340
- """Helper function to set the layout of the polar scatter plot.
341
-
342
- Helps avoid code duplication.
343
-
344
- Args:
345
- max_concentration (float): The maximum concentration value used to update radial axis range.
346
- fig (go.Figure): A plotly figure onto which traces can be drawn.
347
- template (object): A layout template which can be applied to the plot.
348
-
349
- Returns:
350
- fig (go.Figure): A plotly go figure containing the trace of the rose plot.
351
-
352
- """
353
- ticktext = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
354
- polar_dict = {
355
- "radialaxis": {"tickangle": 0, "range": [0.0, 1.01 * max_concentration]},
356
- "radialaxis_angle": 0,
357
- "angularaxis": {
358
- "tickmode": "array",
359
- "ticktext": ticktext,
360
- "direction": "clockwise",
361
- "rotation": 90,
362
- "tickvals": list(np.linspace(0, 360 - (360 / 8), 8)),
363
- },
364
- }
365
-
366
- fig.add_annotation(
367
- x=1,
368
- y=1,
369
- yref="paper",
370
- xref="paper",
371
- xanchor="right",
372
- yanchor="top",
373
- align="left",
374
- font={"size": 18, "color": "#000000"},
375
- showarrow=False,
376
- borderwidth=2,
377
- borderpad=10,
378
- bgcolor="#ffffff",
379
- bordercolor="#000000",
380
- opacity=0.8,
381
- text="<b>Radial Axis:</b> Wind<br>speed in m/s.",
382
- )
383
-
384
- fig.update_layout(polar=polar_dict)
385
- fig.update_layout(template=template)
386
- fig.update_layout(title="Measured Concentration against Wind Direction.")
387
- return fig
1
+ # SPDX-FileCopyrightText: 2026 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # -*- coding: utf-8 -*-
6
+ """Meteorology module.
7
+
8
+ The superclass for the meteorology classes
9
+
10
+ """
11
+
12
+ import warnings
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import plotly.express as px
18
+ import plotly.graph_objects as go
19
+ from pandas.arrays import DatetimeArray
20
+
21
+ from pyelq.coordinate_system import Coordinate
22
+ from pyelq.sensor.sensor import SensorGroup
23
+
24
+
25
+ @dataclass
26
+ class Meteorology:
27
+ """Defines the properties and methods of the meteorology class.
28
+
29
+ Sizes of all attributes should match.
30
+
31
+ Attributes:
32
+ wind_speed (np.ndarray, optional): Wind speed [m/s]
33
+ wind_direction (np.ndarray, optional): Meteorological wind direction (from) [deg], see
34
+ https://confluence.ecmwf.int/pages/viewpage.action?pageId=133262398
35
+ u_component (np.ndarray, optional): u component of wind [m/s] in the easterly direction
36
+ v_component (np.ndarray, optional): v component of wind [m/s] in the northerly direction
37
+ w_component (np.ndarray, optional): w component of wind [m/s] in the vertical direction
38
+ wind_turbulence_horizontal (np.ndarray, optional): Parameter of the wind stability in
39
+ horizontal direction [deg]
40
+ wind_turbulence_vertical (np.ndarray, optional): Parameter of the wind stability in
41
+ vertical direction [deg]
42
+ pressure (np.ndarray, optional): Pressure [kPa]
43
+ temperature (np.ndarray, optional): Temperature [K]
44
+ atmospheric_boundary_layer (np.ndarray, optional): Atmospheric boundary layer [m]
45
+ surface_albedo (np.ndarray, optional): Surface reflectance parameter [unitless]
46
+ time (pandas.arrays.DatetimeArray, optional): Array containing time values associated with the
47
+ meteorological observation
48
+ location: (Coordinate, optional): Coordinate object specifying the meteorological observation locations
49
+ label (str, optional): String label for object
50
+
51
+ """
52
+
53
+ wind_speed: np.ndarray = field(init=False, default=None)
54
+ wind_direction: np.ndarray = field(init=False, default=None)
55
+ u_component: np.ndarray = field(init=False, default=None)
56
+ v_component: np.ndarray = field(init=False, default=None)
57
+ w_component: np.ndarray = field(init=False, default=None)
58
+ wind_turbulence_horizontal: np.ndarray = field(init=False, default=None)
59
+ wind_turbulence_vertical: np.ndarray = field(init=False, default=None)
60
+ pressure: np.ndarray = field(init=False, default=None)
61
+ temperature: np.ndarray = field(init=False, default=None)
62
+ atmospheric_boundary_layer: np.ndarray = field(init=False, default=None)
63
+ surface_albedo: np.ndarray = field(init=False, default=None)
64
+ time: DatetimeArray = field(init=False, default=None)
65
+ location: Coordinate = field(init=False, default=None)
66
+ label: str = field(init=False)
67
+
68
+ @property
69
+ def nof_observations(self) -> int:
70
+ """Number of observations."""
71
+ if self.time is None:
72
+ return 0
73
+ return self.time.size
74
+
75
+ def calculate_wind_speed_from_uv(self) -> None:
76
+ """Calculate wind speed.
77
+
78
+ Calculate the wind speed from u and v components. Result gets stored in the wind_speed attribute
79
+
80
+ """
81
+ self.wind_speed = np.sqrt(self.u_component**2 + self.v_component**2)
82
+
83
+ def calculate_wind_direction_from_uv(self) -> None:
84
+ """Calculate wind direction: meteorological convention 0 is wind from the North.
85
+
86
+ Calculate the wind direction from u and v components. Result gets stored in the wind_direction attribute
87
+ See: https://confluence.ecmwf.int/pages/viewpage.action?pageId=133262398
88
+
89
+ """
90
+ self.wind_direction = (270 - 180 / np.pi * np.arctan2(self.v_component, self.u_component)) % 360
91
+
92
+ def calculate_uv_from_wind_speed_direction(self) -> None:
93
+ """Calculate u and v components from wind speed and direction.
94
+
95
+ Results get stored in the u_component and v_component attributes.
96
+ See: https://confluence.ecmwf.int/pages/viewpage.action?pageId=133262398
97
+
98
+ """
99
+ self.u_component = -1 * self.wind_speed * np.sin(self.wind_direction * (np.pi / 180))
100
+ self.v_component = -1 * self.wind_speed * np.cos(self.wind_direction * (np.pi / 180))
101
+
102
+ def calculate_wind_turbulence_horizontal(self, window: str) -> None:
103
+ """Calculate the horizontal wind turbulence values from the wind direction attribute.
104
+
105
+ Wind turbulence values are calculated as the circular standard deviation of wind direction
106
+ (https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.circstd.html).
107
+ The implementation here is equivalent to using the circstd function from scipy.stats as an apply
108
+ function on a rolling window. However, using the rolling mean on sin and cos speeds up
109
+ the calculation by a factor of 100.
110
+
111
+ Outputted values are calculated at the center of the window and at least 3 observations are required in a
112
+ window for the calculation. If the window contains less values the result will be np.nan.
113
+ The result of the calculation will be stored as the wind_turbulence_horizontal attribute.
114
+
115
+ Args:
116
+ window (str): The size of the window in which values are aggregated specified as an offset alias:
117
+ https://pandas.pydata.org/docs/user_guide/timeseries.html#timeseries-offset-aliases
118
+
119
+ """
120
+ data_series = pd.Series(data=self.wind_direction, index=self.time)
121
+ sin_rolling = (np.sin(data_series * np.pi / 180)).rolling(window=window, center=True, min_periods=3).mean()
122
+ cos_rolling = (np.cos(data_series * np.pi / 180)).rolling(window=window, center=True, min_periods=3).mean()
123
+ aggregated_data = np.sqrt(-2 * np.log((sin_rolling**2 + cos_rolling**2) ** 0.5)) * 180 / np.pi
124
+ self.wind_turbulence_horizontal = aggregated_data.values
125
+
126
+ def plot_polar_hist(self, nof_sectors: int = 16, nof_divisions: int = 5, template: object = None) -> go.Figure():
127
+ """Plots a histogram of wind speed and wind direction in polar Coordinates.
128
+
129
+ Args:
130
+ nof_sectors (int, optional): The number of wind direction sectors into which the data is binned.
131
+ nof_divisions (int, optional): The number of wind speed divisions into which the data is binned.
132
+ template (object): A layout template which can be applied to the plot. Defaults to None.
133
+
134
+ Returns:
135
+ fig (go.Figure): A plotly go figure containing the trace of the rose plot.
136
+
137
+ """
138
+ sector_half_width = 0.5 * (360 / nof_sectors)
139
+ wind_direction_bin_edges = np.linspace(-sector_half_width, 360 - sector_half_width, nof_sectors + 1)
140
+ wind_speed_bin_edges = np.linspace(np.min(self.wind_speed), np.max(self.wind_speed), nof_divisions)
141
+
142
+ dataframe = pd.DataFrame()
143
+ dataframe["wind_direction"] = [x - 360 if x > (360 - sector_half_width) else x for x in self.wind_direction]
144
+ dataframe["wind_speed"] = self.wind_speed
145
+
146
+ dataframe["sector"] = pd.cut(dataframe["wind_direction"], wind_direction_bin_edges, include_lowest=True)
147
+ if np.allclose(wind_speed_bin_edges[0], wind_speed_bin_edges):
148
+ dataframe["speed"] = wind_speed_bin_edges[0]
149
+ else:
150
+ dataframe["speed"] = pd.cut(dataframe["wind_speed"], wind_speed_bin_edges, include_lowest=True)
151
+
152
+ dataframe = dataframe.groupby(["sector", "speed"], observed=False).count()
153
+ dataframe = dataframe.rename(columns={"wind_speed": "count"}).drop(columns=["wind_direction"])
154
+ dataframe["%"] = dataframe["count"] / dataframe["count"].sum()
155
+
156
+ dataframe = dataframe.reset_index()
157
+ dataframe["theta"] = dataframe.apply(lambda x: x["sector"].mid, axis=1)
158
+
159
+ fig = px.bar_polar(
160
+ dataframe,
161
+ r="%",
162
+ theta="theta",
163
+ color="speed",
164
+ direction="clockwise",
165
+ start_angle=90,
166
+ color_discrete_sequence=px.colors.sequential.Sunset_r,
167
+ )
168
+
169
+ ticktext = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
170
+ polar_dict = {
171
+ "radialaxis": {"tickangle": 90},
172
+ "radialaxis_angle": 90,
173
+ "angularaxis": {
174
+ "tickmode": "array",
175
+ "ticktext": ticktext,
176
+ "tickvals": list(np.linspace(0, 360 - (360 / 8), 8)),
177
+ },
178
+ }
179
+ fig.add_annotation(
180
+ x=1,
181
+ y=1,
182
+ yref="paper",
183
+ xref="paper",
184
+ xanchor="right",
185
+ yanchor="top",
186
+ align="left",
187
+ font={"size": 18, "color": "#000000"},
188
+ showarrow=False,
189
+ borderwidth=2,
190
+ borderpad=10,
191
+ bgcolor="#ffffff",
192
+ bordercolor="#000000",
193
+ opacity=0.8,
194
+ text="<b>Radial Axis:</b> Proportion<br>of wind measurements<br>in a given direction.",
195
+ )
196
+
197
+ fig.update_layout(polar=polar_dict)
198
+ fig.update_layout(template=template)
199
+ fig.update_layout(title="Distribution of Wind Speeds and Directions")
200
+
201
+ return fig
202
+
203
+ def plot_polar_scatter(self, fig: go.Figure, sensor_object: SensorGroup, template: object = None) -> go.Figure():
204
+ """Plots a scatter plot of concentration with respect to wind direction in polar Coordinates.
205
+
206
+ This function implements the polar scatter functionality for a (single) Meteorology object. Assuming the all
207
+ Sensors in the SensorGroup are consistent with the Meteorology object.
208
+
209
+ Note we do plot the sensors which do not contain any values when present in the SensorGroup to keep consistency
210
+ in plot colors.
211
+
212
+ Args:
213
+ fig (go.Figure): A plotly figure onto which traces can be drawn.
214
+ sensor_object (SensorGroup): SensorGroup object which contains the concentration information
215
+ template (object): A layout template which can be applied to the plot. Defaults to None.
216
+
217
+ Returns:
218
+ fig (go.Figure): A plotly go figure containing the trace of the rose plot.
219
+
220
+ """
221
+ max_concentration = 0
222
+
223
+ for i, (sensor_key, sensor) in enumerate(sensor_object.items()):
224
+ if sensor.concentration.shape != self.wind_direction.shape:
225
+ warnings.warn(
226
+ f"Concentration values for sensor {sensor_key} are of shape "
227
+ + f"{sensor.concentration.shape}, but self.wind_direction has shape "
228
+ + f"{self.wind_direction.shape}. It will not be plotted on the polar scatter plot."
229
+ )
230
+ else:
231
+ theta = self.wind_direction
232
+ color_idx = i % len(sensor_object.color_map)
233
+
234
+ fig.add_trace(
235
+ go.Scatterpolar(
236
+ r=sensor.concentration,
237
+ theta=theta,
238
+ mode="markers",
239
+ name=sensor_key,
240
+ marker={"color": sensor_object.color_map[color_idx]},
241
+ )
242
+ )
243
+ if sensor.concentration.size > 0:
244
+ max_concentration = np.maximum(np.nanmax(sensor.concentration), max_concentration)
245
+
246
+ fig = set_plot_polar_scatter_layout(max_concentration=max_concentration, fig=fig, template=template)
247
+
248
+ return fig
249
+
250
+
251
+ @dataclass
252
+ class MeteorologyGroup(dict):
253
+ """A dictionary containing multiple Meteorology objects.
254
+
255
+ This class is used when we want to define/store a collection of meteorology objects consistent with an associated
256
+ SensorGroup which can then be used in further processing, e.g. Gaussian plume coupling computation.
257
+
258
+ """
259
+
260
+ @property
261
+ def nof_objects(self) -> int:
262
+ """Int: Number of meteorology objects contained in the MeteorologyGroup."""
263
+ return len(self)
264
+
265
+ def add_object(self, met_object: Meteorology):
266
+ """Add an object to the MeteorologyGroup."""
267
+ self[met_object.label] = met_object
268
+
269
+ def calculate_uv_from_wind_speed_direction(self):
270
+ """Calculate the u and v components for each member of the group."""
271
+ for met in self.values():
272
+ met.calculate_uv_from_wind_speed_direction()
273
+
274
+ def calculate_wind_direction_from_uv(self):
275
+ """Calculate wind direction from the u and v components for each member of the group."""
276
+ for met in self.values():
277
+ met.calculate_wind_direction_from_uv()
278
+
279
+ def calculate_wind_speed_from_uv(self):
280
+ """Calculate wind speed from the u and v components for each member of the group."""
281
+ for met in self.values():
282
+ met.calculate_wind_speed_from_uv()
283
+
284
+ def plot_polar_scatter(self, fig: go.Figure, sensor_object: SensorGroup, template: object = None) -> go.Figure():
285
+ """Plots a scatter plot of concentration with respect to wind direction in polar coordinates.
286
+
287
+ This function implements the polar scatter functionality for a MeteorologyGroup object. It assumes each object
288
+ in the SensorGroup has an associated Meteorology object in the MeteorologyGroup.
289
+
290
+ Note we do plot the sensors which do not contain any values when present in the SensorGroup to keep consistency
291
+ in plot colors.
292
+
293
+ Args:
294
+ fig (go.Figure): A plotly figure onto which traces can be drawn.
295
+ sensor_object (SensorGroup): SensorGroup object which contains the concentration information
296
+ template (object): A layout template which can be applied to the plot. Defaults to None.
297
+
298
+ Returns:
299
+ fig (go.Figure): A plotly go figure containing the trace of the rose plot.
300
+
301
+ Raises
302
+ ValueError: When there is a sensor key which is not present in the MeteorologyGroup.
303
+
304
+ """
305
+ max_concentration = 0
306
+
307
+ for i, (sensor_key, sensor) in enumerate(sensor_object.items()):
308
+ if sensor_key not in self.keys():
309
+ raise ValueError(f"Key {sensor_key} not found in MeteorologyGroup.")
310
+ temp_met_object = self[sensor_key]
311
+ if sensor.concentration.shape != temp_met_object.wind_direction.shape:
312
+ warnings.warn(
313
+ f"Concentration values for sensor {sensor_key} are of shape "
314
+ + f"{sensor.concentration.shape}, but wind_direction values for meteorology object {sensor_key} "
315
+ f"has shape {temp_met_object.wind_direction.shape}. It will not be plotted on the polar scatter "
316
+ f"plot."
317
+ )
318
+ else:
319
+ theta = temp_met_object.wind_direction
320
+ color_idx = i % len(sensor_object.color_map)
321
+
322
+ fig.add_trace(
323
+ go.Scatterpolar(
324
+ r=sensor.concentration,
325
+ theta=theta,
326
+ mode="markers",
327
+ name=sensor_key,
328
+ marker={"color": sensor_object.color_map[color_idx]},
329
+ )
330
+ )
331
+
332
+ if sensor.concentration.size > 0:
333
+ max_concentration = np.maximum(np.nanmax(sensor.concentration), max_concentration)
334
+
335
+ fig = set_plot_polar_scatter_layout(max_concentration=max_concentration, fig=fig, template=template)
336
+
337
+ return fig
338
+
339
+
340
+ def set_plot_polar_scatter_layout(max_concentration: float, fig: go.Figure(), template: object) -> go.Figure:
341
+ """Helper function to set the layout of the polar scatter plot.
342
+
343
+ Helps avoid code duplication.
344
+
345
+ Args:
346
+ max_concentration (float): The maximum concentration value used to update radial axis range.
347
+ fig (go.Figure): A plotly figure onto which traces can be drawn.
348
+ template (object): A layout template which can be applied to the plot.
349
+
350
+ Returns:
351
+ fig (go.Figure): A plotly go figure containing the trace of the rose plot.
352
+
353
+ """
354
+ ticktext = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
355
+ polar_dict = {
356
+ "radialaxis": {"tickangle": 0, "range": [0.0, 1.01 * max_concentration]},
357
+ "radialaxis_angle": 0,
358
+ "angularaxis": {
359
+ "tickmode": "array",
360
+ "ticktext": ticktext,
361
+ "direction": "clockwise",
362
+ "rotation": 90,
363
+ "tickvals": list(np.linspace(0, 360 - (360 / 8), 8)),
364
+ },
365
+ }
366
+
367
+ fig.add_annotation(
368
+ x=1,
369
+ y=1,
370
+ yref="paper",
371
+ xref="paper",
372
+ xanchor="right",
373
+ yanchor="top",
374
+ align="left",
375
+ font={"size": 18, "color": "#000000"},
376
+ showarrow=False,
377
+ borderwidth=2,
378
+ borderpad=10,
379
+ bgcolor="#ffffff",
380
+ bordercolor="#000000",
381
+ opacity=0.8,
382
+ text="<b>Radial Axis:</b> Wind<br>speed in m/s.",
383
+ )
384
+
385
+ fig.update_layout(polar=polar_dict)
386
+ fig.update_layout(template=template)
387
+ fig.update_layout(title="Measured Concentration against Wind Direction.")
388
+ return fig