pyelq 1.1.4__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. pyelq/__init__.py +1 -0
  2. pyelq/component/__init__.py +1 -0
  3. pyelq/component/background.py +19 -13
  4. pyelq/component/component.py +2 -1
  5. pyelq/component/error_model.py +2 -1
  6. pyelq/component/offset.py +2 -1
  7. pyelq/component/source_model.py +78 -29
  8. pyelq/coordinate_system.py +1 -0
  9. pyelq/data_access/__init__.py +1 -0
  10. pyelq/data_access/data_access.py +1 -1
  11. pyelq/dispersion_model/__init__.py +4 -3
  12. pyelq/dispersion_model/dispersion_model.py +202 -0
  13. pyelq/dispersion_model/finite_volume.py +1084 -0
  14. pyelq/dispersion_model/gaussian_plume.py +8 -189
  15. pyelq/dispersion_model/site_layout.py +97 -0
  16. pyelq/dlm.py +11 -15
  17. pyelq/gas_species.py +1 -0
  18. pyelq/meteorology/__init__.py +6 -0
  19. pyelq/{meteorology.py → meteorology/meteorology.py} +388 -387
  20. pyelq/meteorology/meteorology_windfield.py +180 -0
  21. pyelq/model.py +2 -1
  22. pyelq/plotting/__init__.py +1 -0
  23. pyelq/plotting/plot.py +1 -0
  24. pyelq/preprocessing.py +98 -38
  25. pyelq/sensor/__init__.py +1 -0
  26. pyelq/sensor/sensor.py +70 -5
  27. pyelq/source_map.py +1 -0
  28. pyelq/support_functions/__init__.py +1 -0
  29. pyelq/support_functions/post_processing.py +1 -0
  30. pyelq/support_functions/spatio_temporal_interpolation.py +1 -0
  31. {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info}/METADATA +45 -44
  32. pyelq-1.2.0.dist-info/RECORD +37 -0
  33. {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info}/WHEEL +1 -1
  34. pyelq-1.1.4.dist-info/RECORD +0 -32
  35. {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSE.md +0 -0
  36. {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSES/Apache-2.0.txt +0 -0
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
1
+ # SPDX-FileCopyrightText: 2026 Shell Global Solutions International B.V. All Rights Reserved.
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
@@ -10,37 +10,32 @@ The class for the Gaussian Plume dispersion model used in pyELQ.
10
10
  The Mathematics of Atmospheric Dispersion Modeling, John M. Stockie, DOI. 10.1137/10080991X
11
11
 
12
12
  """
13
+
13
14
  from copy import deepcopy
14
15
  from dataclasses import dataclass
15
- from typing import Callable, Union
16
+ from typing import Union
16
17
 
17
18
  import numpy as np
18
19
 
19
- import pyelq.support_functions.spatio_temporal_interpolation as sti
20
20
  from pyelq.coordinate_system import ENU, LLA
21
+ from pyelq.dispersion_model.dispersion_model import DispersionModel
21
22
  from pyelq.gas_species import GasSpecies
22
- from pyelq.meteorology import Meteorology, MeteorologyGroup
23
+ from pyelq.meteorology.meteorology import Meteorology, MeteorologyGroup
23
24
  from pyelq.sensor.beam import Beam
24
25
  from pyelq.sensor.satellite import Satellite
25
26
  from pyelq.sensor.sensor import Sensor, SensorGroup
26
- from pyelq.source_map import SourceMap
27
27
 
28
28
 
29
29
  @dataclass
30
- class GaussianPlume:
30
+ class GaussianPlume(DispersionModel):
31
31
  """Defines the Gaussian plume dispersion model class.
32
32
 
33
33
  Attributes:
34
- source_map (Sourcemap): SourceMap object used for the dispersion model
35
34
  source_half_width (float): Source half width (radius) to be used in the Gaussian plume model (in meters)
36
- minimum_contribution (float): All elements in the Gaussian plume coupling smaller than this number will be set
37
- to 0. Helps to speed up matrix multiplications/matrix inverses, also helps with stability
38
35
 
39
36
  """
40
37
 
41
- source_map: SourceMap
42
38
  source_half_width: float = 1
43
- minimum_contribution: float = 0
44
39
 
45
40
  def compute_coupling(
46
41
  self,
@@ -120,7 +115,7 @@ class GaussianPlume:
120
115
  self,
121
116
  sensor_object: Sensor,
122
117
  meteorology: Meteorology,
123
- gas_object: GasSpecies = None,
118
+ gas_object: Union[GasSpecies, None] = None,
124
119
  run_interpolation: bool = True,
125
120
  ) -> Union[list, np.ndarray]:
126
121
  """Wrapper function to compute the gaussian plume coupling for a single sensor.
@@ -186,7 +181,7 @@ class GaussianPlume:
186
181
  )
187
182
 
188
183
  if sensor_object.source_on is not None:
189
- plume_coupling = plume_coupling * sensor_object.source_on[:, None]
184
+ plume_coupling = plume_coupling * np.where(sensor_object.source_on != 0, 1, 0)[:, None]
190
185
 
191
186
  return plume_coupling
192
187
 
@@ -255,150 +250,6 @@ class GaussianPlume:
255
250
 
256
251
  return plume_coupling
257
252
 
258
- def calculate_gas_density(
259
- self, meteorology: Meteorology, sensor_object: Sensor, gas_object: Union[GasSpecies, None]
260
- ) -> np.ndarray:
261
- """Helper function to calculate the gas density using ideal gas law.
262
-
263
- https://en.wikipedia.org/wiki/Ideal_gas
264
-
265
- When a gas object is passed as input we calculate the density according to that gas. We check if the
266
- meteorology object has a temperature and/or pressure value and use those accordingly. Otherwise, we use Standard
267
- Temperature and Pressure (STP).
268
-
269
- We interpolate the temperature and pressure values to the source locations/times such that this is consistent
270
- with the other calculations, i.e. we only do spatial interpolation when the sensor is a Satellite object
271
- and temporal interpolation otherwise.
272
-
273
- When no gas_object is passed in we just set the gas density value to 1.
274
-
275
- Args:
276
- meteorology (Meteorology): Meteorology object potentially containing temperature or pressure values
277
- sensor_object (Sensor): Sensor object containing information about where to interpolate to
278
- gas_object (Union[GasSpecies, None]): Gas species object which actually calculates the correct density
279
-
280
- Returns:
281
- gas_density (np.ndarray): Numpy array of shape [1 x nof_sources] (Satellite sensor)
282
- or [nof_observations x 1] (otherwise) containing the gas density values to use
283
-
284
- """
285
- if not isinstance(gas_object, GasSpecies):
286
- if isinstance(sensor_object, Satellite):
287
- return np.ones((1, self.source_map.nof_sources))
288
- return np.ones((sensor_object.nof_observations, 1))
289
-
290
- temperature_interpolated = self.interpolate_meteorology(
291
- meteorology=meteorology, variable_name="temperature", sensor_object=sensor_object
292
- )
293
- if temperature_interpolated is None:
294
- temperature_interpolated = np.array([[273.15]])
295
-
296
- pressure_interpolated = self.interpolate_meteorology(
297
- meteorology=meteorology, variable_name="pressure", sensor_object=sensor_object
298
- )
299
- if pressure_interpolated is None:
300
- pressure_interpolated = np.array([[101.325]])
301
-
302
- gas_density = gas_object.gas_density(temperature=temperature_interpolated, pressure=pressure_interpolated)
303
-
304
- return gas_density
305
-
306
- def interpolate_all_meteorology(
307
- self, sensor_object: Sensor, meteorology: Meteorology, gas_object: GasSpecies, run_interpolation: bool
308
- ):
309
- """Function which carries out interpolation of all meteorological information.
310
-
311
- The flag run_interpolation determines whether the interpolation should be carried out. If this
312
- is set to be False, the meteorological parameters are simply set to the values stored on the
313
- meteorology object (i.e. we assume that the meteorology has already been interpolated). This
314
- functionality is required to avoid wasted computation in the case of e.g. a reversible jump run.
315
-
316
- Args:
317
- sensor_object (Sensor): object containing locations/times onto which met information should
318
- be interpolated.
319
- meteorology (Meteorology): object containing meteorology information for interpolation.
320
- gas_object (GasSpecies): object containing gas information.
321
- run_interpolation (bool): logical indicating whether the meteorology information needs to be interpolated.
322
-
323
- Returns:
324
- gas_density (np.ndarray): numpy array of shape [n_data x 1] of gas densities.
325
- u_interpolated (np.ndarray): numpy array of shape [n_data x 1] of northerly wind components.
326
- v_interpolated (np.ndarray): numpy array of shape [n_data x 1] of easterly wind components.
327
- wind_turbulence_horizontal (np.ndarray): numpy array of shape [n_data x 1] of horizontal turbulence
328
- parameters.
329
- wind_turbulence_vertical (np.ndarray): numpy array of shape [n_data x 1] of vertical turbulence
330
- parameters.
331
-
332
- """
333
- if run_interpolation:
334
- gas_density = self.calculate_gas_density(
335
- meteorology=meteorology, sensor_object=sensor_object, gas_object=gas_object
336
- )
337
- u_interpolated = self.interpolate_meteorology(
338
- meteorology=meteorology, variable_name="u_component", sensor_object=sensor_object
339
- )
340
- v_interpolated = self.interpolate_meteorology(
341
- meteorology=meteorology, variable_name="v_component", sensor_object=sensor_object
342
- )
343
- wind_turbulence_horizontal = self.interpolate_meteorology(
344
- meteorology=meteorology, variable_name="wind_turbulence_horizontal", sensor_object=sensor_object
345
- )
346
- wind_turbulence_vertical = self.interpolate_meteorology(
347
- meteorology=meteorology, variable_name="wind_turbulence_vertical", sensor_object=sensor_object
348
- )
349
- else:
350
- gas_density = gas_object.gas_density(temperature=meteorology.temperature, pressure=meteorology.pressure)
351
- gas_density = gas_density.reshape((gas_density.size, 1))
352
- u_interpolated = meteorology.u_component.reshape((meteorology.u_component.size, 1))
353
- v_interpolated = meteorology.v_component.reshape((meteorology.v_component.size, 1))
354
- wind_turbulence_horizontal = meteorology.wind_turbulence_horizontal.reshape(
355
- (meteorology.wind_turbulence_horizontal.size, 1)
356
- )
357
- wind_turbulence_vertical = meteorology.wind_turbulence_vertical.reshape(
358
- (meteorology.wind_turbulence_vertical.size, 1)
359
- )
360
-
361
- return gas_density, u_interpolated, v_interpolated, wind_turbulence_horizontal, wind_turbulence_vertical
362
-
363
- def interpolate_meteorology(
364
- self, meteorology: Meteorology, variable_name: str, sensor_object: Sensor
365
- ) -> Union[np.ndarray, None]:
366
- """Helper function to interpolate meteorology variables.
367
-
368
- This function interpolates meteorological variables to times in Sensor or Sources in sourcemap. It also
369
- calculates the wind speed and mathematical angle between the u- and v-components which in turn gets used in the
370
- calculation of the Gaussian plume.
371
-
372
- When the input sensor object is a Satellite type we use spatial interpolation using the interpolation method
373
- from the coordinate system class as this takes care of the coordinate systems.
374
- When the input sensor object is of another time we use temporal interpolation (assumption is spatial uniformity
375
- for all observations over a small(er) area).
376
-
377
- Args:
378
- meteorology (Meteorology): Meteorology object containing u- and v-components of wind including their
379
- spatial location
380
- variable_name (str): String name of an attribute in the meteorology input object which needs to be
381
- interpolated
382
- sensor_object (Sensor): Sensor object containing information about where to interpolate to
383
-
384
- Returns:
385
- variable_interpolated (np.ndarray): Interpolated values
386
-
387
- """
388
- variable = getattr(meteorology, variable_name)
389
- if variable is None:
390
- return None
391
-
392
- if isinstance(sensor_object, Satellite):
393
- variable_interpolated = meteorology.location.interpolate(variable, self.source_map.location)
394
- variable_interpolated = variable_interpolated.reshape(1, self.source_map.nof_sources)
395
- else:
396
- variable_interpolated = sti.interpolate(
397
- time_in=meteorology.time, values_in=variable, time_out=sensor_object.time
398
- )
399
- variable_interpolated = variable_interpolated.reshape(sensor_object.nof_observations, 1)
400
- return variable_interpolated
401
-
402
253
  def compute_coupling_satellite(
403
254
  self,
404
255
  sensor_object: Sensor,
@@ -559,38 +410,6 @@ class GaussianPlume:
559
410
 
560
411
  return plume_coupling
561
412
 
562
- @staticmethod
563
- def compute_coverage(
564
- couplings: np.ndarray, threshold_function: Callable, coverage_threshold: float = 6, **kwargs
565
- ) -> Union[np.ndarray, dict]:
566
- """Returns a logical vector that indicates which sources in the couplings are, or are not, within the coverage.
567
-
568
- The 'coverage' is the area inside which all sources are well covered by wind data. E.g. If wind exclusively
569
- blows towards East, then all sources to the East of any sensor are 'invisible', and are not within the coverage.
570
-
571
- Couplings are returned in hr/kg. Some threshold function defines the largest allowed coupling value. This is
572
- used to calculate estimated emission rates in kg/hr. Any emissions which are greater than the value of
573
- 'coverage_threshold' are defined as not within the coverage.
574
-
575
- Args:
576
- couplings (np.ndarray): Array of coupling values. Dimensions: n_datapoints x n_sources.
577
- threshold_function (Callable): Callable function which returns some single value that defines the
578
- maximum or 'threshold' coupling. For example: np.quantile(., q=0.95)
579
- coverage_threshold (float, optional): The threshold value of the estimated emission rate which is
580
- considered to be within the coverage. Defaults to 6 kg/hr.
581
- kwargs (dict, optional): Keyword arguments required for the threshold function.
582
-
583
- Returns:
584
- coverage (Union[np.ndarray, dict]): A logical array specifying which sources are within the coverage.
585
-
586
- """
587
- coupling_threshold = threshold_function(couplings, **kwargs)
588
- no_warning_threshold = np.where(coupling_threshold <= 1e-100, 1, coupling_threshold)
589
- no_warning_estimated_emission_rates = np.where(coupling_threshold <= 1e-100, np.inf, 1 / no_warning_threshold)
590
- coverage = no_warning_estimated_emission_rates < coverage_threshold
591
-
592
- return coverage
593
-
594
413
 
595
414
  def _create_enu_sensor_array(
596
415
  inclusion_idx: np.ndarray, sensor_object: Sensor, source_map_location_lla: LLA, current_source: int
@@ -0,0 +1,97 @@
1
+ # SPDX-FileCopyrightText: 2026 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # -*- coding: utf-8 -*-
6
+ """Site layout module.
7
+
8
+ This module defines the SiteLayout class, which represents the layout of a site, giving the option to include obstacles
9
+ (e.g. buildings, tanks, equipment) as cylinders obstructing the flow field.
10
+
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Union
15
+
16
+ import numpy as np
17
+ from scipy import spatial
18
+
19
+ from pyelq.coordinate_system import ENU
20
+
21
+
22
+ @dataclass
23
+ class SiteLayout:
24
+ """Class for site layout defining cylindrical obstacles in the environment.
25
+
26
+ These are used with MeteorologyWindfield to calculate the wind field at each grid point with a potential flow
27
+ around the cylindrical obstacles.
28
+
29
+ Attributes:
30
+ cylinder_coordinates (Union[ENU, None]): The coordinates of the cylindrical obstacles in the site layout. The
31
+ east, north represent the the center of the cylinder and the up coordinate represents the cylinder height.
32
+ cylinder_radius (np.ndarray): The radius of the cylindrical obstacles in the site layout.
33
+ id_obstacles (np.ndarray): Boolean array indicating which grid points are within obstacle regions.
34
+ id_obstacles_index (np.ndarray): The indices of the grid points that are within obstacle regions.
35
+
36
+ """
37
+
38
+ cylinder_coordinates: Union[ENU, None]
39
+ cylinder_radius: np.ndarray
40
+
41
+ id_obstacles: np.ndarray = field(init=False)
42
+ id_obstacles_index: np.ndarray = field(init=False)
43
+
44
+ @property
45
+ def nof_cylinders(self) -> int:
46
+ """Int: Returns the number of cylinders in the site layout."""
47
+ if self.cylinder_coordinates is None:
48
+ return 0
49
+ return self.cylinder_coordinates.nof_observations
50
+
51
+ def find_index_obstacles(self, grid_coordinates: ENU):
52
+ """Find the indices of the grid_coordinates that are within the radius of the obstacles.
53
+
54
+ This method uses a KDTree to efficiently find the indices of the grid points that are within the radius of the
55
+ cylindrical obstacles. It also checks the height of the grid points against the height of the obstacles.
56
+ If the height of the grid point is greater than the height of the obstacle, it is not considered an obstacle.
57
+
58
+ Args:
59
+ grid_coordinates (ENU): The coordinates of the grid points to check.
60
+
61
+ """
62
+ if self.cylinder_coordinates is None or self.cylinder_coordinates.nof_observations == 0:
63
+ self.id_obstacles = np.zeros((grid_coordinates.nof_observations, 1), dtype=bool)
64
+ return
65
+
66
+ self.check_reference_coordinates(grid_coordinates)
67
+
68
+ grid_coordinates_array = grid_coordinates.to_array(dim=2)
69
+ tree = spatial.KDTree(grid_coordinates_array)
70
+ indices = tree.query_ball_point(x=self.cylinder_coordinates.to_array(dim=2), r=self.cylinder_radius.flatten())
71
+
72
+ if grid_coordinates.up is not None:
73
+
74
+ for i, height in enumerate(self.cylinder_coordinates.up):
75
+ indices[i] = np.array(indices[i])
76
+ if len(indices[i]) > 0:
77
+ indices[i] = indices[i][grid_coordinates.up[indices[i]].flatten() <= height]
78
+ indices_conc = np.unique(np.concatenate(indices, axis=0)).astype(int)
79
+ self.id_obstacles_index = indices_conc
80
+ self.id_obstacles = np.zeros((grid_coordinates.nof_observations, 1), dtype=bool)
81
+ self.id_obstacles[[indices_conc]] = True
82
+
83
+ def check_reference_coordinates(self, grid_coordinates: ENU):
84
+ """Check if the reference coordinates of the grid and cylinder coordinates match.
85
+
86
+ Args:
87
+ grid_coordinates (ENU): The coordinates of the grid points to check.
88
+
89
+ Raises:
90
+ ValueError: If the reference coordinates do not match.
91
+ """
92
+ if grid_coordinates.ref_altitude != self.cylinder_coordinates.ref_altitude:
93
+ raise ValueError("Grid coordinates and cylinder coordinates must have the same reference altitude.")
94
+ if grid_coordinates.ref_longitude != self.cylinder_coordinates.ref_longitude:
95
+ raise ValueError("Grid coordinates and cylinder coordinates must have the same reference longitude.")
96
+ if grid_coordinates.ref_latitude != self.cylinder_coordinates.ref_latitude:
97
+ raise ValueError("Grid coordinates and cylinder coordinates must have the same reference latitude.")
pyelq/dlm.py CHANGED
@@ -9,11 +9,12 @@ This module provides a class definition for the Dynamic Linear Models following
9
9
  'Bayesian Forecasting and Dynamic Models' (2nd ed), Springer New York, NY, Chapter 4, https://doi.org/10.1007/b98971
10
10
 
11
11
  """
12
+
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Tuple, Union
14
15
 
15
16
  import numpy as np
16
- from scipy.stats import chi2
17
+ from scipy.stats import chi2, multivariate_normal
17
18
 
18
19
 
19
20
  @dataclass
@@ -119,23 +120,18 @@ class DLM:
119
120
  mean_state_noise = np.zeros(self.nof_state_parameters)
120
121
  mean_observation_noise = np.zeros(self.nof_observables)
121
122
 
122
- random_generator = np.random.default_rng(seed=None)
123
-
124
123
  for i in range(nof_timesteps):
125
124
  if i == 0:
126
- state[:, [i]] = (
127
- self.g_matrix @ init_state
128
- + random_generator.multivariate_normal(mean_state_noise, self.w_matrix, size=1).T
129
- )
125
+ state[:, [i]] = self.g_matrix @ init_state + multivariate_normal.rvs(
126
+ mean=mean_state_noise, cov=self.w_matrix, size=1
127
+ ).reshape((-1, 1))
130
128
  else:
131
- state[:, [i]] = (
132
- self.g_matrix @ state[:, [i - 1]]
133
- + random_generator.multivariate_normal(mean_state_noise, self.w_matrix, size=1).T
134
- )
135
- obs[:, [i]] = (
136
- self.f_matrix.T @ state[:, [i]]
137
- + random_generator.multivariate_normal(mean_observation_noise, self.v_matrix, size=1).T
138
- )
129
+ state[:, [i]] = self.g_matrix @ state[:, [i - 1]] + multivariate_normal.rvs(
130
+ mean=mean_state_noise, cov=self.w_matrix, size=1
131
+ ).reshape((-1, 1))
132
+ obs[:, [i]] = self.f_matrix.T @ state[:, [i]] + multivariate_normal.rvs(
133
+ mean=mean_observation_noise, cov=self.v_matrix, size=1
134
+ ).reshape((-1, 1))
139
135
 
140
136
  return state, obs
141
137
 
pyelq/gas_species.py CHANGED
@@ -9,6 +9,7 @@ The superclass for the Gas species classes. It contains a few gas species with i
9
9
  calculate the density of the gas and do emission rate conversions from m^3/s to kg/hr and back
10
10
 
11
11
  """
12
+
12
13
  from abc import ABC, abstractmethod
13
14
  from dataclasses import dataclass, field
14
15
  from typing import Union
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Data Access Module."""
5
+
6
+ __all__ = ["meteorology", "meteorology_windfield"]