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 ADDED
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Main pyELQ module."""
5
+ __all__ = [
6
+ "component",
7
+ "data_access",
8
+ "dispersion_model",
9
+ "plotting",
10
+ "sensor",
11
+ "support_functions",
12
+ "coordinate_system",
13
+ "dlm",
14
+ "gas_species",
15
+ "meteorology",
16
+ "model",
17
+ "preprocessing",
18
+ "source_map",
19
+ ]
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Components Module."""
6
+ __all__ = ["background", "component", "error_model", "offset", "source_model"]
@@ -0,0 +1,391 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Model components for background modelling."""
6
+
7
+ from abc import abstractmethod
8
+ from copy import deepcopy
9
+ from dataclasses import dataclass, field
10
+ from typing import Union
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from openmcmc import gmrf, parameter
15
+ from openmcmc.distribution.distribution import Gamma
16
+ from openmcmc.distribution.location_scale import Normal
17
+ from openmcmc.model import Model
18
+ from openmcmc.sampler.sampler import NormalGamma, NormalNormal
19
+ from scipy import sparse
20
+ from sklearn.neighbors import NearestNeighbors
21
+
22
+ from pyelq.component.component import Component
23
+ from pyelq.coordinate_system import Coordinate
24
+ from pyelq.gas_species import GasSpecies
25
+ from pyelq.meteorology import MeteorologyGroup
26
+ from pyelq.sensor.beam import Beam
27
+ from pyelq.sensor.sensor import SensorGroup
28
+
29
+
30
+ @dataclass
31
+ class Background(Component):
32
+ """Superclass for background models.
33
+
34
+ Attributes:
35
+ n_obs (int): total number of observations in the background model (across all sensors).
36
+ n_parameter (int): number of parameters in the background model
37
+ bg (np.ndarray): array of sampled background values, populated in self.from_mcmc() after the MCMC run is
38
+ completed.
39
+ precision_scalar (np.ndarray): array of sampled background precision values, populated in self.from_mcmc() after
40
+ the MCMC run is completed. Only populated if update_precision is True.
41
+ precision_matrix (Union[np.ndarray, sparse.csr_array]): un-scaled precision matrix for the background parameter
42
+ vector.
43
+ mean_bg (float): global mean background value. Should be populated from the value specified in the GasSpecies
44
+ object.
45
+ update_precision (bool): logical determining whether the background (scalar) precision parameter should be
46
+ updated as part of the MCMC. Defaults to False.
47
+ prior_precision_shape (float): shape parameter for the prior gamma distribution for the scalar precision
48
+ parameter(s).
49
+ prior_precision_rate (float): rate parameter for the prior gamma distribution for the scalar precision
50
+ parameter(s).
51
+ initial_precision (float): initial value for the scalar precision parameter.
52
+ basis_matrix (sparse.csr_array): [n_obs x n_time] matrix mapping the background model parameters on to the
53
+ observations.
54
+
55
+ """
56
+
57
+ n_obs: int = field(init=False)
58
+ n_parameter: int = field(init=False)
59
+ bg: np.ndarray = field(init=False)
60
+ precision_scalar: np.ndarray = field(init=False)
61
+ precision_matrix: Union[np.ndarray, sparse.csc_matrix] = field(init=False)
62
+ mean_bg: Union[float, None] = None
63
+ update_precision: bool = False
64
+ prior_precision_shape: float = 1e-3
65
+ prior_precision_rate: float = 1e-3
66
+ initial_precision: float = 1.0
67
+ basis_matrix: sparse.csr_array = field(init=False)
68
+
69
+ @abstractmethod
70
+ def initialise(self, sensor_object: SensorGroup, meteorology: MeteorologyGroup, gas_species: GasSpecies):
71
+ """Take data inputs and extract relevant properties.
72
+
73
+ Args:
74
+ sensor_object (SensorGroup): sensor data
75
+ meteorology (MeteorologyGroup): meteorology data
76
+ gas_species (GasSpecies): gas species information
77
+
78
+ """
79
+
80
+ def make_model(self, model: list = None) -> list:
81
+ """Take model list and append new elements from current model component.
82
+
83
+ Args:
84
+ model (list, optional): Current list of model elements. Defaults to None.
85
+
86
+ Returns:
87
+ list: model output list.
88
+
89
+ """
90
+ bg_precision_predictor = parameter.ScaledMatrix(matrix="P_bg", scalar="lambda_bg")
91
+ model.append(Normal("bg", mean="mu_bg", precision=bg_precision_predictor))
92
+ if self.update_precision:
93
+ model.append(Gamma("lambda_bg", shape="a_lam_bg", rate="b_lam_bg"))
94
+ return model
95
+
96
+ def make_sampler(self, model: Model, sampler_list: list = None) -> list:
97
+ """Take sampler list and append new elements from current model component.
98
+
99
+ Args:
100
+ model (Model): Full model list of distributions.
101
+ sampler_list (list, optional): Current list of samplers. Defaults to None.
102
+
103
+ Returns:
104
+ list: sampler output list.
105
+
106
+ """
107
+ if sampler_list is None:
108
+ sampler_list = []
109
+ sampler_list.append(NormalNormal("bg", model))
110
+ if self.update_precision:
111
+ sampler_list.append(NormalGamma("lambda_bg", model))
112
+ return sampler_list
113
+
114
+ def make_state(self, state: dict = None) -> dict:
115
+ """Take state dictionary and append initial values from model component.
116
+
117
+ Args:
118
+ state (dict, optional): current state vector. Defaults to None.
119
+
120
+ Returns:
121
+ dict: current state vector with components added.
122
+
123
+ """
124
+ state["mu_bg"] = np.ones((self.n_parameter, 1)) * self.mean_bg
125
+ state["B_bg"] = self.basis_matrix
126
+ state["bg"] = np.ones((self.n_parameter, 1)) * self.mean_bg
127
+ state["P_bg"] = self.precision_matrix
128
+ state["lambda_bg"] = self.initial_precision
129
+ if self.update_precision:
130
+ state["a_lam_bg"] = self.prior_precision_shape
131
+ state["b_lam_bg"] = self.prior_precision_rate
132
+ return state
133
+
134
+ def from_mcmc(self, store: dict):
135
+ """Extract results of mcmc from mcmc.store and attach to components.
136
+
137
+ Args:
138
+ store (dict): mcmc result dictionary.
139
+
140
+ """
141
+ self.bg = store["bg"]
142
+ if self.update_precision:
143
+ self.precision_scalar = store["lambda_bg"]
144
+
145
+
146
+ @dataclass
147
+ class TemporalBackground(Background):
148
+ """Model which imposes only temporal correlation on the background parameters.
149
+
150
+ Assumes that the prior mean concentration of the background at every location/time point is the global average
151
+ background concentration as defined in the input GasSpecies object.
152
+
153
+ Generates the (un-scaled) prior background precision matrix using the function gmrf.precision_temporal: this
154
+ precision matrix imposes first-oder Markov structure for the temporal dependence.
155
+
156
+ By default, the times used for the model definition are the set of unique times in the observation set.
157
+
158
+ This background model only requires the initialise function, and does not require the implementation of any further
159
+ methods.
160
+
161
+ Attributes:
162
+ time (Union[np.ndarray, pd.arrays.DatetimeArray]): vector of times used in defining the model.
163
+
164
+ """
165
+
166
+ time: Union[np.ndarray, pd.arrays.DatetimeArray] = field(init=False)
167
+
168
+ def initialise(self, sensor_object: SensorGroup, meteorology: MeteorologyGroup, gas_species: GasSpecies):
169
+ """Create temporal background model from sensor, meteorology and gas species inputs.
170
+
171
+ Args:
172
+ sensor_object (SensorGroup): sensor data object.
173
+ meteorology (MeteorologyGroup): meteorology data object.
174
+ gas_species (GasSpecies): gas species data object.
175
+
176
+ """
177
+ self.n_obs = sensor_object.nof_observations
178
+ self.time, unique_inverse = np.unique(sensor_object.time, return_inverse=True)
179
+ self.time = pd.array(self.time, dtype="datetime64[ns]")
180
+ self.n_parameter = len(self.time)
181
+ self.basis_matrix = sparse.csr_array((np.ones(self.n_obs), (np.array(range(self.n_obs)), unique_inverse)))
182
+ self.precision_matrix = gmrf.precision_temporal(time=self.time)
183
+ if self.mean_bg is None:
184
+ self.mean_bg = gas_species.global_background
185
+
186
+
187
+ @dataclass
188
+ class SpatioTemporalBackground(Background):
189
+ """Model which imposes both spatial and temporal correlation on the background parameters.
190
+
191
+ Defines a grid in time, and assumes a correlated time-series per sensor using the defined time grid.
192
+
193
+ The background parameter is an [n_location * n_time x 1] (if self.spatial_dependence is True) or an [n_time x 1]
194
+ vector (if self.spatial_dependence is False). In the spatio-temporal case, the background vector is assumed to
195
+ unwrap over space and time as follows:
196
+ bg = [b_1(t_1), b_2(t_1),..., b_nlct(t_1),...,b_1(t_k),..., b_nlct(t_k),...].T
197
+ where nlct is the number of sensor locations.
198
+ This unwrapping mechanism is chosen as it greatly speeds up the sparse matrix operations in the solver (vs. the
199
+ alternative).
200
+
201
+ self.basis_matrix is set up to map the elements of the full background vector onto the observations, on the basis
202
+ of spatial location and nearest time knot.
203
+
204
+ The temporal background correlation is computed using gmrf.precision_temporal, and the spatial correlation is
205
+ computed using a squared exponential correlation function, parametrized by self.spatial_correlation_param (spatial
206
+ correlation, measured in metres). The full precision matrix is simply a Kronecker product between the two
207
+ component precision matrices.
208
+
209
+ Attributes:
210
+ n_time (int): number of time knots for which the model is defined. Note that this does not need to be the same
211
+ as the number of concentration observations in the analysis.
212
+ n_location (int): number of spatial knots in the model.
213
+ time (pd.arrays.DatetimeArray): vector of times used in defining the model.
214
+ spatial_dependence (bool): flag indicating whether the background parameters should be spatially correlated. If
215
+ True, the model assumes a separate background time-series per sensor location, and assumes these
216
+ time-series to be spatially correlated. If False (default), the background parameters are assumed to be
217
+ common between sensors (only temporally correlated).
218
+ spatial_correlation_param (float): correlation length parameter, determining the degree of spatial correlation
219
+ imposed on the background time-series. Units are metres. Assumes equal correlation in all spatial
220
+ directions. Defaults to 1.0.
221
+ location (np.ndarray): [n_location x 3] array of sensor locations, used for calculating the spatial correlation
222
+ between the sensor background values. If self.spatial_dependence is False, this attribute is simply set to
223
+ be the location of the first sensor in the sensor object.
224
+ temporal_precision_matrix (Union[np.ndarray, sparse.csc_matrix]): temporal component of the precision matrix.
225
+ The full model precision matrix is the Kronecker product of this matrix with self.spatial_precision_matrix.
226
+ spatial_precision_matrix (np.ndarray): spatial component of the precision matrix. The full model precision
227
+ matrix is the Kronecker product of this matrix with the self.temporal_precision_matrix. Simply set to 1 if
228
+ self.spatial_dependence is False.
229
+ precision_time_0 (float): precision relating to the first time stamp in the model. Defaults to 0.01.
230
+
231
+ """
232
+
233
+ n_time: Union[int, None] = None
234
+ n_location: int = field(init=False)
235
+ time: pd.arrays.DatetimeArray = field(init=False)
236
+ spatial_dependence: bool = False
237
+ spatial_correlation_param: float = field(init=False, default=1.0)
238
+ location: Coordinate = field(init=False)
239
+ temporal_precision_matrix: Union[np.ndarray, sparse.csc_matrix] = field(init=False)
240
+ spatial_precision_matrix: np.ndarray = field(init=False)
241
+ precision_time_0: float = field(init=False, default=0.01)
242
+
243
+ def initialise(self, sensor_object: SensorGroup, meteorology: MeteorologyGroup, gas_species: GasSpecies):
244
+ """Take data inputs and extract relevant properties.
245
+
246
+ Args:
247
+ sensor_object (SensorGroup): sensor data
248
+ meteorology (MeteorologyGroup): meteorology data wind data
249
+ gas_species (GasSpecies): gas species information
250
+
251
+ """
252
+ self.make_temporal_knots(sensor_object)
253
+ self.make_spatial_knots(sensor_object)
254
+ self.n_parameter = self.n_time * self.n_location
255
+ self.n_obs = sensor_object.nof_observations
256
+
257
+ self.make_precision_matrix()
258
+ self.make_parameter_mapping(sensor_object)
259
+
260
+ if self.mean_bg is None:
261
+ self.mean_bg = gas_species.global_background
262
+
263
+ def make_parameter_mapping(self, sensor_object: SensorGroup):
264
+ """Create the mapping of parameters onto observations, through creation of the associated basis matrix.
265
+
266
+ The background vector unwraps first over the spatial (sensor) location dimension, then over the temporal
267
+ dimension. For more detail, see the main class docstring.
268
+
269
+ The data vector in the solver state is assumed to consist of the individual sensor data vectors stacked
270
+ consecutively.
271
+
272
+ Args:
273
+ sensor_object (SensorGroup): group of sensor objects.
274
+
275
+ """
276
+ nn_object = NearestNeighbors(n_neighbors=1, algorithm="kd_tree").fit(self.time.to_numpy().reshape(-1, 1))
277
+ for k, sensor in enumerate(sensor_object.values()):
278
+ _, time_index = nn_object.kneighbors(sensor.time.to_numpy().reshape(-1, 1))
279
+ basis_matrix = sparse.csr_array(
280
+ (np.ones(sensor.nof_observations), (np.array(range(sensor.nof_observations)), time_index.flatten())),
281
+ shape=(sensor.nof_observations, self.n_time),
282
+ )
283
+ if self.spatial_dependence:
284
+ basis_matrix = sparse.kron(basis_matrix, np.eye(N=self.n_location, M=1, k=-k).T)
285
+
286
+ if k == 0:
287
+ self.basis_matrix = basis_matrix
288
+ else:
289
+ self.basis_matrix = sparse.vstack([self.basis_matrix, basis_matrix])
290
+
291
+ def make_temporal_knots(self, sensor_object: SensorGroup):
292
+ """Create the temporal grid for the model.
293
+
294
+ If self.n_time is not specified, then the model will use the unique set of times from the sensor data.
295
+
296
+ If self.n_time is specified, then the model will define a time grid with self.n_time elements.
297
+
298
+ Args:
299
+ sensor_object (SensorGroup): group of sensor objects.
300
+
301
+ """
302
+ if self.n_time is None:
303
+ self.time = pd.array(np.unique(sensor_object.time), dtype="datetime64[ns]")
304
+ self.n_time = len(self.time)
305
+ else:
306
+ self.time = pd.array(
307
+ pd.date_range(start=np.min(sensor_object.time), end=np.max(sensor_object.time), periods=self.n_time),
308
+ dtype="datetime64[ns]",
309
+ )
310
+
311
+ def make_spatial_knots(self, sensor_object: SensorGroup):
312
+ """Create the spatial grid for the model.
313
+
314
+ If self.spatial_dependence is False, the code assumes that only a single (arbitrary) location is used, thereby
315
+ eliminating any spatial dependence.
316
+
317
+ If self.spatial_dependence is True, a separate but correlated time-series of background parameters is assumed
318
+ for each sensor location.
319
+
320
+ Args:
321
+ sensor_object (SensorGroup): group of sensor objects.
322
+
323
+ """
324
+ if self.spatial_dependence:
325
+ self.n_location = sensor_object.nof_sensors
326
+ self.get_locations_from_sensors(sensor_object)
327
+ else:
328
+ self.n_location = 1
329
+ self.location = sensor_object[list(sensor_object.keys())[0]].location
330
+
331
+ def make_precision_matrix(self):
332
+ """Create the full precision matrix for the background parameters.
333
+
334
+ Defined as the Kronecker product of the temporal precision matrix and the spatial precision matrix.
335
+
336
+ """
337
+ self.temporal_precision_matrix = gmrf.precision_temporal(time=self.time)
338
+ lam = self.temporal_precision_matrix[0, 0]
339
+ self.temporal_precision_matrix[0, 0] = lam * (2.0 - lam / (self.precision_time_0 + lam))
340
+
341
+ if self.spatial_dependence:
342
+ self.make_spatial_precision_matrix()
343
+ self.precision_matrix = sparse.kron(self.temporal_precision_matrix, self.spatial_precision_matrix)
344
+ else:
345
+ self.precision_matrix = self.temporal_precision_matrix
346
+ if (self.n_parameter == 1) and sparse.issparse(self.precision_matrix):
347
+ self.precision_matrix = self.precision_matrix.toarray()
348
+
349
+ def make_spatial_precision_matrix(self):
350
+ """Create the spatial precision matrix for the model.
351
+
352
+ The spatial precision matrix is simply calculated as the inverse of a squared exponential covariance matrix
353
+ calculated using the sensor locations.
354
+
355
+ """
356
+ location_array = self.location.to_array()
357
+ spatial_covariance_matrix = np.exp(
358
+ -(1 / (2 * np.power(self.spatial_correlation_param, 2)))
359
+ * (
360
+ np.power(location_array[:, [0]] - location_array[:, [0]].T, 2)
361
+ + np.power(location_array[:, [1]] - location_array[:, [1]].T, 2)
362
+ + np.power(location_array[:, [2]] - location_array[:, [2]].T, 2)
363
+ )
364
+ )
365
+ self.spatial_precision_matrix = np.linalg.inv(
366
+ spatial_covariance_matrix + (1e-6) * np.eye(spatial_covariance_matrix.shape[0])
367
+ )
368
+
369
+ def get_locations_from_sensors(self, sensor_object: SensorGroup):
370
+ """Extract the location information from the sensor object.
371
+
372
+ Attaches a Coordinate.ENU object as the self.location attribute, with all the sensor locations stored on the
373
+ same object.
374
+
375
+ Args:
376
+ sensor_object (SensorGroup): group of sensor objects.
377
+
378
+ """
379
+ self.location = deepcopy(sensor_object[list(sensor_object.keys())[0]].location.to_enu())
380
+ self.location.east = np.full(shape=(self.n_location,), fill_value=np.nan)
381
+ self.location.north = np.full(shape=(self.n_location,), fill_value=np.nan)
382
+ self.location.up = np.full(shape=(self.n_location,), fill_value=np.nan)
383
+ for k, sensor in enumerate(sensor_object.values()):
384
+ if isinstance(sensor, Beam):
385
+ self.location.east[k] = np.mean(sensor.location.to_enu().east, axis=0)
386
+ self.location.north[k] = np.mean(sensor.location.to_enu().north, axis=0)
387
+ self.location.up[k] = np.mean(sensor.location.to_enu().up, axis=0)
388
+ else:
389
+ self.location.east[k] = sensor.location.to_enu().east
390
+ self.location.north[k] = sensor.location.to_enu().north
391
+ self.location.up[k] = sensor.location.to_enu().up
@@ -0,0 +1,79 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Superclass for model components."""
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+
9
+ from openmcmc.model import Model
10
+
11
+ from pyelq.gas_species import GasSpecies
12
+ from pyelq.meteorology import MeteorologyGroup
13
+ from pyelq.sensor.sensor import SensorGroup
14
+
15
+
16
+ @dataclass
17
+ class Component(ABC):
18
+ """Abstract class defining methods and rules for model elements.
19
+
20
+ The bulk of attributes will be defined in the subclasses inheriting from this superclass.
21
+
22
+ """
23
+
24
+ @abstractmethod
25
+ def initialise(self, sensor_object: SensorGroup, meteorology: MeteorologyGroup, gas_species: GasSpecies):
26
+ """Take data inputs and extract relevant properties.
27
+
28
+ Args:
29
+ sensor_object (SensorGroup): sensor data
30
+ meteorology (MeteorologyGroup): meteorology data
31
+ gas_species (GasSpecies): gas species information
32
+
33
+ """
34
+
35
+ @abstractmethod
36
+ def make_model(self, model: list) -> list:
37
+ """Take model list and append new elements from current model component.
38
+
39
+ Args:
40
+ model (list, optional): Current list of model elements. Defaults to [].
41
+
42
+ Returns:
43
+ list: model output list.
44
+
45
+ """
46
+
47
+ @abstractmethod
48
+ def make_sampler(self, model: Model, sampler_list: list) -> list:
49
+ """Take sampler list and append new elements from current model component.
50
+
51
+ Args:
52
+ model (Model): Full model list of distributions.
53
+ sampler_list (list, optional): Current list of samplers. Defaults to [].
54
+
55
+ Returns:
56
+ list: sampler output list.
57
+
58
+ """
59
+
60
+ @abstractmethod
61
+ def make_state(self, state: dict) -> dict:
62
+ """Take state dictionary and append initial values from model component.
63
+
64
+ Args:
65
+ state (dict, optional): current state vector. Defaults to {}.
66
+
67
+ Returns:
68
+ dict: current state vector with components added.
69
+
70
+ """
71
+
72
+ @abstractmethod
73
+ def from_mcmc(self, store: dict):
74
+ """Extract results of mcmc from mcmc.store and attach to components.
75
+
76
+ Args:
77
+ store (dict): mcmc result dictionary.
78
+
79
+ """