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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. pyelq/__init__.py +1 -0
  2. pyelq/component/__init__.py +1 -0
  3. pyelq/component/background.py +19 -13
  4. pyelq/component/component.py +2 -1
  5. pyelq/component/error_model.py +2 -1
  6. pyelq/component/offset.py +2 -1
  7. pyelq/component/source_model.py +78 -29
  8. pyelq/coordinate_system.py +1 -0
  9. pyelq/data_access/__init__.py +1 -0
  10. pyelq/data_access/data_access.py +1 -1
  11. pyelq/dispersion_model/__init__.py +4 -3
  12. pyelq/dispersion_model/dispersion_model.py +202 -0
  13. pyelq/dispersion_model/finite_volume.py +1084 -0
  14. pyelq/dispersion_model/gaussian_plume.py +8 -189
  15. pyelq/dispersion_model/site_layout.py +97 -0
  16. pyelq/dlm.py +11 -15
  17. pyelq/gas_species.py +1 -0
  18. pyelq/meteorology/__init__.py +6 -0
  19. pyelq/{meteorology.py → meteorology/meteorology.py} +388 -387
  20. pyelq/meteorology/meteorology_windfield.py +180 -0
  21. pyelq/model.py +2 -1
  22. pyelq/plotting/__init__.py +1 -0
  23. pyelq/plotting/plot.py +16 -25
  24. pyelq/preprocessing.py +98 -38
  25. pyelq/sensor/__init__.py +1 -0
  26. pyelq/sensor/sensor.py +70 -5
  27. pyelq/source_map.py +1 -0
  28. pyelq/support_functions/__init__.py +1 -0
  29. pyelq/support_functions/post_processing.py +1 -0
  30. pyelq/support_functions/spatio_temporal_interpolation.py +1 -0
  31. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info}/METADATA +45 -44
  32. pyelq-1.2.0.dist-info/RECORD +37 -0
  33. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info}/WHEEL +1 -1
  34. pyelq-1.1.3.dist-info/RECORD +0 -32
  35. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSE.md +0 -0
  36. {pyelq-1.1.3.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSES/Apache-2.0.txt +0 -0
@@ -0,0 +1,1084 @@
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
+ """Finite Volume Dispersion Model module.
7
+
8
+ Methods and classes for the finite volume method for the dispersion model.
9
+
10
+ """
11
+
12
+ from abc import ABC, abstractmethod
13
+ from copy import deepcopy
14
+ from dataclasses import dataclass, field
15
+ from typing import Tuple, Union
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ import scipy.sparse as sp
20
+ from scipy.interpolate import RegularGridInterpolator
21
+ from scipy.sparse import csr_array, dia_array
22
+ from scipy.sparse.linalg import spsolve
23
+ from scipy.spatial import KDTree
24
+ from tqdm import tqdm
25
+
26
+ from pyelq.coordinate_system import ENU
27
+ from pyelq.dispersion_model.dispersion_model import DispersionModel
28
+ from pyelq.dispersion_model.site_layout import SiteLayout
29
+ from pyelq.gas_species import GasSpecies
30
+ from pyelq.meteorology.meteorology import Meteorology
31
+ from pyelq.meteorology.meteorology_windfield import MeteorologyWindfield
32
+ from pyelq.sensor.beam import Beam
33
+ from pyelq.sensor.sensor import SensorGroup
34
+
35
+
36
+ @dataclass
37
+ class FiniteVolume(DispersionModel):
38
+ """Dispersion model object which creates a coupling matrix using a finite volume solver.
39
+
40
+ Uses an advection-diffusion solver to create the coupling matrix between a set of source locations and a set of
41
+ sensor locations.
42
+
43
+ Attributes:
44
+ dimensions (list): list of FiniteVolumeDimension for each grid dimension (e.g., x, y, z).
45
+ diffusion_constants (np.ndarray): array of diffusion constants [x,y,z], units m^2/s.
46
+ site_layout (Union[SiteLayout, None]): the layout of the site including cylinder coordinates and radii.
47
+ (default is None). If None, no obstacles are considered in the model.
48
+ dt (float): time step (s) (default is None). (If None, the time step is set using the CFL condition).
49
+ implicit_solver (bool): if True, the solver uses implicit methods. (default is False).
50
+ courant_number (float): Courant number which and represents the fraction of the grid cell that a fluid particle
51
+ can travel in one time step. It is used in calculating dt when not specified. Default is 0.5 which means
52
+ that a fluid particle can travel half the grid cell in one time step.
53
+ burn_in_steady_state (bool): if True, the model runs a burn-in period to reach steady state before
54
+ computing coupling. (default is True).
55
+ use_lookup_table (bool): if True, uses a lookup table for coupling matrix interpolation (default is True).
56
+
57
+ grid_coordinates (np.ndarray): shape=(total_number_cells, number_dimensions), coordinates of the grid points.
58
+ source_grid_link (csr_array): is a sparse matrix linking the source map to the grid coordinates.
59
+ cell_volume (float): volume of a single grid cell.
60
+ total_number_cells (int): total number of cells in the grid.
61
+ grid_size (tuple): size of the grid in each dimension.
62
+ grid_centers (list): centers of the grid cells in each dimension.
63
+ number_dimensions (int): number of dimensions in the grid.
64
+ adv_diff_terms (dict): contains advection and diffusion terms for the solver matrix.
65
+ coupling_lookup_table (np.ndarray): coupling matrix calculated for each grid cell in grid_coordinates computed
66
+ when use_lookup_table=True. It is used for interpolation of coupling values for new source locations without
67
+ the need to re-run the FV solver.
68
+ forward_matrix (dia_array): the solver matrix for the finite volume method.
69
+ _forward_matrix_transpose (dia_array): the transpose of the solver matrix for the finite volume method.
70
+
71
+ """
72
+
73
+ dimensions: list = field(default_factory=list)
74
+ diffusion_constants: np.ndarray = field(default_factory=lambda: np.zeros((3, 1)))
75
+ site_layout: Union[SiteLayout, None] = field(default=None)
76
+ dt: Union[float, None] = field(default=None)
77
+ implicit_solver: bool = field(default=False)
78
+ courant_number: float = field(default=0.5)
79
+ burn_in_steady_state: bool = field(default=True)
80
+ use_lookup_table: bool = field(default=True)
81
+
82
+ grid_coordinates: np.ndarray = field(init=False)
83
+ source_grid_link: csr_array = field(init=False)
84
+ cell_volume: float = field(init=False)
85
+ total_number_cells: int = field(init=False)
86
+ grid_size: tuple = field(init=False)
87
+ grid_centers: list = field(init=False)
88
+ number_dimensions: int = field(init=False)
89
+ adv_diff_terms: dict = field(init=False)
90
+ coupling_lookup_table: np.ndarray = field(init=False, default=None)
91
+ forward_matrix: dia_array = field(init=False, default=None)
92
+ _forward_matrix_transpose: dia_array = field(init=False, default=None)
93
+
94
+ def __post_init__(self) -> None:
95
+ """Post-initialization checks and setup.
96
+
97
+ Creates the grid and neighbourhood for the finite volume solver, and uses the site layout to mask any obstacles
98
+ from the solver grid.
99
+
100
+ """
101
+ if not isinstance(self.source_map.location, ENU):
102
+ raise ValueError("source_map.location must be an ENU object.")
103
+ self.number_dimensions = len(self.dimensions)
104
+ self._setup_grid()
105
+ if self.site_layout is not None:
106
+ self.site_layout.find_index_obstacles(self.grid_coordinates)
107
+ self._setup_neighbourhood()
108
+
109
+ def compute_coupling(
110
+ self,
111
+ sensor_object: SensorGroup,
112
+ met_windfield: MeteorologyWindfield,
113
+ gas_object: Union[GasSpecies, None] = None,
114
+ output_stacked: bool = False,
115
+ **kwargs,
116
+ ) -> Union[np.ndarray, dict]:
117
+ """Compute the coupling matrix for the finite volume method using a lookup table.
118
+
119
+ If self.use_lookup_table == False, or if self.coupling_lookup_table is None, the coupling matrix is computed
120
+ using the FV solver and stored in self.coupling_lookup_table. Otherwise, the coupling matrix is computed using a
121
+ lookup table approach from the previously computed coupling matrix.
122
+
123
+ Args:
124
+ sensor_object (SensorGroup): sensor object containing sensor observations.
125
+ met_windfield (MeteorologyWindfield): meteorology object containing site layout and timeseries of wind data.
126
+ gas_object (Union[GasSpecies, None]): optional input, a gas species object to correctly calculate the
127
+ gas density which is used in the conversion of the units of the Gaussian plume coupling. Defaults to
128
+ None.
129
+ output_stacked (bool): if True, the coupling is stacked across sensors into a single np.ndarray. Otherwise,
130
+ the coupling is returned as a dictionary with an entry per sensor. Defaults to False.
131
+ **kwargs: additional keyword arguments. To accommodate some arguments used in
132
+ GaussianPlume.compute_coupling but not required in FiniteVolume.
133
+
134
+ Returns:
135
+ output (Union[np.ndarray, dict]): List of arrays, single array or dictionary containing the plume coupling
136
+ in hr/kg. If a dictionary of sensor objects is passed in and output_stacked=False, this function returns
137
+ a dictionary consistent with the input dictionary keys, containing the corresponding plume coupling
138
+ outputs for each sensor. If a dictionary of sensor objects is passed in and output_stacked=True, this
139
+ function returns an np.ndarray containing the stacked coupling matrices.
140
+
141
+ """
142
+ if (met_windfield.site_layout is not None) | (self.site_layout is not None):
143
+ if np.any(met_windfield.site_layout.id_obstacles != self.site_layout.id_obstacles):
144
+ raise ValueError("MeteorologyWindfield site layout does not match FiniteVolume site layout.")
145
+ if not isinstance(self.source_map.location, ENU):
146
+ raise ValueError("source_map.location must be an ENU object.")
147
+
148
+ if (not self.use_lookup_table) or (self.coupling_lookup_table is None):
149
+ coupling_sensor = self.compute_coupling_sections(sensor_object, met_windfield, gas_object)
150
+ if self.use_lookup_table:
151
+ self.coupling_lookup_table = coupling_sensor
152
+
153
+ if self.use_lookup_table:
154
+ output = self.interpolate_coupling_lookup_to_source_map(sensor_object)
155
+ else:
156
+ output = coupling_sensor
157
+ if output_stacked:
158
+ output = np.concatenate(tuple(output.values()), axis=0)
159
+ return output
160
+
161
+ def compute_coupling_sections(
162
+ self, sensor_object: SensorGroup, met_windfield: MeteorologyWindfield, gas_object: GasSpecies
163
+ ) -> dict:
164
+ """Compute the coupling sections for the finite volume method.
165
+
166
+ Sections are defined by the source_on attribute of the sensor object. If source_on is None (not specified) or
167
+ all ones, then it is treated as a single section of data and directly moves on to computing the coupling matrix.
168
+
169
+ If there are multiple sections, then the coupling matrix is computed for each section separately and combined
170
+ into a single coupling matrix. This avoids computational effort computing the forward model through time steps
171
+ that are not required and can speed up the computational time substantially in this case. Sections are
172
+ defined by the source_on attribute of the sensor object which indicates which time steps the source is on where
173
+ 0 indicates the source is off and integers starting from 1 indicate different source on sections.
174
+
175
+ To avoid additional computational effort when a source is not emitting we do not compute the forward model when
176
+ the source_on attribute of the sensor object is set to 0. When a source starts emitting we can either assume it
177
+ was already emitting and calculate an equilibrium state by setting the burn_in_steady_state attribute to True.
178
+ Or we assume it starts right then and set this burn_in_steady_state attribute to False. When a source stops
179
+ emitting there is still some gas present in the area of interest and it will take some time for this gas to
180
+ disperse out of the area. However we assume the solution will not improve enough during this time to warrant
181
+ the additional computational effort to compute the forward model for this period. Which is why we stop computing
182
+ the forward model again when the source_on attribute switches from 1 to 0.
183
+
184
+ Args:
185
+ sensor_object (SensorGroup): sensor data object.
186
+ meteorology_object (MeteorologyWindfield): wind field data object.
187
+ gas_object (GasSpecies): gas species object.
188
+
189
+ Returns:
190
+ coupling_sensor (dict): coupling for each sensor, keys corresponding to each sensor: e.g.
191
+ coupling_sensor['sensor_1'] is the coupling matrix for sensor 1.
192
+
193
+ """
194
+ if sensor_object.source_on is None or np.all(sensor_object.source_on == 1):
195
+ return self.finite_volume_time_step_solver(sensor_object, met_windfield, gas_object)
196
+
197
+ number_of_sections = max(sensor_object.source_on)
198
+ coupling_sensor = {}
199
+ for key, sensor in sensor_object.items():
200
+ coupling_sensor[key] = np.full((sensor.time.shape[0], self.source_grid_link.shape[1]), fill_value=0.0)
201
+ for section in range(1, number_of_sections + 1):
202
+ subset_sensor_object = sensor_object.subset_sensor(section_index=section)
203
+ coupling_sensor_section = self.finite_volume_time_step_solver(
204
+ subset_sensor_object, met_windfield, gas_object
205
+ )
206
+ for key, sensor in sensor_object.items():
207
+ section_index = (sensor.source_on == section).flatten()
208
+ coupling_sensor[key][section_index, :] = coupling_sensor_section[key]
209
+ return coupling_sensor
210
+
211
+ def finite_volume_time_step_solver(
212
+ self,
213
+ sensor_object: SensorGroup,
214
+ met_windfield: MeteorologyWindfield,
215
+ gas_object: GasSpecies,
216
+ ) -> dict:
217
+ """Compute the finite volume coupling matrix, by time-stepping the solver.
218
+
219
+ This function calculates the coupling between emission sources and sensor measurements based on a spatial wind
220
+ field derived from meteorological data. The resulting coupling matrices model the transport of gas through a
221
+ discretized domain. The coupling between emissions in all solver grid cells and concentrations in the same set
222
+ of grid cells is calculated by time-stepping a finite volume solver for the advection-diffusion equation. In
223
+ time bins where sensor observations occur, the coupling between any source locations in the source map and the
224
+ locations where sensor observations were obtained are extracted and stored in the rows of the coupling matrix.
225
+
226
+ If dt is not specified, it will be set automatically using a CFL-like condition via self.set_delta_time_cfl().
227
+ If burn_in_steady_state is True, the model runs a burn-in period to reach steady state before computing any
228
+ coupling values. The wind field during the burn-in period is assumed to be constant and the same as the wind
229
+ field at the first time-step.
230
+
231
+ If the coupling matrix is unstable (norm > 1e3), an error is raised suggesting to check the CFL number and dt.
232
+ This condition is checked every 10% of the time steps.
233
+
234
+ Args:
235
+ sensor_object (SensorGroup): sensor data object.
236
+ meteorology_object (MeteorologyWindfield): wind field data object.
237
+ gas_object (GasSpecies): gas species object.
238
+
239
+ Returns:
240
+ coupling_sensor (dict): coupling matrix for each sensor and sources defined by source_grid_link units hr/kg.
241
+ coupling_sensor keys corresponding to each source, e.g. coupling_sensor['sensor_1'] =
242
+ coupling matrix for sensor 1 with shape=(number of observations (sensor_1), number of sources).
243
+
244
+ """
245
+ coupling_sensor = {}
246
+ for key, sensor in sensor_object.items():
247
+ coupling_sensor[key] = np.full((sensor.time.shape[0], self.source_grid_link.shape[1]), fill_value=0.0)
248
+ coupling_grid = None
249
+ time_bins, time_index_sensor, time_index_met = self.compute_time_bins(
250
+ sensor_object=sensor_object, meteorology_object=met_windfield.static_wind_field
251
+ )
252
+ sensor_object = self._prepare_sensor(sensor_object)
253
+ n_burn_steps = self._calculate_number_burn_steps(met_windfield.static_wind_field)
254
+ gas_density = self.calculate_gas_density(
255
+ met_windfield.static_wind_field, sensor_object, gas_object, run_interpolation=False
256
+ )
257
+ met_windfield.calculate_spatial_wind_field(time_index=0, grid_coordinates=self.grid_coordinates)
258
+
259
+ for i_time in tqdm(range(-n_burn_steps, time_bins.size), desc="Computing coupling matrix"):
260
+ if i_time > 0 and (time_index_met[i_time] != time_index_met[i_time - 1]):
261
+ met_windfield.calculate_spatial_wind_field(
262
+ time_index=time_index_met[i_time], grid_coordinates=self.grid_coordinates
263
+ )
264
+ if gas_density.size > 1:
265
+ gas_density_i = gas_density[time_index_met[i_time]]
266
+ else:
267
+ gas_density_i = gas_density
268
+ coupling_grid = self.propagate_solver_single_time_step(met_windfield, coupling_matrix=coupling_grid)
269
+ scaled_coupling = coupling_grid * (1e6 / (gas_density_i.item() * 3600))
270
+ coupling_sensor = self.interpolate_coupling_grid_to_sensor(
271
+ sensor_object=sensor_object,
272
+ scaled_coupling=scaled_coupling,
273
+ time_index_sensor=time_index_sensor,
274
+ i_time=i_time,
275
+ coupling_sensor=coupling_sensor,
276
+ )
277
+ if i_time % np.floor(0.1 * (time_bins.size + n_burn_steps)) == 0:
278
+ coupling_grid_sourcemap_norm = sp.linalg.norm(coupling_grid)
279
+ if coupling_grid_sourcemap_norm > 1e3:
280
+ raise ValueError(
281
+ f"The coupling matrix is unstable, with matrix norm: {coupling_grid_sourcemap_norm:.3g}, "
282
+ f"check the courant_number={self.courant_number:.3f} and calculated dt="
283
+ f"{self.dt:.3f} s"
284
+ )
285
+
286
+ return coupling_sensor
287
+
288
+ def interpolate_coupling_lookup_to_source_map(self, sensor_object: SensorGroup) -> dict:
289
+ """Compute the coupling matrix by interpolation from a lookup table.
290
+
291
+ A coupling matrix from all solver grid centres to all observations is pre-computed and stored on the class.
292
+ Coupling columns for new source locations can then be computed by interpolation from these pre-computed values.
293
+
294
+ The coupling matrix used for lookup is taken from self.coupling_lookup_table which is a sparse matrix computed
295
+ in self.finite_volume_time_step_solver().
296
+
297
+ Args:
298
+ sensor_object (SensorGroup): sensor data object.
299
+
300
+ Returns:
301
+ interpolated_coupling (dict): interpolated coupling matrix for each sensor and sources (units hr/kg).
302
+
303
+ """
304
+ interpolated_coupling = {}
305
+ source_location = self.source_map.location.to_array(dim=self.number_dimensions)
306
+ for key, sensor in sensor_object.items():
307
+ interpolated_coupling[key] = np.full((sensor.time.shape[0], source_location.shape[0]), fill_value=0.0)
308
+ lookup_table_values = self.coupling_lookup_table[key].T
309
+ interpolated_coupling[key] = self._build_interpolator(
310
+ lookup_table_values, locations_to_interpolate=source_location
311
+ ).T
312
+ return interpolated_coupling
313
+
314
+ def propagate_solver_single_time_step(
315
+ self, met_windfield: MeteorologyWindfield, coupling_matrix: Union[sp.csc_array, None] = None
316
+ ) -> sp.csc_array:
317
+ """Time-step the finite volume solver.
318
+
319
+ Time-step the finite volume solver to map the coupling matrix at time t to the coupling matrix at time (t +
320
+ dt).
321
+
322
+ For each time step, the forward matrix is computed based on the current wind field. The coupling matrix is then
323
+ evolved by a single time-step using either an implicit or explicit solver approach, depending on the value of
324
+ self.implicit_solver.
325
+
326
+ coupling_matrix will be a sparse csc_array with shape=(total_number_cells, number of sources)
327
+
328
+ If minimum_contribution is set, all elements in the coupling matrix smaller than this number will be set to 0.
329
+ This can speed up computation.
330
+
331
+ Args:
332
+ met_windfield (MeteorologyWindfield): meteorology object containing wind field information.
333
+ coupling_matrix (Union[(sparse.csc_array, None]): shape=(self.total_number_cells, number of sources).
334
+ coupling matrix matrix on the finite volume grid if None will get preallocated for future time steps
335
+
336
+
337
+ Returns:
338
+ coupling_matrix (sparse.csc_array): shape=(self.total_number_cells, number of sources). Coupling
339
+ matrix on the finite volume grid. Represents the contribution of each cell to the source term in the
340
+ transport equation.
341
+
342
+ """
343
+ self.compute_forward_matrix(met_windfield)
344
+ if coupling_matrix is None:
345
+ coupling_matrix = sp.csc_array(self.source_grid_link.shape, dtype=self.forward_matrix.dtype)
346
+ scale_factor = self.dt / self.cell_volume
347
+ if self.implicit_solver:
348
+ rhs = (1.0 / scale_factor) * coupling_matrix + self.source_grid_link
349
+ coupling_matrix = -spsolve(self.forward_matrix.tocsc(), rhs).reshape(self.source_grid_link.shape)
350
+ if not sp.issparse(coupling_matrix):
351
+ coupling_matrix = sp.csc_array(coupling_matrix)
352
+ else:
353
+ coupling_matrix = scale_factor * (self.forward_matrix @ coupling_matrix + self.source_grid_link)
354
+ if self.minimum_contribution > 0:
355
+ coupling_matrix.data[abs(coupling_matrix.data) <= self.minimum_contribution] = 0
356
+ coupling_matrix.eliminate_zeros()
357
+ if self.site_layout is not None:
358
+ coupling_matrix[self.site_layout.id_obstacles_index, :] = 0
359
+ return coupling_matrix
360
+
361
+ def compute_forward_matrix(self, met_windfield: MeteorologyWindfield) -> None:
362
+ """Construct the forward solver matrix. This can be used to step the solution forward in time.
363
+
364
+ The matrix forward_matrix is constructed using the advection and diffusion terms computed for each face in the
365
+ grid.
366
+
367
+ The overall matrix equation for the FV solver is:
368
+ (V / dt) * [c^(n+1) - c^(n)] + F @ c^(n) - G @ c^(n) = s
369
+ where F is the matrix of advection term coefficients, G is the matrix of diffusion term coefficients, and s is
370
+ the source term.
371
+
372
+ Rearranging gives:
373
+ c^(n+1) = R @ c^(n) + (dt / V) * s
374
+ where R = I - (dt / V) * (F - G).
375
+
376
+ The diagonals of the matrix are constructed using self._construct_diagonals_advection_diffusion() and combined
377
+ using self._combine_advection_diffusion_terms().
378
+
379
+ On first run, the matrix is constructed using self._construct_diagonal_matrix(). On subsequent runs, the matrix
380
+ is updated using self._update_diagonal_matrix() which saves computational time by updating the sparse matrix in
381
+ place.
382
+
383
+ Args:
384
+ met_windfield (MeteorologyWindfield): meteorology object containing wind field information.
385
+
386
+ """
387
+ self._compute_advection_diffusion_terms_by_face(met_windfield)
388
+ self._construct_diagonals_advection_diffusion()
389
+ self._combine_advection_diffusion_terms()
390
+ if self.forward_matrix is None:
391
+ self._construct_diagonal_matrix()
392
+ else:
393
+ self._update_diagonal_matrix()
394
+
395
+ def _compute_advection_diffusion_terms_by_face(self, met_windfield: MeteorologyWindfield) -> None:
396
+ """Compute advection and diffusion terms for each face in the grid.
397
+
398
+ Loops over each dimension and face in the grid and computes the advection using the wind vector and the
399
+ diffusion terms using the diffusion constants.
400
+
401
+ Args:
402
+ met_windfield (MeteorologyWindfield): meteorology object containing wind field information.
403
+
404
+ """
405
+ for i_dim, dim in enumerate(self.dimensions):
406
+ if i_dim == 0:
407
+ wind_component = met_windfield.u_component
408
+ elif i_dim == 1:
409
+ wind_component = met_windfield.v_component
410
+ elif i_dim == 2:
411
+ wind_component = met_windfield.w_component
412
+ else:
413
+ wind_component = None
414
+ for face in dim.faces:
415
+ face.assign_advection(wind_component)
416
+ face.assign_diffusion(self.diffusion_constants[i_dim])
417
+
418
+ def _construct_diagonals_advection_diffusion(self) -> None:
419
+ """Construct the diagonals of the advection and diffusion contributions to the overall solver matrix.
420
+
421
+ In the function self._combine_advection_diffusion_terms(), the coefficients of the advection
422
+ terms are stored in the matrix F, and the coefficients of the diffusion terms are stored in the matrix G. This
423
+ function creates the diagonals of the F and G matrices.
424
+
425
+ The overall diagonals are cumulated by looping over the solver dimensions, and the cell faces in each dimension.
426
+
427
+ """
428
+ num_off_diags = self.number_dimensions * 2
429
+ self.adv_diff_terms = {"advection": SolverDiagonals(), "diffusion": SolverDiagonals()}
430
+ for key, term in self.adv_diff_terms.items():
431
+ term = self.adv_diff_terms[key]
432
+ term.B_central = np.zeros((self.total_number_cells, 1))
433
+ term.B_neighbour = np.zeros((self.total_number_cells, num_off_diags))
434
+ term.b_dirichlet = np.zeros((self.total_number_cells, 1))
435
+ term.b_neumann = np.zeros((self.total_number_cells, 1))
436
+ count = 0
437
+ for dim in self.dimensions:
438
+ for face in dim.faces:
439
+ face_term = face.adv_diff_terms[key]
440
+ term.B_central += face_term.B_central
441
+ term.B_neighbour[:, count] = face_term.B_neighbour.flatten()
442
+ term.b_dirichlet += face_term.b_dirichlet
443
+ term.b_neumann += face_term.b_neumann
444
+ count += 1
445
+ term.B = np.concatenate((term.B_central, term.B_neighbour), axis=1)
446
+
447
+ def _combine_advection_diffusion_terms(self) -> None:
448
+ """Combine the advection and diffusion terms into the solver matrix.
449
+
450
+ The overall matrix equation for the FV solver is:
451
+ (V / dt) * [c^(n+1) - c^(n)] + F @ c^(n) - G @ c^(n) = s
452
+ where F is the matrix of advection term coefficients, G is the matrix of diffusion term coefficients, and s is
453
+ the source term.
454
+
455
+ Rearranging gives:
456
+ c^(n+1) = R @ c^(n) + (dt / V) * s
457
+ where R = I - (dt / V) * (F - G).
458
+
459
+ This function calculates the diagonals of the matrix R by combining the advection and diffusion terms. These
460
+ diagonals are stored in self.adv_diff_terms['combined'].B.
461
+
462
+ """
463
+ num_diags = 1 + self.number_dimensions * 2
464
+ terms = self.adv_diff_terms
465
+ terms["combined"] = SolverDiagonals()
466
+ terms["combined"].B = np.zeros((self.total_number_cells, num_diags))
467
+ if self.implicit_solver:
468
+ terms["combined"].B[:, 0] = terms["combined"].B[:, 0] - self.cell_volume / self.dt
469
+ else:
470
+ terms["combined"].B[:, 0] = terms["combined"].B[:, 0] + self.cell_volume / self.dt
471
+ terms["combined"].B = terms["combined"].B + terms["advection"].B + terms["diffusion"].B
472
+ terms["combined"].b_dirichlet = terms["advection"].b_dirichlet + terms["diffusion"].b_dirichlet
473
+ terms["combined"].b_neumann = terms["advection"].b_neumann + terms["diffusion"].b_neumann
474
+ terms["combined"].B[:, 0] = terms["combined"].B[:, 0] + terms["combined"].b_neumann.flatten()
475
+
476
+ def _construct_diagonal_matrix(self) -> None:
477
+ """Construct the diagonal matrix for the solver.
478
+
479
+ This method creates a sparse diagonal matrix using the diagonals and the specified grid size.
480
+
481
+ The diagonal index is constructed based on the number of dimensions and the grid size using ravel_multi_index.
482
+ This index is used to place the diagonal elements in the correct location within the sparse matrix. It is
483
+ designed to be consistent with the meshgrid with indexing="ij" used to construct grid_coordinates in
484
+ self._setup_grid().
485
+
486
+ The transposed matrix self._forward_matrix_transpose is constructed to deal with the way the zero-padding works
487
+ in dia_array then transposed to self.forward_matrix which is required for forward simulation.
488
+
489
+ The matrix self._forward_matrix_transpose is also stored to allow quick updating in self._update_diagonal_matrix
490
+
491
+ """
492
+ diagonal_index = np.array([0])
493
+ start_coord = np.zeros(self.number_dimensions, dtype=int)
494
+ for i in range(self.number_dimensions):
495
+ diag_coord = start_coord.copy()
496
+ diag_coord[i] += 1
497
+ diag_coord = np.ravel_multi_index(diag_coord, self.grid_size, mode="clip")
498
+ diagonal_index = np.concatenate((diagonal_index, np.array([diag_coord, -diag_coord])))
499
+
500
+ self._forward_matrix_transpose = dia_array(
501
+ (self.adv_diff_terms["combined"].B.T, diagonal_index),
502
+ shape=(self.total_number_cells, self.total_number_cells),
503
+ )
504
+ self.forward_matrix = self._forward_matrix_transpose.T
505
+
506
+ def _update_diagonal_matrix(self) -> None:
507
+ """Update the self.forward_matrix for the solver.
508
+
509
+ This method updates the self.forward_matrix using the updated adv_diff_terms Avoid reconstructing the sparse
510
+ matrix and just updates the data in place for speed purposes.
511
+
512
+ Since the forward_matrix is transposed, we need to update the transposed version of the forward matrix then
513
+ transpose it back.
514
+
515
+ """
516
+ self._forward_matrix_transpose.data = self.adv_diff_terms["combined"].B.T
517
+ self.forward_matrix = self._forward_matrix_transpose.T
518
+
519
+ def _setup_grid(self) -> None:
520
+ """Initializes a structured Cartesian grid using the site limits and number of cells in each dimension.
521
+
522
+ ENU CoordinateSystem reference location is taken from self.source_map.
523
+
524
+ This method builds a multi-dimensional grid by discretizing the spatial domain into equally spaced cells
525
+ along each axis (e.g., x, y, z).
526
+
527
+ Grid construction uses np.meshgrid with indexing="ij" to be consistent with the way the diagonals are
528
+ constructed in self._construct_diagonal_matrix() and the way the neighbourhood is constructed in
529
+ self._setup_neighbourhood(). "ij" is the matrix indexing convention, which means that the first dimension
530
+ corresponds to rows and the second dimension corresponds to columns.
531
+
532
+ Volume and Area Calculations:
533
+ - self.cell_volume stores the volume of a single grid cell (product of widths).
534
+ - For each dimension, self.cell_face_area is computed as the ratio of cell volume to that dimension's width,
535
+ representing the area of a face perpendicular to the given axis.
536
+
537
+ """
538
+ self.cell_volume = np.prod([dim.cell_width for dim in self.dimensions])
539
+ self.grid_centers = [dim.cell_centers for dim in self.dimensions]
540
+ for dim in self.dimensions:
541
+ for face in dim.faces:
542
+ face.cell_volume = self.cell_volume
543
+ face.cell_face_area = self.cell_volume / dim.cell_width
544
+ grid_coordinates = np.meshgrid(*[dim.cell_centers for dim in self.dimensions], indexing="ij")
545
+ self.grid_size = grid_coordinates[0].shape
546
+ self.grid_coordinates = ENU(
547
+ ref_longitude=self.source_map.location.ref_longitude,
548
+ ref_latitude=self.source_map.location.ref_latitude,
549
+ ref_altitude=self.source_map.location.ref_altitude,
550
+ )
551
+ self.grid_coordinates.east = grid_coordinates[0].reshape(-1, 1)
552
+ if self.number_dimensions > 1:
553
+ self.grid_coordinates.north = grid_coordinates[1].reshape(-1, 1)
554
+ if self.number_dimensions > 2:
555
+ self.grid_coordinates.up = grid_coordinates[2].reshape(-1, 1)
556
+ self.total_number_cells = self.grid_coordinates.east.shape[0]
557
+ self._setup_source_link()
558
+
559
+ def _setup_source_link(self) -> None:
560
+ """Setup the source link between the source map and the grid coordinates.
561
+
562
+ This method creates a sparse matrix that links the source map to the grid coordinates.
563
+
564
+ Used in the coupling matrix to link the source map to the grid coordinates.
565
+
566
+ If there are no sources in the source map or if use_lookup_table is True, the source map locations are set to
567
+ the grid coordinates and the source_grid_link is set to an identity matrix.
568
+
569
+ If there are sources in the source map and use_lookup_table is False, a KDTree is used to find the nearest grid
570
+ point for each source location. The source_grid_link is then created as a sparse matrix with ones at the
571
+ locations of the nearest grid points and zeros elsewhere.
572
+
573
+ self.source_grid_link is a sparse matrix linking the source map to the grid coordinates.
574
+
575
+ """
576
+ if self.use_lookup_table or self.source_map.nof_sources == 0:
577
+ if self.source_map.nof_sources == 0:
578
+ self.source_map.location = self.grid_coordinates
579
+ self.source_grid_link = sp.eye_array(self.total_number_cells, format="csr")
580
+ else:
581
+ n_sources = self.source_map.nof_sources
582
+ tree = KDTree(self.grid_coordinates.to_array(dim=self.number_dimensions))
583
+ source_index = tree.query(self.source_map.location.to_array(dim=self.number_dimensions), k=1)[1]
584
+ self.source_grid_link = sp.csr_array(
585
+ (np.ones(n_sources), (source_index, np.array(range(n_sources)))),
586
+ shape=(self.total_number_cells, n_sources),
587
+ )
588
+
589
+ def _setup_neighbourhood(self) -> None:
590
+ """Initializes the neighborhood relationships for each cell in the grid across all dimensions.
591
+
592
+ For a given dim and face, to find the neighbor indices for each cell, the index is unwrapped and converted to
593
+ multi-dimensional indices using np.unravel_index.
594
+ e.g. if grid_size = (10,10) then for the cell index 27,
595
+ index_center = np.unravel_index(27, (10,10)) = (2,7)
596
+ we find the neighbor index by shifting the multi-dimensional indices by the face shift:
597
+ for left face in x-dimension, shift = -1, so the new multi-dimensional indices are (1, 7))
598
+ then we convert back to the unwrapped index using
599
+ index_neighbour = np.ravel_multi_index((1,7), (10,10)) = 17
600
+ for right face in y-dimension, shift = 1, so the new multi-dimensional indices are (2, 8))
601
+ then we convert back to the unwrapped index using
602
+ index_neighbour = np.ravel_multi_index((2,8), (10,10)) = 28
603
+ Cells that lie at the domain boundary (i.e., where a shift would move them outside the grid extent) are detected
604
+ and handled:
605
+ - Their neighbor index is set to `-9999` to indicate an invalid or non-existent neighbor.
606
+ - They are classified as external boundaries.
607
+
608
+ Cells adjacent to user-defined obstacles (as indicated by `self.site_layout.id_obstacle`) are specially treated.
609
+ If a neighboring cell lies within an obstacle region, it's considered an invalid neighbor for flow or
610
+ interaction purposes.
611
+
612
+ For external boundaries, the method assigns Dirichlet or Neumann boundary conditions depending on the
613
+ specification in the grid metadata for that dimension.
614
+
615
+ Each grid dimension is updated with the following information for both 'left' and 'right' directions:
616
+ - neighbour_index: array of neighbor indices for each cell (-9999 for out-of-bounds).
617
+ - boundary_condition: array indicating the type of boundary condition ('internal', 'dirichlet', 'neumann').
618
+ - boundary_conditions: the boundary condition type for the current direction.
619
+
620
+ """
621
+ index_center = np.unravel_index(range(self.total_number_cells), self.grid_size)
622
+ for i, dim in enumerate(self.dimensions):
623
+ for face in dim.faces:
624
+ index_center_shift = list(index_center)
625
+ index_center_shift[i] = index_center_shift[i] + face.shift
626
+ face.neighbour_index = np.ravel_multi_index(index_center_shift, self.grid_size, mode="clip")
627
+ face.neighbour_index = face.neighbour_index.reshape((self.total_number_cells, 1))
628
+ external_boundaries = np.logical_or(
629
+ index_center_shift[i] < 0, index_center_shift[i] >= dim.number_cells
630
+ )
631
+ face.neighbour_index[external_boundaries] = -9999
632
+ face.set_boundary_type(external_boundaries, self.site_layout)
633
+
634
+ def compute_time_bins(
635
+ self, sensor_object: SensorGroup, meteorology_object: Meteorology
636
+ ) -> Tuple[pd.DatetimeIndex, dict, np.ndarray]:
637
+ """Compute discretized time bins for aligning sensor observations and meteorological data.
638
+
639
+ This method constructs a uniform time grid (bins) based on the observation time range of the given sensors.
640
+ The time resolution is determined by `self.dt`. If `self.dt` is not specified, it will be set automatically
641
+ using a CFL-like condition via `self.set_delta_time_cfl()` based on the meteorology object.
642
+
643
+ Once the time bins are established:
644
+ - Each sensor's observation times are digitized to determine which time bin each observation belongs to.
645
+ - A KDTree is used to find the closest meteorological time index corresponding to each time bin, mapping the
646
+ wind field to the solver grid.
647
+
648
+ Args:
649
+ sensor_object (SensorGroup): Sensor data object
650
+ meteorology_object (Meteorology): Meteorology data object.
651
+
652
+ Returns:
653
+ time_bins (pd.DatetimeIndex): The array of uniformly spaced time bins (based on `self.dt`).
654
+ time_index_sensor (dict): A dictionary mapping each sensor ID to its array of time bin indices.
655
+ time_index_met (np.ndarray): An array mapping each time bin to the closest meteorological time index.
656
+
657
+ """
658
+ if self.dt is None:
659
+ self.set_delta_time_cfl(meteorology_object)
660
+ sensor_time = sensor_object.time.reshape(
661
+ -1,
662
+ )
663
+ time_bins = pd.date_range(
664
+ start=sensor_time.min() - pd.Timedelta(self.dt, unit="s"),
665
+ end=sensor_time.max() + pd.Timedelta(self.dt, unit="s"),
666
+ freq=f"{self.dt}s",
667
+ inclusive="both",
668
+ )
669
+ time_index_sensor = {}
670
+ for key, sensor in sensor_object.items():
671
+ time_index_sensor[key] = np.digitize(
672
+ sensor.time.reshape(
673
+ -1,
674
+ ).astype(np.int64),
675
+ time_bins.astype(np.int64),
676
+ )
677
+ tree = KDTree(meteorology_object.time.reshape(-1, 1).astype(np.int64))
678
+ _, time_index_met = tree.query(np.array(time_bins.astype(np.int64)).reshape(-1, 1), k=1)
679
+ return time_bins, time_index_sensor, time_index_met
680
+
681
+ def set_delta_time_cfl(self, meteorology_object: Meteorology) -> None:
682
+ """Use CFL condition to set the time step.
683
+
684
+ The CFL condition is a stability criterion for numerical methods used in solving partial differential equations.
685
+ It ensures that the numerical solution remains stable and converges to the true solution.
686
+
687
+ The CFL condition for advection is given by:
688
+ dt <= min(dx / |u|)
689
+ for all dimensions, where dx is the grid spacing and u is the velocity. This method calculates the maximum
690
+ velocity in each dimension and sets the time step accordingly.
691
+
692
+ The diffusion term is also considered in the CFL condition:
693
+ dt <= (dx^2) / (2 * K)
694
+ for all dimensions, where K is self.diffusion_constants.
695
+
696
+ dt is set to the minimum of the advection and diffusion time steps multiplied by self.courant_number.
697
+
698
+ dt is rounded to the nearest 0.1s due to usage in pd.date_range in other parts of the code.
699
+
700
+ Args:
701
+ meteorology_object (Meteorology): meteorology object containing timeseries of wind data.
702
+
703
+ """
704
+ if meteorology_object.wind_speed is None:
705
+ meteorology_object.calculate_wind_speed_from_uv()
706
+ u_max = np.max(meteorology_object.wind_speed)
707
+ dx = np.min([dim.cell_width for dim in self.dimensions])
708
+
709
+ dt_adv = self.courant_number * dx / u_max
710
+ dt_diff = (self.courant_number * dx**2) / (2 * np.max(self.diffusion_constants))
711
+ self.dt = np.round(np.minimum(dt_adv, dt_diff), decimals=1)
712
+
713
+ def interpolate_coupling_grid_to_sensor(
714
+ self,
715
+ sensor_object: SensorGroup,
716
+ scaled_coupling: sp.csr_array,
717
+ time_index_sensor: np.ndarray,
718
+ i_time: int,
719
+ coupling_sensor: dict,
720
+ ) -> dict:
721
+ """Interpolate coupling grid values to sensor locations.
722
+
723
+ Calculate the coupling for each sensor at a given time step. This function interpolates plume coupling values
724
+ from the coupling matrix to each sensor's location for a specific time step, and updates the output dictionary
725
+ with the results.
726
+
727
+ Args:
728
+ sensor_object (SensorGroup): object containing sensor data.
729
+ scaled_coupling (sp.csr_array): The sparse matrix representing coupling values between sources and grid
730
+ cells for the current time step.
731
+ time_index_sensor (np.ndarray): An array mapping each sensor to its corresponding time step index.
732
+ i_time (int): The index of the current time step.
733
+ coupling_sensor (dict): The output dictionary to be updated with coupling values for each sensor.
734
+
735
+ Returns:
736
+ coupling_sensor (dict): The updated output dictionary with interpolated coupling values at each sensor
737
+ location for the current time step.
738
+
739
+ """
740
+ for key, sensor in sensor_object.items():
741
+ observation_index = time_index_sensor[key] == i_time
742
+ if np.any(observation_index):
743
+ sensor_location = sensor.location.to_array(dim=self.number_dimensions)
744
+ coupling_interp = self._build_interpolator(
745
+ scaled_coupling.toarray(), locations_to_interpolate=sensor_location, method="nearest"
746
+ )
747
+ if isinstance(sensor, Beam):
748
+ coupling_sensor[key][observation_index, :] = np.mean(coupling_interp, axis=0)
749
+ else:
750
+ coupling_sensor[key][observation_index, :] = coupling_interp.flatten()
751
+ return coupling_sensor
752
+
753
+ def _build_interpolator(
754
+ self, tabular_values: np.ndarray, locations_to_interpolate: np.ndarray, method: str = "linear"
755
+ ) -> np.ndarray:
756
+ """Build an interpolator for given tabular values and interpolate at specified locations.
757
+
758
+ Interpolates values at specified locations using interpolation with the method of choosing within the grid,
759
+ and nearest-neighbor extrapolation for out-of-bounds points.
760
+
761
+ Args:
762
+ tabular_values (np.ndarray): Array of data values defined on the grid.
763
+ locations_to_interpolate (np.ndarray): Points at which to evaluate the interpolator, shape (M, D), where D
764
+ is the number of dimensions.
765
+ method (str): Interpolation method to use. Options are 'linear', 'nearest', etc.
766
+
767
+ Returns:
768
+ combined_result (np.ndarray): Interpolated values at the specified locations.
769
+
770
+ """
771
+ shape = list(self.grid_size) + [-1]
772
+ reshaped_values = tabular_values.reshape(*shape)
773
+ method_interp = RegularGridInterpolator(
774
+ self.grid_centers,
775
+ reshaped_values,
776
+ method=method,
777
+ bounds_error=False,
778
+ fill_value=np.nan,
779
+ )
780
+ nearest_interp = RegularGridInterpolator(
781
+ self.grid_centers,
782
+ reshaped_values,
783
+ method="nearest",
784
+ bounds_error=False,
785
+ fill_value=None,
786
+ )
787
+ method_result = method_interp(locations_to_interpolate)
788
+ nearest_result = nearest_interp(locations_to_interpolate)
789
+ combined_result = np.where(np.isnan(method_result), nearest_result, method_result)
790
+ return combined_result
791
+
792
+ def _prepare_sensor(self, sensor_object: SensorGroup) -> SensorGroup:
793
+ """Add beam knots to the sensor object for Beam sensors and convert all sensor locations to ENU coordinates.
794
+
795
+ Args:
796
+ sensor_object (SensorGroup): SensorGroup object containing sensor observations.
797
+
798
+ Returns:
799
+ sensor_object_beam_knots_added (SensorGroup): A new SensorGroup object with beam knots added for Beam
800
+ sensors.
801
+
802
+ """
803
+ sensor_object_beam_knots_added = deepcopy(sensor_object)
804
+ for _, sensor in sensor_object_beam_knots_added.items():
805
+ sensor.location = sensor.location.to_enu(
806
+ ref_latitude=self.grid_coordinates.ref_latitude,
807
+ ref_longitude=self.grid_coordinates.ref_longitude,
808
+ ref_altitude=self.grid_coordinates.ref_altitude,
809
+ )
810
+ if isinstance(sensor, Beam):
811
+ sensor_array = sensor.make_beam_knots(
812
+ ref_latitude=self.grid_coordinates.ref_latitude,
813
+ ref_longitude=self.grid_coordinates.ref_longitude,
814
+ ref_altitude=self.grid_coordinates.ref_altitude,
815
+ )
816
+ sensor.location.from_array(sensor_array)
817
+
818
+ return sensor_object_beam_knots_added
819
+
820
+ def _calculate_number_burn_steps(self, meteorology_object: Meteorology) -> int:
821
+ """Compute the number of burn-in steps for plume stabilization.
822
+
823
+ Computes the approximate amount of time required for a gas parcel to traverse the entire solver domain, based on
824
+ the initial wind conditions. Then, based on the model time step (self.dt), computes the approximate number of
825
+ time steps required for the plume to stabilize before the main analysis begins.
826
+
827
+ If burn_in_steady_state is False, the function returns 0.
828
+
829
+ burn steps are calculated as:
830
+ n_burn_steps = ceil(2 * max_domain_size / (max_wind_speed * dt))
831
+ roughly the time for a plume to travel across the domain twice. Note only consider the horizontal dimensions.
832
+
833
+ Args:
834
+ meteorology_object (Meteorology): Object providing wind field or other meteorological data over time.
835
+
836
+ Returns:
837
+ n_burn_steps (int): The number of burn steps to be used in the coupling calculations.
838
+
839
+ """
840
+ if self.burn_in_steady_state is False:
841
+ return 0
842
+ meteorology_object.calculate_wind_speed_from_uv()
843
+ n_burn_steps = int(
844
+ np.ceil(
845
+ 2
846
+ * np.max([(dim.limits[1] - dim.limits[0]) for dim in self.dimensions[:1]])
847
+ / (meteorology_object.wind_speed[0] * self.dt)
848
+ )
849
+ )
850
+ return n_burn_steps
851
+
852
+
853
+ @dataclass
854
+ class FiniteVolumeDimension:
855
+ """Individual grid dimension for the finite volume method.
856
+
857
+ Assuming that each solver dimension is a regular grid, this class stores grid properties, such as cell edges,
858
+ centre points and cell widths.
859
+
860
+ Attributes:
861
+ label (str): name of this dimension (e.g., 'x', 'y', 'z').
862
+ number_cells (int): number of cells in this dimension.
863
+ limits (list): limits of this dimension (e.g., [0, 100]).
864
+ external_boundary_type (list): type of boundary condition for the faces in this dimension
865
+ e.g., external_boundary_type=['dirichlet', 'neumann'].
866
+ If only 1 type is specified, it is used for both faces of this dimension.
867
+
868
+ cell_edges (np.ndarray): shape=(self.number_cells + 1,) edge locations for the cells in this dimension.
869
+ cell_centers (np.ndarray): shape=(self.number_cells,) central locations of the cells in this dimension.
870
+ cell_width (float): width of the cells in this dimension.
871
+ faces (list(FiniteVolumeFaceLeft, FiniteVolumeFaceRight)): list of objects corresponding to the left and right
872
+ (-ve and +ve) faces of this dimension.
873
+
874
+ """
875
+
876
+ label: str
877
+ number_cells: int
878
+ limits: list
879
+ external_boundary_type: list = field(default_factory=list)
880
+ cell_edges: np.ndarray = field(init=False)
881
+ cell_centers: np.ndarray = field(init=False)
882
+ cell_width: float = field(init=False)
883
+ faces: list = field(init=False)
884
+
885
+ def __post_init__(self) -> None:
886
+ """Post-initialization processing.
887
+
888
+ Validates the external boundary types and initializes the face objects for the dimension. Also calls
889
+ get_dimensions to calculate and store geometric properties of the dimension.
890
+
891
+ Raises:
892
+ ValueError: external_boundary_type must one of ['dirichlet', 'neumann'].
893
+ ValueError: number_cells must be at least 2.
894
+
895
+ """
896
+ if not isinstance(self.external_boundary_type, list):
897
+ raise ValueError("external_boundary_type must be a list.")
898
+ if self.number_cells < 2:
899
+ raise ValueError("number_cells must be at least 2")
900
+ if len(self.external_boundary_type) == 1:
901
+ self.external_boundary_type = [self.external_boundary_type[0], self.external_boundary_type[0]]
902
+ self.faces = [
903
+ FiniteVolumeFaceLeft(self.external_boundary_type[0]),
904
+ FiniteVolumeFaceRight(self.external_boundary_type[1]),
905
+ ]
906
+ self.get_dimensions()
907
+
908
+ def get_dimensions(self) -> None:
909
+ """Setup the face properties for the finite volume method.
910
+
911
+ This function calculates and stores the grid cell edges, cell centres and cell widths, and assigns the cell
912
+ width values to the cell faces.
913
+
914
+ """
915
+ self.cell_edges = np.linspace(self.limits[0], self.limits[1], self.number_cells + 1)
916
+ self.cell_centers = 0.5 * (self.cell_edges[:-1] + self.cell_edges[1:])
917
+ self.cell_width = self.cell_edges[1] - self.cell_edges[0]
918
+ for face in self.faces:
919
+ face.cell_width = self.cell_width
920
+
921
+
922
+ @dataclass
923
+ class FiniteVolumeFace(ABC):
924
+ """Face type for a grid cell in the finite volume method.
925
+
926
+ Attributes:
927
+ external_boundary_type (str): The type of boundary condition for the face. either 'dirichlet' or 'neumann'.
928
+
929
+ cell_face_area (float): The area of the face.
930
+ cell_volume (float): The volume of the face.
931
+ cell_width (float): The width of the cell in the direction normal to the face.
932
+ boundary_type (np.ndarray): shape=(total_number_cells, 1). The type of boundary condition for the face. Each
933
+ entry is a string, either 'internal', 'dirichlet' or 'neumann'.
934
+ neighbour_index (np.ndarray): shape=(total_number_cells, 1). The index of the neighboring cell across the face.
935
+ adv_diff_terms (dict): The advection and diffusion terms for the face. Dictionary has two entries: "advection"
936
+ and "diffusion", each containing a SolverDiagonals object.
937
+
938
+ """
939
+
940
+ external_boundary_type: str
941
+ cell_face_area: float = field(init=False)
942
+ cell_volume: float = field(init=False)
943
+ cell_width: float = field(init=False)
944
+ boundary_type: np.ndarray = field(init=False)
945
+ neighbour_index: np.ndarray = field(init=False)
946
+ adv_diff_terms: dict = field(init=False)
947
+
948
+ @property
949
+ @abstractmethod
950
+ def normal(self):
951
+ """Abstract property to be defined in subclasses."""
952
+
953
+ def __post_init__(self) -> None:
954
+ if self.external_boundary_type not in ["dirichlet", "neumann"]:
955
+ raise ValueError(f"Invalid external boundary type: {self.external_boundary_type}. ")
956
+ self.adv_diff_terms = {"advection": SolverDiagonals(), "diffusion": SolverDiagonals()}
957
+
958
+ def set_boundary_type(self, external_boundaries: np.ndarray, site_layout: Union[SiteLayout, None] = None) -> None:
959
+ """Set the boundary condition for the face based on the external boundary type.
960
+
961
+ External boundaries are set to 'dirichlet' or 'neumann' based on the specified external_boundary_type. Internal
962
+ boundaries are set to 'internal'.
963
+
964
+ The function also handles the case where the face is affected by an obstacle. Obstacle boundaries are set to
965
+ 'neumann'.
966
+
967
+ Args:
968
+ external_boundaries (np.ndarray): shape=(total_number_cells, 1). Boolean array indicating which faces are
969
+ external boundaries.
970
+ site_layout (Union[SiteLayout, None]): SiteLayout object containing obstacle information. Defaults to None.
971
+
972
+ """
973
+ self.boundary_type = np.full(self.neighbour_index.shape, "internal", dtype="<U10")
974
+ self.boundary_type[external_boundaries] = self.external_boundary_type
975
+ if site_layout is not None:
976
+ faces_affected_obstacle = np.isin(self.neighbour_index, np.nonzero(site_layout.id_obstacles)[0])
977
+ self.boundary_type[np.logical_or(faces_affected_obstacle, site_layout.id_obstacles)] = "neumann"
978
+
979
+ def assign_advection(self, wind_vector: np.ndarray) -> None:
980
+ """Assigns the advection terms for the defined set of interfaces to adv_diff_terms['advection'].
981
+
982
+ Uses an upwind scheme for the discretization of the advection term:
983
+ https://en.wikipedia.org/wiki/Upwind_scheme#:~:text=In#20computational#20physics#2C#20the#20term,derivatives#20in#20a#20flow#20field.
984
+
985
+ Upwind scheme for a single dimension has the following form:
986
+ F_i = A * [u^{+} * (c_i - c_{i-1}) + u^{-} * (c_{i+1} - c_{i})]
987
+ where u^{+} = -min(-u, 0) and u^{-} = max(-u, 0), A is the face area, and indices corresponding to other
988
+ dimensions have been dropped.
989
+
990
+ Args:
991
+ wind_vector (np.ndarray): shape=(total_number_cells, 1). Wind speed vector in dimension of this face
992
+ e.g. x, y, z.
993
+
994
+ """
995
+ term = self.adv_diff_terms["advection"]
996
+ u_norm = wind_vector * self.normal
997
+ term.B_central = -self.cell_face_area * -np.minimum(-u_norm, 0)
998
+ neighbour_advection = self.cell_face_area * np.maximum(-u_norm, 0)
999
+ term.B_neighbour = (self.boundary_type == "internal") * neighbour_advection
1000
+ term.b_dirichlet = (self.boundary_type == "dirichlet") * neighbour_advection
1001
+ term.b_neumann = (self.boundary_type == "neumann") * neighbour_advection
1002
+
1003
+ def assign_diffusion(self, diffusion_constants: float) -> None:
1004
+ """Assigns the diffusion terms for the defined set of interfaces to adv_diff_terms['diffusion'].
1005
+
1006
+ If diffusion is already set this function is skipped as the diffusion term is constant.
1007
+
1008
+ The diffusion term for a single dimension has the following form:
1009
+ G_i = K * A * [(c_{i+1} - c_i) / delta - (c_i - c_{i-1}) / delta]
1010
+ where K is the diffusion constant, A is the face area, delta is the cell width, and indices corresponding to
1011
+ other dimensions have been dropped.
1012
+
1013
+ Args:
1014
+ diffusion_constants (float) : diffusion coefficient in this dimension.
1015
+
1016
+ """
1017
+ term = self.adv_diff_terms["diffusion"]
1018
+ if term.B_central is None:
1019
+ diffusion_coefficient = self.cell_face_area * diffusion_constants / self.cell_width
1020
+ term.B_central = -diffusion_coefficient * np.ones(self.boundary_type.shape)
1021
+ term.B_neighbour = (self.boundary_type == "internal") * diffusion_coefficient
1022
+ term.b_dirichlet = (self.boundary_type == "dirichlet") * diffusion_coefficient
1023
+ term.b_neumann = (self.boundary_type == "neumann") * diffusion_coefficient
1024
+
1025
+
1026
+ @dataclass
1027
+ class FiniteVolumeFaceLeft(FiniteVolumeFace):
1028
+ """Set up face properties specific to a left-facing cell (i.e. outward normal is the negative unit vector).
1029
+
1030
+ Attributes:
1031
+ direction (str): direction of the face, either 'left' or 'right'.
1032
+ shift (int): shift in the grid index to find the neighbour cell. -1 for left face.
1033
+ normal (int): normal vector for the face. -1 for left face.
1034
+
1035
+ """
1036
+
1037
+ direction: str = "left"
1038
+ shift: int = -1
1039
+ normal: int = -1
1040
+
1041
+
1042
+ @dataclass
1043
+ class FiniteVolumeFaceRight(FiniteVolumeFace):
1044
+ """Set up face properties specific to a right-facing cell (i.e. outward normal is the positive unit vector).
1045
+
1046
+ Attributes:
1047
+ direction (str): direction of the face, either 'left' or 'right'.
1048
+ shift (int): shift in the grid index to find the neighbour cell. +1 for right face.
1049
+ normal (int): normal vector for the face. +1 for right face.
1050
+
1051
+ """
1052
+
1053
+ direction: str = "right"
1054
+ shift: int = 1
1055
+ normal: int = 1
1056
+
1057
+
1058
+ @dataclass
1059
+ class SolverDiagonals:
1060
+ """Storage for the diagonals of the solver matrix for the finite volume method on a regular grid.
1061
+
1062
+ This class holds the diagonal components to construct the solver matrix. It is used for advection, diffusion and
1063
+ combined terms.
1064
+
1065
+ Attributes:
1066
+ B (Union[np.ndarray, None]): shape=(total_number_cells, 1 + number_faces). Array containing all solver
1067
+ diagonals, i.e. containing all diagonals from self.B_central and self.B_neighbour. The first column is the
1068
+ central diagonal and the remaining columns are the off-diagonal terms.
1069
+ B_central (Union[np.ndarray, None]): shape=(total_number_cells, 1). Array containing the central diagonal of the
1070
+ solver matrix.
1071
+ B_neighbour (Union[np.ndarray, None]): shape=(total_number_cells, number_faces). Array containing the
1072
+ off-diagonals of the solver matrix.
1073
+ b_dirichlet (Union[np.ndarray, None]): shape=(total_number_cells, 1). Vector containing contributions from
1074
+ Dirichlet boundary conditions at edge cells.
1075
+ b_neumann (Union[np.ndarray, None]): shape=(total_number_cells, 1). Vector containing contributions from Neumann
1076
+ boundary conditions.
1077
+
1078
+ """
1079
+
1080
+ B: Union[np.ndarray, None] = field(default=None, init=False)
1081
+ B_central: Union[np.ndarray, None] = field(default=None, init=False)
1082
+ B_neighbour: Union[np.ndarray, None] = field(default=None, init=False)
1083
+ b_dirichlet: Union[np.ndarray, None] = field(default=None, init=False)
1084
+ b_neumann: Union[np.ndarray, None] = field(default=None, init=False)