pyelq 1.1.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.
pyelq/gas_species.py ADDED
@@ -0,0 +1,232 @@
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
+ """Gas Species module.
7
+
8
+ The superclass for the Gas species classes. It contains a few gas species with its properties and functionality to
9
+ calculate the density of the gas and do emission rate conversions from m^3/s to kg/hr and back
10
+
11
+ """
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass, field
14
+ from typing import Union
15
+
16
+ import numpy as np
17
+
18
+
19
+ @dataclass
20
+ class GasSpecies(ABC):
21
+ """Defines the properties of a particular gas species.
22
+
23
+ Attributes:
24
+ global_background (float, optional): Global background concentration [ppm]
25
+ half_life (float, optional): Half life of gas [hr]
26
+ __molar_gas_constant (float): R, molar gas constant [JK^-1mol^-1]
27
+
28
+ """
29
+
30
+ global_background: float = field(init=False)
31
+ half_life: float = field(init=False)
32
+ __molar_gas_constant: float = 8.31446261815324
33
+
34
+ @property
35
+ @abstractmethod
36
+ def name(self) -> str:
37
+ """Str: Name of gas."""
38
+
39
+ @property
40
+ @abstractmethod
41
+ def molar_mass(self) -> float:
42
+ """Float: Molar Mass [g/mol]."""
43
+
44
+ @property
45
+ @abstractmethod
46
+ def formula(self) -> str:
47
+ """Str: Chemical formula of gas."""
48
+
49
+ def gas_density(
50
+ self, temperature: Union[np.ndarray, float] = 273.15, pressure: Union[np.ndarray, float] = 101.325
51
+ ) -> np.ndarray:
52
+ """Calculating the density of the gas.
53
+
54
+ Calculating the density of the gas given temperature and pressure if temperature and pressure are not provided
55
+ we use Standard Temperature and Pressure (STP).
56
+
57
+ https://en.wikipedia.org/wiki/Ideal_gas_law
58
+
59
+ Args:
60
+ temperature (Union[np.ndarray, float], optional): Array of temperatures [Kelvin],
61
+ defaults to 273.15 [K]
62
+ pressure (Union[np.ndarray, float], optional): Array of pressures [kPa],
63
+ defaults to 101.325 [kPa]
64
+
65
+ Returns:
66
+ density (np.ndarray): Array of gas density values [kg/m^3]
67
+
68
+ """
69
+ specific_gas_constant = self.__molar_gas_constant / self.molar_mass
70
+ density = np.divide(pressure, (temperature * specific_gas_constant))
71
+ return density
72
+
73
+ def convert_emission_m3s_to_kghr(
74
+ self,
75
+ emission_m3s: Union[np.ndarray, float],
76
+ temperature: Union[np.ndarray, float] = 273.15,
77
+ pressure: Union[np.ndarray, float] = 101.325,
78
+ ) -> np.ndarray:
79
+ """Converting emission rates from m^3/s to kg/hr given temperature and pressure.
80
+
81
+ If temperature and pressure are not provided we use Standard Temperature and Pressure (STP).
82
+
83
+ Args:
84
+ emission_m3s (Union[np.ndarray, float]): Array of emission rates [m^3/s]
85
+ temperature (Union[np.ndarray, float], optional): Array of temperatures [Kelvin],
86
+ defaults to 273.15 [K]
87
+ pressure (Union[np.ndarray, float], optional): Array of pressures [kPa],
88
+ defaults to 101.325 [kPa]
89
+
90
+ Returns:
91
+ emission_kghr (np.ndarray): [p x 1] array of emission rates in [kg/hr]
92
+
93
+ """
94
+ density = self.gas_density(temperature=temperature, pressure=pressure)
95
+ emission_kghr = np.multiply(emission_m3s, density) * 3600
96
+ return emission_kghr
97
+
98
+ def convert_emission_kghr_to_m3s(
99
+ self,
100
+ emission_kghr: Union[np.ndarray, float],
101
+ temperature: Union[np.ndarray, float] = 273.15,
102
+ pressure: Union[np.ndarray, float] = 101.325,
103
+ ) -> np.ndarray:
104
+ """Converting emission rates from kg/hr to m^3/s given temperature and pressure.
105
+
106
+ If temperature and pressure are not provided we use Standard Temperature and Pressure (STP).
107
+
108
+ Args:
109
+ emission_kghr (np.ndarray): Array of emission rates in [kg/hr]
110
+ temperature (Union[np.ndarray, float], optional): Array of temperatures [Kelvin],
111
+ defaults to 273.15 [K]
112
+ pressure (Union[np.ndarray, float], optional): Array of pressures [kPa],
113
+ defaults to 101.325 [kPa]
114
+
115
+ Returns:
116
+ emission_m3s (Union[np.ndarray, float]): Array of emission rates [m^3/s]
117
+
118
+ """
119
+ density = self.gas_density(temperature=temperature, pressure=pressure)
120
+ emission_m3s = np.divide(emission_kghr, density) / 3600
121
+ return emission_m3s
122
+
123
+
124
+ @dataclass
125
+ class CH4(GasSpecies):
126
+ """Defines the properties of CH4."""
127
+
128
+ @property
129
+ def name(self):
130
+ """Str: Name of gas."""
131
+ return "Methane"
132
+
133
+ @property
134
+ def molar_mass(self):
135
+ """Float: Molar Mass [g/mol]."""
136
+ return 16.04246
137
+
138
+ @property
139
+ def formula(self):
140
+ """Str: Chemical formula of gas."""
141
+ return "CH4"
142
+
143
+ global_background = 1.85
144
+
145
+
146
+ @dataclass
147
+ class C2H6(GasSpecies):
148
+ """Defines the properties of C2H6."""
149
+
150
+ @property
151
+ def name(self):
152
+ """Str: Name of gas."""
153
+ return "Ethane"
154
+
155
+ @property
156
+ def molar_mass(self):
157
+ """Float: Molar Mass [g/mol]."""
158
+ return 30.06904
159
+
160
+ @property
161
+ def formula(self):
162
+ """Str: Chemical formula of gas."""
163
+ return "C2H6"
164
+
165
+ global_background = 5e-4
166
+
167
+
168
+ @dataclass
169
+ class C3H8(GasSpecies):
170
+ """Defines the properties of C3H8."""
171
+
172
+ @property
173
+ def name(self):
174
+ """Str: Name of gas."""
175
+ return "Propane"
176
+
177
+ @property
178
+ def molar_mass(self):
179
+ """Float: Molar Mass [g/mol]."""
180
+ return 46.0055
181
+
182
+ @property
183
+ def formula(self):
184
+ """Str: Chemical formula of gas."""
185
+ return "C3H8"
186
+
187
+ global_background = 5e-4
188
+
189
+
190
+ @dataclass
191
+ class CO2(GasSpecies):
192
+ """Defines the properties of CO2."""
193
+
194
+ @property
195
+ def name(self):
196
+ """Str: Name of gas."""
197
+ return "Carbon Dioxide"
198
+
199
+ @property
200
+ def molar_mass(self):
201
+ """Float: Molar Mass [g/mol]."""
202
+ return 44.0095
203
+
204
+ @property
205
+ def formula(self):
206
+ """Str: Chemical formula of gas."""
207
+ return "CO2"
208
+
209
+ global_background = 400
210
+
211
+
212
+ @dataclass
213
+ class NO2(GasSpecies):
214
+ """Defines the properties of NO2."""
215
+
216
+ @property
217
+ def name(self):
218
+ """Str: Name of gas."""
219
+ return "Nitrogen Dioxide"
220
+
221
+ @property
222
+ def molar_mass(self):
223
+ """Float: Molar Mass [g/mol]."""
224
+ return 46.0055
225
+
226
+ @property
227
+ def formula(self):
228
+ """Str: Chemical formula of gas."""
229
+ return "NO2"
230
+
231
+ global_background = 0
232
+ half_life = 12
pyelq/meteorology.py ADDED
@@ -0,0 +1,387 @@
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