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/__init__.py +19 -0
- pyelq/component/__init__.py +6 -0
- pyelq/component/background.py +391 -0
- pyelq/component/component.py +79 -0
- pyelq/component/error_model.py +327 -0
- pyelq/component/offset.py +183 -0
- pyelq/component/source_model.py +875 -0
- pyelq/coordinate_system.py +598 -0
- pyelq/data_access/__init__.py +5 -0
- pyelq/data_access/data_access.py +104 -0
- pyelq/dispersion_model/__init__.py +5 -0
- pyelq/dispersion_model/gaussian_plume.py +625 -0
- pyelq/dlm.py +497 -0
- pyelq/gas_species.py +232 -0
- pyelq/meteorology.py +387 -0
- pyelq/model.py +209 -0
- pyelq/plotting/__init__.py +5 -0
- pyelq/plotting/plot.py +1183 -0
- pyelq/preprocessing.py +262 -0
- pyelq/sensor/__init__.py +5 -0
- pyelq/sensor/beam.py +55 -0
- pyelq/sensor/satellite.py +59 -0
- pyelq/sensor/sensor.py +241 -0
- pyelq/source_map.py +115 -0
- pyelq/support_functions/__init__.py +5 -0
- pyelq/support_functions/post_processing.py +377 -0
- pyelq/support_functions/spatio_temporal_interpolation.py +229 -0
- pyelq-1.1.0.dist-info/LICENSE.md +202 -0
- pyelq-1.1.0.dist-info/LICENSES/Apache-2.0.txt +73 -0
- pyelq-1.1.0.dist-info/METADATA +127 -0
- pyelq-1.1.0.dist-info/RECORD +32 -0
- pyelq-1.1.0.dist-info/WHEEL +4 -0
pyelq/plotting/plot.py
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
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
|
+
"""Plot module.
|
|
7
|
+
|
|
8
|
+
Large module containing all the plotting code used to create various plots. Contains helper functions and the Plot class
|
|
9
|
+
definition.
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
import warnings
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Callable, Type, Union
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
import plotly.figure_factory as ff
|
|
20
|
+
import plotly.graph_objects as go
|
|
21
|
+
from geojson import Feature, FeatureCollection
|
|
22
|
+
from openmcmc.mcmc import MCMC
|
|
23
|
+
from shapely import geometry
|
|
24
|
+
|
|
25
|
+
from pyelq.component.background import TemporalBackground
|
|
26
|
+
from pyelq.component.error_model import ErrorModel
|
|
27
|
+
from pyelq.component.offset import PerSensor
|
|
28
|
+
from pyelq.component.source_model import SlabAndSpike, SourceModel
|
|
29
|
+
from pyelq.coordinate_system import LLA
|
|
30
|
+
from pyelq.dispersion_model.gaussian_plume import GaussianPlume
|
|
31
|
+
from pyelq.sensor.sensor import Sensor, SensorGroup
|
|
32
|
+
from pyelq.support_functions.post_processing import (
|
|
33
|
+
calculate_rectangular_statistics,
|
|
34
|
+
create_lla_polygons_from_xy_points,
|
|
35
|
+
is_regularly_spaced,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from pyelq.model import ELQModel
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lighter_rgb(rbg_string: str) -> str:
|
|
43
|
+
"""Takes in an RGB string and returns a lighter version of this colour.
|
|
44
|
+
|
|
45
|
+
The colour is made lighter by increasing the magnitude of the RGB values by half of the difference between the
|
|
46
|
+
original value and the number 255.
|
|
47
|
+
|
|
48
|
+
Arguments:
|
|
49
|
+
rbg_string (str): An RGB string.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
rbg_string = rbg_string[4:-1]
|
|
53
|
+
rbg_string = rbg_string.replace(" ", "")
|
|
54
|
+
colors = rbg_string.split(",")
|
|
55
|
+
colors_out = [np.nan, np.nan, np.nan]
|
|
56
|
+
|
|
57
|
+
for i, color in enumerate(colors):
|
|
58
|
+
color = int(color)
|
|
59
|
+
color = min(int(round(color + ((255 - color) * 0.5))), 255)
|
|
60
|
+
colors_out[i] = color
|
|
61
|
+
|
|
62
|
+
return f"rgb({colors_out[0]}, {colors_out[1]}, {colors_out[2]})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def plot_quantiles_from_array(
|
|
66
|
+
fig: go.Figure,
|
|
67
|
+
x_values: Union[np.ndarray, pd.arrays.DatetimeArray],
|
|
68
|
+
y_values: np.ndarray,
|
|
69
|
+
quantiles: Union[tuple, list, np.ndarray],
|
|
70
|
+
color: str,
|
|
71
|
+
name: str = None,
|
|
72
|
+
) -> go.Figure:
|
|
73
|
+
"""Plot quantiles over y-values against x-values.
|
|
74
|
+
|
|
75
|
+
Assuming x-values have size N and y-values have size [N x M] where the second dimension is the dimension to
|
|
76
|
+
calculate the quantiles over.
|
|
77
|
+
|
|
78
|
+
Will plot the median of the y-values as a solid line and a filled area between the lower and upper specified
|
|
79
|
+
quantile.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
fig (go.Figure): Plotly figure to add the traces on.
|
|
83
|
+
x_values (Union[np.ndarray, pd.arrays.DatetimeArray]): Numpy array containing the x-values to plot.
|
|
84
|
+
y_values (np.ndarray): Numpy array containing the y-values to calculate the quantiles for.
|
|
85
|
+
quantiles (Union[tuple, list, np.ndarray]): Values of upper and lower quantile to plot in range (0-100)
|
|
86
|
+
color (str): RGB string specifying color for quantile fill plot.
|
|
87
|
+
name (str, optional): Optional string name to show in the legend.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
fig (go.Figure): Plotly figure with the quantile filled traces and median trace added on it.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
color_fill = f"rgba{color[3:-1]}, 0.3)"
|
|
94
|
+
|
|
95
|
+
median_trace = go.Scatter(
|
|
96
|
+
x=x_values,
|
|
97
|
+
y=np.median(y_values, axis=1),
|
|
98
|
+
mode="lines",
|
|
99
|
+
line={"width": 3, "color": color},
|
|
100
|
+
name=f"Median for {name}",
|
|
101
|
+
legendgroup=name,
|
|
102
|
+
showlegend=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
lower_quantile_trace = go.Scatter(
|
|
106
|
+
x=x_values,
|
|
107
|
+
y=np.quantile(y_values, axis=1, q=quantiles[0] / 100),
|
|
108
|
+
mode="lines",
|
|
109
|
+
line={"width": 0, "color": color_fill},
|
|
110
|
+
name=f"{quantiles[0]}% quantile",
|
|
111
|
+
legendgroup=name,
|
|
112
|
+
showlegend=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
upper_quantile_trace = go.Scatter(
|
|
116
|
+
x=x_values,
|
|
117
|
+
y=np.quantile(y_values, axis=1, q=quantiles[1] / 100),
|
|
118
|
+
fill="tonexty",
|
|
119
|
+
fillcolor=color_fill,
|
|
120
|
+
mode="lines",
|
|
121
|
+
line={"width": 0, "color": color_fill},
|
|
122
|
+
name=f"{quantiles[1]}% quantile",
|
|
123
|
+
legendgroup=name,
|
|
124
|
+
showlegend=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
fig.add_trace(median_trace)
|
|
128
|
+
fig.add_trace(lower_quantile_trace)
|
|
129
|
+
fig.add_trace(upper_quantile_trace)
|
|
130
|
+
|
|
131
|
+
return fig
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_trace_specifics(object_to_plot: Union[Type[SlabAndSpike], SourceModel, MCMC], **kwargs: Any) -> dict:
|
|
135
|
+
"""Specification of different traces of single variables.
|
|
136
|
+
|
|
137
|
+
Provides all details for plots where we want to plot a single variable as a line plot. Based on the object_to_plot
|
|
138
|
+
we select the correct plot to show.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
object_to_plot (Union[Type[SlabAndSpike], SourceModel, MCMC]): Object which we want to plot a single
|
|
142
|
+
variable from
|
|
143
|
+
**kwargs (Any): Additional key word arguments, e.g. burn_in or dict_key, used in some specific plots but not
|
|
144
|
+
applicable to all.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
dict: A dictionary with the following key/values:
|
|
148
|
+
x_values (Union[np.ndarray, pd.arrays.DatetimeArray]): Array containing the x-values to plot.
|
|
149
|
+
y_values (np.ndarray): Numpy array containing the y-values to use in plotting.
|
|
150
|
+
dict_key (str): String key associated with this plot to be used in the figure_dict attribute of the Plot
|
|
151
|
+
class.
|
|
152
|
+
title_text (str): String title of the plot.
|
|
153
|
+
x_label (str): String label of x-axis.
|
|
154
|
+
y_label (str) : String label of y-axis.
|
|
155
|
+
name (str): String name to show in the legend.
|
|
156
|
+
color (str): RGB string specifying color for plot.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: When no specifics are defined for the inputted object to plot.
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
if isinstance(object_to_plot, SourceModel):
|
|
163
|
+
dict_key = kwargs.pop("dict_key", "number_of_sources_plot")
|
|
164
|
+
title_text = "Number of Sources 'on' against MCMC iterations"
|
|
165
|
+
x_label = "MCMC Iteration Number"
|
|
166
|
+
y_label = "Number of Sources 'on'"
|
|
167
|
+
emission_rates = object_to_plot.emission_rate
|
|
168
|
+
if isinstance(object_to_plot, SlabAndSpike):
|
|
169
|
+
total_nof_sources = emission_rates.shape[0]
|
|
170
|
+
y_values = total_nof_sources - np.sum(object_to_plot.allocation, axis=0)
|
|
171
|
+
elif object_to_plot.reversible_jump:
|
|
172
|
+
y_values = np.count_nonzero(np.logical_not(np.isnan(emission_rates)), axis=0)
|
|
173
|
+
else:
|
|
174
|
+
raise TypeError("No plotting routine implemented for this SourceModel type.")
|
|
175
|
+
x_values = np.array(range(y_values.size))
|
|
176
|
+
color = "rgb(248, 156, 116)"
|
|
177
|
+
name = "Number of Sources 'on'"
|
|
178
|
+
|
|
179
|
+
elif isinstance(object_to_plot, MCMC):
|
|
180
|
+
dict_key = kwargs.pop("dict_key", "log_posterior_plot")
|
|
181
|
+
title_text = "Log posterior values against MCMC iterations"
|
|
182
|
+
x_label = "MCMC Iteration Number"
|
|
183
|
+
y_label = "Log Posterior<br>Value"
|
|
184
|
+
y_values = object_to_plot.store["log_post"].flatten()
|
|
185
|
+
x_values = np.array(range(y_values.size))
|
|
186
|
+
color = "rgb(102, 197, 204)"
|
|
187
|
+
name = "Log Posterior"
|
|
188
|
+
|
|
189
|
+
if "burn_in" not in kwargs:
|
|
190
|
+
warnings.warn("Burn in is not specified for the Log Posterior plot, are you sure this is correct?")
|
|
191
|
+
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError("No values to plot")
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"x_values": x_values,
|
|
197
|
+
"y_values": y_values,
|
|
198
|
+
"dict_key": dict_key,
|
|
199
|
+
"title_text": title_text,
|
|
200
|
+
"x_label": x_label,
|
|
201
|
+
"y_label": y_label,
|
|
202
|
+
"name": name,
|
|
203
|
+
"color": color,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def create_plot_specifics(
|
|
208
|
+
object_to_plot: Union[ErrorModel, PerSensor, MCMC], sensor_object: SensorGroup, plot_type: str = "", **kwargs: Any
|
|
209
|
+
) -> dict:
|
|
210
|
+
"""Specification of different traces where we want to plot a trace for each sensor.
|
|
211
|
+
|
|
212
|
+
Provides all details for plots where we want to plot a single variable for each sensor as a line or box plot.
|
|
213
|
+
Based on the object_to_plot we select the correct plot to show.
|
|
214
|
+
|
|
215
|
+
When plotting the MCMC Observations and Predicted Model Values Against Time plot we are assuming time axis is the
|
|
216
|
+
same for all sensors w.r.t. the fitted values from the MCMC store attribute, so we are only using the time axis
|
|
217
|
+
from the first sensor.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
object_to_plot (Union[ErrorModel, PerSensor, MCMC]): Object which we want to plot a single variable from
|
|
221
|
+
sensor_object (SensorGroup): SensorGroup object associated with the object_to_plot
|
|
222
|
+
plot_type (str, optional): String specifying either a line or a box plot.
|
|
223
|
+
**kwargs (Any): Additional key word arguments, e.g. burn_in or dict_key, used in some specific plots but not
|
|
224
|
+
applicable to all.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
dict: A dictionary with the following key/values:
|
|
228
|
+
x_values (Union[np.ndarray, pd.arrays.DatetimeArray]): Array containing the x-values to plot.
|
|
229
|
+
y_values (np.ndarray): Numpy array containing the y-values to use in plotting.
|
|
230
|
+
dict_key (str): String key associated with this plot to be used in the figure_dict attribute of the
|
|
231
|
+
Plot class.
|
|
232
|
+
title_text (str): String title of the plot.
|
|
233
|
+
x_label (str): String label of x-axis.
|
|
234
|
+
y_label (str): String label of y-axis.
|
|
235
|
+
plot_type (str): Type of plot which needs to be generated.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValueError: When no specifics are defined for the inputted object to plot.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
if isinstance(object_to_plot, ErrorModel):
|
|
242
|
+
y_values = np.sqrt(1 / object_to_plot.precision)
|
|
243
|
+
x_values = np.array(range(y_values.shape[1]))
|
|
244
|
+
|
|
245
|
+
if plot_type == "line":
|
|
246
|
+
dict_key = kwargs.pop("dict_key", "error_model_iterations")
|
|
247
|
+
title_text = "Estimated Error Model Values"
|
|
248
|
+
x_label = "MCMC Iteration Number"
|
|
249
|
+
y_label = "Estimated Error Model<br>Standard Deviation (ppm)"
|
|
250
|
+
|
|
251
|
+
elif plot_type == "box":
|
|
252
|
+
dict_key = kwargs.pop("dict_key", "error_model_distributions")
|
|
253
|
+
title_text = "Distributions of Estimated Error Model Values After Burn-In"
|
|
254
|
+
x_label = "Sensor"
|
|
255
|
+
y_label = "Estimated Error Model<br>Standard Deviation (ppm)"
|
|
256
|
+
|
|
257
|
+
else:
|
|
258
|
+
raise ValueError("Only line and box are allowed for the plot_type argument for ErrorModel")
|
|
259
|
+
|
|
260
|
+
if "burn_in" not in kwargs:
|
|
261
|
+
warnings.warn("Burn in is not specified for the ErrorModel plot, are you sure this is correct?")
|
|
262
|
+
|
|
263
|
+
elif isinstance(object_to_plot, PerSensor):
|
|
264
|
+
offset_sensor_name = list(sensor_object.values())[0].label
|
|
265
|
+
y_values = object_to_plot.offset
|
|
266
|
+
nan_row = np.tile(np.nan, (1, y_values.shape[1]))
|
|
267
|
+
y_values = np.concatenate((nan_row, y_values), axis=0)
|
|
268
|
+
x_values = np.array(range(y_values.shape[1]))
|
|
269
|
+
|
|
270
|
+
if plot_type == "line":
|
|
271
|
+
dict_key = kwargs.pop("dict_key", "offset_iterations")
|
|
272
|
+
title_text = f"Estimated Value of Offset w.r.t. {offset_sensor_name}"
|
|
273
|
+
x_label = "MCMC Iteration Number"
|
|
274
|
+
y_label = "Estimated Offset<br>Value (ppm)"
|
|
275
|
+
|
|
276
|
+
elif plot_type == "box":
|
|
277
|
+
dict_key = kwargs.pop("dict_key", "offset_distributions")
|
|
278
|
+
title_text = f"Distributions of Estimated Offset Values w.r.t. {offset_sensor_name} After Burn-In"
|
|
279
|
+
x_label = "Sensor"
|
|
280
|
+
y_label = "Estimated Offset<br>Value (ppm)"
|
|
281
|
+
|
|
282
|
+
else:
|
|
283
|
+
raise ValueError("Only line and box are allowed for the plot_type argument for PerSensor OffsetModel")
|
|
284
|
+
|
|
285
|
+
if "burn_in" not in kwargs:
|
|
286
|
+
warnings.warn("Burn in is not specified for the PerSensor OffsetModel plot, are you sure this is correct?")
|
|
287
|
+
|
|
288
|
+
elif isinstance(object_to_plot, MCMC):
|
|
289
|
+
y_values = object_to_plot.store["y"]
|
|
290
|
+
x_values = list(sensor_object.values())[0].time
|
|
291
|
+
dict_key = kwargs.pop("dict_key", "fitted_values")
|
|
292
|
+
title_text = "Observations and Predicted Model Values Against Time"
|
|
293
|
+
x_label = "Time"
|
|
294
|
+
y_label = "Concentration (ppm)"
|
|
295
|
+
plot_type = "line"
|
|
296
|
+
|
|
297
|
+
else:
|
|
298
|
+
raise ValueError("No values to plot")
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
"x_values": x_values,
|
|
302
|
+
"y_values": y_values,
|
|
303
|
+
"dict_key": dict_key,
|
|
304
|
+
"title_text": title_text,
|
|
305
|
+
"x_label": x_label,
|
|
306
|
+
"y_label": y_label,
|
|
307
|
+
"plot_type": plot_type,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def plot_single_scatter(
|
|
312
|
+
fig: go.Figure,
|
|
313
|
+
x_values: Union[np.ndarray, pd.arrays.DatetimeArray],
|
|
314
|
+
y_values: np.ndarray,
|
|
315
|
+
color: str,
|
|
316
|
+
name: str,
|
|
317
|
+
**kwargs: Any,
|
|
318
|
+
) -> go.Figure:
|
|
319
|
+
"""Plots a single scatter trace on the supplied figure object.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
fig (go.Figure): Plotly figure to add the trace to.
|
|
323
|
+
x_values (Union[np.ndarray, pd.arrays.DatetimeArray]): X values to plot
|
|
324
|
+
y_values (np.ndarray): Numpy array containing the y-values to use in plotting.
|
|
325
|
+
color (str): RGB color string to use for this trace.
|
|
326
|
+
name (str): String name to show in the legend.
|
|
327
|
+
**kwargs (Any): Additional key word arguments, e.g. burn_in, legend_group, show_legend, used in some specific
|
|
328
|
+
plots but not applicable to all.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
fig (go.Figure): Plotly figure with the trace added to it.
|
|
332
|
+
|
|
333
|
+
"""
|
|
334
|
+
burn_in = kwargs.pop("burn_in", 0)
|
|
335
|
+
legend_group = kwargs.pop("legend_group", name)
|
|
336
|
+
show_legend = kwargs.pop("show_legend", True)
|
|
337
|
+
if burn_in > 0:
|
|
338
|
+
fig.add_trace(
|
|
339
|
+
go.Scatter(
|
|
340
|
+
x=x_values[: burn_in + 1],
|
|
341
|
+
y=y_values[: burn_in + 1],
|
|
342
|
+
name=name,
|
|
343
|
+
mode="lines",
|
|
344
|
+
line={"width": 3, "color": lighter_rgb(color)},
|
|
345
|
+
legendgroup=legend_group,
|
|
346
|
+
showlegend=False,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
fig.add_trace(
|
|
351
|
+
go.Scatter(
|
|
352
|
+
x=x_values[burn_in:],
|
|
353
|
+
y=y_values[burn_in:],
|
|
354
|
+
name=name,
|
|
355
|
+
mode="lines",
|
|
356
|
+
line={"width": 3, "color": color},
|
|
357
|
+
legendgroup=legend_group,
|
|
358
|
+
showlegend=show_legend,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return fig
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def plot_single_box(fig: go.Figure, y_values: np.ndarray, color: str, name: str) -> go.Figure:
|
|
366
|
+
"""Plot a single box plot trace on the plot figure.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
fig (go.Figure): Plotly figure to add the trace to.
|
|
370
|
+
y_values (np.ndarray): Numpy array containing the y-values to use in plotting.
|
|
371
|
+
color (str): RGB color string to use for this trace.
|
|
372
|
+
name (str): String name to show in the legend.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
fig (go.Figure): Plotly figure with the trace added to it.
|
|
376
|
+
|
|
377
|
+
"""
|
|
378
|
+
fig.add_trace(go.Box(y=y_values, name=name, legendgroup=name, marker={"color": color}))
|
|
379
|
+
|
|
380
|
+
return fig
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def plot_polygons_on_map(
|
|
384
|
+
polygons: Union[np.ndarray, list], values: np.ndarray, opacity: float, map_color_scale: str, **kwargs: Any
|
|
385
|
+
) -> go.Choroplethmap:
|
|
386
|
+
"""Plot a set of polygons on a map.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
polygons (Union[np.ndarray, list]): Numpy array or list containing the polygons to plot.
|
|
390
|
+
values (np.ndarray): Numpy array consistent with polygons containing the value which is
|
|
391
|
+
used in coloring the polygons on the map.
|
|
392
|
+
opacity (float): Float between 0 and 1 specifying the opacity of the polygon fill color.
|
|
393
|
+
map_color_scale (str): The string which defines which plotly color scale.
|
|
394
|
+
**kwargs (Any): Additional key word arguments which can be passed on the go.Choroplethmap object
|
|
395
|
+
(will override the default values as specified in this function)
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
trace: go.Choroplethmap trace with the colored polygons which can be added to a go.Figure object.
|
|
399
|
+
|
|
400
|
+
"""
|
|
401
|
+
polygon_id = list(range(values.shape[0]))
|
|
402
|
+
feature_collection = FeatureCollection([Feature(geometry=polygons[idx], id_value=idx) for idx in polygon_id])
|
|
403
|
+
text_box = [
|
|
404
|
+
f"<b>Polygon ID</b>: {counter:d}<br><b>Center (lon, lat)</b>: "
|
|
405
|
+
f"({polygons[counter].centroid.coords[0][0]:.4f}, {polygons[counter].centroid.coords[0][1]:.4f})<br>"
|
|
406
|
+
f"<b>Value</b>: {values[counter]:f}<br>"
|
|
407
|
+
for counter in polygon_id
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
trace_options = {
|
|
411
|
+
"geojson": feature_collection,
|
|
412
|
+
"featureidkey": "id_value",
|
|
413
|
+
"locations": polygon_id,
|
|
414
|
+
"z": values,
|
|
415
|
+
"marker": {"line": {"width": 0}, "opacity": opacity},
|
|
416
|
+
"hoverinfo": "text",
|
|
417
|
+
"text": text_box,
|
|
418
|
+
"name": "Values",
|
|
419
|
+
"colorscale": map_color_scale,
|
|
420
|
+
"colorbar": {"title": "Values"},
|
|
421
|
+
"showlegend": True,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for key, value in kwargs.items():
|
|
425
|
+
trace_options[key] = value
|
|
426
|
+
|
|
427
|
+
trace = go.Choroplethmap(**trace_options)
|
|
428
|
+
|
|
429
|
+
return trace
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def plot_regular_grid(
|
|
433
|
+
coordinates: LLA,
|
|
434
|
+
values: np.ndarray,
|
|
435
|
+
opacity: float,
|
|
436
|
+
map_color_scale: str,
|
|
437
|
+
tolerance: float = 1e-7,
|
|
438
|
+
unit: str = "kg/hr",
|
|
439
|
+
name="Values",
|
|
440
|
+
) -> go.Choroplethmap:
|
|
441
|
+
"""Plots a regular grid of LLA data onto a map.
|
|
442
|
+
|
|
443
|
+
So long as the input array is regularly spaced, the value of the spacing is found. A set of rectangles are defined
|
|
444
|
+
where the centre of the rectangle is the LLA coordinate.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
coordinates (LLA object): A LLA coordinate object containing a set of locations.
|
|
448
|
+
values (np.array): A set of values that correspond to locations specified in the coordinates.
|
|
449
|
+
opacity (float): The opacity of the grid cells when they are plotted.
|
|
450
|
+
map_color_scale (str): The string which defines which plotly color scale should be used when plotting
|
|
451
|
+
the values.
|
|
452
|
+
tolerance (float, optional): Absolute value above which the difference between values is considered significant.
|
|
453
|
+
Used to calculate the regular grid of coordinate values. Defaults to 1e-7.
|
|
454
|
+
unit (str, optional): The unit to be added to the colorscale. Defaults to kg/hr.
|
|
455
|
+
name (str, optional): Name for the trace to be used in the color bar as well
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
trace (go.Choroplethmap): Trace with the colored polygons which can be added to a go.Figure object.
|
|
459
|
+
|
|
460
|
+
"""
|
|
461
|
+
_, gridsize_lat = is_regularly_spaced(coordinates.latitude, tolerance=tolerance)
|
|
462
|
+
_, gridsize_lon = is_regularly_spaced(coordinates.longitude, tolerance=tolerance)
|
|
463
|
+
|
|
464
|
+
polygons = [
|
|
465
|
+
geometry.box(
|
|
466
|
+
coordinates.longitude[idx] - gridsize_lon / 2,
|
|
467
|
+
coordinates.latitude[idx] - gridsize_lat / 2,
|
|
468
|
+
coordinates.longitude[idx] + gridsize_lon / 2,
|
|
469
|
+
coordinates.latitude[idx] + gridsize_lat / 2,
|
|
470
|
+
)
|
|
471
|
+
for idx in range(coordinates.nof_observations)
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
trace = plot_polygons_on_map(
|
|
475
|
+
polygons=polygons,
|
|
476
|
+
values=values,
|
|
477
|
+
opacity=opacity,
|
|
478
|
+
name=name,
|
|
479
|
+
colorbar={"title": name + "<br>" + unit},
|
|
480
|
+
map_color_scale=map_color_scale,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
return trace
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def plot_hexagonal_grid(
|
|
487
|
+
coordinates: LLA,
|
|
488
|
+
values: np.ndarray,
|
|
489
|
+
opacity: float,
|
|
490
|
+
map_color_scale: str,
|
|
491
|
+
num_hexagons: Union[int, None],
|
|
492
|
+
show_positions: bool,
|
|
493
|
+
aggregate_function: Callable = np.sum,
|
|
494
|
+
):
|
|
495
|
+
"""Plots a set of values into hexagonal bins with respect to the location of the values.
|
|
496
|
+
|
|
497
|
+
Any data points that fall within the area of a hexagon are used to perform aggregation and bin the data.
|
|
498
|
+
See: https://plotly.com/python-api-reference/generated/plotly.figure_factory.create_hexbin_mapbox.html
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
coordinates (LLA object): A LLA coordinate object containing a set of locations.
|
|
502
|
+
values (np.array): A set of values that correspond to locations specified in the coordinates.
|
|
503
|
+
opacity (float): The opacity of the hexagons when they are plotted.
|
|
504
|
+
map_color_scale (str): Colour scale for plotting values.
|
|
505
|
+
num_hexagons (Union[int, None]): The number of hexagons which define the *horizontal* axis of the plot.
|
|
506
|
+
show_positions (bool): A flag to determine whether the original data should be shown alongside
|
|
507
|
+
the binning hexagons.
|
|
508
|
+
aggregate_function (Callable, optional): Function which to apply on the data in each hexagonal bin to aggregate
|
|
509
|
+
the data and visualise the result.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
(go.Figure): A plotly go figure representing the data which was submitted to this function.
|
|
513
|
+
|
|
514
|
+
"""
|
|
515
|
+
if num_hexagons is None:
|
|
516
|
+
num_hexagons = max(1, np.ceil((np.max(coordinates.longitude) - np.min(coordinates.longitude)) / 0.25))
|
|
517
|
+
|
|
518
|
+
coordinates = coordinates.to_lla()
|
|
519
|
+
|
|
520
|
+
hex_plot = ff.create_hexbin_mapbox(
|
|
521
|
+
lat=coordinates.latitude,
|
|
522
|
+
lon=coordinates.longitude,
|
|
523
|
+
color=values,
|
|
524
|
+
nx_hexagon=num_hexagons,
|
|
525
|
+
opacity=opacity,
|
|
526
|
+
agg_func=aggregate_function,
|
|
527
|
+
color_continuous_scale=map_color_scale,
|
|
528
|
+
show_original_data=show_positions,
|
|
529
|
+
original_data_marker={"color": "black"},
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
return hex_plot
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@dataclass
|
|
536
|
+
class Plot:
|
|
537
|
+
"""Defines the plot class.
|
|
538
|
+
|
|
539
|
+
Can be used to generate various figures from model components while storing general settings to get consistent
|
|
540
|
+
figure appearance.
|
|
541
|
+
|
|
542
|
+
Attributes:
|
|
543
|
+
figure_dict (dict): Figure dictionary, used as storage using keys to identify the different figures.
|
|
544
|
+
layout (dict, optional): Layout template for plotly figures, used in all figures generated using this class
|
|
545
|
+
instance.
|
|
546
|
+
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
figure_dict: dict = field(default_factory=dict)
|
|
550
|
+
layout: dict = field(default_factory=dict)
|
|
551
|
+
|
|
552
|
+
def __post_init__(self):
|
|
553
|
+
"""Using post init to set the default layout, not able to do this in attribute definition/initialization."""
|
|
554
|
+
self.layout = {
|
|
555
|
+
"layout": go.Layout(
|
|
556
|
+
font={"family": "Futura", "size": 20},
|
|
557
|
+
title={"x": 0.5},
|
|
558
|
+
title_font={"size": 30},
|
|
559
|
+
xaxis={"ticks": "outside", "showline": True, "linewidth": 2},
|
|
560
|
+
yaxis={"ticks": "outside", "showline": True, "linewidth": 2},
|
|
561
|
+
legend={
|
|
562
|
+
"orientation": "v",
|
|
563
|
+
"yanchor": "middle",
|
|
564
|
+
"y": 0.5,
|
|
565
|
+
"xanchor": "right",
|
|
566
|
+
"x": 1.2,
|
|
567
|
+
"font": {"size": 14, "color": "black"},
|
|
568
|
+
},
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
def show_all(self, renderer="browser"):
|
|
573
|
+
"""Show all the figures which are in the figure dictionary.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
renderer (str, optional): Default renderer to use when showing the figures.
|
|
577
|
+
|
|
578
|
+
"""
|
|
579
|
+
for fig in self.figure_dict.values():
|
|
580
|
+
fig.show(renderer=renderer)
|
|
581
|
+
|
|
582
|
+
def plot_single_trace(self, object_to_plot: Union[Type[SlabAndSpike], SourceModel, MCMC], **kwargs: Any):
|
|
583
|
+
"""Plotting a trace of a single variable.
|
|
584
|
+
|
|
585
|
+
Depending on the object to plot it creates a figure which is stored in the figure_dict attribute.
|
|
586
|
+
First it grabs all the specifics needed for the plot and then plots the trace.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
object_to_plot (Union[Type[SlabAndSpike], SourceModel, MCMC]): The object from which to plot a variable
|
|
590
|
+
**kwargs (Any): Additional key word arguments, e.g. burn_in, legend_group, show_legend, dict_key, used in
|
|
591
|
+
some specific plots but not applicable to all.
|
|
592
|
+
|
|
593
|
+
"""
|
|
594
|
+
plot_specifics = create_trace_specifics(object_to_plot=object_to_plot, **kwargs)
|
|
595
|
+
|
|
596
|
+
burn_in = kwargs.pop("burn_in", 0)
|
|
597
|
+
|
|
598
|
+
fig = go.Figure()
|
|
599
|
+
fig = plot_single_scatter(
|
|
600
|
+
fig=fig,
|
|
601
|
+
x_values=plot_specifics["x_values"],
|
|
602
|
+
y_values=plot_specifics["y_values"],
|
|
603
|
+
color=plot_specifics["color"],
|
|
604
|
+
name=plot_specifics["name"],
|
|
605
|
+
burn_in=burn_in,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if burn_in > 0:
|
|
609
|
+
fig.add_vline(
|
|
610
|
+
x=burn_in, line_width=3, line_dash="dash", line_color="black", annotation_text=f"\tBurn in: {burn_in}"
|
|
611
|
+
)
|
|
612
|
+
if isinstance(object_to_plot, SlabAndSpike) and isinstance(object_to_plot, SourceModel):
|
|
613
|
+
prior_num_sources_on = round(object_to_plot.emission_rate.shape[0] * object_to_plot.slab_probability, 2)
|
|
614
|
+
|
|
615
|
+
fig.add_hline(
|
|
616
|
+
y=prior_num_sources_on,
|
|
617
|
+
line_width=3,
|
|
618
|
+
line_dash="dash",
|
|
619
|
+
line_color="black",
|
|
620
|
+
annotation_text=f"Prior sources 'on': {prior_num_sources_on}",
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if self.layout is not None:
|
|
624
|
+
fig.update_layout(template=self.layout)
|
|
625
|
+
|
|
626
|
+
fig.update_layout(title=plot_specifics["title_text"])
|
|
627
|
+
fig.update_xaxes(title_standoff=20, automargin=True, title_text=plot_specifics["x_label"])
|
|
628
|
+
fig.update_yaxes(title_standoff=20, automargin=True, title_text=plot_specifics["y_label"])
|
|
629
|
+
|
|
630
|
+
self.figure_dict[plot_specifics["dict_key"]] = fig
|
|
631
|
+
|
|
632
|
+
def plot_trace_per_sensor(
|
|
633
|
+
self,
|
|
634
|
+
object_to_plot: Union[ErrorModel, PerSensor, MCMC],
|
|
635
|
+
sensor_object: Union[SensorGroup, Sensor],
|
|
636
|
+
plot_type: str,
|
|
637
|
+
**kwargs: Any,
|
|
638
|
+
):
|
|
639
|
+
"""Plotting a trace of a single variable per sensor.
|
|
640
|
+
|
|
641
|
+
Depending on the object to plot it creates a figure which is stored in the figure_dict attribute.
|
|
642
|
+
First it grabs all the specifics needed for the plot and then plots the trace per sensor.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
object_to_plot (Union[ErrorModel, PerSensor, MCMC]): The object which to plot a variable from
|
|
646
|
+
sensor_object (Union[SensorGroup, Sensor]): Sensor object associated with the object_to_plot
|
|
647
|
+
plot_type (str): String specifying a line or box plot.
|
|
648
|
+
**kwargs (Any): Additional key word arguments, e.g. burn_in, legend_group, show_legend, dict_key, used in
|
|
649
|
+
some specific plots but not applicable to all.
|
|
650
|
+
|
|
651
|
+
"""
|
|
652
|
+
if isinstance(sensor_object, Sensor):
|
|
653
|
+
temp = SensorGroup()
|
|
654
|
+
temp.add_sensor(sensor_object)
|
|
655
|
+
sensor_object = deepcopy(temp)
|
|
656
|
+
plot_specifics = create_plot_specifics(
|
|
657
|
+
object_to_plot=object_to_plot, sensor_object=sensor_object, plot_type=plot_type, **kwargs
|
|
658
|
+
)
|
|
659
|
+
burn_in = kwargs.pop("burn_in", 0)
|
|
660
|
+
|
|
661
|
+
fig = go.Figure()
|
|
662
|
+
for sensor_idx, sensor_key in enumerate(sensor_object.keys()):
|
|
663
|
+
color_idx = sensor_idx % len(sensor_object.color_map)
|
|
664
|
+
color = sensor_object.color_map[color_idx]
|
|
665
|
+
|
|
666
|
+
if plot_specifics["plot_type"] == "line":
|
|
667
|
+
fig = plot_single_scatter(
|
|
668
|
+
fig=fig,
|
|
669
|
+
x_values=plot_specifics["x_values"],
|
|
670
|
+
y_values=plot_specifics["y_values"][sensor_idx, :],
|
|
671
|
+
color=color,
|
|
672
|
+
name=sensor_key,
|
|
673
|
+
burn_in=burn_in,
|
|
674
|
+
)
|
|
675
|
+
elif plot_specifics["plot_type"] == "box":
|
|
676
|
+
fig = plot_single_box(
|
|
677
|
+
fig=fig,
|
|
678
|
+
y_values=plot_specifics["y_values"][sensor_idx, burn_in:].flatten(),
|
|
679
|
+
color=color,
|
|
680
|
+
name=sensor_key,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if burn_in > 0 and plot_specifics["plot_type"] == "line":
|
|
684
|
+
fig.add_vline(
|
|
685
|
+
x=burn_in, line_width=3, line_dash="dash", line_color="black", annotation_text=f"\tBurn in: {burn_in}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if self.layout is not None:
|
|
689
|
+
fig.update_layout(template=self.layout)
|
|
690
|
+
|
|
691
|
+
fig.update_layout(title=plot_specifics["title_text"])
|
|
692
|
+
fig.update_xaxes(title_standoff=20, automargin=True, title_text=plot_specifics["x_label"])
|
|
693
|
+
fig.update_yaxes(title_standoff=20, automargin=True, title_text=plot_specifics["y_label"])
|
|
694
|
+
|
|
695
|
+
self.figure_dict[plot_specifics["dict_key"]] = fig
|
|
696
|
+
|
|
697
|
+
def plot_fitted_values_per_sensor(
|
|
698
|
+
self,
|
|
699
|
+
mcmc_object: MCMC,
|
|
700
|
+
sensor_object: Union[SensorGroup, Sensor],
|
|
701
|
+
background_model: TemporalBackground = None,
|
|
702
|
+
burn_in: int = 0,
|
|
703
|
+
):
|
|
704
|
+
"""Plot the fitted values from the mcmc object against time, also shows the estimated background when inputted.
|
|
705
|
+
|
|
706
|
+
Based on the inputs it plots the results of the mcmc analysis, being the fitted values of the concentration
|
|
707
|
+
measurements together with the 10th and 90th quantile lines to show the goodness of fit of the estimates.
|
|
708
|
+
|
|
709
|
+
The created figure is stored in the figure_dict attribute.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
mcmc_object (MCMC): MCMC object which contains the fitted values in the store attribute of the object.
|
|
713
|
+
sensor_object (Union[SensorGroup, Sensor]): Sensor object associated with the object_to_plot
|
|
714
|
+
background_model (TemporalBackground, optional): Background model containing the estimated background.
|
|
715
|
+
burn_in (int, optional): Number of burn-in iterations to discard before calculating the quantiles
|
|
716
|
+
and median. Defaults to 0.
|
|
717
|
+
|
|
718
|
+
"""
|
|
719
|
+
if "y" not in mcmc_object.store:
|
|
720
|
+
raise ValueError("Missing fitted values ('y') in mcmc_store_object")
|
|
721
|
+
|
|
722
|
+
if isinstance(sensor_object, Sensor):
|
|
723
|
+
temp = SensorGroup()
|
|
724
|
+
temp.add_sensor(sensor_object)
|
|
725
|
+
sensor_object = deepcopy(temp)
|
|
726
|
+
|
|
727
|
+
y_values_overall = mcmc_object.store["y"]
|
|
728
|
+
dict_key = "fitted_values"
|
|
729
|
+
title_text = "Observations and Predicted Model Values Against Time"
|
|
730
|
+
x_label = "Time"
|
|
731
|
+
y_label = "Concentration (ppm)"
|
|
732
|
+
fig = go.Figure()
|
|
733
|
+
|
|
734
|
+
for sensor_idx, sensor_key in enumerate(sensor_object.keys()):
|
|
735
|
+
plot_idx = np.array(sensor_object.sensor_index == sensor_idx)
|
|
736
|
+
|
|
737
|
+
x_values = sensor_object[sensor_key].time
|
|
738
|
+
y_values = y_values_overall[plot_idx, burn_in:]
|
|
739
|
+
|
|
740
|
+
color_idx = sensor_idx % len(sensor_object.color_map)
|
|
741
|
+
color = sensor_object.color_map[color_idx]
|
|
742
|
+
|
|
743
|
+
fig = plot_quantiles_from_array(
|
|
744
|
+
fig=fig, x_values=x_values, y_values=y_values, quantiles=[10, 90], color=color, name=sensor_key
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if isinstance(background_model, TemporalBackground):
|
|
748
|
+
fig = plot_quantiles_from_array(
|
|
749
|
+
fig=fig,
|
|
750
|
+
x_values=background_model.time,
|
|
751
|
+
y_values=background_model.bg,
|
|
752
|
+
quantiles=[10, 90],
|
|
753
|
+
color="rgb(186, 186, 186)",
|
|
754
|
+
name="Background",
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
fig.for_each_trace(
|
|
758
|
+
lambda trace: (
|
|
759
|
+
trace.update(showlegend=True, name="Background") if trace.name == "Median for Background" else ()
|
|
760
|
+
),
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
fig = sensor_object.plot_timeseries(fig=fig, color_map=sensor_object.color_map, mode="markers")
|
|
764
|
+
|
|
765
|
+
fig.add_annotation(
|
|
766
|
+
x=1,
|
|
767
|
+
y=1.1,
|
|
768
|
+
yref="paper",
|
|
769
|
+
xref="paper",
|
|
770
|
+
xanchor="left",
|
|
771
|
+
yanchor="top",
|
|
772
|
+
font={"size": 12, "color": "#000000"},
|
|
773
|
+
align="left",
|
|
774
|
+
showarrow=False,
|
|
775
|
+
borderwidth=2,
|
|
776
|
+
borderpad=10,
|
|
777
|
+
bgcolor="#ffffff",
|
|
778
|
+
bordercolor="#000000",
|
|
779
|
+
opacity=0.8,
|
|
780
|
+
text=(
|
|
781
|
+
"<b>Point</b>: Real observation<br><b>Line</b>: Predicted Value<br><b>Shading</b>: " + "Quantiles 10-90"
|
|
782
|
+
),
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
if self.layout is not None:
|
|
786
|
+
fig.update_layout(template=self.layout)
|
|
787
|
+
|
|
788
|
+
fig.update_layout(title=title_text)
|
|
789
|
+
fig.update_xaxes(title_standoff=20, automargin=True, title_text=x_label)
|
|
790
|
+
fig.update_yaxes(title_standoff=20, automargin=True, title_text=y_label)
|
|
791
|
+
|
|
792
|
+
self.figure_dict[dict_key] = fig
|
|
793
|
+
|
|
794
|
+
def plot_emission_rate_estimates(self, source_model_object, y_axis_type="linear", **kwargs: Any):
|
|
795
|
+
"""Plot the emission rate estimates source model object against MCMC iteration.
|
|
796
|
+
|
|
797
|
+
Based on the inputs it plots the results of the mcmc analysis, being the estimated emission rate values for
|
|
798
|
+
each source location together with the total emissions estimate, which is the sum over all source locations.
|
|
799
|
+
|
|
800
|
+
The created figure is stored in the figure_dict attribute.
|
|
801
|
+
|
|
802
|
+
After the loop over all sources we add an empty trace to have the legend entry and desired legend group
|
|
803
|
+
behaviour.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
source_model_object (SourceModel): Source model object which contains the estimated emission rate estimates.
|
|
807
|
+
y_axis_type (str, optional): String to indicate whether the y-axis should be linear of log scale.
|
|
808
|
+
**kwargs (Any): Additional key word arguments, e.g. burn_in, dict_key, used in some specific plots but not
|
|
809
|
+
applicable to all.
|
|
810
|
+
|
|
811
|
+
"""
|
|
812
|
+
total_emissions = np.nansum(source_model_object.emission_rate, axis=0)
|
|
813
|
+
x_values = np.array(range(total_emissions.size))
|
|
814
|
+
|
|
815
|
+
burn_in = kwargs.pop("burn_in", 0)
|
|
816
|
+
|
|
817
|
+
dict_key = "estimated_values_plot"
|
|
818
|
+
title_text = "Estimated Values of Sources With Respect to MCMC Iterations"
|
|
819
|
+
x_label = "MCMC Iteration Number"
|
|
820
|
+
y_label = "Estimated Emission<br>Values (kg/hr)"
|
|
821
|
+
|
|
822
|
+
fig = go.Figure()
|
|
823
|
+
|
|
824
|
+
fig = plot_single_scatter(
|
|
825
|
+
fig=fig,
|
|
826
|
+
x_values=x_values,
|
|
827
|
+
y_values=total_emissions,
|
|
828
|
+
color="rgb(239, 85, 59)",
|
|
829
|
+
name="Total Site Emissions",
|
|
830
|
+
burn_in=burn_in,
|
|
831
|
+
show_legend=True,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
for source_idx in range(source_model_object.emission_rate.shape[0]):
|
|
835
|
+
y_values = source_model_object.emission_rate[source_idx, :]
|
|
836
|
+
|
|
837
|
+
fig = plot_single_scatter(
|
|
838
|
+
fig=fig,
|
|
839
|
+
x_values=x_values,
|
|
840
|
+
y_values=y_values,
|
|
841
|
+
color="rgb(102, 197, 204)",
|
|
842
|
+
name=f"Source {source_idx}",
|
|
843
|
+
burn_in=burn_in,
|
|
844
|
+
show_legend=False,
|
|
845
|
+
legend_group="Source traces",
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
fig = plot_single_scatter(
|
|
849
|
+
fig=fig,
|
|
850
|
+
x_values=np.array([None]),
|
|
851
|
+
y_values=np.array([None]),
|
|
852
|
+
color="rgb(102, 197, 204)",
|
|
853
|
+
name="Source traces",
|
|
854
|
+
burn_in=0,
|
|
855
|
+
show_legend=True,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
if burn_in > 0:
|
|
859
|
+
fig.add_vline(
|
|
860
|
+
x=burn_in, line_width=3, line_dash="dash", line_color="black", annotation_text=f"\tBurn in: {burn_in}"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if self.layout is not None:
|
|
864
|
+
fig.update_layout(template=self.layout)
|
|
865
|
+
|
|
866
|
+
fig.add_annotation(
|
|
867
|
+
x=1.05,
|
|
868
|
+
y=1.05,
|
|
869
|
+
yref="paper",
|
|
870
|
+
xref="paper",
|
|
871
|
+
xanchor="left",
|
|
872
|
+
yanchor="top",
|
|
873
|
+
align="left",
|
|
874
|
+
font={"size": 12, "color": "#000000"},
|
|
875
|
+
showarrow=False,
|
|
876
|
+
borderwidth=2,
|
|
877
|
+
borderpad=10,
|
|
878
|
+
bgcolor="#ffffff",
|
|
879
|
+
bordercolor="#000000",
|
|
880
|
+
opacity=0.8,
|
|
881
|
+
text=(
|
|
882
|
+
"<b>Total Site Emissions</b> are<br>the sum of all estimated<br>"
|
|
883
|
+
"emission rates at a given<br>iteration number."
|
|
884
|
+
),
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
fig.update_layout(title=title_text)
|
|
888
|
+
fig.update_xaxes(title_standoff=20, automargin=True, title_text=x_label)
|
|
889
|
+
fig.update_yaxes(title_standoff=20, automargin=True, title_text=y_label)
|
|
890
|
+
if y_axis_type == "log":
|
|
891
|
+
fig.update_yaxes(type="log")
|
|
892
|
+
dict_key = "log_estimated_values_plot"
|
|
893
|
+
elif y_axis_type != "linear":
|
|
894
|
+
raise ValueError(f"Only linear or log y axis type is allowed, {y_axis_type} was currently specified.")
|
|
895
|
+
|
|
896
|
+
self.figure_dict[dict_key] = fig
|
|
897
|
+
|
|
898
|
+
def create_empty_map_figure(self, dict_key: str = "map_plot") -> None:
|
|
899
|
+
"""Creating an empty map figure to use when you want to add additional traces on a map.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
dict_key (str, optional): String key for figure dictionary
|
|
903
|
+
|
|
904
|
+
"""
|
|
905
|
+
self.figure_dict[dict_key] = go.Figure(
|
|
906
|
+
data=go.Scattermap(),
|
|
907
|
+
layout={
|
|
908
|
+
"map_style": "carto-positron",
|
|
909
|
+
"map_center_lat": 0,
|
|
910
|
+
"map_center_lon": 0,
|
|
911
|
+
"map_zoom": 0,
|
|
912
|
+
},
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
def plot_values_on_map(
|
|
916
|
+
self, dict_key: str, coordinates: LLA, values: np.ndarray, aggregate_function: Callable = np.sum, **kwargs: Any
|
|
917
|
+
):
|
|
918
|
+
"""Plot values on a map based on coordinates.
|
|
919
|
+
|
|
920
|
+
Args:
|
|
921
|
+
dict_key (str): Sting key to use in the figure dictionary
|
|
922
|
+
coordinates (LLA): LLA coordinates to use in plotting the values on the map
|
|
923
|
+
values (np.ndarray): Numpy array of values consistent with coordinates to plot on the map
|
|
924
|
+
aggregate_function (Callable, optional): Function which to apply on the data in each hexagonal bin to
|
|
925
|
+
aggregate the data and visualise the result.
|
|
926
|
+
**kwargs (Any): Additional keyword arguments for plotting behaviour (opacity, map_color_scale, num_hexagons,
|
|
927
|
+
show_positions)
|
|
928
|
+
|
|
929
|
+
"""
|
|
930
|
+
map_color_scale = kwargs.pop("map_color_scale", "YlOrRd")
|
|
931
|
+
num_hexagons = kwargs.pop("num_hexagons", None)
|
|
932
|
+
opacity = kwargs.pop("opacity", 0.8)
|
|
933
|
+
show_positions = kwargs.pop("show_positions", False)
|
|
934
|
+
|
|
935
|
+
latitude_check, _ = is_regularly_spaced(coordinates.latitude)
|
|
936
|
+
longitude_check, _ = is_regularly_spaced(coordinates.longitude)
|
|
937
|
+
if latitude_check and longitude_check:
|
|
938
|
+
self.create_empty_map_figure(dict_key=dict_key)
|
|
939
|
+
trace = plot_regular_grid(
|
|
940
|
+
coordinates=coordinates,
|
|
941
|
+
values=values,
|
|
942
|
+
opacity=opacity,
|
|
943
|
+
map_color_scale=map_color_scale,
|
|
944
|
+
tolerance=1e-7,
|
|
945
|
+
unit="",
|
|
946
|
+
)
|
|
947
|
+
self.figure_dict[dict_key].add_trace(trace)
|
|
948
|
+
else:
|
|
949
|
+
fig = plot_hexagonal_grid(
|
|
950
|
+
coordinates=coordinates,
|
|
951
|
+
values=values,
|
|
952
|
+
opacity=opacity,
|
|
953
|
+
map_color_scale=map_color_scale,
|
|
954
|
+
num_hexagons=num_hexagons,
|
|
955
|
+
show_positions=show_positions,
|
|
956
|
+
aggregate_function=aggregate_function,
|
|
957
|
+
)
|
|
958
|
+
fig.update_layout(map_style="carto-positron")
|
|
959
|
+
self.figure_dict[dict_key] = fig
|
|
960
|
+
|
|
961
|
+
center_longitude = np.mean(coordinates.longitude)
|
|
962
|
+
center_latitude = np.mean(coordinates.latitude)
|
|
963
|
+
self.figure_dict[dict_key].update_layout(
|
|
964
|
+
map={"zoom": 10, "center": {"lon": center_longitude, "lat": center_latitude}}
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
if self.layout is not None:
|
|
968
|
+
self.figure_dict[dict_key].update_layout(template=self.layout)
|
|
969
|
+
|
|
970
|
+
def plot_quantification_results_on_map(
|
|
971
|
+
self,
|
|
972
|
+
model_object: "ELQModel",
|
|
973
|
+
bin_size_x: float = 1,
|
|
974
|
+
bin_size_y: float = 1,
|
|
975
|
+
normalized_count_limit: float = 0.005,
|
|
976
|
+
burn_in: int = 0,
|
|
977
|
+
show_summary_results: bool = True,
|
|
978
|
+
):
|
|
979
|
+
"""Function to create a map with the quantification results of the model object.
|
|
980
|
+
|
|
981
|
+
This function takes the ELQModel object and calculates the statistics for the quantification results. It then
|
|
982
|
+
populates the figure dictionary with three different maps showing the normalized count, median emission rate
|
|
983
|
+
and the inter-quartile range of the emission rate estimates.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
model_object (ELQModel): ELQModel object containing the quantification results
|
|
987
|
+
bin_size_x (float, optional): Size of the bins in the x-direction. Defaults to 1.
|
|
988
|
+
bin_size_y (float, optional): Size of the bins in the y-direction. Defaults to 1.
|
|
989
|
+
normalized_count_limit (float, optional): Limit for the normalized count to show on the map.
|
|
990
|
+
Defaults to 0.005.
|
|
991
|
+
burn_in (int, optional): Number of burn-in iterations to discard before calculating the statistics.
|
|
992
|
+
Defaults to 0.
|
|
993
|
+
show_summary_results (bool, optional): Flag to show the summary results on the map. Defaults to True.
|
|
994
|
+
|
|
995
|
+
"""
|
|
996
|
+
ref_latitude = model_object.components["source"].dispersion_model.source_map.location.ref_latitude
|
|
997
|
+
ref_longitude = model_object.components["source"].dispersion_model.source_map.location.ref_longitude
|
|
998
|
+
ref_altitude = model_object.components["source"].dispersion_model.source_map.location.ref_altitude
|
|
999
|
+
|
|
1000
|
+
datetime_min_string = model_object.sensor_object.time.min().strftime("%d-%b-%Y, %H:%M:%S")
|
|
1001
|
+
datetime_max_string = model_object.sensor_object.time.max().strftime("%d-%b-%Y, %H:%M:%S")
|
|
1002
|
+
|
|
1003
|
+
result_weighted, _, normalized_count, count_boolean, enu_points, summary_result = (
|
|
1004
|
+
calculate_rectangular_statistics(
|
|
1005
|
+
model_object=model_object,
|
|
1006
|
+
bin_size_x=bin_size_x,
|
|
1007
|
+
bin_size_y=bin_size_y,
|
|
1008
|
+
burn_in=burn_in,
|
|
1009
|
+
normalized_count_limit=normalized_count_limit,
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
polygons = create_lla_polygons_from_xy_points(
|
|
1014
|
+
points_array=enu_points,
|
|
1015
|
+
ref_latitude=ref_latitude,
|
|
1016
|
+
ref_longitude=ref_longitude,
|
|
1017
|
+
ref_altitude=ref_altitude,
|
|
1018
|
+
boolean_mask=count_boolean,
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
if show_summary_results:
|
|
1022
|
+
summary_trace = self.create_summary_trace(summary_result=summary_result)
|
|
1023
|
+
|
|
1024
|
+
self.create_empty_map_figure(dict_key="count_map")
|
|
1025
|
+
trace = plot_polygons_on_map(
|
|
1026
|
+
polygons=polygons,
|
|
1027
|
+
values=normalized_count[count_boolean].flatten(),
|
|
1028
|
+
opacity=0.8,
|
|
1029
|
+
name="normalized_count",
|
|
1030
|
+
colorbar={"title": "Normalized Count", "orientation": "h"},
|
|
1031
|
+
map_color_scale="Bluered",
|
|
1032
|
+
)
|
|
1033
|
+
self.figure_dict["count_map"].add_trace(trace)
|
|
1034
|
+
self.figure_dict["count_map"].update_layout(
|
|
1035
|
+
map_style="carto-positron",
|
|
1036
|
+
map={"zoom": 15, "center": {"lon": ref_longitude, "lat": ref_latitude}},
|
|
1037
|
+
title=f"Source location probability "
|
|
1038
|
+
f"(>={normalized_count_limit}) for "
|
|
1039
|
+
f"{datetime_min_string} to {datetime_max_string}",
|
|
1040
|
+
font_family="Futura",
|
|
1041
|
+
font_size=15,
|
|
1042
|
+
)
|
|
1043
|
+
model_object.sensor_object.plot_sensor_location(self.figure_dict["count_map"])
|
|
1044
|
+
self.figure_dict["count_map"].update_traces(showlegend=False)
|
|
1045
|
+
|
|
1046
|
+
adjusted_result_weights = result_weighted.copy()
|
|
1047
|
+
adjusted_result_weights[adjusted_result_weights == 0] = np.nan
|
|
1048
|
+
|
|
1049
|
+
median_of_all_emissions = np.nanmedian(adjusted_result_weights, axis=2)
|
|
1050
|
+
|
|
1051
|
+
self.create_empty_map_figure(dict_key="median_map")
|
|
1052
|
+
|
|
1053
|
+
trace = plot_polygons_on_map(
|
|
1054
|
+
polygons=polygons,
|
|
1055
|
+
values=median_of_all_emissions[count_boolean].flatten(),
|
|
1056
|
+
opacity=0.8,
|
|
1057
|
+
name="median_emission",
|
|
1058
|
+
colorbar={"title": "Median Emission", "orientation": "h"},
|
|
1059
|
+
map_color_scale="Bluered",
|
|
1060
|
+
)
|
|
1061
|
+
self.figure_dict["median_map"].add_trace(trace)
|
|
1062
|
+
self.figure_dict["median_map"].update_layout(
|
|
1063
|
+
map_style="carto-positron",
|
|
1064
|
+
map={"zoom": 15, "center": {"lon": ref_longitude, "lat": ref_latitude}},
|
|
1065
|
+
title=f"Median emission rate estimate for {datetime_min_string} to {datetime_max_string}",
|
|
1066
|
+
font_family="Futura",
|
|
1067
|
+
font_size=15,
|
|
1068
|
+
)
|
|
1069
|
+
model_object.sensor_object.plot_sensor_location(self.figure_dict["median_map"])
|
|
1070
|
+
self.figure_dict["median_map"].update_traces(showlegend=False)
|
|
1071
|
+
|
|
1072
|
+
iqr_of_all_emissions = np.nanquantile(a=adjusted_result_weights, q=0.75, axis=2) - np.nanquantile(
|
|
1073
|
+
a=adjusted_result_weights, q=0.25, axis=2
|
|
1074
|
+
)
|
|
1075
|
+
self.create_empty_map_figure(dict_key="iqr_map")
|
|
1076
|
+
|
|
1077
|
+
trace = plot_polygons_on_map(
|
|
1078
|
+
polygons=polygons,
|
|
1079
|
+
values=iqr_of_all_emissions[count_boolean].flatten(),
|
|
1080
|
+
opacity=0.8,
|
|
1081
|
+
name="iqr_emission",
|
|
1082
|
+
colorbar={"title": "IQR", "orientation": "h"},
|
|
1083
|
+
map_color_scale="Bluered",
|
|
1084
|
+
)
|
|
1085
|
+
self.figure_dict["iqr_map"].add_trace(trace)
|
|
1086
|
+
self.figure_dict["iqr_map"].update_layout(
|
|
1087
|
+
map_style="carto-positron",
|
|
1088
|
+
map={"zoom": 15, "center": {"lon": ref_longitude, "lat": ref_latitude}},
|
|
1089
|
+
title=f"Inter Quartile range (25%-75%) of emission rate "
|
|
1090
|
+
f"estimate for {datetime_min_string} to {datetime_max_string}",
|
|
1091
|
+
font_family="Futura",
|
|
1092
|
+
font_size=15,
|
|
1093
|
+
)
|
|
1094
|
+
model_object.sensor_object.plot_sensor_location(self.figure_dict["iqr_map"])
|
|
1095
|
+
self.figure_dict["iqr_map"].update_traces(showlegend=False)
|
|
1096
|
+
|
|
1097
|
+
if show_summary_results:
|
|
1098
|
+
self.figure_dict["count_map"].add_trace(summary_trace)
|
|
1099
|
+
self.figure_dict["count_map"].update_traces(showlegend=True)
|
|
1100
|
+
self.figure_dict["median_map"].add_trace(summary_trace)
|
|
1101
|
+
self.figure_dict["median_map"].update_traces(showlegend=True)
|
|
1102
|
+
self.figure_dict["iqr_map"].add_trace(summary_trace)
|
|
1103
|
+
self.figure_dict["iqr_map"].update_traces(showlegend=True)
|
|
1104
|
+
|
|
1105
|
+
def plot_coverage(
|
|
1106
|
+
self,
|
|
1107
|
+
coordinates: LLA,
|
|
1108
|
+
couplings: np.ndarray,
|
|
1109
|
+
threshold_function: Callable = np.max,
|
|
1110
|
+
coverage_threshold: float = 6,
|
|
1111
|
+
opacity: float = 0.8,
|
|
1112
|
+
map_color_scale="jet",
|
|
1113
|
+
):
|
|
1114
|
+
"""Creates a coverage plot using the coverage function from Gaussian Plume.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
coordinates (LLA object): A LLA coordinate object containing a set of locations.
|
|
1118
|
+
couplings (np.array): The calculated values of coupling (The 'A matrix') for a set of wind data.
|
|
1119
|
+
threshold_function (Callable, optional): Callable function which returns some single value that defines the
|
|
1120
|
+
maximum or 'threshold' coupling. Examples: np.quantile(q=0.9),
|
|
1121
|
+
np.max, np.mean. Defaults to np.max.
|
|
1122
|
+
coverage_threshold (float, optional): The threshold value of the estimated emission rate which is
|
|
1123
|
+
considered to be within the coverage. Defaults to 6 kg/hr.
|
|
1124
|
+
opacity (float): The opacity of the grid cells when they are plotted.
|
|
1125
|
+
map_color_scale (str): The string which defines which plotly colour scale should be used when plotting
|
|
1126
|
+
the values.
|
|
1127
|
+
|
|
1128
|
+
"""
|
|
1129
|
+
coverage_values = GaussianPlume(source_map=None).compute_coverage(
|
|
1130
|
+
couplings=couplings, threshold_function=threshold_function, coverage_threshold=coverage_threshold
|
|
1131
|
+
)
|
|
1132
|
+
self.plot_values_on_map(
|
|
1133
|
+
dict_key="coverage_map",
|
|
1134
|
+
coordinates=coordinates,
|
|
1135
|
+
values=coverage_values,
|
|
1136
|
+
aggregate_function=np.max,
|
|
1137
|
+
opacity=opacity,
|
|
1138
|
+
map_color_scale=map_color_scale,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
@staticmethod
|
|
1142
|
+
def create_summary_trace(
|
|
1143
|
+
summary_result: pd.DataFrame,
|
|
1144
|
+
) -> go.Scattermap:
|
|
1145
|
+
"""Helper function to create the summary information to plot on top of map type plots.
|
|
1146
|
+
|
|
1147
|
+
We use the summary result calculated through the support functions module to create a trace which contains
|
|
1148
|
+
the summary information for each source location.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
summary_result (pd.DataFrame): DataFrame containing the summary information for each source location.
|
|
1152
|
+
|
|
1153
|
+
Returns:
|
|
1154
|
+
summary_trace (go.Scattermap): Trace with summary information to plot on top of map type plots.
|
|
1155
|
+
|
|
1156
|
+
"""
|
|
1157
|
+
summary_text_values = [
|
|
1158
|
+
f"<b>Source ID</b>: {value}<br>"
|
|
1159
|
+
f"<b>(Lon, Lat, Alt)</b> ([deg], [deg], [m]):<br>"
|
|
1160
|
+
f"({summary_result.longitude[value]:.7f}, "
|
|
1161
|
+
f"{summary_result.latitude[value]:.7f}, {summary_result.altitude[value]:.3f})<br>"
|
|
1162
|
+
f"<b>Height</b>: {summary_result.height[value]:.3f} [m]<br>"
|
|
1163
|
+
f"<b>Median emission rate</b>: {summary_result.median_estimate[value]:.4f} [kg/hr]<br>"
|
|
1164
|
+
f"<b>2.5% quantile</b>: {summary_result.quantile_025[value]:.3f} [kg/hr]<br>"
|
|
1165
|
+
f"<b>97.5% quantile</b>: {summary_result.quantile_975[value]:.3f} [kg/hr]<br>"
|
|
1166
|
+
f"<b>IQR</b>: {summary_result.iqr_estimate[value]:.4f} [kg/hr]<br>"
|
|
1167
|
+
f"<b>Blob present during</b>: "
|
|
1168
|
+
f"{summary_result.absolute_count_iterations[value]:.0f} iterations<br>"
|
|
1169
|
+
f"<b>Blob likelihood</b>: {summary_result.blob_likelihood[value]:.5f}<br>"
|
|
1170
|
+
for value in summary_result.index
|
|
1171
|
+
]
|
|
1172
|
+
|
|
1173
|
+
summary_trace = go.Scattermap(
|
|
1174
|
+
lat=summary_result.latitude,
|
|
1175
|
+
lon=summary_result.longitude,
|
|
1176
|
+
mode="markers",
|
|
1177
|
+
marker=go.scattermap.Marker(size=14, color="black"),
|
|
1178
|
+
text=summary_text_values,
|
|
1179
|
+
name="Summary",
|
|
1180
|
+
hoverinfo="text",
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
return summary_trace
|