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.
- pyelq/__init__.py +1 -0
- pyelq/component/__init__.py +1 -0
- pyelq/component/background.py +19 -13
- pyelq/component/component.py +2 -1
- pyelq/component/error_model.py +2 -1
- pyelq/component/offset.py +2 -1
- pyelq/component/source_model.py +78 -29
- pyelq/coordinate_system.py +1 -0
- pyelq/data_access/__init__.py +1 -0
- pyelq/data_access/data_access.py +1 -1
- pyelq/dispersion_model/__init__.py +4 -3
- pyelq/dispersion_model/dispersion_model.py +202 -0
- pyelq/dispersion_model/finite_volume.py +1084 -0
- pyelq/dispersion_model/gaussian_plume.py +8 -189
- pyelq/dispersion_model/site_layout.py +97 -0
- pyelq/dlm.py +11 -15
- pyelq/gas_species.py +1 -0
- pyelq/meteorology/__init__.py +6 -0
- pyelq/{meteorology.py → meteorology/meteorology.py} +388 -387
- pyelq/meteorology/meteorology_windfield.py +180 -0
- pyelq/model.py +2 -1
- pyelq/plotting/__init__.py +1 -0
- pyelq/plotting/plot.py +1 -0
- pyelq/preprocessing.py +98 -38
- pyelq/sensor/__init__.py +1 -0
- pyelq/sensor/sensor.py +70 -5
- pyelq/source_map.py +1 -0
- pyelq/support_functions/__init__.py +1 -0
- pyelq/support_functions/post_processing.py +1 -0
- pyelq/support_functions/spatio_temporal_interpolation.py +1 -0
- {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info}/METADATA +45 -44
- pyelq-1.2.0.dist-info/RECORD +37 -0
- {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info}/WHEEL +1 -1
- pyelq-1.1.4.dist-info/RECORD +0 -32
- {pyelq-1.1.4.dist-info → pyelq-1.2.0.dist-info/licenses}/LICENSE.md +0 -0
- {pyelq-1.1.4.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)
|