From 39c7253f3c5d81970b3bdef84d8da09455d958c5 Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 12:04:01 +0000 Subject: [PATCH 01/37] Restructure tests directory to mirror gprMax --- tests/{test_update_functions.py => updates/test_updates.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_update_functions.py => updates/test_updates.py} (100%) diff --git a/tests/test_update_functions.py b/tests/updates/test_updates.py similarity index 100% rename from tests/test_update_functions.py rename to tests/updates/test_updates.py From 0ff41843a8498b9f8b75ea032bc761ca25afa2d8 Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 12:54:11 +0000 Subject: [PATCH 02/37] Move CPUUpdates to seperate file --- gprMax/solvers.py | 3 +- gprMax/subgrids/updates.py | 2 +- gprMax/updates/cpu_updates.py | 191 ++++++++++++++++++ gprMax/updates/updates.py | 188 +---------------- .../{test_updates.py => test_cpu_updates.py} | 8 +- 5 files changed, 199 insertions(+), 193 deletions(-) create mode 100644 gprMax/updates/cpu_updates.py rename tests/updates/{test_updates.py => test_cpu_updates.py} (94%) diff --git a/gprMax/solvers.py b/gprMax/solvers.py index 1e041468..baedc0c5 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -20,7 +20,8 @@ import gprMax.config as config from .grid import CUDAGrid, FDTDGrid, OpenCLGrid from .subgrids.updates import create_updates as create_subgrid_updates -from .updates.updates import CPUUpdates, CUDAUpdates, OpenCLUpdates +from .updates.cpu_updates import CPUUpdates +from .updates.updates import CUDAUpdates, OpenCLUpdates def create_G(): diff --git a/gprMax/subgrids/updates.py b/gprMax/subgrids/updates.py index 04805d93..a1cb4a67 100644 --- a/gprMax/subgrids/updates.py +++ b/gprMax/subgrids/updates.py @@ -18,7 +18,7 @@ import logging -from ..updates.updates import CPUUpdates +from ..updates.cpu_updates import CPUUpdates from .precursor_nodes import PrecursorNodes, PrecursorNodesFiltered from .subgrid_hsg import SubGridHSG diff --git a/gprMax/updates/cpu_updates.py b/gprMax/updates/cpu_updates.py new file mode 100644 index 00000000..12489641 --- /dev/null +++ b/gprMax/updates/cpu_updates.py @@ -0,0 +1,191 @@ +from importlib import import_module + +from gprMax import config + +from ..cython.fields_updates_normal import update_electric as update_electric_cpu +from ..cython.fields_updates_normal import update_magnetic as update_magnetic_cpu +from ..fields_outputs import store_outputs as store_outputs_cpu +from ..utilities.utilities import timer + + +class CPUUpdates: + """Defines update functions for CPU-based solver.""" + + def __init__(self, G): + """ + Args: + G: FDTDGrid class describing a grid in a model. + """ + + self.grid = G + + def store_outputs(self): + """Stores field component values for every receiver and transmission line.""" + store_outputs_cpu(self.grid) + + def store_snapshots(self, iteration): + """Stores any snapshots. + + Args: + iteration: int for iteration number. + """ + for snap in self.grid.snapshots: + if snap.time == iteration + 1: + snap.store(self.grid) + + def update_magnetic(self): + """Updates magnetic field components.""" + update_magnetic_cpu( + self.grid.nx, + self.grid.ny, + self.grid.nz, + config.get_model_config().ompthreads, + self.grid.updatecoeffsH, + self.grid.ID, + self.grid.Ex, + self.grid.Ey, + self.grid.Ez, + self.grid.Hx, + self.grid.Hy, + self.grid.Hz, + ) + + def update_magnetic_pml(self): + """Updates magnetic field components with the PML correction.""" + for pml in self.grid.pmls["slabs"]: + pml.update_magnetic() + + def update_magnetic_sources(self): + """Updates magnetic field components from sources.""" + for source in self.grid.transmissionlines + self.grid.magneticdipoles: + source.update_magnetic( + self.grid.iteration, + self.grid.updatecoeffsH, + self.grid.ID, + self.grid.Hx, + self.grid.Hy, + self.grid.Hz, + self.grid, + ) + + def update_electric_a(self): + """Updates electric field components.""" + # All materials are non-dispersive so do standard update. + if config.get_model_config().materials["maxpoles"] == 0: + update_electric_cpu( + self.grid.nx, + self.grid.ny, + self.grid.nz, + config.get_model_config().ompthreads, + self.grid.updatecoeffsE, + self.grid.ID, + self.grid.Ex, + self.grid.Ey, + self.grid.Ez, + self.grid.Hx, + self.grid.Hy, + self.grid.Hz, + ) + + # If there are any dispersive materials do 1st part of dispersive update + # (it is split into two parts as it requires present and updated electric field values). + else: + self.dispersive_update_a( + self.grid.nx, + self.grid.ny, + self.grid.nz, + config.get_model_config().ompthreads, + config.get_model_config().materials["maxpoles"], + self.grid.updatecoeffsE, + self.grid.updatecoeffsdispersive, + self.grid.ID, + self.grid.Tx, + self.grid.Ty, + self.grid.Tz, + self.grid.Ex, + self.grid.Ey, + self.grid.Ez, + self.grid.Hx, + self.grid.Hy, + self.grid.Hz, + ) + + def update_electric_pml(self): + """Updates electric field components with the PML correction.""" + for pml in self.grid.pmls["slabs"]: + pml.update_electric() + + def update_electric_sources(self): + """Updates electric field components from sources - + update any Hertzian dipole sources last. + """ + for source in self.grid.voltagesources + self.grid.transmissionlines + self.grid.hertziandipoles: + source.update_electric( + self.grid.iteration, + self.grid.updatecoeffsE, + self.grid.ID, + self.grid.Ex, + self.grid.Ey, + self.grid.Ez, + self.grid, + ) + self.grid.iteration += 1 + + def update_electric_b(self): + """If there are any dispersive materials do 2nd part of dispersive + update - it is split into two parts as it requires present and + updated electric field values. Therefore it can only be completely + updated after the electric field has been updated by the PML and + source updates. + """ + if config.get_model_config().materials["maxpoles"] > 0: + self.dispersive_update_b( + self.grid.nx, + self.grid.ny, + self.grid.nz, + config.get_model_config().ompthreads, + config.get_model_config().materials["maxpoles"], + self.grid.updatecoeffsdispersive, + self.grid.ID, + self.grid.Tx, + self.grid.Ty, + self.grid.Tz, + self.grid.Ex, + self.grid.Ey, + self.grid.Ez, + ) + + def set_dispersive_updates(self): + """Sets dispersive update functions.""" + + poles = "multi" if config.get_model_config().materials["maxpoles"] > 1 else "1" + precision = "float" if config.sim_config.general["precision"] == "single" else "double" + dispersion = ( + "complex" + if config.get_model_config().materials["dispersivedtype"] == config.sim_config.dtypes["complex"] + else "real" + ) + + update_f = "update_electric_dispersive_{}pole_{}_{}_{}" + disp_a = update_f.format(poles, "A", precision, dispersion) + disp_b = update_f.format(poles, "B", precision, dispersion) + + disp_a_f = getattr(import_module("gprMax.cython.fields_updates_dispersive"), disp_a) + disp_b_f = getattr(import_module("gprMax.cython.fields_updates_dispersive"), disp_b) + + self.dispersive_update_a = disp_a_f + self.dispersive_update_b = disp_b_f + + def time_start(self): + """Starts timer used to calculate solving time for model.""" + self.timestart = timer() + + def calculate_solve_time(self): + """Calculates solving time for model.""" + return timer() - self.timestart + + def finalise(self): + pass + + def cleanup(self): + pass diff --git a/gprMax/updates/updates.py b/gprMax/updates/updates.py index 5da7e11a..e8fd6d54 100644 --- a/gprMax/updates/updates.py +++ b/gprMax/updates/updates.py @@ -26,200 +26,14 @@ from jinja2 import Environment, PackageLoader import gprMax.config as config from ..cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_updates, knl_store_outputs -from ..cython.fields_updates_normal import update_electric as update_electric_cpu -from ..cython.fields_updates_normal import update_magnetic as update_magnetic_cpu -from ..fields_outputs import store_outputs as store_outputs_cpu from ..receivers import dtoh_rx_array, htod_rx_arrays from ..snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array from ..sources import htod_src_arrays -from ..utilities.utilities import round32, timer +from ..utilities.utilities import round32 logger = logging.getLogger(__name__) -class CPUUpdates: - """Defines update functions for CPU-based solver.""" - - def __init__(self, G): - """ - Args: - G: FDTDGrid class describing a grid in a model. - """ - - self.grid = G - - def store_outputs(self): - """Stores field component values for every receiver and transmission line.""" - store_outputs_cpu(self.grid) - - def store_snapshots(self, iteration): - """Stores any snapshots. - - Args: - iteration: int for iteration number. - """ - for snap in self.grid.snapshots: - if snap.time == iteration + 1: - snap.store(self.grid) - - def update_magnetic(self): - """Updates magnetic field components.""" - update_magnetic_cpu( - self.grid.nx, - self.grid.ny, - self.grid.nz, - config.get_model_config().ompthreads, - self.grid.updatecoeffsH, - self.grid.ID, - self.grid.Ex, - self.grid.Ey, - self.grid.Ez, - self.grid.Hx, - self.grid.Hy, - self.grid.Hz, - ) - - def update_magnetic_pml(self): - """Updates magnetic field components with the PML correction.""" - for pml in self.grid.pmls["slabs"]: - pml.update_magnetic() - - def update_magnetic_sources(self): - """Updates magnetic field components from sources.""" - for source in self.grid.transmissionlines + self.grid.magneticdipoles: - source.update_magnetic( - self.grid.iteration, - self.grid.updatecoeffsH, - self.grid.ID, - self.grid.Hx, - self.grid.Hy, - self.grid.Hz, - self.grid, - ) - - def update_electric_a(self): - """Updates electric field components.""" - # All materials are non-dispersive so do standard update. - if config.get_model_config().materials["maxpoles"] == 0: - update_electric_cpu( - self.grid.nx, - self.grid.ny, - self.grid.nz, - config.get_model_config().ompthreads, - self.grid.updatecoeffsE, - self.grid.ID, - self.grid.Ex, - self.grid.Ey, - self.grid.Ez, - self.grid.Hx, - self.grid.Hy, - self.grid.Hz, - ) - - # If there are any dispersive materials do 1st part of dispersive update - # (it is split into two parts as it requires present and updated electric field values). - else: - self.dispersive_update_a( - self.grid.nx, - self.grid.ny, - self.grid.nz, - config.get_model_config().ompthreads, - config.get_model_config().materials["maxpoles"], - self.grid.updatecoeffsE, - self.grid.updatecoeffsdispersive, - self.grid.ID, - self.grid.Tx, - self.grid.Ty, - self.grid.Tz, - self.grid.Ex, - self.grid.Ey, - self.grid.Ez, - self.grid.Hx, - self.grid.Hy, - self.grid.Hz, - ) - - def update_electric_pml(self): - """Updates electric field components with the PML correction.""" - for pml in self.grid.pmls["slabs"]: - pml.update_electric() - - def update_electric_sources(self): - """Updates electric field components from sources - - update any Hertzian dipole sources last. - """ - for source in self.grid.voltagesources + self.grid.transmissionlines + self.grid.hertziandipoles: - source.update_electric( - self.grid.iteration, - self.grid.updatecoeffsE, - self.grid.ID, - self.grid.Ex, - self.grid.Ey, - self.grid.Ez, - self.grid, - ) - self.grid.iteration += 1 - - def update_electric_b(self): - """If there are any dispersive materials do 2nd part of dispersive - update - it is split into two parts as it requires present and - updated electric field values. Therefore it can only be completely - updated after the electric field has been updated by the PML and - source updates. - """ - if config.get_model_config().materials["maxpoles"] > 0: - self.dispersive_update_b( - self.grid.nx, - self.grid.ny, - self.grid.nz, - config.get_model_config().ompthreads, - config.get_model_config().materials["maxpoles"], - self.grid.updatecoeffsdispersive, - self.grid.ID, - self.grid.Tx, - self.grid.Ty, - self.grid.Tz, - self.grid.Ex, - self.grid.Ey, - self.grid.Ez, - ) - - def set_dispersive_updates(self): - """Sets dispersive update functions.""" - - poles = "multi" if config.get_model_config().materials["maxpoles"] > 1 else "1" - precision = "float" if config.sim_config.general["precision"] == "single" else "double" - dispersion = ( - "complex" - if config.get_model_config().materials["dispersivedtype"] == config.sim_config.dtypes["complex"] - else "real" - ) - - update_f = "update_electric_dispersive_{}pole_{}_{}_{}" - disp_a = update_f.format(poles, "A", precision, dispersion) - disp_b = update_f.format(poles, "B", precision, dispersion) - - disp_a_f = getattr(import_module("gprMax.cython.fields_updates_dispersive"), disp_a) - disp_b_f = getattr(import_module("gprMax.cython.fields_updates_dispersive"), disp_b) - - self.dispersive_update_a = disp_a_f - self.dispersive_update_b = disp_b_f - - def time_start(self): - """Starts timer used to calculate solving time for model.""" - self.timestart = timer() - - def calculate_solve_time(self): - """Calculates solving time for model.""" - return timer() - self.timestart - - def finalise(self): - pass - - def cleanup(self): - pass - - class CUDAUpdates: """Defines update functions for GPU-based (CUDA) solver.""" diff --git a/tests/updates/test_updates.py b/tests/updates/test_cpu_updates.py similarity index 94% rename from tests/updates/test_updates.py rename to tests/updates/test_cpu_updates.py index 71541ab8..5b5060f5 100644 --- a/tests/updates/test_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -8,7 +8,7 @@ from gprMax.grid import FDTDGrid from gprMax.materials import create_built_in_materials from gprMax.model_build_run import GridBuilder from gprMax.pml import CFS -from gprMax.updates.updates import CPUUpdates +from gprMax.updates.cpu_updates import CPUUpdates def build_grid(nx, ny, nz, dl=0.001, dt=3e-9): @@ -50,7 +50,7 @@ def config_mock(monkeypatch): monkeypatch.setattr(config, "get_model_config", _mock_model_config) -def test_update_magnetic_cpu(config_mock): +def test_update_magnetic(config_mock): grid = build_grid(100, 100, 100) expected_value = np.zeros((101, 101, 101)) @@ -72,7 +72,7 @@ def test_update_magnetic_cpu(config_mock): assert np.equal(pml.EPhi2, 0).all() -def test_update_magnetic_pml_cpu(config_mock): +def test_update_magnetic_pml(config_mock): grid = build_grid(100, 100, 100) grid_expected_value = np.zeros((101, 101, 101)) @@ -94,7 +94,7 @@ def test_update_magnetic_pml_cpu(config_mock): assert np.equal(pml.EPhi2, 0).all() -def test_update_electric_pml_cpu(config_mock): +def test_update_electric_pml(config_mock): grid = build_grid(100, 100, 100) grid_expected_value = np.zeros((101, 101, 101)) From c6be40ae20c9f19ecb9f0f57ebb268af7af24ab6 Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 13:07:38 +0000 Subject: [PATCH 03/37] Move CUDAUpdates and OpenCLUpdates to seperate files --- gprMax/solvers.py | 3 +- gprMax/updates/cuda_updates.py | 584 +++++++++++++++ gprMax/updates/opencl_updates.py | 591 +++++++++++++++ gprMax/updates/updates.py | 1163 ------------------------------ 4 files changed, 1177 insertions(+), 1164 deletions(-) create mode 100644 gprMax/updates/cuda_updates.py create mode 100644 gprMax/updates/opencl_updates.py delete mode 100644 gprMax/updates/updates.py diff --git a/gprMax/solvers.py b/gprMax/solvers.py index baedc0c5..b73de79f 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -21,7 +21,8 @@ import gprMax.config as config from .grid import CUDAGrid, FDTDGrid, OpenCLGrid from .subgrids.updates import create_updates as create_subgrid_updates from .updates.cpu_updates import CPUUpdates -from .updates.updates import CUDAUpdates, OpenCLUpdates +from .updates.cuda_updates import CUDAUpdates +from .updates.opencl_updates import OpenCLUpdates def create_G(): diff --git a/gprMax/updates/cuda_updates.py b/gprMax/updates/cuda_updates.py new file mode 100644 index 00000000..1211725c --- /dev/null +++ b/gprMax/updates/cuda_updates.py @@ -0,0 +1,584 @@ +import logging +from importlib import import_module + +import humanize +import numpy as np +from jinja2 import Environment, PackageLoader + +from gprMax import config +from gprMax.cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_updates, knl_store_outputs +from gprMax.receivers import dtoh_rx_array, htod_rx_arrays +from gprMax.snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array +from gprMax.sources import htod_src_arrays +from gprMax.utilities.utilities import round32 + +logger = logging.getLogger(__name__) + + +class CUDAUpdates: + """Defines update functions for GPU-based (CUDA) solver.""" + + def __init__(self, G): + """ + Args: + G: CUDAGrid class describing a grid in a model. + """ + + self.grid = G + + # Import PyCUDA modules + self.drv = import_module("pycuda.driver") + self.source_module = getattr(import_module("pycuda.compiler"), "SourceModule") + self.drv.init() + + # Create device handle and context on specific GPU device (and make it current context) + self.dev = config.get_model_config().device["dev"] + self.ctx = self.dev.make_context() + + # Set common substitutions for use in kernels + # Substitutions in function arguments + self.subs_name_args = { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "COMPLEX": config.get_model_config().materials["dispersiveCdtype"], + } + # Substitutions in function bodies + self.subs_func = { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "CUDA_IDX": "int i = blockIdx.x * blockDim.x + threadIdx.x;", + "NX_FIELDS": self.grid.nx + 1, + "NY_FIELDS": self.grid.ny + 1, + "NZ_FIELDS": self.grid.nz + 1, + "NX_ID": self.grid.ID.shape[1], + "NY_ID": self.grid.ID.shape[2], + "NZ_ID": self.grid.ID.shape[3], + } + + # Enviroment for templating kernels + self.env = Environment(loader=PackageLoader("gprMax", "cuda_opencl")) + + # Initialise arrays on GPU, prepare kernels, and get kernel functions + self._set_macros() + self._set_field_knls() + if self.grid.pmls["slabs"]: + self._set_pml_knls() + if self.grid.rxs: + self._set_rx_knl() + if self.grid.voltagesources + self.grid.hertziandipoles + self.grid.magneticdipoles: + self._set_src_knls() + if self.grid.snapshots: + self._set_snapshot_knl() + + def _build_knl(self, knl_func, subs_name_args, subs_func): + """Builds a CUDA kernel from templates: 1) function name and args; + and 2) function (kernel) body. + + Args: + knl_func: dict containing templates for function name and args, + and function body. + subs_name_args: dict containing substitutions to be used with + function name and args. + subs_func: dict containing substitutions to be used with function + (kernel) body. + + Returns: + knl: string with complete kernel + """ + + name_plus_args = knl_func["args_cuda"].substitute(subs_name_args) + func_body = knl_func["func"].substitute(subs_func) + knl = self.knl_common + "\n" + name_plus_args + "{" + func_body + "}" + + return knl + + def _set_macros(self): + """Common macros to be used in kernels.""" + + # Set specific values for any dispersive materials + if config.get_model_config().materials["maxpoles"] > 0: + NY_MATDISPCOEFFS = self.grid.updatecoeffsdispersive.shape[1] + NX_T = self.grid.Tx.shape[1] + NY_T = self.grid.Tx.shape[2] + NZ_T = self.grid.Tx.shape[3] + else: # Set to one any substitutions for dispersive materials. + NY_MATDISPCOEFFS = 1 + NX_T = 1 + NY_T = 1 + NZ_T = 1 + + self.knl_common = self.env.get_template("knl_common_cuda.tmpl").render( + REAL=config.sim_config.dtypes["C_float_or_double"], + N_updatecoeffsE=self.grid.updatecoeffsE.size, + N_updatecoeffsH=self.grid.updatecoeffsH.size, + NY_MATCOEFFS=self.grid.updatecoeffsE.shape[1], + NY_MATDISPCOEFFS=NY_MATDISPCOEFFS, + NX_FIELDS=self.grid.nx + 1, + NY_FIELDS=self.grid.ny + 1, + NZ_FIELDS=self.grid.nz + 1, + NX_ID=self.grid.ID.shape[1], + NY_ID=self.grid.ID.shape[2], + NZ_ID=self.grid.ID.shape[3], + NX_T=NX_T, + NY_T=NY_T, + NZ_T=NZ_T, + NY_RXCOORDS=3, + NX_RXS=6, + NY_RXS=self.grid.iterations, + NZ_RXS=len(self.grid.rxs), + NY_SRCINFO=4, + NY_SRCWAVES=self.grid.iterations, + NX_SNAPS=Snapshot.nx_max, + NY_SNAPS=Snapshot.ny_max, + NZ_SNAPS=Snapshot.nz_max, + ) + + def _set_field_knls(self): + """Electric and magnetic field updates - prepares kernels, and + gets kernel functions. + """ + + bld = self._build_knl(knl_fields_updates.update_electric, self.subs_name_args, self.subs_func) + knlE = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.update_electric_dev = knlE.get_function("update_electric") + + bld = self._build_knl(knl_fields_updates.update_magnetic, self.subs_name_args, self.subs_func) + knlH = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.update_magnetic_dev = knlH.get_function("update_magnetic") + + self._copy_mat_coeffs(knlE, knlH) + + # If there are any dispersive materials (updates are split into two + # parts as they require present and updated electric field values). + if config.get_model_config().materials["maxpoles"] > 0: + self.subs_func.update( + { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "REALFUNC": config.get_model_config().materials["crealfunc"], + "NX_T": self.grid.Tx.shape[1], + "NY_T": self.grid.Tx.shape[2], + "NZ_T": self.grid.Tx.shape[3], + } + ) + + bld = self._build_knl(knl_fields_updates.update_electric_dispersive_A, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.dispersive_update_a = knl.get_function("update_electric_dispersive_A") + self._copy_mat_coeffs(knl, knl) + + bld = self._build_knl(knl_fields_updates.update_electric_dispersive_B, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.dispersive_update_b = knl.get_function("update_electric_dispersive_B") + self._copy_mat_coeffs(knl, knl) + + # Set blocks per grid and initialise field arrays on GPU + self.grid.set_blocks_per_grid() + self.grid.htod_geometry_arrays() + self.grid.htod_field_arrays() + if config.get_model_config().materials["maxpoles"] > 0: + self.grid.htod_dispersive_arrays() + + def _set_pml_knls(self): + """PMLS - prepares kernels and gets kernel functions.""" + knl_pml_updates_electric = import_module( + "gprMax.cuda_opencl.knl_pml_updates_electric_" + self.grid.pmls["formulation"] + ) + knl_pml_updates_magnetic = import_module( + "gprMax.cuda_opencl.knl_pml_updates_magnetic_" + self.grid.pmls["formulation"] + ) + + # Initialise arrays on GPU, set block per grid, and get kernel functions + for pml in self.grid.pmls["slabs"]: + pml.htod_field_arrays() + pml.set_blocks_per_grid() + knl_name = f"order{len(pml.CFS)}_{pml.direction}" + self.subs_name_args["FUNC"] = knl_name + + knl_electric = getattr(knl_pml_updates_electric, knl_name) + bld = self._build_knl(knl_electric, self.subs_name_args, self.subs_func) + knlE = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + pml.update_electric_dev = knlE.get_function(knl_name) + + knl_magnetic = getattr(knl_pml_updates_magnetic, knl_name) + bld = self._build_knl(knl_magnetic, self.subs_name_args, self.subs_func) + knlH = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + pml.update_magnetic_dev = knlH.get_function(knl_name) + + # Copy material coefficient arrays to constant memory of GPU - must + # be done for each kernel + self._copy_mat_coeffs(knlE, knlH) + + def _set_rx_knl(self): + """Receivers - initialises arrays on GPU, prepares kernel and gets kernel + function. + """ + self.rxcoords_dev, self.rxs_dev = htod_rx_arrays(self.grid) + + self.subs_func.update( + { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "NY_RXCOORDS": 3, + "NX_RXS": 6, + "NY_RXS": self.grid.iterations, + "NZ_RXS": len(self.grid.rxs), + } + ) + + bld = self._build_knl(knl_store_outputs.store_outputs, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.store_outputs_dev = knl.get_function("store_outputs") + + def _set_src_knls(self): + """Sources - initialises arrays on GPU, prepares kernel and gets kernel + function. + """ + self.subs_func.update({"NY_SRCINFO": 4, "NY_SRCWAVES": self.grid.iteration}) + + if self.grid.hertziandipoles: + self.srcinfo1_hertzian_dev, self.srcinfo2_hertzian_dev, self.srcwaves_hertzian_dev = htod_src_arrays( + self.grid.hertziandipoles, self.grid + ) + bld = self._build_knl(knl_source_updates.update_hertzian_dipole, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.update_hertzian_dipole_dev = knl.get_function("update_hertzian_dipole") + if self.grid.magneticdipoles: + self.srcinfo1_magnetic_dev, self.srcinfo2_magnetic_dev, self.srcwaves_magnetic_dev = htod_src_arrays( + self.grid.magneticdipoles, self.grid + ) + bld = self._build_knl(knl_source_updates.update_magnetic_dipole, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.update_magnetic_dipole_dev = knl.get_function("update_magnetic_dipole") + if self.grid.voltagesources: + self.srcinfo1_voltage_dev, self.srcinfo2_voltage_dev, self.srcwaves_voltage_dev = htod_src_arrays( + self.grid.voltagesources, self.grid + ) + bld = self._build_knl(knl_source_updates.update_voltage_source, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.update_voltage_source_dev = knl.get_function("update_voltage_source") + + self._copy_mat_coeffs(knl, knl) + + def _set_snapshot_knl(self): + """Snapshots - initialises arrays on GPU, prepares kernel and gets kernel + function. + """ + ( + self.snapEx_dev, + self.snapEy_dev, + self.snapEz_dev, + self.snapHx_dev, + self.snapHy_dev, + self.snapHz_dev, + ) = htod_snapshot_array(self.grid) + + self.subs_func.update( + { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "NX_SNAPS": Snapshot.nx_max, + "NY_SNAPS": Snapshot.ny_max, + "NZ_SNAPS": Snapshot.nz_max, + } + ) + + bld = self._build_knl(knl_snapshots.store_snapshot, self.subs_name_args, self.subs_func) + knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) + self.store_snapshot_dev = knl.get_function("store_snapshot") + + def _copy_mat_coeffs(self, knlE, knlH): + """Copies material coefficient arrays to constant memory of GPU + (must be <64KB). + + Args: + knlE: kernel for electric field. + knlH: kernel for magnetic field. + """ + + # Check if coefficient arrays will fit on constant memory of GPU + if ( + self.grid.updatecoeffsE.nbytes + self.grid.updatecoeffsH.nbytes + > config.get_model_config().device["dev"].total_constant_memory + ): + device = config.get_model_config().device["dev"] + logger.exception( + f"Too many materials in the model to fit onto " + + f"constant memory of size {humanize.naturalsize(device.total_constant_memory)} " + + f"on {device.deviceID}: {' '.join(device.name().split())}" + ) + raise ValueError + + updatecoeffsE = knlE.get_global("updatecoeffsE")[0] + updatecoeffsH = knlH.get_global("updatecoeffsH")[0] + self.drv.memcpy_htod(updatecoeffsE, self.grid.updatecoeffsE) + self.drv.memcpy_htod(updatecoeffsH, self.grid.updatecoeffsH) + + def store_outputs(self): + """Stores field component values for every receiver.""" + if self.grid.rxs: + self.store_outputs_dev( + np.int32(len(self.grid.rxs)), + np.int32(self.grid.iteration), + self.rxcoords_dev.gpudata, + self.rxs_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + self.grid.Hx_dev.gpudata, + self.grid.Hy_dev.gpudata, + self.grid.Hz_dev.gpudata, + block=(1, 1, 1), + grid=(round32(len(self.grid.rxs)), 1, 1), + ) + + def store_snapshots(self, iteration): + """Stores any snapshots. + + Args: + iteration: int for iteration number. + """ + + for i, snap in enumerate(self.grid.snapshots): + if snap.time == iteration + 1: + snapno = 0 if config.get_model_config().device["snapsgpu2cpu"] else i + self.store_snapshot_dev( + np.int32(snapno), + np.int32(snap.xs), + np.int32(snap.xf), + np.int32(snap.ys), + np.int32(snap.yf), + np.int32(snap.zs), + np.int32(snap.zf), + np.int32(snap.dx), + np.int32(snap.dy), + np.int32(snap.dz), + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + self.grid.Hx_dev.gpudata, + self.grid.Hy_dev.gpudata, + self.grid.Hz_dev.gpudata, + self.snapEx_dev.gpudata, + self.snapEy_dev.gpudata, + self.snapEz_dev.gpudata, + self.snapHx_dev.gpudata, + self.snapHy_dev.gpudata, + self.snapHz_dev.gpudata, + block=Snapshot.tpb, + grid=Snapshot.bpg, + ) + if config.get_model_config().device["snapsgpu2cpu"]: + dtoh_snapshot_array( + self.snapEx_dev.get(), + self.snapEy_dev.get(), + self.snapEz_dev.get(), + self.snapHx_dev.get(), + self.snapHy_dev.get(), + self.snapHz_dev.get(), + 0, + snap, + ) + + def update_magnetic(self): + """Updates magnetic field components.""" + self.update_magnetic_dev( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + self.grid.ID_dev.gpudata, + self.grid.Hx_dev.gpudata, + self.grid.Hy_dev.gpudata, + self.grid.Hz_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + block=self.grid.tpb, + grid=self.grid.bpg, + ) + + def update_magnetic_pml(self): + """Updates magnetic field components with the PML correction.""" + for pml in self.grid.pmls["slabs"]: + pml.update_magnetic() + + def update_magnetic_sources(self): + """Updates magnetic field components from sources.""" + if self.grid.magneticdipoles: + self.update_magnetic_dipole_dev( + np.int32(len(self.grid.magneticdipoles)), + np.int32(self.grid.iteration), + config.sim_config.dtypes["float_or_double"](self.grid.dx), + config.sim_config.dtypes["float_or_double"](self.grid.dy), + config.sim_config.dtypes["float_or_double"](self.grid.dz), + self.srcinfo1_magnetic_dev.gpudata, + self.srcinfo2_magnetic_dev.gpudata, + self.srcwaves_magnetic_dev.gpudata, + self.grid.ID_dev.gpudata, + self.grid.Hx_dev.gpudata, + self.grid.Hy_dev.gpudata, + self.grid.Hz_dev.gpudata, + block=(1, 1, 1), + grid=(round32(len(self.grid.magneticdipoles)), 1, 1), + ) + + def update_electric_a(self): + """Updates electric field components.""" + # All materials are non-dispersive so do standard update. + if config.get_model_config().materials["maxpoles"] == 0: + self.update_electric_dev( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + self.grid.ID_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + self.grid.Hx_dev.gpudata, + self.grid.Hy_dev.gpudata, + self.grid.Hz_dev.gpudata, + block=self.grid.tpb, + grid=self.grid.bpg, + ) + + # If there are any dispersive materials do 1st part of dispersive update + # (it is split into two parts as it requires present and updated electric field values). + else: + self.dispersive_update_a( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + np.int32(config.get_model_config().materials["maxpoles"]), + self.grid.updatecoeffsdispersive_dev.gpudata, + self.grid.Tx_dev.gpudata, + self.grid.Ty_dev.gpudata, + self.grid.Tz_dev.gpudata, + self.grid.ID_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + self.grid.Hx_dev.gpudata, + self.grid.Hy_dev.gpudata, + self.grid.Hz_dev.gpudata, + block=self.grid.tpb, + grid=self.grid.bpg, + ) + + def update_electric_pml(self): + """Updates electric field components with the PML correction.""" + for pml in self.grid.pmls["slabs"]: + pml.update_electric() + + def update_electric_sources(self): + """Updates electric field components from sources - + update any Hertzian dipole sources last. + """ + if self.grid.voltagesources: + self.update_voltage_source_dev( + np.int32(len(self.grid.voltagesources)), + np.int32(self.grid.iteration), + config.sim_config.dtypes["float_or_double"](self.grid.dx), + config.sim_config.dtypes["float_or_double"](self.grid.dy), + config.sim_config.dtypes["float_or_double"](self.grid.dz), + self.srcinfo1_voltage_dev.gpudata, + self.srcinfo2_voltage_dev.gpudata, + self.srcwaves_voltage_dev.gpudata, + self.grid.ID_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + block=(1, 1, 1), + grid=(round32(len(self.grid.voltagesources)), 1, 1), + ) + + if self.grid.hertziandipoles: + self.update_hertzian_dipole_dev( + np.int32(len(self.grid.hertziandipoles)), + np.int32(self.grid.iteration), + config.sim_config.dtypes["float_or_double"](self.grid.dx), + config.sim_config.dtypes["float_or_double"](self.grid.dy), + config.sim_config.dtypes["float_or_double"](self.grid.dz), + self.srcinfo1_hertzian_dev.gpudata, + self.srcinfo2_hertzian_dev.gpudata, + self.srcwaves_hertzian_dev.gpudata, + self.grid.ID_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + block=(1, 1, 1), + grid=(round32(len(self.grid.hertziandipoles)), 1, 1), + ) + + self.grid.iteration += 1 + + def update_electric_b(self): + """If there are any dispersive materials do 2nd part of dispersive + update - it is split into two parts as it requires present and + updated electric field values. Therefore it can only be completely + updated after the electric field has been updated by the PML and + source updates. + """ + if config.get_model_config().materials["maxpoles"] > 0: + self.dispersive_update_b( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + np.int32(config.get_model_config().materials["maxpoles"]), + self.grid.updatecoeffsdispersive_dev.gpudata, + self.grid.Tx_dev.gpudata, + self.grid.Ty_dev.gpudata, + self.grid.Tz_dev.gpudata, + self.grid.ID_dev.gpudata, + self.grid.Ex_dev.gpudata, + self.grid.Ey_dev.gpudata, + self.grid.Ez_dev.gpudata, + block=self.grid.tpb, + grid=self.grid.bpg, + ) + + def time_start(self): + """Starts event timers used to calculate solving time for model.""" + self.iterstart = self.drv.Event() + self.iterend = self.drv.Event() + self.iterstart.record() + self.iterstart.synchronize() + + def calculate_memory_used(self, iteration): + """Calculates memory used on last iteration. + + Args: + iteration: int for iteration number. + + Returns: + Memory (RAM) used on GPU. + """ + if iteration == self.grid.iterations - 1: + # Total minus free memory in current context + return self.drv.mem_get_info()[1] - self.drv.mem_get_info()[0] + + def calculate_solve_time(self): + """Calculates solving time for model.""" + self.iterend.record() + self.iterend.synchronize() + return self.iterstart.time_till(self.iterend) * 1e-3 + + def finalise(self): + """Copies data from GPU back to CPU to save to file(s).""" + # Copy output from receivers array back to correct receiver objects + if self.grid.rxs: + dtoh_rx_array(self.rxs_dev.get(), self.rxcoords_dev.get(), self.grid) + + # Copy data from any snapshots back to correct snapshot objects + if self.grid.snapshots and not config.get_model_config().device["snapsgpu2cpu"]: + for i, snap in enumerate(self.grid.snapshots): + dtoh_snapshot_array( + self.snapEx_dev.get(), + self.snapEy_dev.get(), + self.snapEz_dev.get(), + self.snapHx_dev.get(), + self.snapHy_dev.get(), + self.snapHz_dev.get(), + i, + snap, + ) + + def cleanup(self): + """Cleanup GPU context.""" + # Remove context from top of stack and clear + self.ctx.pop() + self.ctx = None diff --git a/gprMax/updates/opencl_updates.py b/gprMax/updates/opencl_updates.py new file mode 100644 index 00000000..39b92861 --- /dev/null +++ b/gprMax/updates/opencl_updates.py @@ -0,0 +1,591 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + +import logging +from importlib import import_module + +import numpy as np +from jinja2 import Environment, PackageLoader + +from gprMax import config +from gprMax.cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_updates, knl_store_outputs +from gprMax.receivers import dtoh_rx_array, htod_rx_arrays +from gprMax.snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array +from gprMax.sources import htod_src_arrays + +logger = logging.getLogger(__name__) + + +class OpenCLUpdates: + """Defines update functions for OpenCL-based solver.""" + + def __init__(self, G): + """ + Args: + G: OpenCLGrid class describing a grid in a model. + """ + + self.grid = G + + # Import pyopencl module + self.cl = import_module("pyopencl") + self.elwiseknl = getattr(import_module("pyopencl.elementwise"), "ElementwiseKernel") + + # Select device, create context and command queue + self.dev = config.get_model_config().device["dev"] + self.ctx = self.cl.Context(devices=[self.dev]) + self.queue = self.cl.CommandQueue(self.ctx, properties=self.cl.command_queue_properties.PROFILING_ENABLE) + + # Enviroment for templating kernels + self.env = Environment(loader=PackageLoader("gprMax", "cuda_opencl")) + + # Initialise arrays on device, prepare kernels, and get kernel functions + self._set_macros() + self._set_field_knls() + if self.grid.pmls["slabs"]: + self._set_pml_knls() + if self.grid.rxs: + self._set_rx_knl() + if self.grid.voltagesources + self.grid.hertziandipoles + self.grid.magneticdipoles: + self._set_src_knls() + if self.grid.snapshots: + self._set_snapshot_knl() + + def _set_macros(self): + """Common macros to be used in kernels.""" + + # Set specific values for any dispersive materials + if config.get_model_config().materials["maxpoles"] > 0: + NY_MATDISPCOEFFS = self.grid.updatecoeffsdispersive.shape[1] + NX_T = self.grid.Tx.shape[1] + NY_T = self.grid.Tx.shape[2] + NZ_T = self.grid.Tx.shape[3] + else: # Set to one any substitutions for dispersive materials. + NY_MATDISPCOEFFS = 1 + NX_T = 1 + NY_T = 1 + NZ_T = 1 + + self.knl_common = self.env.get_template("knl_common_opencl.tmpl").render( + updatecoeffsE=self.grid.updatecoeffsE.ravel(), + updatecoeffsH=self.grid.updatecoeffsH.ravel(), + REAL=config.sim_config.dtypes["C_float_or_double"], + N_updatecoeffsE=self.grid.updatecoeffsE.size, + N_updatecoeffsH=self.grid.updatecoeffsH.size, + NY_MATCOEFFS=self.grid.updatecoeffsE.shape[1], + NY_MATDISPCOEFFS=NY_MATDISPCOEFFS, + NX_FIELDS=self.grid.nx + 1, + NY_FIELDS=self.grid.ny + 1, + NZ_FIELDS=self.grid.nz + 1, + NX_ID=self.grid.ID.shape[1], + NY_ID=self.grid.ID.shape[2], + NZ_ID=self.grid.ID.shape[3], + NX_T=NX_T, + NY_T=NY_T, + NZ_T=NZ_T, + NY_RXCOORDS=3, + NX_RXS=6, + NY_RXS=self.grid.iterations, + NZ_RXS=len(self.grid.rxs), + NY_SRCINFO=4, + NY_SRCWAVES=self.grid.iterations, + NX_SNAPS=Snapshot.nx_max, + NY_SNAPS=Snapshot.ny_max, + NZ_SNAPS=Snapshot.nz_max, + ) + + def _set_field_knls(self): + """Electric and magnetic field updates - prepares kernels, and + gets kernel functions. + """ + + subs = { + "CUDA_IDX": "", + "NX_FIELDS": self.grid.nx + 1, + "NY_FIELDS": self.grid.ny + 1, + "NZ_FIELDS": self.grid.nz + 1, + "NX_ID": self.grid.ID.shape[1], + "NY_ID": self.grid.ID.shape[2], + "NZ_ID": self.grid.ID.shape[3], + } + + self.update_electric_dev = self.elwiseknl( + self.ctx, + knl_fields_updates.update_electric["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_fields_updates.update_electric["func"].substitute(subs), + "update_electric", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + self.update_magnetic_dev = self.elwiseknl( + self.ctx, + knl_fields_updates.update_magnetic["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_fields_updates.update_magnetic["func"].substitute(subs), + "update_magnetic", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + # If there are any dispersive materials (updates are split into two + # parts as they require present and updated electric field values). + if config.get_model_config().materials["maxpoles"] > 0: + subs = { + "CUDA_IDX": "", + "REAL": config.sim_config.dtypes["C_float_or_double"], + "REALFUNC": config.get_model_config().materials["crealfunc"], + "NX_FIELDS": self.grid.nx + 1, + "NY_FIELDS": self.grid.ny + 1, + "NZ_FIELDS": self.grid.nz + 1, + "NX_ID": self.grid.ID.shape[1], + "NY_ID": self.grid.ID.shape[2], + "NZ_ID": self.grid.ID.shape[3], + "NX_T": self.grid.Tx.shape[1], + "NY_T": self.grid.Tx.shape[2], + "NZ_T": self.grid.Tx.shape[3], + } + + self.dispersive_update_a = self.elwiseknl( + self.ctx, + knl_fields_updates.update_electric_dispersive_A["args_opencl"].substitute( + { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "COMPLEX": config.get_model_config().materials["dispersiveCdtype"], + } + ), + knl_fields_updates.update_electric_dispersive_A["func"].substitute(subs), + "update_electric_dispersive_A", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + self.dispersive_update_b = self.elwiseknl( + self.ctx, + knl_fields_updates.update_electric_dispersive_B["args_opencl"].substitute( + { + "REAL": config.sim_config.dtypes["C_float_or_double"], + "COMPLEX": config.get_model_config().materials["dispersiveCdtype"], + } + ), + knl_fields_updates.update_electric_dispersive_B["func"].substitute(subs), + "update_electric_dispersive_B", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + # Initialise field arrays on compute device + self.grid.htod_geometry_arrays(self.queue) + self.grid.htod_field_arrays(self.queue) + if config.get_model_config().materials["maxpoles"] > 0: + self.grid.htod_dispersive_arrays(self.queue) + + def _set_pml_knls(self): + """PMLS - prepares kernels and gets kernel functions.""" + knl_pml_updates_electric = import_module( + "gprMax.cuda_opencl.knl_pml_updates_electric_" + self.grid.pmls["formulation"] + ) + knl_pml_updates_magnetic = import_module( + "gprMax.cuda_opencl.knl_pml_updates_magnetic_" + self.grid.pmls["formulation"] + ) + + subs = { + "CUDA_IDX": "", + "REAL": config.sim_config.dtypes["C_float_or_double"], + "NX_FIELDS": self.grid.nx + 1, + "NY_FIELDS": self.grid.ny + 1, + "NZ_FIELDS": self.grid.nz + 1, + "NX_ID": self.grid.ID.shape[1], + "NY_ID": self.grid.ID.shape[2], + "NZ_ID": self.grid.ID.shape[3], + } + + # Set workgroup size, initialise arrays on compute device, and get + # kernel functions + for pml in self.grid.pmls["slabs"]: + pml.set_queue(self.queue) + pml.htod_field_arrays() + knl_name = f"order{len(pml.CFS)}_{pml.direction}" + knl_electric_name = getattr(knl_pml_updates_electric, knl_name) + knl_magnetic_name = getattr(knl_pml_updates_magnetic, knl_name) + + pml.update_electric_dev = self.elwiseknl( + self.ctx, + knl_electric_name["args_opencl"].substitute({"REAL": config.sim_config.dtypes["C_float_or_double"]}), + knl_electric_name["func"].substitute(subs), + f"pml_updates_electric_{knl_name}", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + pml.update_magnetic_dev = self.elwiseknl( + self.ctx, + knl_magnetic_name["args_opencl"].substitute({"REAL": config.sim_config.dtypes["C_float_or_double"]}), + knl_magnetic_name["func"].substitute(subs), + f"pml_updates_magnetic_{knl_name}", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + def _set_rx_knl(self): + """Receivers - initialises arrays on compute device, prepares kernel and + gets kernel function. + """ + self.rxcoords_dev, self.rxs_dev = htod_rx_arrays(self.grid, self.queue) + self.store_outputs_dev = self.elwiseknl( + self.ctx, + knl_store_outputs.store_outputs["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_store_outputs.store_outputs["func"].substitute({"CUDA_IDX": ""}), + "store_outputs", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + def _set_src_knls(self): + """Sources - initialises arrays on compute device, prepares kernel and + gets kernel function. + """ + if self.grid.hertziandipoles: + self.srcinfo1_hertzian_dev, self.srcinfo2_hertzian_dev, self.srcwaves_hertzian_dev = htod_src_arrays( + self.grid.hertziandipoles, self.grid, self.queue + ) + self.update_hertzian_dipole_dev = self.elwiseknl( + self.ctx, + knl_source_updates.update_hertzian_dipole["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_source_updates.update_hertzian_dipole["func"].substitute( + {"CUDA_IDX": "", "REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + "update_hertzian_dipole", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + if self.grid.magneticdipoles: + self.srcinfo1_magnetic_dev, self.srcinfo2_magnetic_dev, self.srcwaves_magnetic_dev = htod_src_arrays( + self.grid.magneticdipoles, self.grid, self.queue + ) + self.update_magnetic_dipole_dev = self.elwiseknl( + self.ctx, + knl_source_updates.update_magnetic_dipole["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_source_updates.update_magnetic_dipole["func"].substitute( + {"CUDA_IDX": "", "REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + "update_magnetic_dipole", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + if self.grid.voltagesources: + self.srcinfo1_voltage_dev, self.srcinfo2_voltage_dev, self.srcwaves_voltage_dev = htod_src_arrays( + self.grid.voltagesources, self.grid, self.queue + ) + self.update_voltage_source_dev = self.elwiseknl( + self.ctx, + knl_source_updates.update_voltage_source["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_source_updates.update_voltage_source["func"].substitute( + {"CUDA_IDX": "", "REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + "update_voltage_source", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + def _set_snapshot_knl(self): + """Snapshots - initialises arrays on compute device, prepares kernel and + gets kernel function. + """ + ( + self.snapEx_dev, + self.snapEy_dev, + self.snapEz_dev, + self.snapHx_dev, + self.snapHy_dev, + self.snapHz_dev, + ) = htod_snapshot_array(self.grid, self.queue) + self.store_snapshot_dev = self.elwiseknl( + self.ctx, + knl_snapshots.store_snapshot["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), + knl_snapshots.store_snapshot["func"].substitute( + {"CUDA_IDX": "", "NX_SNAPS": Snapshot.nx_max, "NY_SNAPS": Snapshot.ny_max, "NZ_SNAPS": Snapshot.nz_max} + ), + "store_snapshot", + preamble=self.knl_common, + options=config.sim_config.devices["compiler_opts"], + ) + + def store_outputs(self): + """Stores field component values for every receiver.""" + if self.grid.rxs: + self.store_outputs_dev( + np.int32(len(self.grid.rxs)), + np.int32(self.grid.iteration), + self.rxcoords_dev, + self.rxs_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + self.grid.Hx_dev, + self.grid.Hy_dev, + self.grid.Hz_dev, + ) + + def store_snapshots(self, iteration): + """Stores any snapshots. + + Args: + iteration: int for iteration number. + """ + + for i, snap in enumerate(self.grid.snapshots): + if snap.time == iteration + 1: + snapno = 0 if config.get_model_config().device["snapsgpu2cpu"] else i + self.store_snapshot_dev( + np.int32(snapno), + np.int32(snap.xs), + np.int32(snap.xf), + np.int32(snap.ys), + np.int32(snap.yf), + np.int32(snap.zs), + np.int32(snap.zf), + np.int32(snap.dx), + np.int32(snap.dy), + np.int32(snap.dz), + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + self.grid.Hx_dev, + self.grid.Hy_dev, + self.grid.Hz_dev, + self.snapEx_dev, + self.snapEy_dev, + self.snapEz_dev, + self.snapHx_dev, + self.snapHy_dev, + self.snapHz_dev, + ) + + if config.get_model_config().device["snapsgpu2cpu"]: + dtoh_snapshot_array( + self.snapEx_dev.get(), + self.snapEy_dev.get(), + self.snapEz_dev.get(), + self.snapHx_dev.get(), + self.snapHy_dev.get(), + self.snapHz_dev.get(), + 0, + snap, + ) + + def update_magnetic(self): + """Updates magnetic field components.""" + self.update_magnetic_dev( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + self.grid.ID_dev, + self.grid.Hx_dev, + self.grid.Hy_dev, + self.grid.Hz_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + ) + + def update_magnetic_pml(self): + """Updates magnetic field components with the PML correction.""" + for pml in self.grid.pmls["slabs"]: + pml.update_magnetic() + + def update_magnetic_sources(self): + """Updates magnetic field components from sources.""" + if self.grid.magneticdipoles: + self.update_magnetic_dipole_dev( + np.int32(len(self.grid.magneticdipoles)), + np.int32(self.grid.iteration), + config.sim_config.dtypes["float_or_double"](self.grid.dx), + config.sim_config.dtypes["float_or_double"](self.grid.dy), + config.sim_config.dtypes["float_or_double"](self.grid.dz), + self.srcinfo1_magnetic_dev, + self.srcinfo2_magnetic_dev, + self.srcwaves_magnetic_dev, + self.grid.ID_dev, + self.grid.Hx_dev, + self.grid.Hy_dev, + self.grid.Hz_dev, + ) + + def update_electric_a(self): + """Updates electric field components.""" + # All materials are non-dispersive so do standard update. + if config.get_model_config().materials["maxpoles"] == 0: + self.update_electric_dev( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + self.grid.ID_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + self.grid.Hx_dev, + self.grid.Hy_dev, + self.grid.Hz_dev, + ) + + # If there are any dispersive materials do 1st part of dispersive update + # (it is split into two parts as it requires present and updated electric field values). + else: + self.dispersive_update_a( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + np.int32(config.get_model_config().materials["maxpoles"]), + self.grid.ID_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + self.grid.Hx_dev, + self.grid.Hy_dev, + self.grid.Hz_dev, + self.grid.updatecoeffsdispersive_dev, + self.grid.Tx_dev, + self.grid.Ty_dev, + self.grid.Tz_dev, + ) + + def update_electric_pml(self): + """Updates electric field components with the PML correction.""" + for pml in self.grid.pmls["slabs"]: + pml.update_electric() + + def update_electric_sources(self): + """Updates electric field components from sources - + update any Hertzian dipole sources last. + """ + if self.grid.voltagesources: + self.update_voltage_source_dev( + np.int32(len(self.grid.voltagesources)), + np.int32(self.grid.iteration), + config.sim_config.dtypes["float_or_double"](self.grid.dx), + config.sim_config.dtypes["float_or_double"](self.grid.dy), + config.sim_config.dtypes["float_or_double"](self.grid.dz), + self.srcinfo1_voltage_dev, + self.srcinfo2_voltage_dev, + self.srcwaves_voltage_dev, + self.grid.ID_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + ) + + if self.grid.hertziandipoles: + self.update_hertzian_dipole_dev( + np.int32(len(self.grid.hertziandipoles)), + np.int32(self.grid.iteration), + config.sim_config.dtypes["float_or_double"](self.grid.dx), + config.sim_config.dtypes["float_or_double"](self.grid.dy), + config.sim_config.dtypes["float_or_double"](self.grid.dz), + self.srcinfo1_hertzian_dev, + self.srcinfo2_hertzian_dev, + self.srcwaves_hertzian_dev, + self.grid.ID_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + ) + + self.grid.iteration += 1 + + def update_electric_b(self): + """If there are any dispersive materials do 2nd part of dispersive + update - it is split into two parts as it requires present and + updated electric field values. Therefore it can only be completely + updated after the electric field has been updated by the PML and + source updates. + """ + if config.get_model_config().materials["maxpoles"] > 0: + self.dispersive_update_b( + np.int32(self.grid.nx), + np.int32(self.grid.ny), + np.int32(self.grid.nz), + np.int32(config.get_model_config().materials["maxpoles"]), + self.grid.ID_dev, + self.grid.Ex_dev, + self.grid.Ey_dev, + self.grid.Ez_dev, + self.grid.updatecoeffsdispersive_dev, + self.grid.Tx_dev, + self.grid.Ty_dev, + self.grid.Tz_dev, + ) + + def time_start(self): + """Starts event timers used to calculate solving time for model.""" + self.event_marker1 = self.cl.enqueue_marker(self.queue) + self.event_marker1.wait() + + def calculate_memory_used(self, iteration): + """Calculates memory used on last iteration. + + Args: + iteration: int for iteration number. + + Returns: + Memory (RAM) used on compute device. + """ + # No clear way to determine memory used from PyOpenCL unlike PyCUDA. + pass + + def calculate_solve_time(self): + """Calculates solving time for model.""" + event_marker2 = self.cl.enqueue_marker(self.queue) + event_marker2.wait() + return (event_marker2.profile.end - self.event_marker1.profile.start) * 1e-9 + + def finalise(self): + """Copies data from compute device back to CPU to save to file(s).""" + # Copy output from receivers array back to correct receiver objects + if self.grid.rxs: + dtoh_rx_array(self.rxs_dev.get(), self.rxcoords_dev.get(), self.grid) + + # Copy data from any snapshots back to correct snapshot objects + if self.grid.snapshots and not config.get_model_config().device["snapsgpu2cpu"]: + for i, snap in enumerate(self.grid.snapshots): + dtoh_snapshot_array( + self.snapEx_dev.get(), + self.snapEy_dev.get(), + self.snapEz_dev.get(), + self.snapHx_dev.get(), + self.snapHy_dev.get(), + self.snapHz_dev.get(), + i, + snap, + ) + + def cleanup(self): + pass diff --git a/gprMax/updates/updates.py b/gprMax/updates/updates.py deleted file mode 100644 index e8fd6d54..00000000 --- a/gprMax/updates/updates.py +++ /dev/null @@ -1,1163 +0,0 @@ -# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom -# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley -# -# This file is part of gprMax. -# -# gprMax is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# gprMax is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with gprMax. If not, see . - -import logging -from importlib import import_module - -import humanize -import numpy as np -from jinja2 import Environment, PackageLoader - -import gprMax.config as config - -from ..cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_updates, knl_store_outputs -from ..receivers import dtoh_rx_array, htod_rx_arrays -from ..snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array -from ..sources import htod_src_arrays -from ..utilities.utilities import round32 - -logger = logging.getLogger(__name__) - - -class CUDAUpdates: - """Defines update functions for GPU-based (CUDA) solver.""" - - def __init__(self, G): - """ - Args: - G: CUDAGrid class describing a grid in a model. - """ - - self.grid = G - - # Import PyCUDA modules - self.drv = import_module("pycuda.driver") - self.source_module = getattr(import_module("pycuda.compiler"), "SourceModule") - self.drv.init() - - # Create device handle and context on specific GPU device (and make it current context) - self.dev = config.get_model_config().device["dev"] - self.ctx = self.dev.make_context() - - # Set common substitutions for use in kernels - # Substitutions in function arguments - self.subs_name_args = { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "COMPLEX": config.get_model_config().materials["dispersiveCdtype"], - } - # Substitutions in function bodies - self.subs_func = { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "CUDA_IDX": "int i = blockIdx.x * blockDim.x + threadIdx.x;", - "NX_FIELDS": self.grid.nx + 1, - "NY_FIELDS": self.grid.ny + 1, - "NZ_FIELDS": self.grid.nz + 1, - "NX_ID": self.grid.ID.shape[1], - "NY_ID": self.grid.ID.shape[2], - "NZ_ID": self.grid.ID.shape[3], - } - - # Enviroment for templating kernels - self.env = Environment(loader=PackageLoader("gprMax", "cuda_opencl")) - - # Initialise arrays on GPU, prepare kernels, and get kernel functions - self._set_macros() - self._set_field_knls() - if self.grid.pmls["slabs"]: - self._set_pml_knls() - if self.grid.rxs: - self._set_rx_knl() - if self.grid.voltagesources + self.grid.hertziandipoles + self.grid.magneticdipoles: - self._set_src_knls() - if self.grid.snapshots: - self._set_snapshot_knl() - - def _build_knl(self, knl_func, subs_name_args, subs_func): - """Builds a CUDA kernel from templates: 1) function name and args; - and 2) function (kernel) body. - - Args: - knl_func: dict containing templates for function name and args, - and function body. - subs_name_args: dict containing substitutions to be used with - function name and args. - subs_func: dict containing substitutions to be used with function - (kernel) body. - - Returns: - knl: string with complete kernel - """ - - name_plus_args = knl_func["args_cuda"].substitute(subs_name_args) - func_body = knl_func["func"].substitute(subs_func) - knl = self.knl_common + "\n" + name_plus_args + "{" + func_body + "}" - - return knl - - def _set_macros(self): - """Common macros to be used in kernels.""" - - # Set specific values for any dispersive materials - if config.get_model_config().materials["maxpoles"] > 0: - NY_MATDISPCOEFFS = self.grid.updatecoeffsdispersive.shape[1] - NX_T = self.grid.Tx.shape[1] - NY_T = self.grid.Tx.shape[2] - NZ_T = self.grid.Tx.shape[3] - else: # Set to one any substitutions for dispersive materials. - NY_MATDISPCOEFFS = 1 - NX_T = 1 - NY_T = 1 - NZ_T = 1 - - self.knl_common = self.env.get_template("knl_common_cuda.tmpl").render( - REAL=config.sim_config.dtypes["C_float_or_double"], - N_updatecoeffsE=self.grid.updatecoeffsE.size, - N_updatecoeffsH=self.grid.updatecoeffsH.size, - NY_MATCOEFFS=self.grid.updatecoeffsE.shape[1], - NY_MATDISPCOEFFS=NY_MATDISPCOEFFS, - NX_FIELDS=self.grid.nx + 1, - NY_FIELDS=self.grid.ny + 1, - NZ_FIELDS=self.grid.nz + 1, - NX_ID=self.grid.ID.shape[1], - NY_ID=self.grid.ID.shape[2], - NZ_ID=self.grid.ID.shape[3], - NX_T=NX_T, - NY_T=NY_T, - NZ_T=NZ_T, - NY_RXCOORDS=3, - NX_RXS=6, - NY_RXS=self.grid.iterations, - NZ_RXS=len(self.grid.rxs), - NY_SRCINFO=4, - NY_SRCWAVES=self.grid.iterations, - NX_SNAPS=Snapshot.nx_max, - NY_SNAPS=Snapshot.ny_max, - NZ_SNAPS=Snapshot.nz_max, - ) - - def _set_field_knls(self): - """Electric and magnetic field updates - prepares kernels, and - gets kernel functions. - """ - - bld = self._build_knl(knl_fields_updates.update_electric, self.subs_name_args, self.subs_func) - knlE = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.update_electric_dev = knlE.get_function("update_electric") - - bld = self._build_knl(knl_fields_updates.update_magnetic, self.subs_name_args, self.subs_func) - knlH = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.update_magnetic_dev = knlH.get_function("update_magnetic") - - self._copy_mat_coeffs(knlE, knlH) - - # If there are any dispersive materials (updates are split into two - # parts as they require present and updated electric field values). - if config.get_model_config().materials["maxpoles"] > 0: - self.subs_func.update( - { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "REALFUNC": config.get_model_config().materials["crealfunc"], - "NX_T": self.grid.Tx.shape[1], - "NY_T": self.grid.Tx.shape[2], - "NZ_T": self.grid.Tx.shape[3], - } - ) - - bld = self._build_knl(knl_fields_updates.update_electric_dispersive_A, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.dispersive_update_a = knl.get_function("update_electric_dispersive_A") - self._copy_mat_coeffs(knl, knl) - - bld = self._build_knl(knl_fields_updates.update_electric_dispersive_B, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.dispersive_update_b = knl.get_function("update_electric_dispersive_B") - self._copy_mat_coeffs(knl, knl) - - # Set blocks per grid and initialise field arrays on GPU - self.grid.set_blocks_per_grid() - self.grid.htod_geometry_arrays() - self.grid.htod_field_arrays() - if config.get_model_config().materials["maxpoles"] > 0: - self.grid.htod_dispersive_arrays() - - def _set_pml_knls(self): - """PMLS - prepares kernels and gets kernel functions.""" - knl_pml_updates_electric = import_module( - "gprMax.cuda_opencl.knl_pml_updates_electric_" + self.grid.pmls["formulation"] - ) - knl_pml_updates_magnetic = import_module( - "gprMax.cuda_opencl.knl_pml_updates_magnetic_" + self.grid.pmls["formulation"] - ) - - # Initialise arrays on GPU, set block per grid, and get kernel functions - for pml in self.grid.pmls["slabs"]: - pml.htod_field_arrays() - pml.set_blocks_per_grid() - knl_name = f"order{len(pml.CFS)}_{pml.direction}" - self.subs_name_args["FUNC"] = knl_name - - knl_electric = getattr(knl_pml_updates_electric, knl_name) - bld = self._build_knl(knl_electric, self.subs_name_args, self.subs_func) - knlE = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - pml.update_electric_dev = knlE.get_function(knl_name) - - knl_magnetic = getattr(knl_pml_updates_magnetic, knl_name) - bld = self._build_knl(knl_magnetic, self.subs_name_args, self.subs_func) - knlH = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - pml.update_magnetic_dev = knlH.get_function(knl_name) - - # Copy material coefficient arrays to constant memory of GPU - must - # be done for each kernel - self._copy_mat_coeffs(knlE, knlH) - - def _set_rx_knl(self): - """Receivers - initialises arrays on GPU, prepares kernel and gets kernel - function. - """ - self.rxcoords_dev, self.rxs_dev = htod_rx_arrays(self.grid) - - self.subs_func.update( - { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "NY_RXCOORDS": 3, - "NX_RXS": 6, - "NY_RXS": self.grid.iterations, - "NZ_RXS": len(self.grid.rxs), - } - ) - - bld = self._build_knl(knl_store_outputs.store_outputs, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.store_outputs_dev = knl.get_function("store_outputs") - - def _set_src_knls(self): - """Sources - initialises arrays on GPU, prepares kernel and gets kernel - function. - """ - self.subs_func.update({"NY_SRCINFO": 4, "NY_SRCWAVES": self.grid.iteration}) - - if self.grid.hertziandipoles: - self.srcinfo1_hertzian_dev, self.srcinfo2_hertzian_dev, self.srcwaves_hertzian_dev = htod_src_arrays( - self.grid.hertziandipoles, self.grid - ) - bld = self._build_knl(knl_source_updates.update_hertzian_dipole, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.update_hertzian_dipole_dev = knl.get_function("update_hertzian_dipole") - if self.grid.magneticdipoles: - self.srcinfo1_magnetic_dev, self.srcinfo2_magnetic_dev, self.srcwaves_magnetic_dev = htod_src_arrays( - self.grid.magneticdipoles, self.grid - ) - bld = self._build_knl(knl_source_updates.update_magnetic_dipole, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.update_magnetic_dipole_dev = knl.get_function("update_magnetic_dipole") - if self.grid.voltagesources: - self.srcinfo1_voltage_dev, self.srcinfo2_voltage_dev, self.srcwaves_voltage_dev = htod_src_arrays( - self.grid.voltagesources, self.grid - ) - bld = self._build_knl(knl_source_updates.update_voltage_source, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.update_voltage_source_dev = knl.get_function("update_voltage_source") - - self._copy_mat_coeffs(knl, knl) - - def _set_snapshot_knl(self): - """Snapshots - initialises arrays on GPU, prepares kernel and gets kernel - function. - """ - ( - self.snapEx_dev, - self.snapEy_dev, - self.snapEz_dev, - self.snapHx_dev, - self.snapHy_dev, - self.snapHz_dev, - ) = htod_snapshot_array(self.grid) - - self.subs_func.update( - { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "NX_SNAPS": Snapshot.nx_max, - "NY_SNAPS": Snapshot.ny_max, - "NZ_SNAPS": Snapshot.nz_max, - } - ) - - bld = self._build_knl(knl_snapshots.store_snapshot, self.subs_name_args, self.subs_func) - knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) - self.store_snapshot_dev = knl.get_function("store_snapshot") - - def _copy_mat_coeffs(self, knlE, knlH): - """Copies material coefficient arrays to constant memory of GPU - (must be <64KB). - - Args: - knlE: kernel for electric field. - knlH: kernel for magnetic field. - """ - - # Check if coefficient arrays will fit on constant memory of GPU - if ( - self.grid.updatecoeffsE.nbytes + self.grid.updatecoeffsH.nbytes - > config.get_model_config().device["dev"].total_constant_memory - ): - device = config.get_model_config().device["dev"] - logger.exception( - f"Too many materials in the model to fit onto " - + f"constant memory of size {humanize.naturalsize(device.total_constant_memory)} " - + f"on {device.deviceID}: {' '.join(device.name().split())}" - ) - raise ValueError - - updatecoeffsE = knlE.get_global("updatecoeffsE")[0] - updatecoeffsH = knlH.get_global("updatecoeffsH")[0] - self.drv.memcpy_htod(updatecoeffsE, self.grid.updatecoeffsE) - self.drv.memcpy_htod(updatecoeffsH, self.grid.updatecoeffsH) - - def store_outputs(self): - """Stores field component values for every receiver.""" - if self.grid.rxs: - self.store_outputs_dev( - np.int32(len(self.grid.rxs)), - np.int32(self.grid.iteration), - self.rxcoords_dev.gpudata, - self.rxs_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - self.grid.Hx_dev.gpudata, - self.grid.Hy_dev.gpudata, - self.grid.Hz_dev.gpudata, - block=(1, 1, 1), - grid=(round32(len(self.grid.rxs)), 1, 1), - ) - - def store_snapshots(self, iteration): - """Stores any snapshots. - - Args: - iteration: int for iteration number. - """ - - for i, snap in enumerate(self.grid.snapshots): - if snap.time == iteration + 1: - snapno = 0 if config.get_model_config().device["snapsgpu2cpu"] else i - self.store_snapshot_dev( - np.int32(snapno), - np.int32(snap.xs), - np.int32(snap.xf), - np.int32(snap.ys), - np.int32(snap.yf), - np.int32(snap.zs), - np.int32(snap.zf), - np.int32(snap.dx), - np.int32(snap.dy), - np.int32(snap.dz), - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - self.grid.Hx_dev.gpudata, - self.grid.Hy_dev.gpudata, - self.grid.Hz_dev.gpudata, - self.snapEx_dev.gpudata, - self.snapEy_dev.gpudata, - self.snapEz_dev.gpudata, - self.snapHx_dev.gpudata, - self.snapHy_dev.gpudata, - self.snapHz_dev.gpudata, - block=Snapshot.tpb, - grid=Snapshot.bpg, - ) - if config.get_model_config().device["snapsgpu2cpu"]: - dtoh_snapshot_array( - self.snapEx_dev.get(), - self.snapEy_dev.get(), - self.snapEz_dev.get(), - self.snapHx_dev.get(), - self.snapHy_dev.get(), - self.snapHz_dev.get(), - 0, - snap, - ) - - def update_magnetic(self): - """Updates magnetic field components.""" - self.update_magnetic_dev( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - self.grid.ID_dev.gpudata, - self.grid.Hx_dev.gpudata, - self.grid.Hy_dev.gpudata, - self.grid.Hz_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - block=self.grid.tpb, - grid=self.grid.bpg, - ) - - def update_magnetic_pml(self): - """Updates magnetic field components with the PML correction.""" - for pml in self.grid.pmls["slabs"]: - pml.update_magnetic() - - def update_magnetic_sources(self): - """Updates magnetic field components from sources.""" - if self.grid.magneticdipoles: - self.update_magnetic_dipole_dev( - np.int32(len(self.grid.magneticdipoles)), - np.int32(self.grid.iteration), - config.sim_config.dtypes["float_or_double"](self.grid.dx), - config.sim_config.dtypes["float_or_double"](self.grid.dy), - config.sim_config.dtypes["float_or_double"](self.grid.dz), - self.srcinfo1_magnetic_dev.gpudata, - self.srcinfo2_magnetic_dev.gpudata, - self.srcwaves_magnetic_dev.gpudata, - self.grid.ID_dev.gpudata, - self.grid.Hx_dev.gpudata, - self.grid.Hy_dev.gpudata, - self.grid.Hz_dev.gpudata, - block=(1, 1, 1), - grid=(round32(len(self.grid.magneticdipoles)), 1, 1), - ) - - def update_electric_a(self): - """Updates electric field components.""" - # All materials are non-dispersive so do standard update. - if config.get_model_config().materials["maxpoles"] == 0: - self.update_electric_dev( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - self.grid.ID_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - self.grid.Hx_dev.gpudata, - self.grid.Hy_dev.gpudata, - self.grid.Hz_dev.gpudata, - block=self.grid.tpb, - grid=self.grid.bpg, - ) - - # If there are any dispersive materials do 1st part of dispersive update - # (it is split into two parts as it requires present and updated electric field values). - else: - self.dispersive_update_a( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - np.int32(config.get_model_config().materials["maxpoles"]), - self.grid.updatecoeffsdispersive_dev.gpudata, - self.grid.Tx_dev.gpudata, - self.grid.Ty_dev.gpudata, - self.grid.Tz_dev.gpudata, - self.grid.ID_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - self.grid.Hx_dev.gpudata, - self.grid.Hy_dev.gpudata, - self.grid.Hz_dev.gpudata, - block=self.grid.tpb, - grid=self.grid.bpg, - ) - - def update_electric_pml(self): - """Updates electric field components with the PML correction.""" - for pml in self.grid.pmls["slabs"]: - pml.update_electric() - - def update_electric_sources(self): - """Updates electric field components from sources - - update any Hertzian dipole sources last. - """ - if self.grid.voltagesources: - self.update_voltage_source_dev( - np.int32(len(self.grid.voltagesources)), - np.int32(self.grid.iteration), - config.sim_config.dtypes["float_or_double"](self.grid.dx), - config.sim_config.dtypes["float_or_double"](self.grid.dy), - config.sim_config.dtypes["float_or_double"](self.grid.dz), - self.srcinfo1_voltage_dev.gpudata, - self.srcinfo2_voltage_dev.gpudata, - self.srcwaves_voltage_dev.gpudata, - self.grid.ID_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - block=(1, 1, 1), - grid=(round32(len(self.grid.voltagesources)), 1, 1), - ) - - if self.grid.hertziandipoles: - self.update_hertzian_dipole_dev( - np.int32(len(self.grid.hertziandipoles)), - np.int32(self.grid.iteration), - config.sim_config.dtypes["float_or_double"](self.grid.dx), - config.sim_config.dtypes["float_or_double"](self.grid.dy), - config.sim_config.dtypes["float_or_double"](self.grid.dz), - self.srcinfo1_hertzian_dev.gpudata, - self.srcinfo2_hertzian_dev.gpudata, - self.srcwaves_hertzian_dev.gpudata, - self.grid.ID_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - block=(1, 1, 1), - grid=(round32(len(self.grid.hertziandipoles)), 1, 1), - ) - - self.grid.iteration += 1 - - def update_electric_b(self): - """If there are any dispersive materials do 2nd part of dispersive - update - it is split into two parts as it requires present and - updated electric field values. Therefore it can only be completely - updated after the electric field has been updated by the PML and - source updates. - """ - if config.get_model_config().materials["maxpoles"] > 0: - self.dispersive_update_b( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - np.int32(config.get_model_config().materials["maxpoles"]), - self.grid.updatecoeffsdispersive_dev.gpudata, - self.grid.Tx_dev.gpudata, - self.grid.Ty_dev.gpudata, - self.grid.Tz_dev.gpudata, - self.grid.ID_dev.gpudata, - self.grid.Ex_dev.gpudata, - self.grid.Ey_dev.gpudata, - self.grid.Ez_dev.gpudata, - block=self.grid.tpb, - grid=self.grid.bpg, - ) - - def time_start(self): - """Starts event timers used to calculate solving time for model.""" - self.iterstart = self.drv.Event() - self.iterend = self.drv.Event() - self.iterstart.record() - self.iterstart.synchronize() - - def calculate_memory_used(self, iteration): - """Calculates memory used on last iteration. - - Args: - iteration: int for iteration number. - - Returns: - Memory (RAM) used on GPU. - """ - if iteration == self.grid.iterations - 1: - # Total minus free memory in current context - return self.drv.mem_get_info()[1] - self.drv.mem_get_info()[0] - - def calculate_solve_time(self): - """Calculates solving time for model.""" - self.iterend.record() - self.iterend.synchronize() - return self.iterstart.time_till(self.iterend) * 1e-3 - - def finalise(self): - """Copies data from GPU back to CPU to save to file(s).""" - # Copy output from receivers array back to correct receiver objects - if self.grid.rxs: - dtoh_rx_array(self.rxs_dev.get(), self.rxcoords_dev.get(), self.grid) - - # Copy data from any snapshots back to correct snapshot objects - if self.grid.snapshots and not config.get_model_config().device["snapsgpu2cpu"]: - for i, snap in enumerate(self.grid.snapshots): - dtoh_snapshot_array( - self.snapEx_dev.get(), - self.snapEy_dev.get(), - self.snapEz_dev.get(), - self.snapHx_dev.get(), - self.snapHy_dev.get(), - self.snapHz_dev.get(), - i, - snap, - ) - - def cleanup(self): - """Cleanup GPU context.""" - # Remove context from top of stack and clear - self.ctx.pop() - self.ctx = None - - -class OpenCLUpdates: - """Defines update functions for OpenCL-based solver.""" - - def __init__(self, G): - """ - Args: - G: OpenCLGrid class describing a grid in a model. - """ - - self.grid = G - - # Import pyopencl module - self.cl = import_module("pyopencl") - self.elwiseknl = getattr(import_module("pyopencl.elementwise"), "ElementwiseKernel") - - # Select device, create context and command queue - self.dev = config.get_model_config().device["dev"] - self.ctx = self.cl.Context(devices=[self.dev]) - self.queue = self.cl.CommandQueue(self.ctx, properties=self.cl.command_queue_properties.PROFILING_ENABLE) - - # Enviroment for templating kernels - self.env = Environment(loader=PackageLoader("gprMax", "cuda_opencl")) - - # Initialise arrays on device, prepare kernels, and get kernel functions - self._set_macros() - self._set_field_knls() - if self.grid.pmls["slabs"]: - self._set_pml_knls() - if self.grid.rxs: - self._set_rx_knl() - if self.grid.voltagesources + self.grid.hertziandipoles + self.grid.magneticdipoles: - self._set_src_knls() - if self.grid.snapshots: - self._set_snapshot_knl() - - def _set_macros(self): - """Common macros to be used in kernels.""" - - # Set specific values for any dispersive materials - if config.get_model_config().materials["maxpoles"] > 0: - NY_MATDISPCOEFFS = self.grid.updatecoeffsdispersive.shape[1] - NX_T = self.grid.Tx.shape[1] - NY_T = self.grid.Tx.shape[2] - NZ_T = self.grid.Tx.shape[3] - else: # Set to one any substitutions for dispersive materials. - NY_MATDISPCOEFFS = 1 - NX_T = 1 - NY_T = 1 - NZ_T = 1 - - self.knl_common = self.env.get_template("knl_common_opencl.tmpl").render( - updatecoeffsE=self.grid.updatecoeffsE.ravel(), - updatecoeffsH=self.grid.updatecoeffsH.ravel(), - REAL=config.sim_config.dtypes["C_float_or_double"], - N_updatecoeffsE=self.grid.updatecoeffsE.size, - N_updatecoeffsH=self.grid.updatecoeffsH.size, - NY_MATCOEFFS=self.grid.updatecoeffsE.shape[1], - NY_MATDISPCOEFFS=NY_MATDISPCOEFFS, - NX_FIELDS=self.grid.nx + 1, - NY_FIELDS=self.grid.ny + 1, - NZ_FIELDS=self.grid.nz + 1, - NX_ID=self.grid.ID.shape[1], - NY_ID=self.grid.ID.shape[2], - NZ_ID=self.grid.ID.shape[3], - NX_T=NX_T, - NY_T=NY_T, - NZ_T=NZ_T, - NY_RXCOORDS=3, - NX_RXS=6, - NY_RXS=self.grid.iterations, - NZ_RXS=len(self.grid.rxs), - NY_SRCINFO=4, - NY_SRCWAVES=self.grid.iterations, - NX_SNAPS=Snapshot.nx_max, - NY_SNAPS=Snapshot.ny_max, - NZ_SNAPS=Snapshot.nz_max, - ) - - def _set_field_knls(self): - """Electric and magnetic field updates - prepares kernels, and - gets kernel functions. - """ - - subs = { - "CUDA_IDX": "", - "NX_FIELDS": self.grid.nx + 1, - "NY_FIELDS": self.grid.ny + 1, - "NZ_FIELDS": self.grid.nz + 1, - "NX_ID": self.grid.ID.shape[1], - "NY_ID": self.grid.ID.shape[2], - "NZ_ID": self.grid.ID.shape[3], - } - - self.update_electric_dev = self.elwiseknl( - self.ctx, - knl_fields_updates.update_electric["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_fields_updates.update_electric["func"].substitute(subs), - "update_electric", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - self.update_magnetic_dev = self.elwiseknl( - self.ctx, - knl_fields_updates.update_magnetic["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_fields_updates.update_magnetic["func"].substitute(subs), - "update_magnetic", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - # If there are any dispersive materials (updates are split into two - # parts as they require present and updated electric field values). - if config.get_model_config().materials["maxpoles"] > 0: - subs = { - "CUDA_IDX": "", - "REAL": config.sim_config.dtypes["C_float_or_double"], - "REALFUNC": config.get_model_config().materials["crealfunc"], - "NX_FIELDS": self.grid.nx + 1, - "NY_FIELDS": self.grid.ny + 1, - "NZ_FIELDS": self.grid.nz + 1, - "NX_ID": self.grid.ID.shape[1], - "NY_ID": self.grid.ID.shape[2], - "NZ_ID": self.grid.ID.shape[3], - "NX_T": self.grid.Tx.shape[1], - "NY_T": self.grid.Tx.shape[2], - "NZ_T": self.grid.Tx.shape[3], - } - - self.dispersive_update_a = self.elwiseknl( - self.ctx, - knl_fields_updates.update_electric_dispersive_A["args_opencl"].substitute( - { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "COMPLEX": config.get_model_config().materials["dispersiveCdtype"], - } - ), - knl_fields_updates.update_electric_dispersive_A["func"].substitute(subs), - "update_electric_dispersive_A", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - self.dispersive_update_b = self.elwiseknl( - self.ctx, - knl_fields_updates.update_electric_dispersive_B["args_opencl"].substitute( - { - "REAL": config.sim_config.dtypes["C_float_or_double"], - "COMPLEX": config.get_model_config().materials["dispersiveCdtype"], - } - ), - knl_fields_updates.update_electric_dispersive_B["func"].substitute(subs), - "update_electric_dispersive_B", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - # Initialise field arrays on compute device - self.grid.htod_geometry_arrays(self.queue) - self.grid.htod_field_arrays(self.queue) - if config.get_model_config().materials["maxpoles"] > 0: - self.grid.htod_dispersive_arrays(self.queue) - - def _set_pml_knls(self): - """PMLS - prepares kernels and gets kernel functions.""" - knl_pml_updates_electric = import_module( - "gprMax.cuda_opencl.knl_pml_updates_electric_" + self.grid.pmls["formulation"] - ) - knl_pml_updates_magnetic = import_module( - "gprMax.cuda_opencl.knl_pml_updates_magnetic_" + self.grid.pmls["formulation"] - ) - - subs = { - "CUDA_IDX": "", - "REAL": config.sim_config.dtypes["C_float_or_double"], - "NX_FIELDS": self.grid.nx + 1, - "NY_FIELDS": self.grid.ny + 1, - "NZ_FIELDS": self.grid.nz + 1, - "NX_ID": self.grid.ID.shape[1], - "NY_ID": self.grid.ID.shape[2], - "NZ_ID": self.grid.ID.shape[3], - } - - # Set workgroup size, initialise arrays on compute device, and get - # kernel functions - for pml in self.grid.pmls["slabs"]: - pml.set_queue(self.queue) - pml.htod_field_arrays() - knl_name = f"order{len(pml.CFS)}_{pml.direction}" - knl_electric_name = getattr(knl_pml_updates_electric, knl_name) - knl_magnetic_name = getattr(knl_pml_updates_magnetic, knl_name) - - pml.update_electric_dev = self.elwiseknl( - self.ctx, - knl_electric_name["args_opencl"].substitute({"REAL": config.sim_config.dtypes["C_float_or_double"]}), - knl_electric_name["func"].substitute(subs), - f"pml_updates_electric_{knl_name}", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - pml.update_magnetic_dev = self.elwiseknl( - self.ctx, - knl_magnetic_name["args_opencl"].substitute({"REAL": config.sim_config.dtypes["C_float_or_double"]}), - knl_magnetic_name["func"].substitute(subs), - f"pml_updates_magnetic_{knl_name}", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - def _set_rx_knl(self): - """Receivers - initialises arrays on compute device, prepares kernel and - gets kernel function. - """ - self.rxcoords_dev, self.rxs_dev = htod_rx_arrays(self.grid, self.queue) - self.store_outputs_dev = self.elwiseknl( - self.ctx, - knl_store_outputs.store_outputs["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_store_outputs.store_outputs["func"].substitute({"CUDA_IDX": ""}), - "store_outputs", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - def _set_src_knls(self): - """Sources - initialises arrays on compute device, prepares kernel and - gets kernel function. - """ - if self.grid.hertziandipoles: - self.srcinfo1_hertzian_dev, self.srcinfo2_hertzian_dev, self.srcwaves_hertzian_dev = htod_src_arrays( - self.grid.hertziandipoles, self.grid, self.queue - ) - self.update_hertzian_dipole_dev = self.elwiseknl( - self.ctx, - knl_source_updates.update_hertzian_dipole["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_source_updates.update_hertzian_dipole["func"].substitute( - {"CUDA_IDX": "", "REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - "update_hertzian_dipole", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - if self.grid.magneticdipoles: - self.srcinfo1_magnetic_dev, self.srcinfo2_magnetic_dev, self.srcwaves_magnetic_dev = htod_src_arrays( - self.grid.magneticdipoles, self.grid, self.queue - ) - self.update_magnetic_dipole_dev = self.elwiseknl( - self.ctx, - knl_source_updates.update_magnetic_dipole["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_source_updates.update_magnetic_dipole["func"].substitute( - {"CUDA_IDX": "", "REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - "update_magnetic_dipole", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - if self.grid.voltagesources: - self.srcinfo1_voltage_dev, self.srcinfo2_voltage_dev, self.srcwaves_voltage_dev = htod_src_arrays( - self.grid.voltagesources, self.grid, self.queue - ) - self.update_voltage_source_dev = self.elwiseknl( - self.ctx, - knl_source_updates.update_voltage_source["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_source_updates.update_voltage_source["func"].substitute( - {"CUDA_IDX": "", "REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - "update_voltage_source", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - def _set_snapshot_knl(self): - """Snapshots - initialises arrays on compute device, prepares kernel and - gets kernel function. - """ - ( - self.snapEx_dev, - self.snapEy_dev, - self.snapEz_dev, - self.snapHx_dev, - self.snapHy_dev, - self.snapHz_dev, - ) = htod_snapshot_array(self.grid, self.queue) - self.store_snapshot_dev = self.elwiseknl( - self.ctx, - knl_snapshots.store_snapshot["args_opencl"].substitute( - {"REAL": config.sim_config.dtypes["C_float_or_double"]} - ), - knl_snapshots.store_snapshot["func"].substitute( - {"CUDA_IDX": "", "NX_SNAPS": Snapshot.nx_max, "NY_SNAPS": Snapshot.ny_max, "NZ_SNAPS": Snapshot.nz_max} - ), - "store_snapshot", - preamble=self.knl_common, - options=config.sim_config.devices["compiler_opts"], - ) - - def store_outputs(self): - """Stores field component values for every receiver.""" - if self.grid.rxs: - self.store_outputs_dev( - np.int32(len(self.grid.rxs)), - np.int32(self.grid.iteration), - self.rxcoords_dev, - self.rxs_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - self.grid.Hx_dev, - self.grid.Hy_dev, - self.grid.Hz_dev, - ) - - def store_snapshots(self, iteration): - """Stores any snapshots. - - Args: - iteration: int for iteration number. - """ - - for i, snap in enumerate(self.grid.snapshots): - if snap.time == iteration + 1: - snapno = 0 if config.get_model_config().device["snapsgpu2cpu"] else i - self.store_snapshot_dev( - np.int32(snapno), - np.int32(snap.xs), - np.int32(snap.xf), - np.int32(snap.ys), - np.int32(snap.yf), - np.int32(snap.zs), - np.int32(snap.zf), - np.int32(snap.dx), - np.int32(snap.dy), - np.int32(snap.dz), - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - self.grid.Hx_dev, - self.grid.Hy_dev, - self.grid.Hz_dev, - self.snapEx_dev, - self.snapEy_dev, - self.snapEz_dev, - self.snapHx_dev, - self.snapHy_dev, - self.snapHz_dev, - ) - - if config.get_model_config().device["snapsgpu2cpu"]: - dtoh_snapshot_array( - self.snapEx_dev.get(), - self.snapEy_dev.get(), - self.snapEz_dev.get(), - self.snapHx_dev.get(), - self.snapHy_dev.get(), - self.snapHz_dev.get(), - 0, - snap, - ) - - def update_magnetic(self): - """Updates magnetic field components.""" - self.update_magnetic_dev( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - self.grid.ID_dev, - self.grid.Hx_dev, - self.grid.Hy_dev, - self.grid.Hz_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - ) - - def update_magnetic_pml(self): - """Updates magnetic field components with the PML correction.""" - for pml in self.grid.pmls["slabs"]: - pml.update_magnetic() - - def update_magnetic_sources(self): - """Updates magnetic field components from sources.""" - if self.grid.magneticdipoles: - self.update_magnetic_dipole_dev( - np.int32(len(self.grid.magneticdipoles)), - np.int32(self.grid.iteration), - config.sim_config.dtypes["float_or_double"](self.grid.dx), - config.sim_config.dtypes["float_or_double"](self.grid.dy), - config.sim_config.dtypes["float_or_double"](self.grid.dz), - self.srcinfo1_magnetic_dev, - self.srcinfo2_magnetic_dev, - self.srcwaves_magnetic_dev, - self.grid.ID_dev, - self.grid.Hx_dev, - self.grid.Hy_dev, - self.grid.Hz_dev, - ) - - def update_electric_a(self): - """Updates electric field components.""" - # All materials are non-dispersive so do standard update. - if config.get_model_config().materials["maxpoles"] == 0: - self.update_electric_dev( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - self.grid.ID_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - self.grid.Hx_dev, - self.grid.Hy_dev, - self.grid.Hz_dev, - ) - - # If there are any dispersive materials do 1st part of dispersive update - # (it is split into two parts as it requires present and updated electric field values). - else: - self.dispersive_update_a( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - np.int32(config.get_model_config().materials["maxpoles"]), - self.grid.ID_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - self.grid.Hx_dev, - self.grid.Hy_dev, - self.grid.Hz_dev, - self.grid.updatecoeffsdispersive_dev, - self.grid.Tx_dev, - self.grid.Ty_dev, - self.grid.Tz_dev, - ) - - def update_electric_pml(self): - """Updates electric field components with the PML correction.""" - for pml in self.grid.pmls["slabs"]: - pml.update_electric() - - def update_electric_sources(self): - """Updates electric field components from sources - - update any Hertzian dipole sources last. - """ - if self.grid.voltagesources: - self.update_voltage_source_dev( - np.int32(len(self.grid.voltagesources)), - np.int32(self.grid.iteration), - config.sim_config.dtypes["float_or_double"](self.grid.dx), - config.sim_config.dtypes["float_or_double"](self.grid.dy), - config.sim_config.dtypes["float_or_double"](self.grid.dz), - self.srcinfo1_voltage_dev, - self.srcinfo2_voltage_dev, - self.srcwaves_voltage_dev, - self.grid.ID_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - ) - - if self.grid.hertziandipoles: - self.update_hertzian_dipole_dev( - np.int32(len(self.grid.hertziandipoles)), - np.int32(self.grid.iteration), - config.sim_config.dtypes["float_or_double"](self.grid.dx), - config.sim_config.dtypes["float_or_double"](self.grid.dy), - config.sim_config.dtypes["float_or_double"](self.grid.dz), - self.srcinfo1_hertzian_dev, - self.srcinfo2_hertzian_dev, - self.srcwaves_hertzian_dev, - self.grid.ID_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - ) - - self.grid.iteration += 1 - - def update_electric_b(self): - """If there are any dispersive materials do 2nd part of dispersive - update - it is split into two parts as it requires present and - updated electric field values. Therefore it can only be completely - updated after the electric field has been updated by the PML and - source updates. - """ - if config.get_model_config().materials["maxpoles"] > 0: - self.dispersive_update_b( - np.int32(self.grid.nx), - np.int32(self.grid.ny), - np.int32(self.grid.nz), - np.int32(config.get_model_config().materials["maxpoles"]), - self.grid.ID_dev, - self.grid.Ex_dev, - self.grid.Ey_dev, - self.grid.Ez_dev, - self.grid.updatecoeffsdispersive_dev, - self.grid.Tx_dev, - self.grid.Ty_dev, - self.grid.Tz_dev, - ) - - def time_start(self): - """Starts event timers used to calculate solving time for model.""" - self.event_marker1 = self.cl.enqueue_marker(self.queue) - self.event_marker1.wait() - - def calculate_memory_used(self, iteration): - """Calculates memory used on last iteration. - - Args: - iteration: int for iteration number. - - Returns: - Memory (RAM) used on compute device. - """ - # No clear way to determine memory used from PyOpenCL unlike PyCUDA. - pass - - def calculate_solve_time(self): - """Calculates solving time for model.""" - event_marker2 = self.cl.enqueue_marker(self.queue) - event_marker2.wait() - return (event_marker2.profile.end - self.event_marker1.profile.start) * 1e-9 - - def finalise(self): - """Copies data from compute device back to CPU to save to file(s).""" - # Copy output from receivers array back to correct receiver objects - if self.grid.rxs: - dtoh_rx_array(self.rxs_dev.get(), self.rxcoords_dev.get(), self.grid) - - # Copy data from any snapshots back to correct snapshot objects - if self.grid.snapshots and not config.get_model_config().device["snapsgpu2cpu"]: - for i, snap in enumerate(self.grid.snapshots): - dtoh_snapshot_array( - self.snapEx_dev.get(), - self.snapEy_dev.get(), - self.snapEz_dev.get(), - self.snapHx_dev.get(), - self.snapHy_dev.get(), - self.snapHz_dev.get(), - i, - snap, - ) - - def cleanup(self): - pass From 47b8c87e06fbf493fdfdafa196519d2e340f25a2 Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 13:08:25 +0000 Subject: [PATCH 04/37] Change relative imports to absolute imports --- gprMax/updates/cpu_updates.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/gprMax/updates/cpu_updates.py b/gprMax/updates/cpu_updates.py index 12489641..8e64e32e 100644 --- a/gprMax/updates/cpu_updates.py +++ b/gprMax/updates/cpu_updates.py @@ -1,11 +1,10 @@ from importlib import import_module from gprMax import config - -from ..cython.fields_updates_normal import update_electric as update_electric_cpu -from ..cython.fields_updates_normal import update_magnetic as update_magnetic_cpu -from ..fields_outputs import store_outputs as store_outputs_cpu -from ..utilities.utilities import timer +from gprMax.cython.fields_updates_normal import update_electric as update_electric_cpu +from gprMax.cython.fields_updates_normal import update_magnetic as update_magnetic_cpu +from gprMax.fields_outputs import store_outputs as store_outputs_cpu +from gprMax.utilities.utilities import timer class CPUUpdates: From 5c5dae1f433bf55f218cf3d1ca1803fe790143a3 Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 13:12:55 +0000 Subject: [PATCH 05/37] Add copyright statements --- gprMax/updates/cpu_updates.py | 18 ++++++++++++++++++ gprMax/updates/cuda_updates.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/gprMax/updates/cpu_updates.py b/gprMax/updates/cpu_updates.py index 8e64e32e..a7257ad0 100644 --- a/gprMax/updates/cpu_updates.py +++ b/gprMax/updates/cpu_updates.py @@ -1,3 +1,21 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + from importlib import import_module from gprMax import config diff --git a/gprMax/updates/cuda_updates.py b/gprMax/updates/cuda_updates.py index 1211725c..848b487a 100644 --- a/gprMax/updates/cuda_updates.py +++ b/gprMax/updates/cuda_updates.py @@ -1,3 +1,21 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + import logging from importlib import import_module From 800ce2c29df7151f8a455995fcad53f16abd410c Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 13:29:53 +0000 Subject: [PATCH 06/37] Add first update electric test for non dispersive materials --- tests/updates/test_cpu_updates.py | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/updates/test_cpu_updates.py b/tests/updates/test_cpu_updates.py index 5b5060f5..61c9550a 100644 --- a/tests/updates/test_cpu_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -72,6 +72,38 @@ def test_update_magnetic(config_mock): assert np.equal(pml.EPhi2, 0).all() +def test_update_electric_a_non_dispersive(config_mock): + grid = build_grid(100, 100, 100) + + expected_value = np.zeros((101, 101, 101)) + + cpu_updates = CPUUpdates(grid) + cpu_updates.update_electric_a() + + assert np.equal(grid.Ex, expected_value).all() + assert np.equal(grid.Ey, expected_value).all() + assert np.equal(grid.Ez, expected_value).all() + assert np.equal(grid.Hx, expected_value).all() + assert np.equal(grid.Hy, expected_value).all() + assert np.equal(grid.Hz, expected_value).all() + + +def test_update_electric_b_non_dispersive(config_mock): + grid = build_grid(100, 100, 100) + + expected_value = np.zeros((101, 101, 101)) + + cpu_updates = CPUUpdates(grid) + cpu_updates.update_electric_b() + + assert np.equal(grid.Ex, expected_value).all() + assert np.equal(grid.Ey, expected_value).all() + assert np.equal(grid.Ez, expected_value).all() + assert np.equal(grid.Hx, expected_value).all() + assert np.equal(grid.Hy, expected_value).all() + assert np.equal(grid.Hz, expected_value).all() + + def test_update_magnetic_pml(config_mock): grid = build_grid(100, 100, 100) From 791cd67c3adbd0808769a69404f75a9c8e8e64ae Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 9 Feb 2024 14:16:21 +0000 Subject: [PATCH 07/37] Add definitions for future CPUUpdates tests --- tests/updates/test_cpu_updates.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/updates/test_cpu_updates.py b/tests/updates/test_cpu_updates.py index 61c9550a..b7d5cb25 100644 --- a/tests/updates/test_cpu_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -104,6 +104,14 @@ def test_update_electric_b_non_dispersive(config_mock): assert np.equal(grid.Hz, expected_value).all() +def test_update_electric_a_dispersive(config_mock): + assert False + + +def test_update_electric_b_dispersive(config_mock): + assert False + + def test_update_magnetic_pml(config_mock): grid = build_grid(100, 100, 100) @@ -146,3 +154,19 @@ def test_update_electric_pml(config_mock): assert np.equal(pml.HPhi2, 0).all() assert np.equal(pml.EPhi1, 0).all() assert np.equal(pml.EPhi2, 0).all() + + +def test_update_magnetic_sources(config_mock): + assert False + + +def test_update_electric_sources(config_mock): + assert False + + +def test_dispersive_update_a(config_mock): + assert False + + +def test_dispersive_update_b(config_mock): + assert False From 28ed5b62e14df4d4f455219e5e795873e46bcd67 Mon Sep 17 00:00:00 2001 From: nmannall Date: Tue, 20 Feb 2024 18:07:43 +0000 Subject: [PATCH 08/37] Add non-zero test for update_magnetic --- tests/updates/test_cpu_updates.py | 45 +++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/updates/test_cpu_updates.py b/tests/updates/test_cpu_updates.py index b7d5cb25..40f9164c 100644 --- a/tests/updates/test_cpu_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -2,6 +2,7 @@ import argparse import numpy as np import pytest +from pytest import MonkeyPatch from gprMax import config, gprMax from gprMax.grid import FDTDGrid @@ -11,7 +12,7 @@ from gprMax.pml import CFS from gprMax.updates.cpu_updates import CPUUpdates -def build_grid(nx, ny, nz, dl=0.001, dt=3e-9): +def build_grid(nx: int, ny: int, nz: int, dl: float = 0.001, dt: float = 3e-9) -> FDTDGrid: grid = FDTDGrid() grid.nx = nx grid.ny = ny @@ -35,13 +36,13 @@ def build_grid(nx, ny, nz, dl=0.001, dt=3e-9): @pytest.fixture -def config_mock(monkeypatch): - def _mock_simulation_config(): +def config_mock(monkeypatch: MonkeyPatch): + def _mock_simulation_config() -> config.SimulationConfig: args = argparse.Namespace(**gprMax.args_defaults) args.inputfile = "test.in" return config.SimulationConfig(args) - def _mock_model_config(): + def _mock_model_config() -> config.ModelConfig: model_config = config.ModelConfig() model_config.ompthreads = 1 return model_config @@ -50,7 +51,7 @@ def config_mock(monkeypatch): monkeypatch.setattr(config, "get_model_config", _mock_model_config) -def test_update_magnetic(config_mock): +def test_update_magnetic_zero_grid(config_mock): grid = build_grid(100, 100, 100) expected_value = np.zeros((101, 101, 101)) @@ -72,6 +73,40 @@ def test_update_magnetic(config_mock): assert np.equal(pml.EPhi2, 0).all() +def test_update_magnetic(config_mock): + grid = build_grid(11, 11, 11) + + # Why does fields_updates_normal use i+1, j+1 and k+1 everywhere? + grid.updatecoeffsH[1] = 1 + + grid.Ex = np.tile(np.array([[[1, 2], [2, 1]], [[2, 1], [1, 2]]], dtype=np.float32), (6, 6, 6)) + grid.Ey = np.tile(np.array([[[1, 3], [3, 1]], [[3, 1], [1, 3]]], dtype=np.float32), (6, 6, 6)) + grid.Ez = np.tile(np.array([[[1, 4], [4, 1]], [[4, 1], [1, 4]]], dtype=np.float32), (6, 6, 6)) + grid.Hx = np.tile(np.array([[[3, 1], [1, 3]], [[1, 3], [3, 1]]], dtype=np.float32), (6, 6, 6)) + grid.Hy = np.tile(np.array([[[1, 5], [5, 1]], [[5, 1], [1, 5]]], dtype=np.float32), (6, 6, 6)) + grid.Hz = np.tile(np.array([[[5, 3], [3, 5]], [[3, 5], [5, 3]]], dtype=np.float32), (6, 6, 6)) + + expected_Ex = grid.Ex.copy() + expected_Ey = grid.Ey.copy() + expected_Ez = grid.Ez.copy() + expected_Hx = grid.Hx.copy() + expected_Hy = grid.Hy.copy() + expected_Hz = grid.Hz.copy() + expected_Hx[1:, :-1, :-1] = np.tile(np.array([[[2]]], dtype=np.float32), (11, 11, 11)) + expected_Hy[:-1, 1:, :-1] = np.tile(np.array([[[3]]], dtype=np.float32), (11, 11, 11)) + expected_Hz[:-1, :-1, 1:] = np.tile(np.array([[[4]]], dtype=np.float32), (11, 11, 11)) + + cpu_updates = CPUUpdates(grid) + cpu_updates.update_magnetic() + + assert np.equal(grid.Ex, expected_Ex).all() + assert np.equal(grid.Ey, expected_Ey).all() + assert np.equal(grid.Ez, expected_Ez).all() + assert np.equal(grid.Hx, expected_Hx).all() + assert np.equal(grid.Hy, expected_Hy).all() + assert np.equal(grid.Hz, expected_Hz).all() + + def test_update_electric_a_non_dispersive(config_mock): grid = build_grid(100, 100, 100) From a48c0c30ae4295d1577f1bfcf144d9756e63d10d Mon Sep 17 00:00:00 2001 From: nmannall Date: Wed, 21 Feb 2024 12:21:22 +0000 Subject: [PATCH 09/37] Add non-zero test for update_electric --- tests/updates/test_cpu_updates.py | 41 +++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/updates/test_cpu_updates.py b/tests/updates/test_cpu_updates.py index 40f9164c..b4d83c03 100644 --- a/tests/updates/test_cpu_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -76,7 +76,6 @@ def test_update_magnetic_zero_grid(config_mock): def test_update_magnetic(config_mock): grid = build_grid(11, 11, 11) - # Why does fields_updates_normal use i+1, j+1 and k+1 everywhere? grid.updatecoeffsH[1] = 1 grid.Ex = np.tile(np.array([[[1, 2], [2, 1]], [[2, 1], [1, 2]]], dtype=np.float32), (6, 6, 6)) @@ -96,6 +95,7 @@ def test_update_magnetic(config_mock): expected_Hy[:-1, 1:, :-1] = np.tile(np.array([[[3]]], dtype=np.float32), (11, 11, 11)) expected_Hz[:-1, :-1, 1:] = np.tile(np.array([[[4]]], dtype=np.float32), (11, 11, 11)) + # Why does fields_updates_normal use i+1, j+1 and k+1 everywhere? cpu_updates = CPUUpdates(grid) cpu_updates.update_magnetic() @@ -107,7 +107,7 @@ def test_update_magnetic(config_mock): assert np.equal(grid.Hz, expected_Hz).all() -def test_update_electric_a_non_dispersive(config_mock): +def test_update_electric_a_non_dispersive_zero_grid(config_mock): grid = build_grid(100, 100, 100) expected_value = np.zeros((101, 101, 101)) @@ -123,6 +123,43 @@ def test_update_electric_a_non_dispersive(config_mock): assert np.equal(grid.Hz, expected_value).all() +def test_update_electric_a_non_dispersive(config_mock): + grid = build_grid(11, 11, 11) + + print(grid.updatecoeffsE) + print(grid.updatecoeffsE[1]) + grid.updatecoeffsE[1] = 1 + print(grid.updatecoeffsE[1]) + + grid.Ex = np.tile(np.array([[[3, 1], [1, 3]], [[1, 3], [3, 1]]], dtype=np.float32), (6, 6, 6)) + grid.Ey = np.tile(np.array([[[1, 5], [5, 1]], [[5, 1], [1, 5]]], dtype=np.float32), (6, 6, 6)) + grid.Ez = np.tile(np.array([[[5, 3], [3, 5]], [[3, 5], [5, 3]]], dtype=np.float32), (6, 6, 6)) + grid.Hx = np.tile(np.array([[[1, 2], [2, 1]], [[2, 1], [1, 2]]], dtype=np.float32), (6, 6, 6)) + grid.Hy = np.tile(np.array([[[1, 3], [3, 1]], [[3, 1], [1, 3]]], dtype=np.float32), (6, 6, 6)) + grid.Hz = np.tile(np.array([[[1, 4], [4, 1]], [[4, 1], [1, 4]]], dtype=np.float32), (6, 6, 6)) + + expected_Ex = grid.Ex.copy() + expected_Ey = grid.Ey.copy() + expected_Ez = grid.Ez.copy() + expected_Hx = grid.Hx.copy() + expected_Hy = grid.Hy.copy() + expected_Hz = grid.Hz.copy() + # Why is there not a full (11x11x11) section of the frid being updated? + expected_Ex[:-1, 1:-1, 1:-1] = np.tile(np.array([[[2]]], dtype=np.float32), (11, 10, 10)) + expected_Ey[1:-1, :-1, 1:-1] = np.tile(np.array([[[3]]], dtype=np.float32), (10, 11, 10)) + expected_Ez[1:-1, 1:-1, :-1] = np.tile(np.array([[[4]]], dtype=np.float32), (10, 10, 11)) + + cpu_updates = CPUUpdates(grid) + cpu_updates.update_electric_a() + + assert np.equal(grid.Ex, expected_Ex).all() + assert np.equal(grid.Ey, expected_Ey).all() + assert np.equal(grid.Ez, expected_Ez).all() + assert np.equal(grid.Hx, expected_Hx).all() + assert np.equal(grid.Hy, expected_Hy).all() + assert np.equal(grid.Hz, expected_Hz).all() + + def test_update_electric_b_non_dispersive(config_mock): grid = build_grid(100, 100, 100) From da215a2514f79043f3c3947ce270b7b72a19dfd1 Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 1 Mar 2024 14:53:05 +0000 Subject: [PATCH 10/37] Prepare to split up grid.py --- gprMax/grid/cuda_grid.py | 49 +++++++ gprMax/grid/fdtd_grid.py | 282 +++++++++++++++++++++++++++++++++++++ gprMax/grid/opencl_grid.py | 183 ++++++++++++++++++++++++ 3 files changed, 514 insertions(+) create mode 100644 gprMax/grid/cuda_grid.py create mode 100644 gprMax/grid/fdtd_grid.py create mode 100644 gprMax/grid/opencl_grid.py diff --git a/gprMax/grid/cuda_grid.py b/gprMax/grid/cuda_grid.py new file mode 100644 index 00000000..40c87318 --- /dev/null +++ b/gprMax/grid/cuda_grid.py @@ -0,0 +1,49 @@ +from importlib import import_module + +import numpy as np + +from gprMax.grid.fdtd_grid import FDTDGrid + + +class CUDAGrid(FDTDGrid): + """Additional grid methods for solving on GPU using CUDA.""" + + def __init__(self): + super().__init__() + + self.gpuarray = import_module("pycuda.gpuarray") + + # Threads per block - used for main electric/magnetic field updates + self.tpb = (128, 1, 1) + # Blocks per grid - used for main electric/magnetic field updates + self.bpg = None + + def set_blocks_per_grid(self): + """Set the blocks per grid size used for updating the electric and + magnetic field arrays on a GPU. + """ + + self.bpg = (int(np.ceil(((self.nx + 1) * (self.ny + 1) * (self.nz + 1)) / self.tpb[0])), 1, 1) + + def htod_geometry_arrays(self): + """Initialise an array for cell edge IDs (ID) on compute device.""" + + self.ID_dev = self.gpuarray.to_gpu(self.ID) + + def htod_field_arrays(self): + """Initialise field arrays on compute device.""" + + self.Ex_dev = self.gpuarray.to_gpu(self.Ex) + self.Ey_dev = self.gpuarray.to_gpu(self.Ey) + self.Ez_dev = self.gpuarray.to_gpu(self.Ez) + self.Hx_dev = self.gpuarray.to_gpu(self.Hx) + self.Hy_dev = self.gpuarray.to_gpu(self.Hy) + self.Hz_dev = self.gpuarray.to_gpu(self.Hz) + + def htod_dispersive_arrays(self): + """Initialise dispersive material coefficient arrays on compute device.""" + + self.updatecoeffsdispersive_dev = self.gpuarray.to_gpu(self.updatecoeffsdispersive) + self.Tx_dev = self.gpuarray.to_gpu(self.Tx) + self.Ty_dev = self.gpuarray.to_gpu(self.Ty) + self.Tz_dev = self.gpuarray.to_gpu(self.Tz) diff --git a/gprMax/grid/fdtd_grid.py b/gprMax/grid/fdtd_grid.py new file mode 100644 index 00000000..cc3153f7 --- /dev/null +++ b/gprMax/grid/fdtd_grid.py @@ -0,0 +1,282 @@ +import decimal +from collections import OrderedDict + +import numpy as np + +from gprMax import config +from gprMax.pml import PML +from gprMax.utilities.utilities import round_value + + +class FDTDGrid: + """Holds attributes associated with entire grid. A convenient way for + accessing regularly used parameters. + """ + + def __init__(self): + self.title = "" + self.name = "main_grid" + self.mem_use = 0 + + self.nx = 0 + self.ny = 0 + self.nz = 0 + self.dx = 0 + self.dy = 0 + self.dz = 0 + self.dt = 0 + self.dt_mod = None # Time step stability factor + self.iteration = 0 # Current iteration number + self.iterations = 0 # Total number of iterations + self.timewindow = 0 + + # PML parameters - set some defaults to use if not user provided + self.pmls = {} + self.pmls["formulation"] = "HORIPML" + self.pmls["cfs"] = [] + self.pmls["slabs"] = [] + # Ordered dictionary required so *updating* the PMLs always follows the + # same order (the order for *building* PMLs does not matter). The order + # itself does not matter, however, if must be the same from model to + # model otherwise the numerical precision from adding the PML + # corrections will be different. + self.pmls["thickness"] = OrderedDict((key, 10) for key in PML.boundaryIDs) + + self.materials = [] + self.mixingmodels = [] + self.averagevolumeobjects = True + self.fractalvolumes = [] + self.geometryviews = [] + self.geometryobjectswrite = [] + self.waveforms = [] + self.voltagesources = [] + self.hertziandipoles = [] + self.magneticdipoles = [] + self.transmissionlines = [] + self.rxs = [] + self.srcsteps = [0, 0, 0] + self.rxsteps = [0, 0, 0] + self.snapshots = [] + self.subgrids = [] + + def within_bounds(self, p): + if p[0] < 0 or p[0] > self.nx: + raise ValueError("x") + if p[1] < 0 or p[1] > self.ny: + raise ValueError("y") + if p[2] < 0 or p[2] > self.nz: + raise ValueError("z") + + def discretise_point(self, p): + x = round_value(float(p[0]) / self.dx) + y = round_value(float(p[1]) / self.dy) + z = round_value(float(p[2]) / self.dz) + return (x, y, z) + + def round_to_grid(self, p): + p = self.discretise_point(p) + p_r = (p[0] * self.dx, p[1] * self.dy, p[2] * self.dz) + return p_r + + def within_pml(self, p): + if ( + p[0] < self.pmls["thickness"]["x0"] + or p[0] > self.nx - self.pmls["thickness"]["xmax"] + or p[1] < self.pmls["thickness"]["y0"] + or p[1] > self.ny - self.pmls["thickness"]["ymax"] + or p[2] < self.pmls["thickness"]["z0"] + or p[2] > self.nz - self.pmls["thickness"]["zmax"] + ): + return True + else: + return False + + def initialise_geometry_arrays(self): + """Initialise an array for volumetric material IDs (solid); + boolean arrays for specifying whether materials can have dielectric + smoothing (rigid); and an array for cell edge IDs (ID). + Solid and ID arrays are initialised to free_space (one); + rigid arrays to allow dielectric smoothing (zero). + """ + self.solid = np.ones((self.nx, self.ny, self.nz), dtype=np.uint32) + self.rigidE = np.zeros((12, self.nx, self.ny, self.nz), dtype=np.int8) + self.rigidH = np.zeros((6, self.nx, self.ny, self.nz), dtype=np.int8) + self.ID = np.ones((6, self.nx + 1, self.ny + 1, self.nz + 1), dtype=np.uint32) + self.IDlookup = {"Ex": 0, "Ey": 1, "Ez": 2, "Hx": 3, "Hy": 4, "Hz": 5} + + def initialise_field_arrays(self): + """Initialise arrays for the electric and magnetic field components.""" + self.Ex = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Ey = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Ez = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Hx = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Hy = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Hz = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + + def initialise_std_update_coeff_arrays(self): + """Initialise arrays for storing update coefficients.""" + self.updatecoeffsE = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) + self.updatecoeffsH = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) + + def initialise_dispersive_arrays(self): + """Initialise field arrays when there are dispersive materials present.""" + self.Tx = np.zeros( + (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + self.Ty = np.zeros( + (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + self.Tz = np.zeros( + (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + + def initialise_dispersive_update_coeff_array(self): + """Initialise array for storing update coefficients when there are dispersive + materials present. + """ + self.updatecoeffsdispersive = np.zeros( + (len(self.materials), 3 * config.get_model_config().materials["maxpoles"]), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + + def reset_fields(self): + """Clear arrays for field components and PMLs.""" + # Clear arrays for field components + self.initialise_field_arrays() + if config.get_model_config().materials["maxpoles"] > 0: + self.initialise_dispersive_arrays() + + # Clear arrays for fields in PML + for pml in self.pmls["slabs"]: + pml.initialise_field_arrays() + + def mem_est_basic(self): + """Estimates the amount of memory (RAM) required for grid arrays. + + Returns: + mem_use: int of memory (bytes). + """ + + solidarray = self.nx * self.ny * self.nz * np.dtype(np.uint32).itemsize + + # 12 x rigidE array components + 6 x rigidH array components + rigidarrays = (12 + 6) * self.nx * self.ny * self.nz * np.dtype(np.int8).itemsize + + # 6 x field arrays + 6 x ID arrays + fieldarrays = ( + (6 + 6) + * (self.nx + 1) + * (self.ny + 1) + * (self.nz + 1) + * np.dtype(config.sim_config.dtypes["float_or_double"]).itemsize + ) + + # PML arrays + pmlarrays = 0 + for k, v in self.pmls["thickness"].items(): + if v > 0: + if "x" in k: + pmlarrays += (v + 1) * self.ny * (self.nz + 1) + pmlarrays += (v + 1) * (self.ny + 1) * self.nz + pmlarrays += v * self.ny * (self.nz + 1) + pmlarrays += v * (self.ny + 1) * self.nz + elif "y" in k: + pmlarrays += self.nx * (v + 1) * (self.nz + 1) + pmlarrays += (self.nx + 1) * (v + 1) * self.nz + pmlarrays += (self.nx + 1) * v * self.nz + pmlarrays += self.nx * v * (self.nz + 1) + elif "z" in k: + pmlarrays += self.nx * (self.ny + 1) * (v + 1) + pmlarrays += (self.nx + 1) * self.ny * (v + 1) + pmlarrays += (self.nx + 1) * self.ny * v + pmlarrays += self.nx * (self.ny + 1) * v + + mem_use = int(fieldarrays + solidarray + rigidarrays + pmlarrays) + + return mem_use + + def mem_est_dispersive(self): + """Estimates the amount of memory (RAM) required for dispersive grid arrays. + + Returns: + mem_use: int of memory (bytes). + """ + + mem_use = int( + 3 + * config.get_model_config().materials["maxpoles"] + * (self.nx + 1) + * (self.ny + 1) + * (self.nz + 1) + * np.dtype(config.get_model_config().materials["dispersivedtype"]).itemsize + ) + return mem_use + + def mem_est_fractals(self): + """Estimates the amount of memory (RAM) required to build any objects + which use the FractalVolume/FractalSurface classes. + + Returns: + mem_use: int of memory (bytes). + """ + + mem_use = 0 + + for vol in self.fractalvolumes: + mem_use += vol.nx * vol.ny * vol.nz * vol.dtype.itemsize + for surface in vol.fractalsurfaces: + surfacedims = surface.get_surface_dims() + mem_use += surfacedims[0] * surfacedims[1] * surface.dtype.itemsize + + return mem_use + + def tmx(self): + """Add PEC boundaries to invariant direction in 2D TMx mode. + N.B. 2D modes are a single cell slice of 3D grid. + """ + # Ey & Ez components + self.ID[1, 0, :, :] = 0 + self.ID[1, 1, :, :] = 0 + self.ID[2, 0, :, :] = 0 + self.ID[2, 1, :, :] = 0 + + def tmy(self): + """Add PEC boundaries to invariant direction in 2D TMy mode. + N.B. 2D modes are a single cell slice of 3D grid. + """ + # Ex & Ez components + self.ID[0, :, 0, :] = 0 + self.ID[0, :, 1, :] = 0 + self.ID[2, :, 0, :] = 0 + self.ID[2, :, 1, :] = 0 + + def tmz(self): + """Add PEC boundaries to invariant direction in 2D TMz mode. + N.B. 2D modes are a single cell slice of 3D grid. + """ + # Ex & Ey components + self.ID[0, :, :, 0] = 0 + self.ID[0, :, :, 1] = 0 + self.ID[1, :, :, 0] = 0 + self.ID[1, :, :, 1] = 0 + + def calculate_dt(self): + """Calculate time step at the CFL limit.""" + if config.get_model_config().mode == "2D TMx": + self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dy**2) + (1 / self.dz**2))) + elif config.get_model_config().mode == "2D TMy": + self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dz**2))) + elif config.get_model_config().mode == "2D TMz": + self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2))) + else: + self.dt = 1 / ( + config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2) + (1 / self.dz**2)) + ) + + # Round down time step to nearest float with precision one less than + # hardware maximum. Avoids inadvertently exceeding the CFL due to + # binary representation of floating point number. + self.dt = round_value(self.dt, decimalplaces=decimal.getcontext().prec - 1) diff --git a/gprMax/grid/opencl_grid.py b/gprMax/grid/opencl_grid.py new file mode 100644 index 00000000..635b48f9 --- /dev/null +++ b/gprMax/grid/opencl_grid.py @@ -0,0 +1,183 @@ +from importlib import import_module + +import numpy as np + +from gprMax import config +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.utilities.utilities import fft_power, round_value + + +class OpenCLGrid(FDTDGrid): + """Additional grid methods for solving on compute device using OpenCL.""" + + def __init__(self): + super().__init__() + + self.clarray = import_module("pyopencl.array") + + def htod_geometry_arrays(self, queue): + """Initialise an array for cell edge IDs (ID) on compute device. + + Args: + queue: pyopencl queue. + """ + + self.ID_dev = self.clarray.to_device(queue, self.ID) + + def htod_field_arrays(self, queue): + """Initialise field arrays on compute device. + + Args: + queue: pyopencl queue. + """ + + self.Ex_dev = self.clarray.to_device(queue, self.Ex) + self.Ey_dev = self.clarray.to_device(queue, self.Ey) + self.Ez_dev = self.clarray.to_device(queue, self.Ez) + self.Hx_dev = self.clarray.to_device(queue, self.Hx) + self.Hy_dev = self.clarray.to_device(queue, self.Hy) + self.Hz_dev = self.clarray.to_device(queue, self.Hz) + + def htod_dispersive_arrays(self, queue): + """Initialise dispersive material coefficient arrays on compute device. + + Args: + queue: pyopencl queue. + """ + + self.updatecoeffsdispersive_dev = self.clarray.to_device(queue, self.updatecoeffsdispersive) + # self.updatecoeffsdispersive_dev = self.clarray.to_device(queue, np.ones((95,95,95), dtype=np.float32)) + self.Tx_dev = self.clarray.to_device(queue, self.Tx) + self.Ty_dev = self.clarray.to_device(queue, self.Ty) + self.Tz_dev = self.clarray.to_device(queue, self.Tz) + + +def dispersion_analysis(G): + """Analysis of numerical dispersion (Taflove et al, 2005, p112) - + worse case of maximum frequency and minimum wavelength + + Args: + G: FDTDGrid class describing a grid in a model. + + Returns: + results: dict of results from dispersion analysis. + """ + + # deltavp: physical phase velocity error (percentage) + # N: grid sampling density + # material: material with maximum permittivity + # maxfreq: maximum significant frequency + # error: error message + results = {"deltavp": None, "N": None, "material": None, "maxfreq": [], "error": ""} + + # Find maximum significant frequency + if G.waveforms: + for waveform in G.waveforms: + if waveform.type in ["sine", "contsine"]: + results["maxfreq"].append(4 * waveform.freq) + + elif waveform.type == "impulse": + results["error"] = "impulse waveform used." + + elif waveform.type == "user": + results["error"] = "user waveform detected." + + else: + # Time to analyse waveform - 4*pulse_width as using entire + # time window can result in demanding FFT + waveform.calculate_coefficients() + iterations = round_value(4 * waveform.chi / G.dt) + iterations = min(iterations, G.iterations) + waveformvalues = np.zeros(G.iterations) + for iteration in range(G.iterations): + waveformvalues[iteration] = waveform.calculate_value(iteration * G.dt, G.dt) + + # Ensure source waveform is not being overly truncated before attempting any FFT + if np.abs(waveformvalues[-1]) < np.abs(np.amax(waveformvalues)) / 100: + # FFT + freqs, power = fft_power(waveformvalues, G.dt) + # Get frequency for max power + freqmaxpower = np.where(np.isclose(power, 0))[0][0] + + # Set maximum frequency to a threshold drop from maximum power, ignoring DC value + try: + freqthres = ( + np.where( + power[freqmaxpower:] < -config.get_model_config().numdispersion["highestfreqthres"] + )[0][0] + + freqmaxpower + ) + results["maxfreq"].append(freqs[freqthres]) + except ValueError: + results["error"] = ( + "unable to calculate maximum power " + + "from waveform, most likely due to " + + "undersampling." + ) + + # Ignore case where someone is using a waveform with zero amplitude, i.e. on a receiver + elif waveform.amp == 0: + pass + + # If waveform is truncated don't do any further analysis + else: + results["error"] = ( + "waveform does not fit within specified " + "time window and is therefore being truncated." + ) + else: + results["error"] = "no waveform detected." + + if results["maxfreq"]: + results["maxfreq"] = max(results["maxfreq"]) + + # Find minimum wavelength (material with maximum permittivity) + maxer = 0 + matmaxer = "" + for x in G.materials: + if x.se != float("inf"): + er = x.er + # If there are dispersive materials calculate the complex + # relative permittivity at maximum frequency and take the real part + if x.__class__.__name__ == "DispersiveMaterial": + er = x.calculate_er(results["maxfreq"]) + er = er.real + if er > maxer: + maxer = er + matmaxer = x.ID + results["material"] = next(x for x in G.materials if x.ID == matmaxer) + + # Minimum velocity + minvelocity = config.c / np.sqrt(maxer) + + # Minimum wavelength + minwavelength = minvelocity / results["maxfreq"] + + # Maximum spatial step + if "3D" in config.get_model_config().mode: + delta = max(G.dx, G.dy, G.dz) + elif "2D" in config.get_model_config().mode: + if G.nx == 1: + delta = max(G.dy, G.dz) + elif G.ny == 1: + delta = max(G.dx, G.dz) + elif G.nz == 1: + delta = max(G.dx, G.dy) + + # Courant stability factor + S = (config.c * G.dt) / delta + + # Grid sampling density + results["N"] = minwavelength / delta + + # Check grid sampling will result in physical wave propagation + if int(np.floor(results["N"])) >= config.get_model_config().numdispersion["mingridsampling"]: + # Numerical phase velocity + vp = np.pi / (results["N"] * np.arcsin((1 / S) * np.sin((np.pi * S) / results["N"]))) + + # Physical phase velocity error (percentage) + results["deltavp"] = (((vp * config.c) - config.c) / config.c) * 100 + + # Store rounded down value of grid sampling density + results["N"] = int(np.floor(results["N"])) + + return results From 955113cacafda08b5548ff01238e2ca7f24af5ba Mon Sep 17 00:00:00 2001 From: nmannall Date: Fri, 1 Mar 2024 14:55:11 +0000 Subject: [PATCH 11/37] Add MPIGrid --- gprMax/grid/mpi_grid.py | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 gprMax/grid/mpi_grid.py diff --git a/gprMax/grid/mpi_grid.py b/gprMax/grid/mpi_grid.py new file mode 100644 index 00000000..6661d1aa --- /dev/null +++ b/gprMax/grid/mpi_grid.py @@ -0,0 +1,47 @@ +from typing import Optional + +from mpi4py import MPI + +from gprMax.grid.fdtd_grid import FDTDGrid + + +class MPIGrid(FDTDGrid): + xmin: int + ymin: int + zmin: int + xmax: int + ymax: int + zmax: int + + def __init__(self, mpi_tasks_x: int, mpi_tasks_y: int, mpi_tasks_z: int, comm: Optional[MPI.Intracomm] = None): + super().__init__() + + if comm is None: + self.comm = MPI.COMM_WORLD + else: + self.comm = comm + + if mpi_tasks_x * mpi_tasks_y * mpi_tasks_z > self.size: + # TODO: Raise expection - insufficient MPI tasks to create the grid as requested + pass + + self.mpi_tasks_x = mpi_tasks_x + self.mpi_tasks_y = mpi_tasks_y + self.mpi_tasks_z = mpi_tasks_z + + self.rank = self.comm.rank + self.size = self.comm.size + + def initialise_field_arrays(self): + super().initialise_field_arrays() + + self.local_grid_size_x = self.nx // self.mpi_tasks_x + self.local_grid_size_y = self.ny // self.mpi_tasks_y + self.local_grid_size_z = self.nz // self.mpi_tasks_z + + self.xmin = (self.rank % self.nx) * self.local_grid_size_x + self.ymin = ((self.mpi_tasks_x * self.rank) % self.ny) * self.local_grid_size_y + self.zmin = ((self.mpi_tasks_y * self.mpi_tasks_x * self.rank) % self.nz) * self.local_grid_size_z + self.xmax = self.xmin + self.local_grid_size_x + self.ymax = self.ymin + self.local_grid_size_y + self.zmax = self.zmin + self.local_grid_size_z From d9a397e4192d82e7aa81ea1c7f67e108685a8838 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 14:18:46 +0000 Subject: [PATCH 12/37] Complete seperating out grid classes --- gprMax/grid.py | 524 ---------------------- gprMax/grid/cuda_grid.py | 18 + gprMax/grid/fdtd_grid.py | 713 ++++++++++++++++++------------ gprMax/grid/mpi_grid.py | 22 +- gprMax/grid/opencl_grid.py | 153 +------ gprMax/model_build_run.py | 4 +- gprMax/solvers.py | 3 + gprMax/subgrids/grid.py | 2 +- tests/updates/test_cpu_updates.py | 2 +- 9 files changed, 495 insertions(+), 946 deletions(-) delete mode 100644 gprMax/grid.py diff --git a/gprMax/grid.py b/gprMax/grid.py deleted file mode 100644 index 50693226..00000000 --- a/gprMax/grid.py +++ /dev/null @@ -1,524 +0,0 @@ -# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom -# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley -# -# This file is part of gprMax. -# -# gprMax is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# gprMax is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with gprMax. If not, see . - -import decimal as d -from collections import OrderedDict -from importlib import import_module - -import numpy as np - -import gprMax.config as config - -from .pml import PML -from .utilities.utilities import fft_power, round_value - -np.seterr(invalid="raise") - - -class FDTDGrid: - """Holds attributes associated with entire grid. A convenient way for - accessing regularly used parameters. - """ - - def __init__(self): - self.title = "" - self.name = "main_grid" - self.mem_use = 0 - - self.nx = 0 - self.ny = 0 - self.nz = 0 - self.dx = 0 - self.dy = 0 - self.dz = 0 - self.dt = 0 - self.dt_mod = None # Time step stability factor - self.iteration = 0 # Current iteration number - self.iterations = 0 # Total number of iterations - self.timewindow = 0 - - # PML parameters - set some defaults to use if not user provided - self.pmls = {} - self.pmls["formulation"] = "HORIPML" - self.pmls["cfs"] = [] - self.pmls["slabs"] = [] - # Ordered dictionary required so *updating* the PMLs always follows the - # same order (the order for *building* PMLs does not matter). The order - # itself does not matter, however, if must be the same from model to - # model otherwise the numerical precision from adding the PML - # corrections will be different. - self.pmls["thickness"] = OrderedDict((key, 10) for key in PML.boundaryIDs) - - self.materials = [] - self.mixingmodels = [] - self.averagevolumeobjects = True - self.fractalvolumes = [] - self.geometryviews = [] - self.geometryobjectswrite = [] - self.waveforms = [] - self.voltagesources = [] - self.hertziandipoles = [] - self.magneticdipoles = [] - self.transmissionlines = [] - self.rxs = [] - self.srcsteps = [0, 0, 0] - self.rxsteps = [0, 0, 0] - self.snapshots = [] - self.subgrids = [] - - def within_bounds(self, p): - if p[0] < 0 or p[0] > self.nx: - raise ValueError("x") - if p[1] < 0 or p[1] > self.ny: - raise ValueError("y") - if p[2] < 0 or p[2] > self.nz: - raise ValueError("z") - - def discretise_point(self, p): - x = round_value(float(p[0]) / self.dx) - y = round_value(float(p[1]) / self.dy) - z = round_value(float(p[2]) / self.dz) - return (x, y, z) - - def round_to_grid(self, p): - p = self.discretise_point(p) - p_r = (p[0] * self.dx, p[1] * self.dy, p[2] * self.dz) - return p_r - - def within_pml(self, p): - if ( - p[0] < self.pmls["thickness"]["x0"] - or p[0] > self.nx - self.pmls["thickness"]["xmax"] - or p[1] < self.pmls["thickness"]["y0"] - or p[1] > self.ny - self.pmls["thickness"]["ymax"] - or p[2] < self.pmls["thickness"]["z0"] - or p[2] > self.nz - self.pmls["thickness"]["zmax"] - ): - return True - else: - return False - - def initialise_geometry_arrays(self): - """Initialise an array for volumetric material IDs (solid); - boolean arrays for specifying whether materials can have dielectric - smoothing (rigid); and an array for cell edge IDs (ID). - Solid and ID arrays are initialised to free_space (one); - rigid arrays to allow dielectric smoothing (zero). - """ - self.solid = np.ones((self.nx, self.ny, self.nz), dtype=np.uint32) - self.rigidE = np.zeros((12, self.nx, self.ny, self.nz), dtype=np.int8) - self.rigidH = np.zeros((6, self.nx, self.ny, self.nz), dtype=np.int8) - self.ID = np.ones((6, self.nx + 1, self.ny + 1, self.nz + 1), dtype=np.uint32) - self.IDlookup = {"Ex": 0, "Ey": 1, "Ez": 2, "Hx": 3, "Hy": 4, "Hz": 5} - - def initialise_field_arrays(self): - """Initialise arrays for the electric and magnetic field components.""" - self.Ex = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Ey = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Ez = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hx = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hy = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hz = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - - def initialise_std_update_coeff_arrays(self): - """Initialise arrays for storing update coefficients.""" - self.updatecoeffsE = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) - self.updatecoeffsH = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) - - def initialise_dispersive_arrays(self): - """Initialise field arrays when there are dispersive materials present.""" - self.Tx = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - self.Ty = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - self.Tz = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - - def initialise_dispersive_update_coeff_array(self): - """Initialise array for storing update coefficients when there are dispersive - materials present. - """ - self.updatecoeffsdispersive = np.zeros( - (len(self.materials), 3 * config.get_model_config().materials["maxpoles"]), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - - def reset_fields(self): - """Clear arrays for field components and PMLs.""" - # Clear arrays for field components - self.initialise_field_arrays() - if config.get_model_config().materials["maxpoles"] > 0: - self.initialise_dispersive_arrays() - - # Clear arrays for fields in PML - for pml in self.pmls["slabs"]: - pml.initialise_field_arrays() - - def mem_est_basic(self): - """Estimates the amount of memory (RAM) required for grid arrays. - - Returns: - mem_use: int of memory (bytes). - """ - - solidarray = self.nx * self.ny * self.nz * np.dtype(np.uint32).itemsize - - # 12 x rigidE array components + 6 x rigidH array components - rigidarrays = (12 + 6) * self.nx * self.ny * self.nz * np.dtype(np.int8).itemsize - - # 6 x field arrays + 6 x ID arrays - fieldarrays = ( - (6 + 6) - * (self.nx + 1) - * (self.ny + 1) - * (self.nz + 1) - * np.dtype(config.sim_config.dtypes["float_or_double"]).itemsize - ) - - # PML arrays - pmlarrays = 0 - for k, v in self.pmls["thickness"].items(): - if v > 0: - if "x" in k: - pmlarrays += (v + 1) * self.ny * (self.nz + 1) - pmlarrays += (v + 1) * (self.ny + 1) * self.nz - pmlarrays += v * self.ny * (self.nz + 1) - pmlarrays += v * (self.ny + 1) * self.nz - elif "y" in k: - pmlarrays += self.nx * (v + 1) * (self.nz + 1) - pmlarrays += (self.nx + 1) * (v + 1) * self.nz - pmlarrays += (self.nx + 1) * v * self.nz - pmlarrays += self.nx * v * (self.nz + 1) - elif "z" in k: - pmlarrays += self.nx * (self.ny + 1) * (v + 1) - pmlarrays += (self.nx + 1) * self.ny * (v + 1) - pmlarrays += (self.nx + 1) * self.ny * v - pmlarrays += self.nx * (self.ny + 1) * v - - mem_use = int(fieldarrays + solidarray + rigidarrays + pmlarrays) - - return mem_use - - def mem_est_dispersive(self): - """Estimates the amount of memory (RAM) required for dispersive grid arrays. - - Returns: - mem_use: int of memory (bytes). - """ - - mem_use = int( - 3 - * config.get_model_config().materials["maxpoles"] - * (self.nx + 1) - * (self.ny + 1) - * (self.nz + 1) - * np.dtype(config.get_model_config().materials["dispersivedtype"]).itemsize - ) - return mem_use - - def mem_est_fractals(self): - """Estimates the amount of memory (RAM) required to build any objects - which use the FractalVolume/FractalSurface classes. - - Returns: - mem_use: int of memory (bytes). - """ - - mem_use = 0 - - for vol in self.fractalvolumes: - mem_use += vol.nx * vol.ny * vol.nz * vol.dtype.itemsize - for surface in vol.fractalsurfaces: - surfacedims = surface.get_surface_dims() - mem_use += surfacedims[0] * surfacedims[1] * surface.dtype.itemsize - - return mem_use - - def tmx(self): - """Add PEC boundaries to invariant direction in 2D TMx mode. - N.B. 2D modes are a single cell slice of 3D grid. - """ - # Ey & Ez components - self.ID[1, 0, :, :] = 0 - self.ID[1, 1, :, :] = 0 - self.ID[2, 0, :, :] = 0 - self.ID[2, 1, :, :] = 0 - - def tmy(self): - """Add PEC boundaries to invariant direction in 2D TMy mode. - N.B. 2D modes are a single cell slice of 3D grid. - """ - # Ex & Ez components - self.ID[0, :, 0, :] = 0 - self.ID[0, :, 1, :] = 0 - self.ID[2, :, 0, :] = 0 - self.ID[2, :, 1, :] = 0 - - def tmz(self): - """Add PEC boundaries to invariant direction in 2D TMz mode. - N.B. 2D modes are a single cell slice of 3D grid. - """ - # Ex & Ey components - self.ID[0, :, :, 0] = 0 - self.ID[0, :, :, 1] = 0 - self.ID[1, :, :, 0] = 0 - self.ID[1, :, :, 1] = 0 - - def calculate_dt(self): - """Calculate time step at the CFL limit.""" - if config.get_model_config().mode == "2D TMx": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dy**2) + (1 / self.dz**2))) - elif config.get_model_config().mode == "2D TMy": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dz**2))) - elif config.get_model_config().mode == "2D TMz": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2))) - else: - self.dt = 1 / ( - config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2) + (1 / self.dz**2)) - ) - - # Round down time step to nearest float with precision one less than - # hardware maximum. Avoids inadvertently exceeding the CFL due to - # binary representation of floating point number. - self.dt = round_value(self.dt, decimalplaces=d.getcontext().prec - 1) - - -class CUDAGrid(FDTDGrid): - """Additional grid methods for solving on GPU using CUDA.""" - - def __init__(self): - super().__init__() - - self.gpuarray = import_module("pycuda.gpuarray") - - # Threads per block - used for main electric/magnetic field updates - self.tpb = (128, 1, 1) - # Blocks per grid - used for main electric/magnetic field updates - self.bpg = None - - def set_blocks_per_grid(self): - """Set the blocks per grid size used for updating the electric and - magnetic field arrays on a GPU. - """ - - self.bpg = (int(np.ceil(((self.nx + 1) * (self.ny + 1) * (self.nz + 1)) / self.tpb[0])), 1, 1) - - def htod_geometry_arrays(self): - """Initialise an array for cell edge IDs (ID) on compute device.""" - - self.ID_dev = self.gpuarray.to_gpu(self.ID) - - def htod_field_arrays(self): - """Initialise field arrays on compute device.""" - - self.Ex_dev = self.gpuarray.to_gpu(self.Ex) - self.Ey_dev = self.gpuarray.to_gpu(self.Ey) - self.Ez_dev = self.gpuarray.to_gpu(self.Ez) - self.Hx_dev = self.gpuarray.to_gpu(self.Hx) - self.Hy_dev = self.gpuarray.to_gpu(self.Hy) - self.Hz_dev = self.gpuarray.to_gpu(self.Hz) - - def htod_dispersive_arrays(self): - """Initialise dispersive material coefficient arrays on compute device.""" - - self.updatecoeffsdispersive_dev = self.gpuarray.to_gpu(self.updatecoeffsdispersive) - self.Tx_dev = self.gpuarray.to_gpu(self.Tx) - self.Ty_dev = self.gpuarray.to_gpu(self.Ty) - self.Tz_dev = self.gpuarray.to_gpu(self.Tz) - - -class OpenCLGrid(FDTDGrid): - """Additional grid methods for solving on compute device using OpenCL.""" - - def __init__(self): - super().__init__() - - self.clarray = import_module("pyopencl.array") - - def htod_geometry_arrays(self, queue): - """Initialise an array for cell edge IDs (ID) on compute device. - - Args: - queue: pyopencl queue. - """ - - self.ID_dev = self.clarray.to_device(queue, self.ID) - - def htod_field_arrays(self, queue): - """Initialise field arrays on compute device. - - Args: - queue: pyopencl queue. - """ - - self.Ex_dev = self.clarray.to_device(queue, self.Ex) - self.Ey_dev = self.clarray.to_device(queue, self.Ey) - self.Ez_dev = self.clarray.to_device(queue, self.Ez) - self.Hx_dev = self.clarray.to_device(queue, self.Hx) - self.Hy_dev = self.clarray.to_device(queue, self.Hy) - self.Hz_dev = self.clarray.to_device(queue, self.Hz) - - def htod_dispersive_arrays(self, queue): - """Initialise dispersive material coefficient arrays on compute device. - - Args: - queue: pyopencl queue. - """ - - self.updatecoeffsdispersive_dev = self.clarray.to_device(queue, self.updatecoeffsdispersive) - # self.updatecoeffsdispersive_dev = self.clarray.to_device(queue, np.ones((95,95,95), dtype=np.float32)) - self.Tx_dev = self.clarray.to_device(queue, self.Tx) - self.Ty_dev = self.clarray.to_device(queue, self.Ty) - self.Tz_dev = self.clarray.to_device(queue, self.Tz) - - -def dispersion_analysis(G): - """Analysis of numerical dispersion (Taflove et al, 2005, p112) - - worse case of maximum frequency and minimum wavelength - - Args: - G: FDTDGrid class describing a grid in a model. - - Returns: - results: dict of results from dispersion analysis. - """ - - # deltavp: physical phase velocity error (percentage) - # N: grid sampling density - # material: material with maximum permittivity - # maxfreq: maximum significant frequency - # error: error message - results = {"deltavp": None, "N": None, "material": None, "maxfreq": [], "error": ""} - - # Find maximum significant frequency - if G.waveforms: - for waveform in G.waveforms: - if waveform.type in ["sine", "contsine"]: - results["maxfreq"].append(4 * waveform.freq) - - elif waveform.type == "impulse": - results["error"] = "impulse waveform used." - - elif waveform.type == "user": - results["error"] = "user waveform detected." - - else: - # Time to analyse waveform - 4*pulse_width as using entire - # time window can result in demanding FFT - waveform.calculate_coefficients() - iterations = round_value(4 * waveform.chi / G.dt) - iterations = min(iterations, G.iterations) - waveformvalues = np.zeros(G.iterations) - for iteration in range(G.iterations): - waveformvalues[iteration] = waveform.calculate_value(iteration * G.dt, G.dt) - - # Ensure source waveform is not being overly truncated before attempting any FFT - if np.abs(waveformvalues[-1]) < np.abs(np.amax(waveformvalues)) / 100: - # FFT - freqs, power = fft_power(waveformvalues, G.dt) - # Get frequency for max power - freqmaxpower = np.where(np.isclose(power, 0))[0][0] - - # Set maximum frequency to a threshold drop from maximum power, ignoring DC value - try: - freqthres = ( - np.where( - power[freqmaxpower:] < -config.get_model_config().numdispersion["highestfreqthres"] - )[0][0] - + freqmaxpower - ) - results["maxfreq"].append(freqs[freqthres]) - except ValueError: - results["error"] = ( - "unable to calculate maximum power " - + "from waveform, most likely due to " - + "undersampling." - ) - - # Ignore case where someone is using a waveform with zero amplitude, i.e. on a receiver - elif waveform.amp == 0: - pass - - # If waveform is truncated don't do any further analysis - else: - results["error"] = ( - "waveform does not fit within specified " + "time window and is therefore being truncated." - ) - else: - results["error"] = "no waveform detected." - - if results["maxfreq"]: - results["maxfreq"] = max(results["maxfreq"]) - - # Find minimum wavelength (material with maximum permittivity) - maxer = 0 - matmaxer = "" - for x in G.materials: - if x.se != float("inf"): - er = x.er - # If there are dispersive materials calculate the complex - # relative permittivity at maximum frequency and take the real part - if x.__class__.__name__ == "DispersiveMaterial": - er = x.calculate_er(results["maxfreq"]) - er = er.real - if er > maxer: - maxer = er - matmaxer = x.ID - results["material"] = next(x for x in G.materials if x.ID == matmaxer) - - # Minimum velocity - minvelocity = config.c / np.sqrt(maxer) - - # Minimum wavelength - minwavelength = minvelocity / results["maxfreq"] - - # Maximum spatial step - if "3D" in config.get_model_config().mode: - delta = max(G.dx, G.dy, G.dz) - elif "2D" in config.get_model_config().mode: - if G.nx == 1: - delta = max(G.dy, G.dz) - elif G.ny == 1: - delta = max(G.dx, G.dz) - elif G.nz == 1: - delta = max(G.dx, G.dy) - - # Courant stability factor - S = (config.c * G.dt) / delta - - # Grid sampling density - results["N"] = minwavelength / delta - - # Check grid sampling will result in physical wave propagation - if int(np.floor(results["N"])) >= config.get_model_config().numdispersion["mingridsampling"]: - # Numerical phase velocity - vp = np.pi / (results["N"] * np.arcsin((1 / S) * np.sin((np.pi * S) / results["N"]))) - - # Physical phase velocity error (percentage) - results["deltavp"] = (((vp * config.c) - config.c) / config.c) * 100 - - # Store rounded down value of grid sampling density - results["N"] = int(np.floor(results["N"])) - - return results diff --git a/gprMax/grid/cuda_grid.py b/gprMax/grid/cuda_grid.py index 40c87318..11adf31d 100644 --- a/gprMax/grid/cuda_grid.py +++ b/gprMax/grid/cuda_grid.py @@ -1,3 +1,21 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + from importlib import import_module import numpy as np diff --git a/gprMax/grid/fdtd_grid.py b/gprMax/grid/fdtd_grid.py index cc3153f7..7156a8c9 100644 --- a/gprMax/grid/fdtd_grid.py +++ b/gprMax/grid/fdtd_grid.py @@ -1,282 +1,431 @@ -import decimal -from collections import OrderedDict - -import numpy as np - -from gprMax import config -from gprMax.pml import PML -from gprMax.utilities.utilities import round_value - - -class FDTDGrid: - """Holds attributes associated with entire grid. A convenient way for - accessing regularly used parameters. - """ - - def __init__(self): - self.title = "" - self.name = "main_grid" - self.mem_use = 0 - - self.nx = 0 - self.ny = 0 - self.nz = 0 - self.dx = 0 - self.dy = 0 - self.dz = 0 - self.dt = 0 - self.dt_mod = None # Time step stability factor - self.iteration = 0 # Current iteration number - self.iterations = 0 # Total number of iterations - self.timewindow = 0 - - # PML parameters - set some defaults to use if not user provided - self.pmls = {} - self.pmls["formulation"] = "HORIPML" - self.pmls["cfs"] = [] - self.pmls["slabs"] = [] - # Ordered dictionary required so *updating* the PMLs always follows the - # same order (the order for *building* PMLs does not matter). The order - # itself does not matter, however, if must be the same from model to - # model otherwise the numerical precision from adding the PML - # corrections will be different. - self.pmls["thickness"] = OrderedDict((key, 10) for key in PML.boundaryIDs) - - self.materials = [] - self.mixingmodels = [] - self.averagevolumeobjects = True - self.fractalvolumes = [] - self.geometryviews = [] - self.geometryobjectswrite = [] - self.waveforms = [] - self.voltagesources = [] - self.hertziandipoles = [] - self.magneticdipoles = [] - self.transmissionlines = [] - self.rxs = [] - self.srcsteps = [0, 0, 0] - self.rxsteps = [0, 0, 0] - self.snapshots = [] - self.subgrids = [] - - def within_bounds(self, p): - if p[0] < 0 or p[0] > self.nx: - raise ValueError("x") - if p[1] < 0 or p[1] > self.ny: - raise ValueError("y") - if p[2] < 0 or p[2] > self.nz: - raise ValueError("z") - - def discretise_point(self, p): - x = round_value(float(p[0]) / self.dx) - y = round_value(float(p[1]) / self.dy) - z = round_value(float(p[2]) / self.dz) - return (x, y, z) - - def round_to_grid(self, p): - p = self.discretise_point(p) - p_r = (p[0] * self.dx, p[1] * self.dy, p[2] * self.dz) - return p_r - - def within_pml(self, p): - if ( - p[0] < self.pmls["thickness"]["x0"] - or p[0] > self.nx - self.pmls["thickness"]["xmax"] - or p[1] < self.pmls["thickness"]["y0"] - or p[1] > self.ny - self.pmls["thickness"]["ymax"] - or p[2] < self.pmls["thickness"]["z0"] - or p[2] > self.nz - self.pmls["thickness"]["zmax"] - ): - return True - else: - return False - - def initialise_geometry_arrays(self): - """Initialise an array for volumetric material IDs (solid); - boolean arrays for specifying whether materials can have dielectric - smoothing (rigid); and an array for cell edge IDs (ID). - Solid and ID arrays are initialised to free_space (one); - rigid arrays to allow dielectric smoothing (zero). - """ - self.solid = np.ones((self.nx, self.ny, self.nz), dtype=np.uint32) - self.rigidE = np.zeros((12, self.nx, self.ny, self.nz), dtype=np.int8) - self.rigidH = np.zeros((6, self.nx, self.ny, self.nz), dtype=np.int8) - self.ID = np.ones((6, self.nx + 1, self.ny + 1, self.nz + 1), dtype=np.uint32) - self.IDlookup = {"Ex": 0, "Ey": 1, "Ez": 2, "Hx": 3, "Hy": 4, "Hz": 5} - - def initialise_field_arrays(self): - """Initialise arrays for the electric and magnetic field components.""" - self.Ex = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Ey = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Ez = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hx = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hy = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hz = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - - def initialise_std_update_coeff_arrays(self): - """Initialise arrays for storing update coefficients.""" - self.updatecoeffsE = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) - self.updatecoeffsH = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) - - def initialise_dispersive_arrays(self): - """Initialise field arrays when there are dispersive materials present.""" - self.Tx = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - self.Ty = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - self.Tz = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - - def initialise_dispersive_update_coeff_array(self): - """Initialise array for storing update coefficients when there are dispersive - materials present. - """ - self.updatecoeffsdispersive = np.zeros( - (len(self.materials), 3 * config.get_model_config().materials["maxpoles"]), - dtype=config.get_model_config().materials["dispersivedtype"], - ) - - def reset_fields(self): - """Clear arrays for field components and PMLs.""" - # Clear arrays for field components - self.initialise_field_arrays() - if config.get_model_config().materials["maxpoles"] > 0: - self.initialise_dispersive_arrays() - - # Clear arrays for fields in PML - for pml in self.pmls["slabs"]: - pml.initialise_field_arrays() - - def mem_est_basic(self): - """Estimates the amount of memory (RAM) required for grid arrays. - - Returns: - mem_use: int of memory (bytes). - """ - - solidarray = self.nx * self.ny * self.nz * np.dtype(np.uint32).itemsize - - # 12 x rigidE array components + 6 x rigidH array components - rigidarrays = (12 + 6) * self.nx * self.ny * self.nz * np.dtype(np.int8).itemsize - - # 6 x field arrays + 6 x ID arrays - fieldarrays = ( - (6 + 6) - * (self.nx + 1) - * (self.ny + 1) - * (self.nz + 1) - * np.dtype(config.sim_config.dtypes["float_or_double"]).itemsize - ) - - # PML arrays - pmlarrays = 0 - for k, v in self.pmls["thickness"].items(): - if v > 0: - if "x" in k: - pmlarrays += (v + 1) * self.ny * (self.nz + 1) - pmlarrays += (v + 1) * (self.ny + 1) * self.nz - pmlarrays += v * self.ny * (self.nz + 1) - pmlarrays += v * (self.ny + 1) * self.nz - elif "y" in k: - pmlarrays += self.nx * (v + 1) * (self.nz + 1) - pmlarrays += (self.nx + 1) * (v + 1) * self.nz - pmlarrays += (self.nx + 1) * v * self.nz - pmlarrays += self.nx * v * (self.nz + 1) - elif "z" in k: - pmlarrays += self.nx * (self.ny + 1) * (v + 1) - pmlarrays += (self.nx + 1) * self.ny * (v + 1) - pmlarrays += (self.nx + 1) * self.ny * v - pmlarrays += self.nx * (self.ny + 1) * v - - mem_use = int(fieldarrays + solidarray + rigidarrays + pmlarrays) - - return mem_use - - def mem_est_dispersive(self): - """Estimates the amount of memory (RAM) required for dispersive grid arrays. - - Returns: - mem_use: int of memory (bytes). - """ - - mem_use = int( - 3 - * config.get_model_config().materials["maxpoles"] - * (self.nx + 1) - * (self.ny + 1) - * (self.nz + 1) - * np.dtype(config.get_model_config().materials["dispersivedtype"]).itemsize - ) - return mem_use - - def mem_est_fractals(self): - """Estimates the amount of memory (RAM) required to build any objects - which use the FractalVolume/FractalSurface classes. - - Returns: - mem_use: int of memory (bytes). - """ - - mem_use = 0 - - for vol in self.fractalvolumes: - mem_use += vol.nx * vol.ny * vol.nz * vol.dtype.itemsize - for surface in vol.fractalsurfaces: - surfacedims = surface.get_surface_dims() - mem_use += surfacedims[0] * surfacedims[1] * surface.dtype.itemsize - - return mem_use - - def tmx(self): - """Add PEC boundaries to invariant direction in 2D TMx mode. - N.B. 2D modes are a single cell slice of 3D grid. - """ - # Ey & Ez components - self.ID[1, 0, :, :] = 0 - self.ID[1, 1, :, :] = 0 - self.ID[2, 0, :, :] = 0 - self.ID[2, 1, :, :] = 0 - - def tmy(self): - """Add PEC boundaries to invariant direction in 2D TMy mode. - N.B. 2D modes are a single cell slice of 3D grid. - """ - # Ex & Ez components - self.ID[0, :, 0, :] = 0 - self.ID[0, :, 1, :] = 0 - self.ID[2, :, 0, :] = 0 - self.ID[2, :, 1, :] = 0 - - def tmz(self): - """Add PEC boundaries to invariant direction in 2D TMz mode. - N.B. 2D modes are a single cell slice of 3D grid. - """ - # Ex & Ey components - self.ID[0, :, :, 0] = 0 - self.ID[0, :, :, 1] = 0 - self.ID[1, :, :, 0] = 0 - self.ID[1, :, :, 1] = 0 - - def calculate_dt(self): - """Calculate time step at the CFL limit.""" - if config.get_model_config().mode == "2D TMx": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dy**2) + (1 / self.dz**2))) - elif config.get_model_config().mode == "2D TMy": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dz**2))) - elif config.get_model_config().mode == "2D TMz": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2))) - else: - self.dt = 1 / ( - config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2) + (1 / self.dz**2)) - ) - - # Round down time step to nearest float with precision one less than - # hardware maximum. Avoids inadvertently exceeding the CFL due to - # binary representation of floating point number. - self.dt = round_value(self.dt, decimalplaces=decimal.getcontext().prec - 1) +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + +import decimal +from collections import OrderedDict + +import numpy as np + +from gprMax import config +from gprMax.pml import PML +from gprMax.utilities.utilities import fft_power, round_value + + +class FDTDGrid: + """Holds attributes associated with entire grid. A convenient way for + accessing regularly used parameters. + """ + + def __init__(self): + self.title = "" + self.name = "main_grid" + self.mem_use = 0 + + self.nx = 0 + self.ny = 0 + self.nz = 0 + self.dx = 0 + self.dy = 0 + self.dz = 0 + self.dt = 0 + self.dt_mod = None # Time step stability factor + self.iteration = 0 # Current iteration number + self.iterations = 0 # Total number of iterations + self.timewindow = 0 + + # PML parameters - set some defaults to use if not user provided + self.pmls = {} + self.pmls["formulation"] = "HORIPML" + self.pmls["cfs"] = [] + self.pmls["slabs"] = [] + # Ordered dictionary required so *updating* the PMLs always follows the + # same order (the order for *building* PMLs does not matter). The order + # itself does not matter, however, if must be the same from model to + # model otherwise the numerical precision from adding the PML + # corrections will be different. + self.pmls["thickness"] = OrderedDict((key, 10) for key in PML.boundaryIDs) + + self.materials = [] + self.mixingmodels = [] + self.averagevolumeobjects = True + self.fractalvolumes = [] + self.geometryviews = [] + self.geometryobjectswrite = [] + self.waveforms = [] + self.voltagesources = [] + self.hertziandipoles = [] + self.magneticdipoles = [] + self.transmissionlines = [] + self.rxs = [] + self.srcsteps = [0, 0, 0] + self.rxsteps = [0, 0, 0] + self.snapshots = [] + self.subgrids = [] + + def within_bounds(self, p): + if p[0] < 0 or p[0] > self.nx: + raise ValueError("x") + if p[1] < 0 or p[1] > self.ny: + raise ValueError("y") + if p[2] < 0 or p[2] > self.nz: + raise ValueError("z") + + def discretise_point(self, p): + x = round_value(float(p[0]) / self.dx) + y = round_value(float(p[1]) / self.dy) + z = round_value(float(p[2]) / self.dz) + return (x, y, z) + + def round_to_grid(self, p): + p = self.discretise_point(p) + p_r = (p[0] * self.dx, p[1] * self.dy, p[2] * self.dz) + return p_r + + def within_pml(self, p): + if ( + p[0] < self.pmls["thickness"]["x0"] + or p[0] > self.nx - self.pmls["thickness"]["xmax"] + or p[1] < self.pmls["thickness"]["y0"] + or p[1] > self.ny - self.pmls["thickness"]["ymax"] + or p[2] < self.pmls["thickness"]["z0"] + or p[2] > self.nz - self.pmls["thickness"]["zmax"] + ): + return True + else: + return False + + def initialise_geometry_arrays(self): + """Initialise an array for volumetric material IDs (solid); + boolean arrays for specifying whether materials can have dielectric + smoothing (rigid); and an array for cell edge IDs (ID). + Solid and ID arrays are initialised to free_space (one); + rigid arrays to allow dielectric smoothing (zero). + """ + self.solid = np.ones((self.nx, self.ny, self.nz), dtype=np.uint32) + self.rigidE = np.zeros((12, self.nx, self.ny, self.nz), dtype=np.int8) + self.rigidH = np.zeros((6, self.nx, self.ny, self.nz), dtype=np.int8) + self.ID = np.ones((6, self.nx + 1, self.ny + 1, self.nz + 1), dtype=np.uint32) + self.IDlookup = {"Ex": 0, "Ey": 1, "Ez": 2, "Hx": 3, "Hy": 4, "Hz": 5} + + def initialise_field_arrays(self): + """Initialise arrays for the electric and magnetic field components.""" + self.Ex = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Ey = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Ez = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Hx = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Hy = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Hz = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + + def initialise_std_update_coeff_arrays(self): + """Initialise arrays for storing update coefficients.""" + self.updatecoeffsE = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) + self.updatecoeffsH = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) + + def initialise_dispersive_arrays(self): + """Initialise field arrays when there are dispersive materials present.""" + self.Tx = np.zeros( + (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + self.Ty = np.zeros( + (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + self.Tz = np.zeros( + (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + + def initialise_dispersive_update_coeff_array(self): + """Initialise array for storing update coefficients when there are dispersive + materials present. + """ + self.updatecoeffsdispersive = np.zeros( + (len(self.materials), 3 * config.get_model_config().materials["maxpoles"]), + dtype=config.get_model_config().materials["dispersivedtype"], + ) + + def reset_fields(self): + """Clear arrays for field components and PMLs.""" + # Clear arrays for field components + self.initialise_field_arrays() + if config.get_model_config().materials["maxpoles"] > 0: + self.initialise_dispersive_arrays() + + # Clear arrays for fields in PML + for pml in self.pmls["slabs"]: + pml.initialise_field_arrays() + + def mem_est_basic(self): + """Estimates the amount of memory (RAM) required for grid arrays. + + Returns: + mem_use: int of memory (bytes). + """ + + solidarray = self.nx * self.ny * self.nz * np.dtype(np.uint32).itemsize + + # 12 x rigidE array components + 6 x rigidH array components + rigidarrays = (12 + 6) * self.nx * self.ny * self.nz * np.dtype(np.int8).itemsize + + # 6 x field arrays + 6 x ID arrays + fieldarrays = ( + (6 + 6) + * (self.nx + 1) + * (self.ny + 1) + * (self.nz + 1) + * np.dtype(config.sim_config.dtypes["float_or_double"]).itemsize + ) + + # PML arrays + pmlarrays = 0 + for k, v in self.pmls["thickness"].items(): + if v > 0: + if "x" in k: + pmlarrays += (v + 1) * self.ny * (self.nz + 1) + pmlarrays += (v + 1) * (self.ny + 1) * self.nz + pmlarrays += v * self.ny * (self.nz + 1) + pmlarrays += v * (self.ny + 1) * self.nz + elif "y" in k: + pmlarrays += self.nx * (v + 1) * (self.nz + 1) + pmlarrays += (self.nx + 1) * (v + 1) * self.nz + pmlarrays += (self.nx + 1) * v * self.nz + pmlarrays += self.nx * v * (self.nz + 1) + elif "z" in k: + pmlarrays += self.nx * (self.ny + 1) * (v + 1) + pmlarrays += (self.nx + 1) * self.ny * (v + 1) + pmlarrays += (self.nx + 1) * self.ny * v + pmlarrays += self.nx * (self.ny + 1) * v + + mem_use = int(fieldarrays + solidarray + rigidarrays + pmlarrays) + + return mem_use + + def mem_est_dispersive(self): + """Estimates the amount of memory (RAM) required for dispersive grid arrays. + + Returns: + mem_use: int of memory (bytes). + """ + + mem_use = int( + 3 + * config.get_model_config().materials["maxpoles"] + * (self.nx + 1) + * (self.ny + 1) + * (self.nz + 1) + * np.dtype(config.get_model_config().materials["dispersivedtype"]).itemsize + ) + return mem_use + + def mem_est_fractals(self): + """Estimates the amount of memory (RAM) required to build any objects + which use the FractalVolume/FractalSurface classes. + + Returns: + mem_use: int of memory (bytes). + """ + + mem_use = 0 + + for vol in self.fractalvolumes: + mem_use += vol.nx * vol.ny * vol.nz * vol.dtype.itemsize + for surface in vol.fractalsurfaces: + surfacedims = surface.get_surface_dims() + mem_use += surfacedims[0] * surfacedims[1] * surface.dtype.itemsize + + return mem_use + + def tmx(self): + """Add PEC boundaries to invariant direction in 2D TMx mode. + N.B. 2D modes are a single cell slice of 3D grid. + """ + # Ey & Ez components + self.ID[1, 0, :, :] = 0 + self.ID[1, 1, :, :] = 0 + self.ID[2, 0, :, :] = 0 + self.ID[2, 1, :, :] = 0 + + def tmy(self): + """Add PEC boundaries to invariant direction in 2D TMy mode. + N.B. 2D modes are a single cell slice of 3D grid. + """ + # Ex & Ez components + self.ID[0, :, 0, :] = 0 + self.ID[0, :, 1, :] = 0 + self.ID[2, :, 0, :] = 0 + self.ID[2, :, 1, :] = 0 + + def tmz(self): + """Add PEC boundaries to invariant direction in 2D TMz mode. + N.B. 2D modes are a single cell slice of 3D grid. + """ + # Ex & Ey components + self.ID[0, :, :, 0] = 0 + self.ID[0, :, :, 1] = 0 + self.ID[1, :, :, 0] = 0 + self.ID[1, :, :, 1] = 0 + + def calculate_dt(self): + """Calculate time step at the CFL limit.""" + if config.get_model_config().mode == "2D TMx": + self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dy**2) + (1 / self.dz**2))) + elif config.get_model_config().mode == "2D TMy": + self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dz**2))) + elif config.get_model_config().mode == "2D TMz": + self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2))) + else: + self.dt = 1 / ( + config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2) + (1 / self.dz**2)) + ) + + # Round down time step to nearest float with precision one less than + # hardware maximum. Avoids inadvertently exceeding the CFL due to + # binary representation of floating point number. + self.dt = round_value(self.dt, decimalplaces=decimal.getcontext().prec - 1) + + +def dispersion_analysis(G): + """Analysis of numerical dispersion (Taflove et al, 2005, p112) - + worse case of maximum frequency and minimum wavelength + + Args: + G: FDTDGrid class describing a grid in a model. + + Returns: + results: dict of results from dispersion analysis. + """ + + # deltavp: physical phase velocity error (percentage) + # N: grid sampling density + # material: material with maximum permittivity + # maxfreq: maximum significant frequency + # error: error message + results = {"deltavp": None, "N": None, "material": None, "maxfreq": [], "error": ""} + + # Find maximum significant frequency + if G.waveforms: + for waveform in G.waveforms: + if waveform.type in ["sine", "contsine"]: + results["maxfreq"].append(4 * waveform.freq) + + elif waveform.type == "impulse": + results["error"] = "impulse waveform used." + + elif waveform.type == "user": + results["error"] = "user waveform detected." + + else: + # Time to analyse waveform - 4*pulse_width as using entire + # time window can result in demanding FFT + waveform.calculate_coefficients() + iterations = round_value(4 * waveform.chi / G.dt) + iterations = min(iterations, G.iterations) + waveformvalues = np.zeros(G.iterations) + for iteration in range(G.iterations): + waveformvalues[iteration] = waveform.calculate_value(iteration * G.dt, G.dt) + + # Ensure source waveform is not being overly truncated before attempting any FFT + if np.abs(waveformvalues[-1]) < np.abs(np.amax(waveformvalues)) / 100: + # FFT + freqs, power = fft_power(waveformvalues, G.dt) + # Get frequency for max power + freqmaxpower = np.where(np.isclose(power, 0))[0][0] + + # Set maximum frequency to a threshold drop from maximum power, ignoring DC value + try: + freqthres = ( + np.where( + power[freqmaxpower:] < -config.get_model_config().numdispersion["highestfreqthres"] + )[0][0] + + freqmaxpower + ) + results["maxfreq"].append(freqs[freqthres]) + except ValueError: + results["error"] = ( + "unable to calculate maximum power " + + "from waveform, most likely due to " + + "undersampling." + ) + + # Ignore case where someone is using a waveform with zero amplitude, i.e. on a receiver + elif waveform.amp == 0: + pass + + # If waveform is truncated don't do any further analysis + else: + results["error"] = ( + "waveform does not fit within specified " + "time window and is therefore being truncated." + ) + else: + results["error"] = "no waveform detected." + + if results["maxfreq"]: + results["maxfreq"] = max(results["maxfreq"]) + + # Find minimum wavelength (material with maximum permittivity) + maxer = 0 + matmaxer = "" + for x in G.materials: + if x.se != float("inf"): + er = x.er + # If there are dispersive materials calculate the complex + # relative permittivity at maximum frequency and take the real part + if x.__class__.__name__ == "DispersiveMaterial": + er = x.calculate_er(results["maxfreq"]) + er = er.real + if er > maxer: + maxer = er + matmaxer = x.ID + results["material"] = next(x for x in G.materials if x.ID == matmaxer) + + # Minimum velocity + minvelocity = config.c / np.sqrt(maxer) + + # Minimum wavelength + minwavelength = minvelocity / results["maxfreq"] + + # Maximum spatial step + if "3D" in config.get_model_config().mode: + delta = max(G.dx, G.dy, G.dz) + elif "2D" in config.get_model_config().mode: + if G.nx == 1: + delta = max(G.dy, G.dz) + elif G.ny == 1: + delta = max(G.dx, G.dz) + elif G.nz == 1: + delta = max(G.dx, G.dy) + + # Courant stability factor + S = (config.c * G.dt) / delta + + # Grid sampling density + results["N"] = minwavelength / delta + + # Check grid sampling will result in physical wave propagation + if int(np.floor(results["N"])) >= config.get_model_config().numdispersion["mingridsampling"]: + # Numerical phase velocity + vp = np.pi / (results["N"] * np.arcsin((1 / S) * np.sin((np.pi * S) / results["N"]))) + + # Physical phase velocity error (percentage) + results["deltavp"] = (((vp * config.c) - config.c) / config.c) * 100 + + # Store rounded down value of grid sampling density + results["N"] = int(np.floor(results["N"])) + + return results diff --git a/gprMax/grid/mpi_grid.py b/gprMax/grid/mpi_grid.py index 6661d1aa..52de80a7 100644 --- a/gprMax/grid/mpi_grid.py +++ b/gprMax/grid/mpi_grid.py @@ -1,3 +1,21 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + from typing import Optional from mpi4py import MPI @@ -13,6 +31,8 @@ class MPIGrid(FDTDGrid): ymax: int zmax: int + comm: MPI.Intracomm + def __init__(self, mpi_tasks_x: int, mpi_tasks_y: int, mpi_tasks_z: int, comm: Optional[MPI.Intracomm] = None): super().__init__() @@ -21,7 +41,7 @@ class MPIGrid(FDTDGrid): else: self.comm = comm - if mpi_tasks_x * mpi_tasks_y * mpi_tasks_z > self.size: + if mpi_tasks_x * mpi_tasks_y * mpi_tasks_z > self.comm.size: # TODO: Raise expection - insufficient MPI tasks to create the grid as requested pass diff --git a/gprMax/grid/opencl_grid.py b/gprMax/grid/opencl_grid.py index 635b48f9..93a80e88 100644 --- a/gprMax/grid/opencl_grid.py +++ b/gprMax/grid/opencl_grid.py @@ -1,10 +1,24 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + from importlib import import_module -import numpy as np - -from gprMax import config from gprMax.grid.fdtd_grid import FDTDGrid -from gprMax.utilities.utilities import fft_power, round_value class OpenCLGrid(FDTDGrid): @@ -50,134 +64,3 @@ class OpenCLGrid(FDTDGrid): self.Tx_dev = self.clarray.to_device(queue, self.Tx) self.Ty_dev = self.clarray.to_device(queue, self.Ty) self.Tz_dev = self.clarray.to_device(queue, self.Tz) - - -def dispersion_analysis(G): - """Analysis of numerical dispersion (Taflove et al, 2005, p112) - - worse case of maximum frequency and minimum wavelength - - Args: - G: FDTDGrid class describing a grid in a model. - - Returns: - results: dict of results from dispersion analysis. - """ - - # deltavp: physical phase velocity error (percentage) - # N: grid sampling density - # material: material with maximum permittivity - # maxfreq: maximum significant frequency - # error: error message - results = {"deltavp": None, "N": None, "material": None, "maxfreq": [], "error": ""} - - # Find maximum significant frequency - if G.waveforms: - for waveform in G.waveforms: - if waveform.type in ["sine", "contsine"]: - results["maxfreq"].append(4 * waveform.freq) - - elif waveform.type == "impulse": - results["error"] = "impulse waveform used." - - elif waveform.type == "user": - results["error"] = "user waveform detected." - - else: - # Time to analyse waveform - 4*pulse_width as using entire - # time window can result in demanding FFT - waveform.calculate_coefficients() - iterations = round_value(4 * waveform.chi / G.dt) - iterations = min(iterations, G.iterations) - waveformvalues = np.zeros(G.iterations) - for iteration in range(G.iterations): - waveformvalues[iteration] = waveform.calculate_value(iteration * G.dt, G.dt) - - # Ensure source waveform is not being overly truncated before attempting any FFT - if np.abs(waveformvalues[-1]) < np.abs(np.amax(waveformvalues)) / 100: - # FFT - freqs, power = fft_power(waveformvalues, G.dt) - # Get frequency for max power - freqmaxpower = np.where(np.isclose(power, 0))[0][0] - - # Set maximum frequency to a threshold drop from maximum power, ignoring DC value - try: - freqthres = ( - np.where( - power[freqmaxpower:] < -config.get_model_config().numdispersion["highestfreqthres"] - )[0][0] - + freqmaxpower - ) - results["maxfreq"].append(freqs[freqthres]) - except ValueError: - results["error"] = ( - "unable to calculate maximum power " - + "from waveform, most likely due to " - + "undersampling." - ) - - # Ignore case where someone is using a waveform with zero amplitude, i.e. on a receiver - elif waveform.amp == 0: - pass - - # If waveform is truncated don't do any further analysis - else: - results["error"] = ( - "waveform does not fit within specified " + "time window and is therefore being truncated." - ) - else: - results["error"] = "no waveform detected." - - if results["maxfreq"]: - results["maxfreq"] = max(results["maxfreq"]) - - # Find minimum wavelength (material with maximum permittivity) - maxer = 0 - matmaxer = "" - for x in G.materials: - if x.se != float("inf"): - er = x.er - # If there are dispersive materials calculate the complex - # relative permittivity at maximum frequency and take the real part - if x.__class__.__name__ == "DispersiveMaterial": - er = x.calculate_er(results["maxfreq"]) - er = er.real - if er > maxer: - maxer = er - matmaxer = x.ID - results["material"] = next(x for x in G.materials if x.ID == matmaxer) - - # Minimum velocity - minvelocity = config.c / np.sqrt(maxer) - - # Minimum wavelength - minwavelength = minvelocity / results["maxfreq"] - - # Maximum spatial step - if "3D" in config.get_model_config().mode: - delta = max(G.dx, G.dy, G.dz) - elif "2D" in config.get_model_config().mode: - if G.nx == 1: - delta = max(G.dy, G.dz) - elif G.ny == 1: - delta = max(G.dx, G.dz) - elif G.nz == 1: - delta = max(G.dx, G.dy) - - # Courant stability factor - S = (config.c * G.dt) / delta - - # Grid sampling density - results["N"] = minwavelength / delta - - # Check grid sampling will result in physical wave propagation - if int(np.floor(results["N"])) >= config.get_model_config().numdispersion["mingridsampling"]: - # Numerical phase velocity - vp = np.pi / (results["N"] * np.arcsin((1 / S) * np.sin((np.pi * S) / results["N"]))) - - # Physical phase velocity error (percentage) - results["deltavp"] = (((vp * config.c) - config.c) / config.c) * 100 - - # Store rounded down value of grid sampling density - results["N"] = int(np.floor(results["N"])) - - return results diff --git a/gprMax/model_build_run.py b/gprMax/model_build_run.py index b375eff7..e20164f9 100644 --- a/gprMax/model_build_run.py +++ b/gprMax/model_build_run.py @@ -36,7 +36,7 @@ import gprMax.config as config from .cython.yee_cell_build import build_electric_components, build_magnetic_components from .fields_outputs import write_hdf5_outputfile from .geometry_outputs import save_geometry_views -from .grid import dispersion_analysis +from .grid.fdtd_grid import dispersion_analysis from .hash_cmds_file import parse_hash_commands from .materials import process_materials from .pml import CFS, build_pml, print_pml_info @@ -351,7 +351,7 @@ class ModelBuildRun: if config.sim_config.general["solver"] == "cuda": mem_str = f" host + ~{humanize.naturalsize(solver.memused)} device" elif config.sim_config.general["solver"] == "opencl": - mem_str = f" host + unknown for device" + mem_str = f" host + unknown for device" logger.info(f"\nMemory used (estimated): " + f"~{humanize.naturalsize(self.p.memory_full_info().uss)}{mem_str}") logger.info( diff --git a/gprMax/solvers.py b/gprMax/solvers.py index b73de79f..5e31dc48 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -17,6 +17,9 @@ # along with gprMax. If not, see . import gprMax.config as config +from gprMax.grid.cuda_grid import CUDAGrid +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.grid.opencl_grid import OpenCLGrid from .grid import CUDAGrid, FDTDGrid, OpenCLGrid from .subgrids.updates import create_updates as create_subgrid_updates diff --git a/gprMax/subgrids/grid.py b/gprMax/subgrids/grid.py index 1711c2cb..b0e51563 100644 --- a/gprMax/subgrids/grid.py +++ b/gprMax/subgrids/grid.py @@ -18,7 +18,7 @@ import logging -from ..grid import FDTDGrid +from gprMax.grid.fdtd_grid import FDTDGrid logger = logging.getLogger(__name__) diff --git a/tests/updates/test_cpu_updates.py b/tests/updates/test_cpu_updates.py index b4d83c03..67d4a6c5 100644 --- a/tests/updates/test_cpu_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -5,7 +5,7 @@ import pytest from pytest import MonkeyPatch from gprMax import config, gprMax -from gprMax.grid import FDTDGrid +from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.materials import create_built_in_materials from gprMax.model_build_run import GridBuilder from gprMax.pml import CFS From a0bcc2718b82afe0fb54db6ebe113d898645d6e9 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 14:26:42 +0000 Subject: [PATCH 13/37] Create Updates abstract class --- gprMax/updates/cpu_updates.py | 3 +- gprMax/updates/cuda_updates.py | 3 +- gprMax/updates/opencl_updates.py | 3 +- gprMax/updates/updates.py | 96 ++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 gprMax/updates/updates.py diff --git a/gprMax/updates/cpu_updates.py b/gprMax/updates/cpu_updates.py index a7257ad0..6b48c1ee 100644 --- a/gprMax/updates/cpu_updates.py +++ b/gprMax/updates/cpu_updates.py @@ -22,10 +22,11 @@ from gprMax import config from gprMax.cython.fields_updates_normal import update_electric as update_electric_cpu from gprMax.cython.fields_updates_normal import update_magnetic as update_magnetic_cpu from gprMax.fields_outputs import store_outputs as store_outputs_cpu +from gprMax.updates.updates import Updates from gprMax.utilities.utilities import timer -class CPUUpdates: +class CPUUpdates(Updates): """Defines update functions for CPU-based solver.""" def __init__(self, G): diff --git a/gprMax/updates/cuda_updates.py b/gprMax/updates/cuda_updates.py index 848b487a..387c2b44 100644 --- a/gprMax/updates/cuda_updates.py +++ b/gprMax/updates/cuda_updates.py @@ -28,12 +28,13 @@ from gprMax.cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_upd from gprMax.receivers import dtoh_rx_array, htod_rx_arrays from gprMax.snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array from gprMax.sources import htod_src_arrays +from gprMax.updates.updates import Updates from gprMax.utilities.utilities import round32 logger = logging.getLogger(__name__) -class CUDAUpdates: +class CUDAUpdates(Updates): """Defines update functions for GPU-based (CUDA) solver.""" def __init__(self, G): diff --git a/gprMax/updates/opencl_updates.py b/gprMax/updates/opencl_updates.py index 39b92861..315fde48 100644 --- a/gprMax/updates/opencl_updates.py +++ b/gprMax/updates/opencl_updates.py @@ -27,11 +27,12 @@ from gprMax.cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_upd from gprMax.receivers import dtoh_rx_array, htod_rx_arrays from gprMax.snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array from gprMax.sources import htod_src_arrays +from gprMax.updates.updates import Updates logger = logging.getLogger(__name__) -class OpenCLUpdates: +class OpenCLUpdates(Updates): """Defines update functions for OpenCL-based solver.""" def __init__(self, G): diff --git a/gprMax/updates/updates.py b/gprMax/updates/updates.py new file mode 100644 index 00000000..182abb7f --- /dev/null +++ b/gprMax/updates/updates.py @@ -0,0 +1,96 @@ +# Copyright (C) 2015-2024: The University of Edinburgh, United Kingdom +# Authors: Craig Warren, Antonis Giannopoulos, and John Hartley +# +# This file is part of gprMax. +# +# gprMax is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gprMax is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gprMax. If not, see . + + +from abc import ABC, abstractmethod + + +class Updates(ABC): + """Defines update functions for a solver.""" + + @abstractmethod + def store_outputs(self) -> None: + """Stores field component values for every receiver and transmission line.""" + pass + + @abstractmethod + def store_snapshots(self, iteration: int) -> None: + """Stores any snapshots. + + Args: + iteration: int for iteration number. + """ + pass + + @abstractmethod + def update_magnetic(self) -> None: + """Updates magnetic field components.""" + pass + + @abstractmethod + def update_magnetic_pml(self) -> None: + """Updates magnetic field components with the PML correction.""" + pass + + @abstractmethod + def update_magnetic_sources(self) -> None: + """Updates magnetic field components from sources.""" + pass + + @abstractmethod + def update_electric_a(self) -> None: + """Updates electric field components.""" + pass + + @abstractmethod + def update_electric_pml(self) -> None: + """Updates electric field components with the PML correction.""" + pass + + @abstractmethod + def update_electric_sources(self) -> None: + """Updates electric field components from sources - + update any Hertzian dipole sources last. + """ + pass + + @abstractmethod + def update_electric_b(self) -> None: + """If there are any dispersive materials do 2nd part of dispersive + update - it is split into two parts as it requires present and + updated electric field values. Therefore it can only be completely + updated after the electric field has been updated by the PML and + source updates. + """ + pass + + @abstractmethod + def time_start(self) -> None: + """Starts timer used to calculate solving time for model.""" + pass + + @abstractmethod + def calculate_solve_time(self) -> float: + """Calculates solving time for model.""" + pass + + def finalise(self) -> None: + pass + + def cleanup(self) -> None: + pass From e0a0f5f7d7845e0dadbc6db6bc49ad0d5d89a538 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 15:00:58 +0000 Subject: [PATCH 14/37] Use Updates type information in Solver loop --- gprMax/solvers.py | 107 +++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/gprMax/solvers.py b/gprMax/solvers.py index 5e31dc48..9a48b955 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -17,18 +17,19 @@ # along with gprMax. If not, see . import gprMax.config as config -from gprMax.grid.cuda_grid import CUDAGrid -from gprMax.grid.fdtd_grid import FDTDGrid -from gprMax.grid.opencl_grid import OpenCLGrid -from .grid import CUDAGrid, FDTDGrid, OpenCLGrid +from .grid.cuda_grid import CUDAGrid +from .grid.fdtd_grid import FDTDGrid +from .grid.opencl_grid import OpenCLGrid +from .subgrids.updates import SubgridUpdates from .subgrids.updates import create_updates as create_subgrid_updates from .updates.cpu_updates import CPUUpdates from .updates.cuda_updates import CUDAUpdates from .updates.opencl_updates import OpenCLUpdates +from .updates.updates import Updates -def create_G(): +def create_G() -> FDTDGrid: """Create grid object according to solver. Returns: @@ -45,7 +46,53 @@ def create_G(): return G -def create_solver(G): +class Solver: + """Generic solver for Update objects""" + + def __init__(self, updates: Updates, hsg=False): + """ + Args: + updates: Updates contains methods to run FDTD algorithm. + hsg: boolean to use sub-gridding. + """ + + self.updates = updates + self.hsg = hsg + self.solvetime = 0 + self.memused = 0 + + def solve(self, iterator): + """Time step the FDTD model. + + Args: + iterator: can be range() or tqdm() + """ + + self.updates.time_start() + + for iteration in iterator: + self.updates.store_outputs() + self.updates.store_snapshots(iteration) + self.updates.update_magnetic() + self.updates.update_magnetic_pml() + self.updates.update_magnetic_sources() + if isinstance(self.updates, SubgridUpdates): + self.updates.hsg_2() + self.updates.update_electric_a() + self.updates.update_electric_pml() + self.updates.update_electric_sources() + if isinstance(self.updates, SubgridUpdates): + self.updates.hsg_1() + self.updates.update_electric_b() + if isinstance(self.updates, CUDAUpdates): + self.memused = self.updates.calculate_memory_used(iteration) + + self.updates.finalise() + self.solvetime = self.updates.calculate_solve_time() + self.updates.cleanup() + + +def create_solver(G: FDTDGrid) -> Solver: """Create configured solver object. N.B. A large range of different functions exist to advance the time step for @@ -84,50 +131,4 @@ def create_solver(G): updates = OpenCLUpdates(G) solver = Solver(updates) - return solver - - -class Solver: - """Generic solver for Update objects""" - - def __init__(self, updates, hsg=False): - """ - Args: - updates: Updates contains methods to run FDTD algorithm. - hsg: boolean to use sub-gridding. - """ - - self.updates = updates - self.hsg = hsg - self.solvetime = 0 - self.memused = 0 - - def solve(self, iterator): - """Time step the FDTD model. - - Args: - iterator: can be range() or tqdm() - """ - - self.updates.time_start() - - for iteration in iterator: - self.updates.store_outputs() - self.updates.store_snapshots(iteration) - self.updates.update_magnetic() - self.updates.update_magnetic_pml() - self.updates.update_magnetic_sources() - if self.hsg: - self.updates.hsg_2() - self.updates.update_electric_a() - self.updates.update_electric_pml() - self.updates.update_electric_sources() - if self.hsg: - self.updates.hsg_1() - self.updates.update_electric_b() - if config.sim_config.general["solver"] == "cuda": - self.memused = self.updates.calculate_memory_used(iteration) - - self.updates.finalise() - self.solvetime = self.updates.calculate_solve_time() - self.updates.cleanup() + return solver From d70170b5af3a40c703bcca79c3b30a3247c63761 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 15:11:53 +0000 Subject: [PATCH 15/37] Add typing information --- gprMax/config.py | 42 ++++++++++++++++++++++-------------------- gprMax/contexts.py | 4 ++-- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/gprMax/config.py b/gprMax/config.py index 6d18bcad..a406e81a 100644 --- a/gprMax/config.py +++ b/gprMax/config.py @@ -20,6 +20,7 @@ import logging import sys import warnings from pathlib import Path +from typing import List, Union import cython import numpy as np @@ -35,24 +36,6 @@ from .utilities.utilities import get_terminal_width logger = logging.getLogger(__name__) -# Single instance of SimConfig to hold simulation configuration parameters. -sim_config = None - -# Instances of ModelConfig that hold model configuration parameters. -model_configs = [] - -# Each model in a simulation is given a unique number when the instance of -# ModelConfig is created -model_num = 0 - - -def get_model_config(): - """Return ModelConfig instace for specific model.""" - if sim_config.args.taskfarm: - return model_configs - else: - return model_configs[model_num] - class ModelConfig: """Configuration parameters for a model. @@ -73,7 +56,7 @@ class ModelConfig: if sim_config.general["solver"] in ["cuda", "opencl"]: if sim_config.general["solver"] == "cuda": devs = sim_config.args.gpu - elif sim_config.general["solver"] == "opencl": + else: # opencl devs = sim_config.args.opencl # If a list of lists of deviceIDs is found, flatten it @@ -251,7 +234,7 @@ class SimulationConfig: # Suppress CompilerWarning (sub-class of UserWarning) warnings.filterwarnings("ignore", category=UserWarning) - + # Suppress unused variable warnings on gcc # if sys.platform != 'win32': self.devices['compiler_opts'] = ['-w'] @@ -362,3 +345,22 @@ class SimulationConfig: # API/CLI else: self.input_file_path = Path(self.args.inputfile) + + +# Single instance of SimConfig to hold simulation configuration parameters. +sim_config: SimulationConfig = None + +# Instances of ModelConfig that hold model configuration parameters. +model_configs: Union[ModelConfig, List[ModelConfig]] = [] + +# Each model in a simulation is given a unique number when the instance of +# ModelConfig is created +model_num: int = 0 + + +def get_model_config() -> ModelConfig: + """Return ModelConfig instace for specific model.""" + if isinstance(model_configs, ModelConfig): + return model_configs + else: + return model_configs[model_num] diff --git a/gprMax/contexts.py b/gprMax/contexts.py index 7f94f79c..574b2f68 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -45,8 +45,8 @@ class Context: def __init__(self): self.model_range = range(config.sim_config.model_start, config.sim_config.model_end) - self.tsimend = None - self.tsimstart = None + self.tsimend = 0 + self.tsimstart = 0 def run(self): """Run the simulation in the correct context. From 67535c0adda0fcccb969e4fcb55646743c1199c0 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 16:15:08 +0000 Subject: [PATCH 16/37] Set max line length to 100 --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a34683c0..6a2664d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,9 +14,9 @@ repos: rev: 23.12.0 hooks: - id: black - args: ["--line-length", "120"] # Adjust the max line length value as needed. + args: ["--line-length", "100"] # Adjust the max line length value as needed. - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort - args: ["--line-length", "120", "--profile", "black"] + args: ["--line-length", "100", "--profile", "black"] From 20b45fcb78e62e074d8ce59d7fa554cb4f808869 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 16:16:26 +0000 Subject: [PATCH 17/37] Reformat help_msg dict and doc strings --- gprMax/gprMax.py | 163 ++++++++++++++++++++++++++++------------------- 1 file changed, 99 insertions(+), 64 deletions(-) diff --git a/gprMax/gprMax.py b/gprMax/gprMax.py index 598e1dec..365f38ad 100644 --- a/gprMax/gprMax.py +++ b/gprMax/gprMax.py @@ -44,34 +44,48 @@ args_defaults = { # Argument help messages (used for CLI argparse) help_msg = { - "scenes": "(list, req): Scenes to run the model. Multiple scene objects " - "can given in order to run multiple simulation runs. Each scene " - "must contain the essential simulation objects", - "inputfile": "(str, opt): Input file path. Can also run simulation by " "providing an input file.", + "scenes": ( + "(list, req): Scenes to run the model. Multiple scene objects can given in order to run" + " multiple simulation runs. Each scene must contain the essential simulation objects" + ), + "inputfile": "(str, opt): Input file path. Can also run simulation by providing an input file.", "outputfile": "(str, req): File path to the output data file.", "n": "(int, req): Number of required simulation runs.", - "i": "(int, opt): Model number to start/restart simulation from. It would " - "typically be used to restart a series of models from a specific " - "model number, with the n argument, e.g. to restart from A-scan 45 " - "when creating a B-scan with 60 traces.", - "taskfarm": "(bool, opt): Flag to use Message Passing Interface (MPI) task farm. " - "This option is most usefully combined with n to allow individual " - "models to be farmed out using a MPI task farm, e.g. to create a " - "B-scan with 60 traces and use MPI to farm out each trace. For " - "further details see the performance section of the User Guide.", - "gpu": "(list/bool, opt): Flag to use NVIDIA GPU or list of NVIDIA GPU " "device ID(s) for specific GPU card(s).", - "opencl": "(list/bool, opt): Flag to use OpenCL or list of OpenCL device " "ID(s) for specific compute device(s).", + "i": ( + "(int, opt): Model number to start/restart simulation from. It would typically be used to" + " restart a series of models from a specific model number, with the n argument, e.g. to" + " restart from A-scan 45 when creating a B-scan with 60 traces." + ), + "taskfarm": ( + "(bool, opt): Flag to use Message Passing Interface (MPI) task farm. This option is most" + " usefully combined with n to allow individual models to be farmed out using a MPI task" + " farm, e.g. to create a B-scan with 60 traces and use MPI to farm out each trace. For" + " further details see the performance section of the User Guide." + ), + "gpu": ( + "(list/bool, opt): Flag to use NVIDIA GPU or list of NVIDIA GPU device ID(s) for specific" + " GPU card(s)." + ), + "opencl": ( + "(list/bool, opt): Flag to use OpenCL or list of OpenCL device ID(s) for specific compute" + " device(s)." + ), "subgrid": "(bool, opt): Flag to use sub-gridding.", - "autotranslate": "(bool, opt): For sub-gridding - auto translate objects " - "with main grid coordinates to their equivalent local " - "grid coordinate within the subgrid. If this option is " - "off users must specify sub-grid object point within the " - "global subgrid space.", - "geometry_only": "(bool, opt): Build a model and produce any geometry " "views but do not run the simulation.", - "geometry_fixed": "(bool, opt): Run a series of models where the geometry " "does not change between models.", - "write_processed": "(bool, opt): Writes another input file after any " - "Python code (#python blocks) and in the original input " - "file has been processed.", + "autotranslate": ( + "(bool, opt): For sub-gridding - auto translate objects with main grid coordinates to their" + " equivalent local grid coordinate within the subgrid. If this option is off users must" + " specify sub-grid object point within the global subgrid space." + ), + "geometry_only": ( + "(bool, opt): Build a model and produce any geometry views but do not run the simulation." + ), + "geometry_fixed": ( + "(bool, opt): Run a series of models where the geometry does not change between models." + ), + "write_processed": ( + "(bool, opt): Writes another input file after any Python code (#python blocks) and in the" + " original input file has been processed." + ), "log_level": "(int, opt): Level of logging to use.", "log_file": "(bool, opt): Write logging information to file.", } @@ -94,44 +108,47 @@ def run( log_level=args_defaults["log_level"], log_file=args_defaults["log_file"], ): - """Entry point for application programming interface (API). Runs the - simulation for the given list of scenes. + """Entry point for application programming interface (API). + + Runs the simulation for the given list of scenes. Args: - scenes: list of the scenes to run the model. Multiple scene objects can - be given in order to run multiple simulation runs. Each scene - must contain the essential simulation objects. - inputfile: optional string for input file path. Can also run simulation - by providing an input file. + scenes: list of the scenes to run the model. Multiple scene + objects can be given in order to run multiple simulation + runs. Each scene must contain the essential simulation + objects. + inputfile: optional string for input file path. Can also run + simulation by providing an input file. outputfile: string for file path to the output data file n: optional int for number of required simulation runs. - i: optional int for model number to start/restart simulation from. - It would typically be used to restart a series of models from a - specific model number, with the n argument, e.g. to restart from - A-scan 45 when creating a B-scan with 60 traces. - taskfarm: optional boolean flag to use Message Passing Interface (MPI) task - farm. This option is most usefully combined with n to allow - individual models to be farmed out using a MPI task farm, - e.g. to create a B-scan with 60 traces and use MPI to farm out - each trace. For further details see the performance section of - the User Guide - gpu: optional list/boolean to use NVIDIA GPU or list of NVIDIA GPU device - ID(s) for specific GPU card(s). - opencl: optional list/boolean to use OpenCL or list of OpenCL device ID(s) - for specific compute device(s). + i: optional int for model number to start/restart simulation + from. It would typically be used to restart a series of + models from a specific model number, with the n argument, + e.g. to restart from A-scan 45 when creating a B-scan with + 60 traces. + taskfarm: optional boolean flag to use Message Passing Interface + (MPI) task farm. This option is most usefully combined with + n to allow individual models to be farmed out using a MPI + task farm, e.g. to create a B-scan with 60 traces and use + MPI to farm out each trace. For further details see the + performance section of the User Guide + gpu: optional list/boolean to use NVIDIA GPU or list of NVIDIA + GPU device ID(s) for specific GPU card(s). + opencl: optional list/boolean to use OpenCL or list of OpenCL + device ID(s) for specific compute device(s). subgrid: optional boolean to use sub-gridding. - autotranslate: optional boolean for sub-gridding to auto translate - objects with main grid coordinates to their equivalent - local grid coordinate within the subgrid. If this option - is off users must specify sub-grid object point within - the global subgrid space. + autotranslate: optional boolean for sub-gridding to auto + translate objects with main grid coordinates to their + equivalent local grid coordinate within the subgrid. If this + option is off users must specify sub-grid object point + within the global subgrid space. geometry_only: optional boolean to build a model and produce any - geometry views but do not run the simulation. - geometry_fixed: optional boolean to run a series of models where the - geometry does not change between models. - write_processed: optional boolean to write another input file after any - #python blocks (which are deprecated) in the - original input file has been processed. + geometry views but do not run the simulation. + geometry_fixed: optional boolean to run a series of models where + the geometry does not change between models. + write_processed: optional boolean to write another input file + after any #python blocks (which are deprecated) in the + original input file has been processed. log_level: optional int for level of logging to use. log_file: optional boolean to write logging information to file. """ @@ -163,16 +180,26 @@ def cli(): """Entry point for command line interface (CLI).""" # Parse command line arguments - parser = argparse.ArgumentParser(prog="gprMax", formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser = argparse.ArgumentParser( + prog="gprMax", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) parser.add_argument("inputfile", help=help_msg["inputfile"]) parser.add_argument("-outputfile", "-o", help=help_msg["outputfile"]) parser.add_argument("-n", default=args_defaults["n"], type=int, help=help_msg["n"]) parser.add_argument("-i", type=int, help=help_msg["i"]) - parser.add_argument("-taskfarm", action="store_true", default=args_defaults["taskfarm"], help=help_msg["taskfarm"]) + parser.add_argument( + "-taskfarm", + action="store_true", + default=args_defaults["taskfarm"], + help=help_msg["taskfarm"], + ) parser.add_argument("-gpu", type=int, action="append", nargs="*", help=help_msg["gpu"]) parser.add_argument("-opencl", type=int, action="append", nargs="*", help=help_msg["opencl"]) parser.add_argument( - "--geometry-only", action="store_true", default=args_defaults["geometry_only"], help=help_msg["geometry_only"] + "--geometry-only", + action="store_true", + default=args_defaults["geometry_only"], + help=help_msg["geometry_only"], ) parser.add_argument( "--geometry-fixed", @@ -186,8 +213,15 @@ def cli(): default=args_defaults["write_processed"], help=help_msg["write_processed"], ) - parser.add_argument("--log-level", type=int, default=args_defaults["log_level"], help=help_msg["log_level"]) - parser.add_argument("--log-file", action="store_true", default=args_defaults["log_file"], help=help_msg["log_file"]) + parser.add_argument( + "--log-level", type=int, default=args_defaults["log_level"], help=help_msg["log_level"] + ) + parser.add_argument( + "--log-file", + action="store_true", + default=args_defaults["log_file"], + help=help_msg["log_file"], + ) args = parser.parse_args() results = run_main(args) @@ -202,8 +236,9 @@ def run_main(args): args: namespace with arguments from either API or CLI. Returns: - results: dict that can contain useful results/data from simulation. - Enables these to be propagated to calling script. + results: dict that can contain useful results/data from + simulation. Enables these to be propagated to calling + script. """ results = {} From 3069fec88326e0ba6db86c7e1cf82ba3ed247d17 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 16:21:14 +0000 Subject: [PATCH 18/37] Add MPI flag to CLI and API --- gprMax/gprMax.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/gprMax/gprMax.py b/gprMax/gprMax.py index 365f38ad..65a0a8b7 100644 --- a/gprMax/gprMax.py +++ b/gprMax/gprMax.py @@ -31,6 +31,7 @@ args_defaults = { "n": 1, "i": None, "taskfarm": False, + "mpi": False, "gpu": None, "opencl": None, "subgrid": False, @@ -62,6 +63,10 @@ help_msg = { " farm, e.g. to create a B-scan with 60 traces and use MPI to farm out each trace. For" " further details see the performance section of the User Guide." ), + "mpi": ( + "(bool, opt): Flag to use Message Passing Interface (MPI) to divide the model between MPI" + "ranks." + ), "gpu": ( "(list/bool, opt): Flag to use NVIDIA GPU or list of NVIDIA GPU device ID(s) for specific" " GPU card(s)." @@ -98,6 +103,7 @@ def run( n=args_defaults["n"], i=args_defaults["i"], taskfarm=args_defaults["taskfarm"], + mpi=args_defaults["mpi"], gpu=args_defaults["gpu"], opencl=args_defaults["opencl"], subgrid=args_defaults["subgrid"], @@ -131,7 +137,9 @@ def run( n to allow individual models to be farmed out using a MPI task farm, e.g. to create a B-scan with 60 traces and use MPI to farm out each trace. For further details see the - performance section of the User Guide + performance section of the User Guide. + mpi: optional boolean flag to use Message Passing Interface + (MPI) to divide the model between MPI ranks. gpu: optional list/boolean to use NVIDIA GPU or list of NVIDIA GPU device ID(s) for specific GPU card(s). opencl: optional list/boolean to use OpenCL or list of OpenCL @@ -161,6 +169,7 @@ def run( "n": n, "i": i, "taskfarm": taskfarm, + "mpi": mpi, "gpu": gpu, "opencl": opencl, "subgrid": subgrid, @@ -193,6 +202,9 @@ def cli(): default=args_defaults["taskfarm"], help=help_msg["taskfarm"], ) + parser.add_argument( + "-mpi", action="store_true", default=args_defaults["mpi"], help=help_msg["mpi"] + ) parser.add_argument("-gpu", type=int, action="append", nargs="*", help=help_msg["gpu"]) parser.add_argument("-opencl", type=int, action="append", nargs="*", help=help_msg["opencl"]) parser.add_argument( From b706580e9966cc4737636c853a9220543cd4bca1 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 17:38:39 +0000 Subject: [PATCH 19/37] Use grid type to determine updates class --- gprMax/solvers.py | 22 ++++++------- gprMax/updates/cpu_updates.py | 10 ++++-- gprMax/updates/cuda_updates.py | 56 +++++++++++++++++++++++--------- gprMax/updates/opencl_updates.py | 53 +++++++++++++++++++++--------- 4 files changed, 97 insertions(+), 44 deletions(-) diff --git a/gprMax/solvers.py b/gprMax/solvers.py index 9a48b955..596adcfe 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -95,13 +95,13 @@ class Solver: def create_solver(G: FDTDGrid) -> Solver: """Create configured solver object. - N.B. A large range of different functions exist to advance the time step for - dispersive materials. The correct function is set by the - set_dispersive_updates method, based on the required numerical - precision and dispersive material type. - This is done for solvers running on CPU, i.e. where Cython is used. - CUDA and OpenCL dispersive material functions are handled through - templating and substitution at runtime. + N.B. A large range of different functions exist to advance the time + step for dispersive materials. The correct function is set by the + set_dispersive_updates method, based on the required numerical + precision and dispersive material type. This is done for solvers + running on CPU, i.e. where Cython is used. CUDA and OpenCL + dispersive material functions are handled through templating and + substitution at runtime. Args: G: FDTDGrid class describing a grid in a model. @@ -119,16 +119,16 @@ def create_solver(G: FDTDGrid) -> Solver: for u in updates.updaters: u.set_dispersive_updates() solver = Solver(updates, hsg=True) - elif config.sim_config.general["solver"] == "cpu": + elif type(G) is FDTDGrid: updates = CPUUpdates(G) if config.get_model_config().materials["maxpoles"] != 0: updates.set_dispersive_updates() solver = Solver(updates) - elif config.sim_config.general["solver"] == "cuda": + elif type(G) is CUDAGrid: updates = CUDAUpdates(G) solver = Solver(updates) - elif config.sim_config.general["solver"] == "opencl": + elif type(G) is OpenCLGrid: updates = OpenCLUpdates(G) solver = Solver(updates) - return solver + return solver diff --git a/gprMax/updates/cpu_updates.py b/gprMax/updates/cpu_updates.py index 6b48c1ee..043e70a9 100644 --- a/gprMax/updates/cpu_updates.py +++ b/gprMax/updates/cpu_updates.py @@ -22,6 +22,7 @@ from gprMax import config from gprMax.cython.fields_updates_normal import update_electric as update_electric_cpu from gprMax.cython.fields_updates_normal import update_magnetic as update_magnetic_cpu from gprMax.fields_outputs import store_outputs as store_outputs_cpu +from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.updates.updates import Updates from gprMax.utilities.utilities import timer @@ -29,7 +30,7 @@ from gprMax.utilities.utilities import timer class CPUUpdates(Updates): """Defines update functions for CPU-based solver.""" - def __init__(self, G): + def __init__(self, G: FDTDGrid): """ Args: G: FDTDGrid class describing a grid in a model. @@ -137,7 +138,9 @@ class CPUUpdates(Updates): """Updates electric field components from sources - update any Hertzian dipole sources last. """ - for source in self.grid.voltagesources + self.grid.transmissionlines + self.grid.hertziandipoles: + for source in ( + self.grid.voltagesources + self.grid.transmissionlines + self.grid.hertziandipoles + ): source.update_electric( self.grid.iteration, self.grid.updatecoeffsE, @@ -180,7 +183,8 @@ class CPUUpdates(Updates): precision = "float" if config.sim_config.general["precision"] == "single" else "double" dispersion = ( "complex" - if config.get_model_config().materials["dispersivedtype"] == config.sim_config.dtypes["complex"] + if config.get_model_config().materials["dispersivedtype"] + == config.sim_config.dtypes["complex"] else "real" ) diff --git a/gprMax/updates/cuda_updates.py b/gprMax/updates/cuda_updates.py index 387c2b44..c67b0d7a 100644 --- a/gprMax/updates/cuda_updates.py +++ b/gprMax/updates/cuda_updates.py @@ -24,7 +24,13 @@ import numpy as np from jinja2 import Environment, PackageLoader from gprMax import config -from gprMax.cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_updates, knl_store_outputs +from gprMax.cuda_opencl import ( + knl_fields_updates, + knl_snapshots, + knl_source_updates, + knl_store_outputs, +) +from gprMax.grid.cuda_grid import CUDAGrid from gprMax.receivers import dtoh_rx_array, htod_rx_arrays from gprMax.snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array from gprMax.sources import htod_src_arrays @@ -37,7 +43,7 @@ logger = logging.getLogger(__name__) class CUDAUpdates(Updates): """Defines update functions for GPU-based (CUDA) solver.""" - def __init__(self, G): + def __init__(self, G: CUDAGrid): """ Args: G: CUDAGrid class describing a grid in a model. @@ -155,11 +161,15 @@ class CUDAUpdates(Updates): gets kernel functions. """ - bld = self._build_knl(knl_fields_updates.update_electric, self.subs_name_args, self.subs_func) + bld = self._build_knl( + knl_fields_updates.update_electric, self.subs_name_args, self.subs_func + ) knlE = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.update_electric_dev = knlE.get_function("update_electric") - bld = self._build_knl(knl_fields_updates.update_magnetic, self.subs_name_args, self.subs_func) + bld = self._build_knl( + knl_fields_updates.update_magnetic, self.subs_name_args, self.subs_func + ) knlH = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.update_magnetic_dev = knlH.get_function("update_magnetic") @@ -178,12 +188,16 @@ class CUDAUpdates(Updates): } ) - bld = self._build_knl(knl_fields_updates.update_electric_dispersive_A, self.subs_name_args, self.subs_func) + bld = self._build_knl( + knl_fields_updates.update_electric_dispersive_A, self.subs_name_args, self.subs_func + ) knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.dispersive_update_a = knl.get_function("update_electric_dispersive_A") self._copy_mat_coeffs(knl, knl) - bld = self._build_knl(knl_fields_updates.update_electric_dispersive_B, self.subs_name_args, self.subs_func) + bld = self._build_knl( + knl_fields_updates.update_electric_dispersive_B, self.subs_name_args, self.subs_func + ) knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.dispersive_update_b = knl.get_function("update_electric_dispersive_B") self._copy_mat_coeffs(knl, knl) @@ -252,24 +266,36 @@ class CUDAUpdates(Updates): self.subs_func.update({"NY_SRCINFO": 4, "NY_SRCWAVES": self.grid.iteration}) if self.grid.hertziandipoles: - self.srcinfo1_hertzian_dev, self.srcinfo2_hertzian_dev, self.srcwaves_hertzian_dev = htod_src_arrays( - self.grid.hertziandipoles, self.grid + ( + self.srcinfo1_hertzian_dev, + self.srcinfo2_hertzian_dev, + self.srcwaves_hertzian_dev, + ) = htod_src_arrays(self.grid.hertziandipoles, self.grid) + bld = self._build_knl( + knl_source_updates.update_hertzian_dipole, self.subs_name_args, self.subs_func ) - bld = self._build_knl(knl_source_updates.update_hertzian_dipole, self.subs_name_args, self.subs_func) knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.update_hertzian_dipole_dev = knl.get_function("update_hertzian_dipole") if self.grid.magneticdipoles: - self.srcinfo1_magnetic_dev, self.srcinfo2_magnetic_dev, self.srcwaves_magnetic_dev = htod_src_arrays( - self.grid.magneticdipoles, self.grid + ( + self.srcinfo1_magnetic_dev, + self.srcinfo2_magnetic_dev, + self.srcwaves_magnetic_dev, + ) = htod_src_arrays(self.grid.magneticdipoles, self.grid) + bld = self._build_knl( + knl_source_updates.update_magnetic_dipole, self.subs_name_args, self.subs_func ) - bld = self._build_knl(knl_source_updates.update_magnetic_dipole, self.subs_name_args, self.subs_func) knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.update_magnetic_dipole_dev = knl.get_function("update_magnetic_dipole") if self.grid.voltagesources: - self.srcinfo1_voltage_dev, self.srcinfo2_voltage_dev, self.srcwaves_voltage_dev = htod_src_arrays( - self.grid.voltagesources, self.grid + ( + self.srcinfo1_voltage_dev, + self.srcinfo2_voltage_dev, + self.srcwaves_voltage_dev, + ) = htod_src_arrays(self.grid.voltagesources, self.grid) + bld = self._build_knl( + knl_source_updates.update_voltage_source, self.subs_name_args, self.subs_func ) - bld = self._build_knl(knl_source_updates.update_voltage_source, self.subs_name_args, self.subs_func) knl = self.source_module(bld, options=config.sim_config.devices["nvcc_opts"]) self.update_voltage_source_dev = knl.get_function("update_voltage_source") diff --git a/gprMax/updates/opencl_updates.py b/gprMax/updates/opencl_updates.py index 315fde48..8438619b 100644 --- a/gprMax/updates/opencl_updates.py +++ b/gprMax/updates/opencl_updates.py @@ -23,7 +23,13 @@ import numpy as np from jinja2 import Environment, PackageLoader from gprMax import config -from gprMax.cuda_opencl import knl_fields_updates, knl_snapshots, knl_source_updates, knl_store_outputs +from gprMax.cuda_opencl import ( + knl_fields_updates, + knl_snapshots, + knl_source_updates, + knl_store_outputs, +) +from gprMax.grid.opencl_grid import OpenCLGrid from gprMax.receivers import dtoh_rx_array, htod_rx_arrays from gprMax.snapshots import Snapshot, dtoh_snapshot_array, htod_snapshot_array from gprMax.sources import htod_src_arrays @@ -35,7 +41,7 @@ logger = logging.getLogger(__name__) class OpenCLUpdates(Updates): """Defines update functions for OpenCL-based solver.""" - def __init__(self, G): + def __init__(self, G: OpenCLGrid): """ Args: G: OpenCLGrid class describing a grid in a model. @@ -50,7 +56,9 @@ class OpenCLUpdates(Updates): # Select device, create context and command queue self.dev = config.get_model_config().device["dev"] self.ctx = self.cl.Context(devices=[self.dev]) - self.queue = self.cl.CommandQueue(self.ctx, properties=self.cl.command_queue_properties.PROFILING_ENABLE) + self.queue = self.cl.CommandQueue( + self.ctx, properties=self.cl.command_queue_properties.PROFILING_ENABLE + ) # Enviroment for templating kernels self.env = Environment(loader=PackageLoader("gprMax", "cuda_opencl")) @@ -230,7 +238,9 @@ class OpenCLUpdates(Updates): pml.update_electric_dev = self.elwiseknl( self.ctx, - knl_electric_name["args_opencl"].substitute({"REAL": config.sim_config.dtypes["C_float_or_double"]}), + knl_electric_name["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), knl_electric_name["func"].substitute(subs), f"pml_updates_electric_{knl_name}", preamble=self.knl_common, @@ -239,7 +249,9 @@ class OpenCLUpdates(Updates): pml.update_magnetic_dev = self.elwiseknl( self.ctx, - knl_magnetic_name["args_opencl"].substitute({"REAL": config.sim_config.dtypes["C_float_or_double"]}), + knl_magnetic_name["args_opencl"].substitute( + {"REAL": config.sim_config.dtypes["C_float_or_double"]} + ), knl_magnetic_name["func"].substitute(subs), f"pml_updates_magnetic_{knl_name}", preamble=self.knl_common, @@ -267,9 +279,11 @@ class OpenCLUpdates(Updates): gets kernel function. """ if self.grid.hertziandipoles: - self.srcinfo1_hertzian_dev, self.srcinfo2_hertzian_dev, self.srcwaves_hertzian_dev = htod_src_arrays( - self.grid.hertziandipoles, self.grid, self.queue - ) + ( + self.srcinfo1_hertzian_dev, + self.srcinfo2_hertzian_dev, + self.srcwaves_hertzian_dev, + ) = htod_src_arrays(self.grid.hertziandipoles, self.grid, self.queue) self.update_hertzian_dipole_dev = self.elwiseknl( self.ctx, knl_source_updates.update_hertzian_dipole["args_opencl"].substitute( @@ -283,9 +297,11 @@ class OpenCLUpdates(Updates): options=config.sim_config.devices["compiler_opts"], ) if self.grid.magneticdipoles: - self.srcinfo1_magnetic_dev, self.srcinfo2_magnetic_dev, self.srcwaves_magnetic_dev = htod_src_arrays( - self.grid.magneticdipoles, self.grid, self.queue - ) + ( + self.srcinfo1_magnetic_dev, + self.srcinfo2_magnetic_dev, + self.srcwaves_magnetic_dev, + ) = htod_src_arrays(self.grid.magneticdipoles, self.grid, self.queue) self.update_magnetic_dipole_dev = self.elwiseknl( self.ctx, knl_source_updates.update_magnetic_dipole["args_opencl"].substitute( @@ -299,9 +315,11 @@ class OpenCLUpdates(Updates): options=config.sim_config.devices["compiler_opts"], ) if self.grid.voltagesources: - self.srcinfo1_voltage_dev, self.srcinfo2_voltage_dev, self.srcwaves_voltage_dev = htod_src_arrays( - self.grid.voltagesources, self.grid, self.queue - ) + ( + self.srcinfo1_voltage_dev, + self.srcinfo2_voltage_dev, + self.srcwaves_voltage_dev, + ) = htod_src_arrays(self.grid.voltagesources, self.grid, self.queue) self.update_voltage_source_dev = self.elwiseknl( self.ctx, knl_source_updates.update_voltage_source["args_opencl"].substitute( @@ -333,7 +351,12 @@ class OpenCLUpdates(Updates): {"REAL": config.sim_config.dtypes["C_float_or_double"]} ), knl_snapshots.store_snapshot["func"].substitute( - {"CUDA_IDX": "", "NX_SNAPS": Snapshot.nx_max, "NY_SNAPS": Snapshot.ny_max, "NZ_SNAPS": Snapshot.nz_max} + { + "CUDA_IDX": "", + "NX_SNAPS": Snapshot.nx_max, + "NY_SNAPS": Snapshot.ny_max, + "NZ_SNAPS": Snapshot.nz_max, + } ), "store_snapshot", preamble=self.knl_common, From b4d5e11dc92ad359d743eaf67373c2b35da96d9d Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 17:44:12 +0000 Subject: [PATCH 20/37] Begin creating MPI context --- gprMax/contexts.py | 38 +++++++++++++++++++++++++++++--------- gprMax/gprMax.py | 5 ++++- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/gprMax/contexts.py b/gprMax/contexts.py index 574b2f68..b92b0fab 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -38,15 +38,17 @@ logger = logging.getLogger(__name__) class Context: - """Standard context - models are run one after another and each model - can exploit parallelisation using either OpenMP (CPU), CUDA (GPU), or - OpenCL (CPU/GPU). + """Standard context for building and running models. + + Models are run one after another and each model can exploit + parallelisation using either OpenMP (CPU), CUDA (GPU), or OpenCL + (CPU/GPU). """ def __init__(self): self.model_range = range(config.sim_config.model_start, config.sim_config.model_end) - self.tsimend = 0 - self.tsimstart = 0 + self.sim_start_time = 0 + self.sim_end_time = 0 def run(self): """Run the simulation in the correct context. @@ -55,7 +57,7 @@ class Context: results: dict that can contain useful results/data from simulation. """ - self.tsimstart = timer() + self.sim_start_time = timer() self.print_logo_copyright() print_host_info(config.sim_config.hostinfo) if config.sim_config.general["solver"] == "cuda": @@ -94,7 +96,7 @@ class Context: gc.collect() - self.tsimend = timer() + self.sim_end_time = timer() self.print_sim_time_taken() return {} @@ -108,11 +110,27 @@ class Context: """Prints the total simulation time based on context.""" s = ( f"\n=== Simulation completed in " - f"{humanize.precisedelta(datetime.timedelta(seconds=self.tsimend - self.tsimstart), format='%0.4f')}" + f"{humanize.precisedelta(datetime.timedelta(seconds=self.sim_end_time - self.sim_start_time), format='%0.4f')}" ) logger.basic(f"{s} {'=' * (get_terminal_width() - 1 - len(s))}\n") +class MPIContext(Context): + def __init__(self): + super().__init__() + from mpi4py import MPI + + self.comm = MPI.COMM_WORLD + self.rank = self.comm.rank + + def run(self): + if self.rank == 0: + super().run() + else: + grid = create_G() + solver = create_solver(grid) + + class TaskfarmContext(Context): """Mixed mode MPI/OpenMP/CUDA context - MPI task farm is used to distribute models, and each model parallelised using either OpenMP (CPU), @@ -180,7 +198,9 @@ class TaskfarmContext(Context): print_opencl_info(config.sim_config.devices["devs"]) s = f"\n--- Input file: {config.sim_config.input_file_path}" - logger.basic(Fore.GREEN + f"{s} {'-' * (get_terminal_width() - 1 - len(s))}\n" + Style.RESET_ALL) + logger.basic( + Fore.GREEN + f"{s} {'-' * (get_terminal_width() - 1 - len(s))}\n" + Style.RESET_ALL + ) sys.stdout.flush() diff --git a/gprMax/gprMax.py b/gprMax/gprMax.py index 65a0a8b7..9fef00ed 100644 --- a/gprMax/gprMax.py +++ b/gprMax/gprMax.py @@ -20,7 +20,7 @@ import argparse import gprMax.config as config -from .contexts import Context, TaskfarmContext +from .contexts import Context, MPIContext, TaskfarmContext from .utilities.logging import logging_config # Arguments (used for API) and their default values (used for API and CLI) @@ -260,6 +260,9 @@ def run_main(args): # MPI taskfarm running with (OpenMP/CUDA/OpenCL) if config.sim_config.args.taskfarm: context = TaskfarmContext() + # MPI running to divide model between ranks + elif config.sim_config.args.mpi: + context = MPIContext() # Standard running (OpenMP/CUDA/OpenCL) else: context = Context() From 3380a19e3f31ff3169a59db16e7585cf77ed0a96 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 4 Mar 2024 17:47:32 +0000 Subject: [PATCH 21/37] Move initialising variables to init function --- gprMax/grid/mpi_grid.py | 44 +++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/gprMax/grid/mpi_grid.py b/gprMax/grid/mpi_grid.py index 52de80a7..146aba93 100644 --- a/gprMax/grid/mpi_grid.py +++ b/gprMax/grid/mpi_grid.py @@ -24,16 +24,13 @@ from gprMax.grid.fdtd_grid import FDTDGrid class MPIGrid(FDTDGrid): - xmin: int - ymin: int - zmin: int - xmax: int - ymax: int - zmax: int - - comm: MPI.Intracomm - - def __init__(self, mpi_tasks_x: int, mpi_tasks_y: int, mpi_tasks_z: int, comm: Optional[MPI.Intracomm] = None): + def __init__( + self, + mpi_tasks_x: int, + mpi_tasks_y: int, + mpi_tasks_z: int, + comm: Optional[MPI.Intracomm] = None, + ): super().__init__() if comm is None: @@ -52,16 +49,25 @@ class MPIGrid(FDTDGrid): self.rank = self.comm.rank self.size = self.comm.size + self.xmin = 0 + self.ymin = 0 + self.zmin = 0 + self.xmax = 0 + self.ymax = 0 + self.zmax = 0 + def initialise_field_arrays(self): super().initialise_field_arrays() - self.local_grid_size_x = self.nx // self.mpi_tasks_x - self.local_grid_size_y = self.ny // self.mpi_tasks_y - self.local_grid_size_z = self.nz // self.mpi_tasks_z + local_grid_size_x = self.nx // self.mpi_tasks_x + local_grid_size_y = self.ny // self.mpi_tasks_y + local_grid_size_z = self.nz // self.mpi_tasks_z - self.xmin = (self.rank % self.nx) * self.local_grid_size_x - self.ymin = ((self.mpi_tasks_x * self.rank) % self.ny) * self.local_grid_size_y - self.zmin = ((self.mpi_tasks_y * self.mpi_tasks_x * self.rank) % self.nz) * self.local_grid_size_z - self.xmax = self.xmin + self.local_grid_size_x - self.ymax = self.ymin + self.local_grid_size_y - self.zmax = self.zmin + self.local_grid_size_z + self.xmin = (self.rank % self.nx) * local_grid_size_x + self.ymin = ((self.mpi_tasks_x * self.rank) % self.ny) * local_grid_size_y + self.zmin = ( + (self.mpi_tasks_y * self.mpi_tasks_x * self.rank) % self.nz + ) * local_grid_size_z + self.xmax = self.xmin + local_grid_size_x + self.ymax = self.ymin + local_grid_size_y + self.zmax = self.zmin + local_grid_size_z From 86393cf242d82c74ebc793be88f5a4a732688f95 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 11 Mar 2024 17:03:26 +0000 Subject: [PATCH 22/37] Get global grid position from MPI rank --- gprMax/contexts.py | 30 ++++++++++++++++++++++++------ gprMax/gprMax.py | 27 +++++++++++++++++++-------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/gprMax/contexts.py b/gprMax/contexts.py index b92b0fab..38b75c97 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -116,19 +116,37 @@ class Context: class MPIContext(Context): - def __init__(self): + def __init__(self, x_dim: int, y_dim: int, z_dim: int): super().__init__() from mpi4py import MPI self.comm = MPI.COMM_WORLD self.rank = self.comm.rank - def run(self): - if self.rank == 0: - super().run() + if self.rank >= x_dim * y_dim * z_dim: + logger.warn( + ( + f"Rank {self.rank}: Only {x_dim * y_dim * z_dim} MPI ranks required for the" + " dimensions specified. Either increase your MPI dimension size, or request" + " fewer MPI tasks." + ) + ) + self.x = -1 + self.y = -1 + self.z = -1 else: - grid = create_G() - solver = create_solver(grid) + self.x = self.rank % x_dim + self.y = (self.rank // x_dim) % y_dim + self.z = (self.rank // (x_dim * y_dim)) % z_dim + + def run(self): + print(f"I am rank {self.rank} and I will run at grid position {self.x}, {self.y}, {self.z}") + + if self.rank == 0: + print("Rank 0 is running the simulation") + return super().run() + else: + pass class TaskfarmContext(Context): diff --git a/gprMax/gprMax.py b/gprMax/gprMax.py index 9fef00ed..122fb3d7 100644 --- a/gprMax/gprMax.py +++ b/gprMax/gprMax.py @@ -31,7 +31,7 @@ args_defaults = { "n": 1, "i": None, "taskfarm": False, - "mpi": False, + "mpi": None, "gpu": None, "opencl": None, "subgrid": False, @@ -64,8 +64,9 @@ help_msg = { " further details see the performance section of the User Guide." ), "mpi": ( - "(bool, opt): Flag to use Message Passing Interface (MPI) to divide the model between MPI" - "ranks." + "(list, opt): Flag to use Message Passing Interface (MPI) to divide the model between MPI" + " ranks. Three integers should be provided to define the number of MPI processes (min 1) in" + " the x, y, and z dimensions." ), "gpu": ( "(list/bool, opt): Flag to use NVIDIA GPU or list of NVIDIA GPU device ID(s) for specific" @@ -138,8 +139,10 @@ def run( task farm, e.g. to create a B-scan with 60 traces and use MPI to farm out each trace. For further details see the performance section of the User Guide. - mpi: optional boolean flag to use Message Passing Interface - (MPI) to divide the model between MPI ranks. + mpi: optional flag to use Message Passing Interface (MPI) to + divide the model between MPI ranks. Three integers should be + provided to define the number of MPI processes (min 1) in + the x, y, and z dimensions. gpu: optional list/boolean to use NVIDIA GPU or list of NVIDIA GPU device ID(s) for specific GPU card(s). opencl: optional list/boolean to use OpenCL or list of OpenCL @@ -203,7 +206,12 @@ def cli(): help=help_msg["taskfarm"], ) parser.add_argument( - "-mpi", action="store_true", default=args_defaults["mpi"], help=help_msg["mpi"] + "-mpi", + type=int, + action="store", + nargs=3, + default=args_defaults["mpi"], + help=help_msg["mpi"], ) parser.add_argument("-gpu", type=int, action="append", nargs="*", help=help_msg["gpu"]) parser.add_argument("-opencl", type=int, action="append", nargs="*", help=help_msg["opencl"]) @@ -261,8 +269,11 @@ def run_main(args): if config.sim_config.args.taskfarm: context = TaskfarmContext() # MPI running to divide model between ranks - elif config.sim_config.args.mpi: - context = MPIContext() + elif config.sim_config.args.mpi is not None: + x = config.sim_config.args.mpi[0] + y = config.sim_config.args.mpi[1] + z = config.sim_config.args.mpi[2] + context = MPIContext(x, y, z) # Standard running (OpenMP/CUDA/OpenCL) else: context = Context() From 44fe3bf95a6edaf199bc0fbf12e204d89d889ab8 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 11 Mar 2024 17:04:02 +0000 Subject: [PATCH 23/37] Remove unnecessary hsg attribute --- gprMax/solvers.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/gprMax/solvers.py b/gprMax/solvers.py index 596adcfe..7cb9c11f 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -49,7 +49,7 @@ def create_G() -> FDTDGrid: class Solver: """Generic solver for Update objects""" - def __init__(self, updates: Updates, hsg=False): + def __init__(self, updates: Updates): """ Args: updates: Updates contains methods to run FDTD algorithm. @@ -57,7 +57,6 @@ class Solver: """ self.updates = updates - self.hsg = hsg self.solvetime = 0 self.memused = 0 @@ -92,7 +91,7 @@ class Solver: self.updates.cleanup() -def create_solver(G: FDTDGrid) -> Solver: +def create_solver(grid: FDTDGrid) -> Solver: """Create configured solver object. N.B. A large range of different functions exist to advance the time @@ -111,24 +110,22 @@ def create_solver(G: FDTDGrid) -> Solver: """ if config.sim_config.general["subgrid"]: - updates = create_subgrid_updates(G) + updates = create_subgrid_updates(grid) if config.get_model_config().materials["maxpoles"] != 0: # Set dispersive update functions for both SubgridUpdates and # SubgridUpdaters subclasses updates.set_dispersive_updates() for u in updates.updaters: u.set_dispersive_updates() - solver = Solver(updates, hsg=True) - elif type(G) is FDTDGrid: - updates = CPUUpdates(G) + elif type(grid) is FDTDGrid: + updates = CPUUpdates(grid) if config.get_model_config().materials["maxpoles"] != 0: updates.set_dispersive_updates() - solver = Solver(updates) - elif type(G) is CUDAGrid: - updates = CUDAUpdates(G) - solver = Solver(updates) - elif type(G) is OpenCLGrid: - updates = OpenCLUpdates(G) - solver = Solver(updates) + elif type(grid) is CUDAGrid: + updates = CUDAUpdates(grid) + elif type(grid) is OpenCLGrid: + updates = OpenCLUpdates(grid) + + solver = Solver(updates) return solver From 96b7cb9eb0d6a00cca632cfe5d96b94b720eab19 Mon Sep 17 00:00:00 2001 From: nmannall Date: Thu, 14 Mar 2024 16:33:12 +0000 Subject: [PATCH 24/37] Extract running a single model into a separate function --- gprMax/contexts.py | 145 ++++++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/gprMax/contexts.py b/gprMax/contexts.py index 38b75c97..24b5f21a 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -20,6 +20,7 @@ import datetime import gc import logging import sys +from typing import Any, Dict, List, Optional import humanize from colorama import Fore, Style, init @@ -50,13 +51,11 @@ class Context: self.sim_start_time = 0 self.sim_end_time = 0 - def run(self): - """Run the simulation in the correct context. + def _start_simulation(self) -> None: + """Run pre-simulation steps - Returns: - results: dict that can contain useful results/data from simulation. + Start simulation timer. Output copyright notice and host info. """ - self.sim_start_time = timer() self.print_logo_copyright() print_host_info(config.sim_config.hostinfo) @@ -65,48 +64,77 @@ class Context: elif config.sim_config.general["solver"] == "opencl": print_opencl_info(config.sim_config.devices["devs"]) + def _end_simulation(self) -> None: + """Run post-simulation steps + + Stop simulation timer. Output timing information. + """ + self.sim_end_time = timer() + self.print_sim_time_taken() + + def run(self) -> Dict: + """Run the simulation in the correct context. + + Returns: + results: dict that can contain useful results/data from simulation. + """ + + self._start_simulation() + # Clear list of model configs. It can be retained when gprMax is # called in a loop, and want to avoid this. config.model_configs = [] for i in self.model_range: - config.model_num = i - model_config = config.ModelConfig() - config.model_configs.append(model_config) + self._run_model(i) - # Always create a grid for the first model. The next model to run - # only gets a new grid if the geometry is not re-used. - if i != 0 and config.sim_config.args.geometry_fixed: - config.get_model_config().reuse_geometry = True - else: - G = create_G() - - model = ModelBuildRun(G) - model.build() - - if not config.sim_config.args.geometry_only: - solver = create_solver(G) - model.solve(solver) - del solver, model - - if not config.sim_config.args.geometry_fixed: - # Manual garbage collection required to stop memory leak on GPUs - # when using pycuda - del G - - gc.collect() - - self.sim_end_time = timer() - self.print_sim_time_taken() + self._end_simulation() return {} - def print_logo_copyright(self): + def _run_model(self, model_num: int) -> None: + """Process for running a single model. + + Args: + model_num: index of model to be run + """ + + config.model_num = model_num + self._set_model_config() + + # Always create a grid for the first model. The next model to run + # only gets a new grid if the geometry is not re-used. + if model_num != 0 and config.sim_config.args.geometry_fixed: + config.get_model_config().reuse_geometry = True + else: + G = create_G() + + model = ModelBuildRun(G) + model.build() + + if not config.sim_config.args.geometry_only: + solver = create_solver(G) + model.solve(solver) + del solver, model + + if not config.sim_config.args.geometry_fixed: + # Manual garbage collection required to stop memory leak on GPUs + # when using pycuda + del G + + gc.collect() + + def _set_model_config(self) -> None: + """Create model config and save to global config.""" + model_config = config.ModelConfig() + config.model_configs.append(model_config) + + def print_logo_copyright(self) -> None: """Prints gprMax logo, version, and copyright/licencing information.""" logo_copyright = logo(f"{__version__} ({codename})") logger.basic(logo_copyright) - def print_sim_time_taken(self): + def print_sim_time_taken(self) -> None: """Prints the total simulation time based on context.""" s = ( f"\n=== Simulation completed in " @@ -139,14 +167,14 @@ class MPIContext(Context): self.y = (self.rank // x_dim) % y_dim self.z = (self.rank // (x_dim * y_dim)) % z_dim - def run(self): + def run(self) -> Dict: print(f"I am rank {self.rank} and I will run at grid position {self.x}, {self.y}, {self.z}") if self.rank == 0: print("Rank 0 is running the simulation") return super().run() else: - pass + return {} class TaskfarmContext(Context): @@ -165,16 +193,11 @@ class TaskfarmContext(Context): self.rank = self.comm.rank self.TaskfarmExecutor = TaskfarmExecutor - def _run_model(self, **work): - """Process for running a single model. + def _set_model_config(self) -> None: + """Create model config and save to global config. - Args: - work: dict of any additional information that is passed to MPI - workers. By default only model number (i) is used. + Set device in model config according to MPI rank. """ - - # Create configuration for model - config.model_num = work["i"] model_config = config.ModelConfig() # Set GPU deviceID according to worker rank if config.sim_config.general["solver"] == "cuda": @@ -185,21 +208,16 @@ class TaskfarmContext(Context): } config.model_configs = model_config - G = create_G() - model = ModelBuildRun(G) - model.build() + def _run_model(self, **work) -> None: + """Process for running a single model. - if not config.sim_config.args.geometry_only: - solver = create_solver(G) - model.solve(solver) - del solver, model + Args: + work: dict of any additional information that is passed to MPI + workers. By default only model number (i) is used. + """ + return super()._run_model(work["i"]) - # Manual garbage collection required to stop memory leak on GPUs when - # using pycuda - del G - gc.collect() - - def run(self): + def run(self) -> Optional[List[Optional[Dict]]]: """Specialise how the models are run. Returns: @@ -207,13 +225,7 @@ class TaskfarmContext(Context): """ if self.rank == 0: - self.tsimstart = timer() - self.print_logo_copyright() - print_host_info(config.sim_config.hostinfo) - if config.sim_config.general["solver"] == "cuda": - print_cuda_info(config.sim_config.devices["devs"]) - elif config.sim_config.general["solver"] == "opencl": - print_opencl_info(config.sim_config.devices["devs"]) + self._start_simulation() s = f"\n--- Input file: {config.sim_config.input_file_path}" logger.basic( @@ -248,6 +260,5 @@ class TaskfarmContext(Context): executor.join() if executor.is_master(): - self.tsimend = timer() - self.print_sim_time_taken() - return results + self._end_simulation() + return results From d75ea4dc45315485f33e5b8ccac07501468851da Mon Sep 17 00:00:00 2001 From: Craig Warren Date: Mon, 18 Mar 2024 10:47:19 +0000 Subject: [PATCH 25/37] Fixed import of em constants --- toolboxes/AntennaPatterns/initial_save.py | 17 +++++++++++------ toolboxes/AntennaPatterns/plot_fields.py | 14 ++++++++++---- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/toolboxes/AntennaPatterns/initial_save.py b/toolboxes/AntennaPatterns/initial_save.py index 1c849ab4..7a7fe4cb 100644 --- a/toolboxes/AntennaPatterns/initial_save.py +++ b/toolboxes/AntennaPatterns/initial_save.py @@ -13,10 +13,15 @@ import h5py import matplotlib.pyplot as plt import numpy as np -import gprMax.config as config +from scipy.constants import c +from scipy.constants import epsilon_0 as e0 +from scipy.constants import mu_0 as m0 + logger = logging.getLogger(__name__) +# Impedance of free space (Ohms) +z0 = np.sqrt(m0 / e0) # Parse command line arguments parser = argparse.ArgumentParser( @@ -59,9 +64,9 @@ traceno = np.s_[:] # All traces # Critical angle and velocity if epsr: mr = 1 - z1 = np.sqrt(mr / epsr) * config.sim_config.em_consts["z0"] - v1 = config.sim_config.em_consts["c"] / np.sqrt(epsr) - thetac = np.round(np.arcsin(v1 / config.sim_config.em_consts["c"]) * (180 / np.pi)) + z1 = np.sqrt(mr / epsr) * z0 + v1 = c / np.sqrt(epsr) + thetac = np.round(np.arcsin(v1 / c * (180 / np.pi)) wavelength = v1 / f # Print some useful information @@ -189,8 +194,8 @@ for radius in range(0, len(radii)): Ethetasum[index] = np.sum(Etheta[:, index] ** 2) / z1 Hthetasum[index] = np.sum(Htheta[:, index] ** 2) / z1 else: - Ethetasum[index] = np.sum(Etheta[:, index] ** 2) / config.sim_config.em_consts["z0"] - Hthetasum[index] = np.sum(Htheta[:, index] ** 2) / config.sim_config.em_consts["z0"] + Ethetasum[index] = np.sum(Etheta[:, index] ** 2) / z0 + Hthetasum[index] = np.sum(Htheta[:, index] ** 2) / z0 index += 1 diff --git a/toolboxes/AntennaPatterns/plot_fields.py b/toolboxes/AntennaPatterns/plot_fields.py index d6f85e4b..b43160ed 100644 --- a/toolboxes/AntennaPatterns/plot_fields.py +++ b/toolboxes/AntennaPatterns/plot_fields.py @@ -12,11 +12,17 @@ import os import matplotlib.pyplot as plt import numpy as np -import gprMax.config as config +from scipy.constants import c +from scipy.constants import epsilon_0 as e0 +from scipy.constants import mu_0 as m0 + logger = logging.getLogger(__name__) +# Impedance of free space (Ohms) +z0 = np.sqrt(m0 / e0) + # Parse command line arguments parser = argparse.ArgumentParser( description="Plot field patterns from a simulation with receivers positioned in circles around an antenna. This module should be used after the field pattern data has been processed and stored using the initial_save.py module.", @@ -56,9 +62,9 @@ step = 12 # Critical angle and velocity if epsr: mr = 1 - z1 = np.sqrt(mr / epsr) * config.sim_config.em_consts["z0"] - v1 = config.sim_config.em_consts["c"] / np.sqrt(epsr) - thetac = np.round(np.rad2deg(np.arcsin(v1 / config.sim_config.em_consts["c"]))) + z1 = np.sqrt(mr / epsr) * z0 + v1 = c / np.sqrt(epsr) + thetac = np.round(np.rad2deg(np.arcsin(v1 / c))) wavelength = v1 / f # Print some useful information From af10677efbd437cf28708dc44883e7ccf1bc9bf0 Mon Sep 17 00:00:00 2001 From: Craig Warren Date: Tue, 19 Mar 2024 11:26:35 +0000 Subject: [PATCH 26/37] Add explicit encoding to parsing README.rst --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 743af3c6..409a97bd 100644 --- a/setup.py +++ b/setup.py @@ -253,7 +253,7 @@ else: ) # Parse long_description from README.rst file. - with open("README.rst", "r") as fd: + with open("README.rst", "r", encoding="utf-8") as fd: long_description = fd.read() setup( From 7f4ccc8b28b94aeafb51b0767dff9117ad96baa3 Mon Sep 17 00:00:00 2001 From: Craig Warren Date: Tue, 19 Mar 2024 11:41:27 +0000 Subject: [PATCH 27/37] Fix for specifying rx outputs --- gprMax/cmds_multiuse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gprMax/cmds_multiuse.py b/gprMax/cmds_multiuse.py index b151cb9a..385f6f92 100644 --- a/gprMax/cmds_multiuse.py +++ b/gprMax/cmds_multiuse.py @@ -871,7 +871,7 @@ class Rx(UserObjectMulti): try: r.ID = self.kwargs["id"] - outputs = [self.kwargs["outputs"]] + outputs = list(self.kwargs["outputs"]) except KeyError: # If no ID or outputs are specified, use default r.ID = f"{r.__class__.__name__}({str(r.xcoord)},{str(r.ycoord)},{str(r.zcoord)})" From e5d093f87ea79da956f90e71754f40017d4744cd Mon Sep 17 00:00:00 2001 From: Craig Warren Date: Tue, 19 Mar 2024 14:07:15 +0000 Subject: [PATCH 28/37] Initial add --- requirements.txt | 131 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..4b467a35 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,131 @@ +aiofiles==23.2.1 +aiosqlite==0.20.0 +anyio==4.3.0 +appnope==0.1.4 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +asttokens==2.4.1 +attrs==23.2.0 +Babel==2.14.0 +backcall==0.2.0 +beautifulsoup4==4.12.3 +bleach==6.1.0 +brotlipy==0.7.0 +certifi==2024.2.2 +cffi==1.16.0 +charset-normalizer==3.3.2 +click==8.1.7 +colorama==0.4.6 +comm==0.2.2 +contourpy==1.2.0 +cryptography==42.0.5 +cycler==0.12.1 +Cython==3.0.9 +debugpy==1.8.1 +decorator==5.1.1 +defusedxml==0.7.1 +entrypoints==0.4 +evtk==2.0.0 +executing==2.0.1 +fastjsonschema==2.19.1 +fonttools==4.50.0 +gprMax==4.0.0b0 +h5py==3.10.0 +humanize==4.9.0 +idna==3.6 +ipykernel==6.29.3 +ipython==8.22.2 +ipython-genutils==0.2.0 +ipywidgets==8.1.2 +jedi==0.19.1 +Jinja2==3.1.3 +json5==0.9.24 +jsonschema==4.21.1 +jupyter==1.0.0 +jupyter_client==8.6.1 +jupyter-console==6.6.3 +jupyter_core==5.7.2 +jupyter-events==0.10.0 +jupyter-server==2.13.0 +jupyter_server_fileid==0.9.1 +jupyter_server_ydoc==0.8.0 +jupyter-ydoc==2.0.1 +jupyterlab==4.1.5 +jupyterlab-pygments==0.3.0 +jupyterlab_server==2.25.4 +jupyterlab-widgets==3.0.10 +kiwisolver==1.4.5 +lxml==5.1.0 +MarkupSafe==2.1.5 +matplotlib==3.8.3 +matplotlib-inline==0.1.6 +mistune==3.0.2 +munkres==1.1.4 +nbclassic==1.0.0 +nbclient==0.10.0 +nbconvert==7.16.2 +nbformat==5.10.3 +nest-asyncio==1.6.0 +notebook==7.1.2 +notebook_shim==0.2.4 +numpy==1.26.4 +numpy-stl==3.1.1 +packaging==24.0 +pandocfilters==1.5.1 +parso==0.8.3 +pexpect==4.9.0 +pickleshare==0.7.5 +Pillow==10.2.0 +pip==24.0 +platformdirs==4.2.0 +ply==3.11 +prometheus-client==0.20.0 +prompt-toolkit==3.0.43 +psutil==5.9.8 +ptyprocess==0.7.0 +pur==7.3.1 +pure-eval==0.2.2 +pycparser==2.21 +Pygments==2.17.2 +pyopencl==2024.1 +pyOpenSSL==24.1.0 +pyparsing==3.1.2 +PyQt5-sip==12.13.0 +pyrsistent==0.20.0 +PySocks==1.7.1 +python-dateutil==2.9.0.post0 +python-json-logger==2.0.7 +python-utils==3.8.2 +pytools==2023.1.1 +pytz==2024.1 +PyYAML==6.0.1 +pyzmq==25.1.2 +qtconsole==5.5.1 +QtPy==2.4.1 +requests==2.31.0 +rfc3339-validator==0.1.4 +rfc3986-validator==0.1.1 +scipy==1.12.0 +Send2Trash==1.8.2 +setuptools==69.2.0 +sip==6.8.3 +six==1.16.0 +sniffio==1.3.1 +soupsieve==2.5 +stack-data==0.6.3 +terminado==0.18.1 +terminaltables==3.1.10 +tinycss2==1.2.1 +toml==0.10.2 +tornado==6.4 +tqdm==4.66.2 +traitlets==5.14.2 +typing_extensions==4.10.0 +urllib3==2.2.1 +wcwidth==0.2.13 +webencodings==0.5.1 +websocket-client==1.7.0 +wheel==0.43.0 +widgetsnbextension==4.0.10 +y-py==0.6.2 +ypy-websocket==0.12.4 From 3032ede5e4b4ca6fa27fd532318e94691e5eec61 Mon Sep 17 00:00:00 2001 From: nmannall Date: Tue, 26 Mar 2024 16:34:09 +0000 Subject: [PATCH 29/37] Add BScan regression test without using a taskfarm --- reframe_tests/reframe_tests.py | 20 +++++++++++++++++++ reframe_tests/regression_checks/BScanTest.h5 | Bin 0 -> 988072 bytes 2 files changed, 20 insertions(+) create mode 100644 reframe_tests/regression_checks/BScanTest.h5 diff --git a/reframe_tests/reframe_tests.py b/reframe_tests/reframe_tests.py index 4d0ff0fc..fd81a174 100644 --- a/reframe_tests/reframe_tests.py +++ b/reframe_tests/reframe_tests.py @@ -36,6 +36,26 @@ class TaskfarmTest(GprMaxRegressionTest): self.keep_files = [self.input_file, self.output_file, "{self.model}_merged.pdf"] +@rfm.simple_test +class BScanTest(GprMaxRegressionTest): + tags = {"test", "bscan"} + + model = parameter(["cylinder_Bscan_2D"]) + + num_cpus_per_task = 16 + + @run_after("init") + def set_filenames(self): + self.input_file = f"{self.model}.in" + self.output_file = f"{self.model}_merged.h5" + self.executable_opts = [self.input_file, "-n", "64"] + self.postrun_cmds = [ + f"python -m toolboxes.Utilities.outputfiles_merge {self.model}", + f"python -m toolboxes.Plotting.plot_Bscan -save {self.output_file} Ez", + ] + self.keep_files = [self.input_file, self.output_file, "{self.model}_merged.pdf"] + + @rfm.simple_test class BasicModelsTest(GprMaxRegressionTest): tags = {"test", "serial", "regression"} diff --git a/reframe_tests/regression_checks/BScanTest.h5 b/reframe_tests/regression_checks/BScanTest.h5 new file mode 100644 index 0000000000000000000000000000000000000000..ff6c76024ad9a2ec92339c932102f7efcf5ed4dd GIT binary patch literal 988072 zcmeF&3Aond+V}BQY0#t$NuekdMe{ta8>LV(Bq2#;CZR!5lp+lzWk_Wx6_qrKRqMWK zT9sCs(@gUyg{b%YtLyaqkM**j=iQHY+x9vg$7i3{{J*ZBrM-```!u}#%6z$tjG5bZ+?hu`t?qeC&ABejXdz z#6MXJhM(7rKi7Ga>V@y*OgcR&SJFL?w7;fx$E2jQvSc~FL-#vzhZ8@scAuQu6@G{{ zmN%)IMp&DNcCMtWI<#%yy2HH>KXl?5Nd;JYVtKu=?s!S+8s42ZB$6fRWcC+MdZ=To zhdQ--sPlw+$2yzs~;AKX28-yj`_@ z$MWHxCq^3D#6MZDzqZNcx?%W7Bxzsv69@g4Q@FU@^Pl|8fBd#T`ukkG$cd-dJNra! z5NmSv@oe=A7JpgJ$A6uoU+L0vmzKNp#6KgNUwPtx2B_(Oc>@>}`6ngt-~D_PD0bpQ|MfdWf6|Bjn7>5= z8GoO+TDbKc;nxj$P+w65qBYWNd`hGD#67;hNH?e`&z+W{GB#@{Di8Ggg^w|yCY zpEJXD?}h}jBxOJGd%hvKEJ>w~&t%DZV!-~Ksa{y0v7e1UIMB+1#f$N0$NH;y(wVuE z&V2fKJBibC9iPvd^~8a>!XGqb%QE_cu*8lG-S7hudjINj;^!*IZ-4%fbnZXCLHKR+ zq??QX<8_6@bi4EZ@j5%E++D2|9IV>J^L5_T#XEWH2QD# zbM+VIQ7rrsn&-g}Ilu0_8UCxn^Ei>mSttJGpWptKg78bhU;FFuU--|<;Jhai__zA` z2!B(&cmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsF zo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^; z;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq z0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke) zC*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6My zcmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjY zfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsF zo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^; z;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq z0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke) zC*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6My zcmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjY zfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsF zo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^; z;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq z0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke) zC*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6My zcmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjY zfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsF zo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^; z;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq z0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke) zC*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6My zcmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjY zfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsF zo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^; z;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq z0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke) zC*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6My zcmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjY zfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsF zo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^; z;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq z0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke) zC*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6My zcmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjY zfG6Mycmke)C*TQq0-k^;;0bsFo`5Id33vjYfG6Mycmke)C*TQq0-k^;;0bsFo`5Id z33vjYfG6Mycmke)Cy?n9c>LvV#WJ%pjrN_FnXOuTPPxo%&AtoDXJ)-u{!$?`8(3~z z#mwwro_v)vvn!rVubi1pSuwOqW;Xk9#j2Uvu`Hif&CDJ>=i+LaS(Bo@t7T>n4vAOG z%(m|MwOVF&+ewA1XJ(IHQ?`0$R_4d#>Y3Twm)%i4GrOZiuj-lExL4n+o|#=cW>NLb ztXPAj8kyOYHD}kz%ywU0t43xvV8HD)GPAa~^{A1Vee}|h8kyPJR+DOEX7PuXPYf;?3s0r(da=nf*9^UFcL=XTZ1ta9`A5d(79%4~**H;<&2?Q(vmb1`qe1Z6PdnL3%y zFy8z&hNItEt&V$%n%VT_%~MzTkC;f+JWoAvJfb&Jts>c3q^seYztQ~!? z#wt#qUZ*@NK<#L)6vj0Eccr)4yX**otq1Eum^o<9|20x6xayZSS+H zxf}ZItJ4&!&4TM9?Hg%d|2Jdru$uAydY{HL|Lcrgb&o*xmkczXaqREZ|L^Xv`8L-S z{mq@Ue^2W_82`Ps|88S?U-VAaw_4(|a&^slw9gNH)smra;~M+B(3_%fiQe1h^sbMG zG^qOtR3~6kq(3=~Yfj^TCwh~;C3+ z^hSHD+Cxe{UH3JpMh%Vh-=O{a-+=CH?7sFkjcd&MsOFdwy~V1=%*5=A>Y9&fi}h92AOAb!(KY`r?Fkv~uD-p;s_v@y$VYf{+}65pVHm8f zzIlVTsIU2dcNy=kcSY}R?@=9!)6dFU?~H%I=B@stwSV&chnLZ{3eo>aB;>4YWo5Z=gAw*SOkNZSJqAzjue|*v4>AdzZaI zeS4eUm-O=0zsW*7D^%s!!2fF7Sakhg+17nBe&5FQ24nZuTQd5UvBBN$I_|X$)y9V~ zuDPgJ!&+POS5>=H^!pj_X73uronOW)f!3+L`ulG6RzY7f&8yi2AVNCNj_g7ZCXY@X5e;@7_owqeMuQh5P zK%XC@ z^~LU~F}>{_j6lEh_tkqD12HJHRr`mrSHk$KkvXlmajpHU=`OmTs@+R>)R^5(;{(FH z=G4~P%j`UV6utds@{U)waH>-gvOZ`SrgKexRyvRpZvT zwpESWoK@}nvmFX&MyuLl-&OsiduiMp$k5(Tzu37I>z{*j1NEb#b6TUm=52k(c^m(; zs(s6Rd|LL|<7QGtebpJE+PKxv!njo%|Nm9(9(FH%M{SMU-K|FN6kTt3 zvvt}lQ!#IA|N09sA88qCbWZbH7hP-XHKw(HHht61wC~&4T;|j^c2~QfjoY0w-f<3l zzeJDmZ`FS`c#axnb2et{RC@+CrnP@Ioo#2Tt$RejXMNp8>+GI3p0V0ls?XqQbbobR z{T_kZ&qiom{pUipb?V!ks_nC}=o$Z;cJw@JN2}e-s=j6Oc5i#1)n~$epQd(2wXrkn zR}ZR2+BG8WnzXI2_3CS_?NzmT)jx}!YiHX%GoEkbb|33&osHRjt1zZ^B~(C;Y>VpW z46LnsGIAkfJA0T@we{MswSN{nPv_g2Y8#u6R<-RuRyAhhy01Bjc2*qCx~P7(2^;F4 zgp4%@W7)%;Ra>XItYMGl{%pES^qclwRclATsr9;(&0EcQj`el-W8*i}{|WuSI9Pu` zFo>%DK)e#_%T&=CRgJ4^jmA`?^J@QZ*_rkoyN~+%zTL&Xqj}xk`c}2h?yE7a)0p1a z7roHslI+P{q1p{k1sW5LMcYqD=A&!XwsrqMs@)@FwL3(|tsT9S#?;q4r4ydSxK9ft ze+bo&LR)>cjr!vwY~0pcwKeKT+tKy^Y}?L@R{O5q@4x%5y>l%08-r2Ue)c)X?W9KS ziu8BTR^MoywRcC>sV$m|wr%bIYmI&<+TIhs@kjTyJK6Vao!%kauoYWWZ@&1r?UA~J zwrFk}G$(3n-1=(oiu8AedE5WLtrnoRZH%H z=b-kp=oR`l-jlv)jpo$WI_s-#YpiNctpCqe`;MLWd^j_Dw(g|6>Km%jduyG}ura;y zX>>&=yxqIOaqomWoT~N+So_^Drg61J^CO|LXjNM@|G#Bt+qYD0%7}73T#$r4^#JJG^DD`c{+-%{V!EeP`(6ZJKxIu~Cc6-TDNk$eLEGwtz9?tRb$Y&)~kxfw8zG6zt;Ru* zYc;R8X>=Az}l*!HJY=wjX%qn*gmT^ud1{Ek#1pLRr|Fz zdQP+*y=(MNb}xks@JE^=#-{sQrp(3THmU^ zE!wLw(L3}$ouM~MZ}h-Z_`1oq4(npf4>m?EL6{Yug&t=y&Zrjq81S=XcnQ^;nAvcbxs3Dzq!1 zD%7qX+Sac|U!r}ji>|e{YIME!RF3SmebK6U^|e;?efyr;zjq(4vo&hld1~uCJ5%q{ zTg#y=N~0t$Zg@egOmG>s94eqZDu${twZ-N%ueSEsTJ>$6&D$Q^r}}%eF1knkN@1;? zA3ZC2hV9ilu{){So66GGJN4cRP!eZj{9~8KCSnpMV=ATvYEKDcGpN%ez(E)`p&`-ZL8xbE2uO>s3Eq8@7Ckw#JUc_*v2*PV?Xh!gozBobH6E>B(HFf<@B9+8Fa;kWTlV{6IgtmsQ2?g~ z1w&s|PLGTiqJ3J1u~Q;_o6{a^tLkixodG*TZOy5#Iep9aXw3SyKU!^0`*e5pMeY2^ z8|ZzxkOe=VbYJWMzQw6sI>ZX$?BIN=IghrK4E4g$*8Bw#nlAyZ)f%;<^R`dxjOJ}T zy4KFJbvjGb*LS1y(K%~d)m@6?oS-PRFiyeANW#9(9by+{dpdS0DhD;FQVTUhtsQC| zYTd|~+BQ}%jMt}Xy~agt%~{{7_D0u5`*yD8brX$7XDa_>T7027H zJlkjU*4H}wj?VZtLhssybk zxaXkQ5gbL9UV~y;kv+&k%?Z_9k*YCsav0YdYug@G+h_CYN9Q$e>`eQ<*6K`KZ)e#$ z+o!6rlVD?NXA5I`*D>DrGk(JN*o951?+=ab#R2?)pYU^}{z6-h;s|~XZB?Ur?NQ&> zTh%_zX|LKgZZ*2c&ePiHx7619=-TLhoBuKF)12NR8assj*ayA$TdYAkc78Z4wg>z1 zJ$?!fhkAtiE3E!P`)C+b{W-K%ZH?MmXMLTizKz+ujYX@zW$RSckFK}7*q&p|>pPG;@i|#u}U$jo&wQv32`L@pHwBG1@JFyMl zVGCAc3Fc!OZvJLmtTo!AUGNa~VLS-6ABlLBw%EAZnr|P*RjvJ4n6qkY)wi~4bdSEP zv5adpra67f=Ijjnp8C3%d4TqPxCeLR4(Pp2&;XUOtK7uck2r#)@+ah==7g%8jNH(e zYA$G8RdcEu&l_rvX1S^gGs%uD3gBukNG1XwAYv zecgXf5T~YK8ph*&3`QTU?lv>F89Q(Qhk~D|zaZ)96OPh8hF_s}Hpa8S#xst} zIaTdZwX-yDZS65yW9u|#Yc!@kM(1hH_Srq{ZW`D9f5buTgWk6VdhcSSU?QpvPKnjQ zRcM0iaRY8fOWclDxHZ(ps`gmbdYxtaH6A@za~hL2u)dwGnsJZD zv`+W5wyM6N`-|RlEgB*jdT(hI!O6I$L2B$Kv_V_6!$W9~4tN4j;&F5g)bA8&tKS(~ zuW_|Sju>>VchmV75dt5dsL%mXs!CX|6_Ondefb_1=pf3 z%AqKp>z#-V#9J7K;TVIDFb?A}A^4a&DVRvLzS>hlUt?-(-6znTYP7F4lc6e_x4za- zh1TmFjoTS^uI}~R8?8!lMV!iMZUd3A&f%ow)#^3{t!-s*|8ngb`(EgaJ_2Z$j zk3!YBt<~6sFs`wQurZyhwyM@>UT0ZfRbyHs_8pDsd!l>W-G|YB6NB&~x}zi7;YL)$ zCynRE5}1dtu@UR>ExyAp?8F}I4Q$N%sv0|h-OyUu7yA3D(Y3ZtW17=it2U=<=h!-( zt+t(QYt`2scVKIv`>(|sEW=!+LhpSC{qQJ~ADA0!f}3zB9>Ifn3?0!0PX*nmPopcI z3DoZatp@ZJmvEXH0Fa*F8JoN$B1kpm*GdwrCE$ zvo6Y@5PrIUZtVSC{H!4b^RWyouoj!J5#NQjs%*n9Y!7x)w_*!+K=Wer-$ur@PIKC$ zwd#w`)%xAgdabd!=$UHU9kgC+bWe?`%6jPjtFZ)&Fbk6~0t4^_T4H$o%h))iU_QRW zDtv>D*nsb_8CyepJ9Q6s1iM51ma4g^o%FR|Rkj7TM`vig)~Kql@7TEZSX(vYyw0<` zscykKtcBjN1ZkLs@puC-;bGi}irW&g7;Z*u+=CA2h|cJO?sx`0f?m`Y&@)iKH=e_@ zVN7%CYtF`=4`Uj)wyM_X8``69*m|wgxf##Wyyk4})1j^U6rP0c{{-$u8|Zykq8v)# zBy8Kte|KJ&8v6n(u^Bt?BYweAWNnrj%Z^+@ZfYK!0=4sos(${+n8rnG^M$JU=(y%= zOy_GZCr*aubiVeg+L+d9PE~774%)hB5_EStgk9Kz6_|&K7>;Mr21^cq9{Uc5k^RTd zWBE}S=b{8k;Ubj7rJLe$cKVFi3Ve&7kag*dSWXl~VVr}axCkY1 zL7;X?l){BjU+vP+8m+hXCA781##FUe^BNc1tFeorHFmDfvv1os)juD0pW?KOLH9ii zx_@?L!2#a25o;00JLrvj&|uBv*sZu9&)@~TgrOLL_wgzk@tV`o@j-_$vpQ+pIf;~flv-q8o0a4&8{L!6IX z*s^+ZY;Nuev31yota&EH^5F~=$HhTeYGss1#n7%qt%4e;8X2oeyE>``>T6!>G^RPx zcpYe5{o0{wjm9)z3;K@QHm0icv`61pTUF~W4N6nbMIq!z67;?`n1grF3$1WR%MW6m z&=`BbA!dy^k6A<8NS9Utj2PzgvQig3(d(otcdir&c?r?t+i{QbsE=NteXZi()u%%LlP-b4J8+%pDOs3zwiKuEKS= z2`$h%xSJ~X;9fk42SVHGee@p=;~IMu8oNJ?X`kAUK>JnYA!wcE#m>>V*4w;&$IfZX zobIGM-Ui*bIhvv|s^c;g!%^P)Ef!)dIw9B9Lt_`CHm<{+codz`8PDQ{;AN`3f>-ex z28TK%)Irptf#%$MK zoBPMA^y?YB36G#BhTvUH!eo4b`Iv`vEXH!Iz}HxYRj@HtS&Nmy8mg^P)wuS`QbhNz zrf>W74beB2z`m#TUqSbg1h#)?7$AO z=6D9LVJhZg1GZx)e!yXz^m2z-4jiMMCA8Hyn$H*dxv2RAwKXo;kP}JJc%IPL+Ps10 zb(ZFSMkQ3y-=cZe-6d21~D1-Hf; zq66NI?Ce$R6q&n+h^lk=$_fIgF7#P-Z2dAQ3oeu5$}Bz7hqk9TVvO)t{-~= z3-JpsTvMO_jZi<<3eVvMyopi46zUAj#HW~zY4{SeFb65n+~=XMxdlkWTr9*QED3$J zHKx7uFh9_~1ir#jq(l4lZLQHgWGX(vI~as6XbHWe4D_xq(Ho6%XjT2#7&J%K$rr>r zVhVQQj42ny>fm~GLJz!*kr<81_ykii4YQ!JFGG6{H5E2z<6khQxj58bh%{(yKC~_Y z?bW>2sc-WuY0rb^Q$n3V)m>y1MxYPc<0jNXQS9JdAEQ0WWAi5$#D=0WHhr2iR(@vA z*xh&s^YJrI{VZp!5-!ILXoJV_6dp&{pcl0}`r-w=6llC3`rx@xpQrXlPrMk$RJBiQ z)Ycl&8W{kcH55Gpo!JfguI?gtpf#>UITXbq-mwHDa1Snm-uo)9!ulCGV;%P_PADYl?ID%uyc3^RGuHY1EeiXsa%>960u?I)7 zFSPek591)z);U`LBMw08^Kr&*YEJ4V>P{rlUQN{<7GWNS;dMNS`Y3@o?|2kvU?}f9 z5B+%W-rb9nlhJY4;^c|n_D#O~yS~Y5(PnGkbqHgHyNnO|FU>sE+G# zJ6fYN?nZmGLkILgU)+LQ(FQl-el$lTG{bec0e3)WS#3dE-*`CGrqruLTi>mN2Dk(z zkObXx5r*I?oQcnPPg`u^eRtw(-g^=5+R`_9(q_iL&zpSi4|$Ut9?F}1#o@fk1%Aw% zJP!kY%A33lt$xm%+#Ab~?MU9_^Kk{*U@#uVLUhBM=!n_IVA>83Qx>FrO1*yCf|Q$?+dOGON^izSPhXIdjq#j}Z^ll>T5-mww7b#Hi9C}Rq_m{J zKlkfCnd_3(N_;yadv1Rsxlpp8u%{1DJ7Nk6~enHBcn-`?aUwGc?o%4|5l4RR+aDFU|2XvEJumRC`*`0P z-np6gmOEH1^^+fpr4Bw+EVbZ=Z>PRG?(NhyW8Y3~k9A|-PJI?-u=0bqQ-|Rm)I@b; z$I{Vnr*1?XORxZ6Aq|>Sdk(f@G~UD*48?eSh*vQRZ$slBU?PTN0^Wi4eU51ui%*e) z)tHMNID&lKsWh77RV=|p-2ZXtJrDD)E$GQROY+`XxaXs{Q@3GF(F3VhVR(@Psh8m0 zvks(I$Bx1WQlCRZ96R$s>RNn>chCtBpf%c}J?_A5Xc6jlRIR%l6@&WJhGDEqsMV=; zaV@S4?WWXZR7G=KjheUzx8p(Rn=jxsOu#xEMt$zv8H=EIyoHLqYZA)x&RrOA_JP!5 z=dcgS&zFer>{TK@5-o5ZHufwLpNU>*iyA14({Kv1V&`)u;`^`z2eBC&u@)P!2HUX( ztFaVYu?||J_3{n21)9_P@9-^jj@lbT`w&&%+z;I$7xLh2R6%XDL3fP7b`;}1cVY}y zp#krG8{eR8?-KF)C7Z-IlxPxv3s>TFq@CX+{w_M=Iuyg1*mGW!`1jb4udo^muoNly z95e7~s57Zr_XR#fI%Y%b)Si#ISOJ}rz+$99`_$IB_AkL&Xsy1hyXYQ=kSE-+JhcfP z!|Pa!(|Au~=zU92oA(aEa+JEDNjz_&OMG;^OZ-mc!m`vZ@!q%< zqwpdIqC2|c5j=!PaewIFLDhU`bPDaqs9N(Jxz7dZQ0s#+w+4 z@tA`pIEa(Oy>NAl$b~J#2gT=OI^IJ+^g?^w zk9KH_JJAx?qZw`pnow^Jum|}^4vJU)d~7@)HhwlXJ`=Ct3EYSn%HwPlKo%UBIX1on z`>-9Guo`Qy0pDOb)?q2W4%UbMvd~`=s`^@I{q!)deJg{tRDDC|>KuJj-`WP1UxHrK*37nw6^cM`)iE#x$>W#?H&hSYG4_wFtEsE`;uM zC7R$qbi;7W#6}e2UFFdXPvZ?t#dLHnl0ESNTHs1l#CbRczn+ynaRA$~9?OG8)Hr5i z7E&=Ym_hvv(?V5SYo>?(r_|2_&8e-i6sT|2*63Wd^}R%>Us834#aN0J*nn*~h-1+G zi=hGJkA58Z;Ns9n(!9qHN1^6_yn`C2pjPOvh%Jp zaRF+fF>VaDO+GuZ0$*Y*Mxr;K!K1hxP0;`~Pz_aa5iZ7sI4?MxdQMP`S`_C8>KBLV zxzHZ#zu^_zV*;4r4G1?_wz4z+0iJ|2hWa6%4?ufyQ3OK-ir6TB9+wWe~Jy z2wn?qo%?n$lKKHY!VDy^7;CW`Kj9SKQ4HnL7Ci4gEeTa)eeDsgzc|o-ovCkCL?zTl zeKf(XXan817hc8tn1)5zjKesBW6-;IqFB~OiL;Oczhr5Y*o5Wy9Ha3DUPgC3g9p(T z_u|&zHtLPIIn*1dO>sT0##Ly9YXZ&LSmQ9KdC~Z_(0G$T>($n|`bG=1gx%u_+Rx%e zyom{zjWle;ugJywilPKc;vAfeeDB?mI2niDy&O7sL^~umT8lW+n;%3~AyP^Aa!%G;6 z$@mP*upbAIjdvD6e&obn#l$AK*3gMi)Ga*0>c-aTV&KHZDgk zlt<-ID^e?=DyoIC3ZZID>#9KQD6Op?=4w*aSJk)bp*|X+37SK9YKw=^4KHFiCgF1| z#}4eoVdM<&{h9VgJp6gP#QnG%*P{_Cp$PKh=g-*gbDZ{7)>3Dx9|qu z4(*ZDcQFFP0?oYxjjO&N+Ul$Q9yC5G&^JE9c<7tcpgYXLQY^_nZJ*eJt@sLQn25J92wibE+M)rjMjcc@MU=vMxBx{^7)5b5&I;{w zsKrBDb6TUmoD1!@zSfs_3MSdLqzH?#|Rw5O%Fa={V4kPg@hT=8!5B&kum+&I`;T333 z`e0zBuQ9C`jccFAv`=U2+#$gT>L`4OPcR$vu>#w05IMN_>8ONc)P>&H3OAuCsz7V9 z4D0@zBX4z29K=p+!#7xpG|a+hn1V_82p?h$hGPWYi?oN)mNx>m-^5#ZJ+xIdZ`IZg z4fE>14V`aQ-x!6l&>cR(r%1(IEWs*l#y%XvPsqtTbKw~6U$6r!@Wo?26Z4?@C8lFC zCgNSZiPz8@FQ6;B;7L4&_P7ss<00G|+!e;|q_&2tw29O^XiKX=>uk){wPj3atKNeL zpzrA0kE1i5#fun#p%{+wn1Y$m+ZJLG^u}2jgTcsqsCVKF6vXMsfs^p-54{unu@m27 z1y*4`<{*yGF%#1;5z{dRpI`zsHXa{Fd=$nsuD0gJ1h!7&+HYrkOj{;H-})4@p*zgQ zS6GSl*p9t8hAh0}WE6tlrg{qW#(m7M#*|`x6RDVm*_evS7=z(>1FxVDdZIHr;9=a4 zd(aMd;LhMSs@#rNxD~eqEhA&L#^$tMV>(OwT8FB0@50^C_Z~odJb^BF2E8#5y6*^l zgbA2|1U?Ipbj7}%{SrrT48Pzfe2;C|h*emOG^Ah#CSeRlVAh9m<+{Rk*f8g^%_@OXKCN?Q1y-XFe(^F{TNg68D?QF(y<0xumgJA&-fKT;vh7( z9t$x6$u|v5G{OzI7R_)K>Y)ZMLkV1ff;bhqkp;(Y9GEzapM%}hJy6{n90*n88jIHN z>FWl<7mp%4n-WMo5D{B&?o;s_4m0Di#t*o!@(?vIS^2;;k{`(SfwYmca{ zGY&)Nsp=b9!Z&kLbr0R=TwH)MsElOjP1m6ru7=)qB{Y9Na$pnZ?LU8T;ux}*7@R18 z{K$)3ILi1B*oO^RkFW6+79frRh3*oDP*bW zm-q_lScT2d-45a?^qx~9P7m$e)cwq_fzIxW&1GIo{D^F2UrQ7~ZsbQ^XzUl}4r2#) zVJC2@2 zCOCkd*o95l5Ui!H!fJdI+RLe52O3)$#+OB^#%zz)=p516Td@s0@B@B=?vaE0oPslP z9`uefu(wpET?Hj^7PNi~`)1=cWN$Y(Q3MrG9raKjwNMjPPzI%-b%l@@*^q<-_wdgL zzQ@+!Tk4ibU+vAodg=~n>^p42_Rv;;8+Kw>sG8f4gE)*M(6>)Q0q8#Gq9iUuW$50u zp|>QX26X>3kpmkzBNYR2@r|!0>Z1kj#3OhR_u~QFgODasAmBchWzi?n;EM{UcR$~J;2I?=xe54`;lQACeVFU(aC|jmh%m!Y%Yz;KMjhnR%V5ywJ&g_W?otE#^UvoQ)f_ij{0 zRvc{6KT-6S{)w`<99N?`T87$;+60=bhbp)fWpFl%qX6=uV5qsMIgkZMIbV+87ijDN z^(Pz(V?R=VhSnJEIR>32I{#D@g1%cCm!K+Yp#d7>M%;v3p}RN26{w7JahNl|##?v* zyW96ooc>tf#3i^KSK}764z)S;CR~fFP!p9<0VPlxMR5v>KxgGg9%K(rq9)-e-^xKf zLOmAoD{aZbTvn*s-h8yB5DMdLT!`|hh+2r@8eEUt&;q)@`g+fm(0$LrLB6v91JM@k zi}Xnh#wS>a75EN&u^n6SEjD5`((nalV+y8WJVszNhT|;^!63YfSMV}k3hjZ^0nmK^ z(6%|P7wsR4VR#!ldkplgPmzi_(0!I;9dyrK(7pFScV2}>mzNpbr!c=*_rzA5 zTD*JWLe#-kXoluNQ)&}jfx4)UvbY4rf+Ezs$c~&i%9&ZIk|k7){S3`(EC)1}EzE1J z=1xW~ZLFg}OKk1&|lnL(Li5s@ZUmZ{?@vz{$vhT*!^A z$cKVB9p|DL%HUGyJI!z#^!={Tecr&k7?1Zb3A+COf8LKI2}1~h_&BfA(mn)KEgFcIb?r=!;Hx1b5*ET!*UA`U1#@tT@6M+i?h6u@=jM zRn+BJip7{8+Uli@|zQVu@<>j+?=S2=4g#a(FKp-ZnVS|!G+Y4$c@A7{RM}x z8GErD3-KjVLtRXri5ZxNS%~8c%t0C!VI6dz9oUbP_{Ql$acW)E!fj}ShoCz?fjgo5 zUxXy+9&h0)?B>0_P!Pkv<}FjNPJDdP3e3a+D8M>49QEKbGA$ce4&S%w9eh57hA_=Nf~-od2MR$uM$n1ace zjRfXkMX-x{7+Ls6QItkyTnXK!DH@?MbT7>(VKaMPK_h(2`}*Tjr1vS5xEH6R=CYiL z;W&g^%X21P#bO-8#Vc|quEA}%759aH6KZob#APUkA~*@(vu_i=!M8}qBFskupCOLf zn1#9cGSoTLudxo_VSAu+PeW0ZK@D6B-JvD!Mr*V}UFc3HV-5RXL?dkFeJ`Rk=6}t4 zWWzl#?vGdQyFb1igI=QDZ-4wVlG&F7g}Rk`68nmwAkIT$Jb~UAjE}Gu+mMAj zUcmpC1i#|S3kSu!;1k4=bkU&r8tO0%hUOl|6}S{-Q2<%7i2Z5!44>d5OvXrzz_?I9 zpw58SYR^jSK_1Sig$ihlu6P|EUWl*F+24jE+dd*_?S6 z>R>buL-)88jZg(AL-*^BN@&Ku2RO4I@3@8cOygaxc;5!z*_ij{Pi6hAcJZk@eXH{j=x^0bbQj8rQ_=hmySPvR_S=bBBkRuVK5RnidQfLT~Hp? zv52+paXISX60G8kG)%y3^uwbVh_UF5XYetm;xP8$9QNLV7x5|9qXOUPg)x|qV?W!n$ef|APB}$2%V9J;!)gYu8W?EPEU=mO;63TK0USlhV;~98`D!;Zenh8dg^j&AL;;%z_sYcST8JL z-DcMJ$8C5P<2d6*+=T~_17EI7Pfenh#Sz+PP%lR^HsO18XYVL1zO`R^z?N^V3sbo5LAjrl+1- zs8s4%MN6etFJ3CO>V>6J^OPx-I=ft{)bW)|rOv5dDz#eeQmJ$6l}ephzf|hXSgF+Z z$)!^JQY&6T|MF6)r7@kcGPsU4U)C*^ngd(dcU_%QsXJE~p8F!#KLy?JVsO3hw{ zyOl4Mdd4NCQYY}P8;X@m&0C~Y>Ymd}rS>SmH}aH9edOd)smmt3U;o8X@7Le-=KJ-x z^nbtpg6H0^Kfd$(^%p<#e*Jyz-mhP+_51bz4^#IY*Yo~9e%#)hP_}H@gp9ZM>-8!l zTbbDzha=-oH{>v>&C z;@l2Drg-ZYPxRI&jPur?oZzkhJ>FY?WVE+_+gNY?4fgcxy|nk%pC(3~?5)2~{G;jK z`eX2&;jM2q%Ukb--PH0ab!m>bJ_we)58>=JG~^Rsi@YsUz4aGyg<7Ae-Or95xu-v# zaAzEM+p=1*Zla$}++BfqJL)y2@00XD*~MG$u+>|CmARabd+UEOXVYum`UMZX_2Y%Q z&%E`QqrH?rzIrKJK6@!cKX@sd-+L)9LcEmKZ@rX}L3rb(oOHx;7Lv~o}u?^FJ&(TXE7gXC{OK8tPZe4E7Za&a;hPKI#Y0s z+N044Iox>$oADe^uz>v~#BCyHDm5+Wdp-TP4E0icnP&!bIWk{U=Cowq;%{C`)ORl> zEY?doUaO9}tY#fGv3ebKakV2D_Nu^h`8ujwxjL!|mY1!g z9>IUOhD5wT6VArL8VeBxEAnbW!M`{RH`wDGu3`;bFa`r)j7=l=GqzzUS|N=)Ucm!@ zVKsd52?IF4j>+Wqr(a9@UQho%%#q4GYnba9^L1y=!OXj{b{*9d8L%v{Rg3a$)lIp! zYS$cF^>P;fE?}mu+AhOZy_jaJ-c7Yt>!Ml;@5#36qa<7PHj0pphU}GrIoz-lmk^Kf zAckbu-rDFPN9|qs9iT!vIvrQ|>&C`^d%^_8y}xISB}$*O&C& zng07Q$4=(y#9Wh@?>TcO<=d*E%>2zbH>M5PtlqWStTuv6|IO-yew)>@SkQN~>SMWC zwZPjxn^gyN#O>aj)qimv$8Zwo;Eiwii5kS!#2`$^pV)}a*ol4Eiv!q={a67f81Jz{ zA2dK7_3{yfyV!+I@W53hp*3;#2tY6Ty$2`y&oR!xEKbZ-j`@spj$_{4IFG|<W%X!)q-;;RShS+PpS=Z!t11JjWW1<_N2N5E8qYPb1@4C5r7Z`A_*PH z>4i=hjL}$*VHk@E7>9o7in=HuF9x6R1h=sh>tKh8ScMI^hh!M*pF<=1J&g(UpAK*4 zv1hIf9A(C?%xi;H7>*k~AE=gy?D0VDj>Fv_s2{pLP^)2c*9U5Lmj~)()WZ#n2kHsf zpc(4mN9PA>E!IY`!UC*@8~#HOzQTZ5{6t4;C9-Ct2&woEW9&7YhBMY-K1QMo+M+U= zq8A2WDGnhGe^WOWYw5cc{YSx_c{(#!2<9+n6w+bd>w)@-wbFz4>h=5Y)hY0~_g?LY zxp&{I|J`}7nql+p_iE6s_v#kZLPI>e`CeU*m6(Vn*or;y!BeD^s!M!F)P^O-VmbOl zfem`WcxH?(hXQi_@B~+}1?%t^=3zT_;XJ}%jQ@kYJj|irO7!gsSLSHQJhzQAG2dHs zW8TqNimI62HC^ov>5{IRw#jr`8YLMF6hh4k8hRs>JldY{X+B zeSX9adTveszpJ)E3qYID?DWiyQF8Ih=(Dc3}tB z!4>wH3}aoR0WjWWyu-*CcLE`(Lw_3fAOQpE-yPAgWUgg63FEA{u^l5(sjZn-*v3pV z;2;Je+uTgMjiJbHZKgfKJoG_KD>Ka>w{a9_a2z|a51zP=D=^?a-XR|uD1foXFBBmc z#{GDFLpTI4aqj=3jJKmxg1L%ac!V63Vcx9L_bsfc zliO$!c!-_okE}^;v`er->clqMJ^TSH#M-yf?!yy%u?}0X0LN3k0oaKU=)$A7pDK^_Ls*An=mGChyNc_d*7^SI$WzQQ=~ zQ|y8r%6#mi#ee9cCE_}kqCMV4bkR1#9C6`Yv@=+YHpqJ4MSF#3IDj)ajMKP*o45~u zBq9#U$VVZnQX`wS4C_zU93&wXVKCq^1irY9gE$9cZR1%A($Ik#^WlMb45D8*d`1W6 zSb?j?d60*4%p1hI52Ntzq^0%BxpSZe>EEy9mmYR51YZIN}%QuD(jY=(QO zoviz?2i~}d$9Mr_d}yg`)*wXUBi`X9p5g|)a0KV@KN#z}Vm}VyIzAyD&FE)5{zWW$ z((h)xLr3PA2Tueb7$G=?StyI-^Mkci6eAc{uo#u__S|621x@h9d$6`2i_ss|knc5E zdjr7>81M*>@de-T0U3x#0SaM8ZV77z*0M0}bV5?PwRRkRk(y?$oyOm= zK?~%hT5E}TUCNL3CLZBEVi16D_>L50p(43u@ssC#WFQS+5RH#`TIvStSv-UBo|iDz zC_}!AWjKy>44}`gh(UMycf}3k9H^H0e=~-b?#*V6Ag=bRk=iH>KtEJR#LAJ{F$_mJ zoc?R1HWFpkzp}O+YK9(#l_B(v^l7UPn{-dC$R{#F%%YPjjrg47U+l( zu*OtOfFs6Xe(Cdg*2yrQ*jQ!=no?dq$3RRh7 zBqm}E#-lx+ayA*M#2=kCO$$c>auI=x_y>LPZRRx182nrq42);d_yl7e!81I-Q@q1HM4$|Jw!mDhLja6@je{qC z!-~Gw;5y=vfDZ`9S@gmk;zr|f@C@w-I=r2snWGtUc)x?K=z^@E8JZ6qQ48+_XK357 z1|87^^-v9!Q21tsR#2)X?@iGdZD4%wT>9RQwRx#Vtab2t?VTa}_MZ zHM~Hh(!Nf!7GWs;?tyWx%FNQHRDIS=_8wp~o)S9>p02aCS7@+qw$>OOP#O2xJBi__ zgeNYuwbkf^)U~s<3pfBhI-n&?Q33gDW^3hGE1@PDqYlhb51r8nEnwU;Lqk+YS>%#m zghCXcGJ9pv3%1yd+xUj=rG4FJtw*0@;e%i_XO4EIGnuk}VDDe_#989&V!)!g+F!Vf zASB^E++p1BgwKD>)po-M-vkf7DUikIATtZyt`?QLM%=v_%6H9C6fg@f+27 zk73P6S)Oa5JnF&>HPIZ#xJoD?t}3fB_E%|6Ii71kkKu5{Zd^wL`WT0UNI?(!bi!j4 zpb2xBmMX`3jlKRzB=!&7qsIJ3^R&;X(|Dd{i|M6|&&IuY&UQmXa>IGrb<99_d@`A* zy@Ch+z-TDw4_)bVTh{Iv1P!(r3cJ#0d)DDF#t+7bQhHWn+&DPk81~{mB2kI^7vl_4 zVC-)zf>4FNjs1_pcnn7yL~~}Gx0v{Ch@sBIXh+RD#!>qRf0ceSmk7drJi-;+#6{e~Z3N>1op=eB>i|`+eb3DWgT!jmC2q$hh!pYU6SIJ!MF{=HZs}08_%*IM|Lj}Cz zd@Fh*_19eO0*0YJl77zB3^;^8@E0axGWuc$tT7B495D)djKNThg$+i)7-I)xys^d- zY{Y3?MGjhXuOp5i7Jcb!HJ+mp{r19Cj4oY`b7XOL1rE4HPH$YGmgYQ16EyvMjy4_6 z*oIx0i!LaS$HcgxJ@N94#E@aTeRK2{W+^E8&Di*oYOFizTJ&a@M8z z6SJ_a^mzm8Uijb^QenY;OK=_G=t4gmaT`_X(-uzn8~4;0-xTV2k3>vot?k#da*gRM?;wY%vJE(G%TE zpN(f0Xb-7D)H~MS7|Xr;a33c0GZ#mYfCltD6#Xz2Lr@uyIU9vy za^~R{bxaYkce>VX-*jy!Vqm_1x;7M@VTCgI%>H34K~)5jvmN%R48xx3+7;}E1MJZY z{m~Ac(Y{no))wf4CTLpv-hs7Q>AP{?m~Vp#SdEQ{Mp^C~kNtQHbNcxczDPzT`n5)P zOhP;4uzw21*vsVfhOyrJ-P5(3HdD3sL#Jw+@EyH}P1R;%IA)};af7ZS*#tp_yEXM|T<2CAVS9dt$F6z+B zI2?wt_qz1i9>%_>;ZJl!S)Ad#Gj@^J8tbSRtns{ZqUMDvt0rnrc!o?AqN4LeEtmBH z{>2DX#(Cnm!5k@nP1LSn4JM;0Opvu=qE?CZ1?zi!!)N&66%t|G|A+_#!g!uususDe z&ght=o}<9y5Uko<1AM{Nrn zoiR%5GINx68)macX^z-~2)xE;T)<9PqCV~s>xAZrCjTxL80)|kiPJ}E@wkV}IEoY4 z32*GiDjdLG7|-^=7si|rd_^|Qs5cP1u@^tkjC&X0F>+w+YYDvZ7KYMwGwTeL!)fA% z-~##OVC6eEL8wI^)36_RP=scs zeV6cj55}Hb;2H5_5JinHILkd9@Q;V0)jX5a{hwE1GvHwZBQ3MM-(j*PjMa|xCJk4DSbc5>IuP<(lwSf8`Y>`g9UKMcXZ>vO?ZuB zOsAI}IE(KnKpF%GFcaU1`ycYjKZZPN+au_(qV>HzPz!)|WuW#5Rj&@z7GXOc;u7v+ zH(b#kWpR$!)#!`}@-JW+2BSK@TpFm|!eyMm8mz{0tiYdGh?S+!#+Y5$kDCZWIyzEo z5*FecnsMJsJV6R3(93Fgz#lJ4?Of6+iJALHHZPQ4z0+-+;C#q(%T%V@MSbqK#Th(9F)FOG)PA!XJ;xOcLk#gV@RAzV2%&x_cr0Vh z=%yK9nb}Rdje%L+w8QX2`Rs05MbtzQ@3AL;yqxC`uIYf&G-XN&>rdO-Lz=D zMlk-vNeFy#12^#!5y(Ux@&;iwP9P2qslNoCh(Q2{7Vu2#rgiPnQ47GZo*nsf-j3STUL7?rM?3V#IPDURbPros%biQ9xxNFv`@V;(x760TAAFdQ)n-7yf&P!X-r z2qU4xdYr-wWTF!_7ULKib4Oda;|;2C|3oatSzN$fT)`^n@Q%3UsE8fZD2ol$O=#If z+X_pZ{Z?OV@V&lv5f-uaH8*+<+4_e4@4i z`>-6tumD5R1_PmBA=bj!O8_#_jQmkJi!fMGe-U1xDfdptK0HP&-XQ}YaSG#+MC^7{ z#VKl3f;Y8m;am)Tz|*~k)@Vl!?GT#ntf4JMnSX0&qj3z;sJg3$Rt?5Aj`!Etgn_7r zv*av9LwuypZj47ZwO_&uP8fw*7>(ZOi-|Dq?SnBc07d9Z{wxT7Lr?7!NX2mO+>aZ` zLwWj2Wv$5ijnxMukwxtPP(=P-82dQ|Q}}GJp$!0kX^=Lobw%w8tjsHFC!lLnQCo=! zRBu~Rv%y5nL`O7&@yrABkw?xmtVJh8Qp+79P!&nsaRK|V3ID(ai*N}4;VnMl7Y32D z4i8{Ty(QR#8r(GuUdTrc`Y`rohfx@Y=J?Lp7WBje^19#_waj6l{shFftf;L!+386dKd&_ zUpEklVK%YDMej?rLaOidlxYZ$BAnICvrl_HNiycoTlCiJg4?JBy&#y zEYKd4FbE6bj@Rfz+%@RPOT=mFw4z=)YA-@x?zx73-0KHZdKr(^IEA`+#C})&M_eE| zf#koXjwkg#QrnYzDqz@}6!rJ&6m{$ANOiSmq`L7;q`Kv7r25V)QeEgBsctRR@A(DFzp$>kWic~AInxX+# zVK4d-_Yz~st4xiTP^edz+AlGMdy4P}_r8WoA49PV$59to*l&T=#2q5Xi~JkZ*-6b; z)IP{P$=p8@Dnh;msk^=hsdr+7)T?np>Z|x5)gd8B^+^m;?S2HQ>Clpb)cv@GG@QX= z#BtUW5yZYHr!ic}ze%0?SV7G!YIi^s_c)^+Vq${SVASC`i**g_3Ctv}7;DHIKzKeBL)IEDBC81ws6=Qs9H_bT<9b59oL zbMJLDp@#+-gjnA1q74oZbA|XTIa^8s8XP8HxCHXPiAI8liH@3!d+%K#l1%jXH=b>QA_Z_|BSjj;EbB_^o;t? zvoq@C=V#P0FV3h@=>PJJdH@Hp4?_{e{y#WFoVhWUoGIk)rN&8W9i-+n>RV!s-x;+G z0-l^vFT)Ni5s6|1bJm8qXj~?56ZylaV^6)h)Q!Pq?ikB`saRgxhp-x-n$y$QDSb?hOMa&Cg)5(b-Kb<;h)GMZLCAe_MvF`^|b6o#+K=s5T z{DCA?V*dmB5oe4qPwqzqQYV~x<)`jfdQ3hRvb(o0i}Mgs3QVTlu*KO;7UoJL0E zTTo{h^_;04%^k*m1|gI??@ZmMx=-7tnmBAz6VPzRHnqvjZE7G+&f2DqnY~SII%k{u z7&Yf^Q-8AN!+;a0hRMXdCN_nfx8(az2em#?_XG7iL7TEoy)}88+5&l#h(#1^c|U-` zoPWg=Vn331hx`cYyrW(+b=Ohfm3zu_-)>Z(m*yCZAH2VW2{sXPir7eUs-Xh;?Wog} zdfTY`nfeD{10C*7a#k&-II9b$I;)i(oK=?@&U|m;tme*kR(H>JR(%|u)iLv()gud> z)jd0)o<0?x+~XG#1D@@A6%mO6K+S8EP6>18_i9Opha?lt9pXRJdjJY!Gg ztP(LZiH)U~HnW`74$R}qT%OFgjJho*JF6$TBW4yt{ygWCA5gX)3*VLl$Q=g8TFKnJxMu{Pu^B)9fc z2lWT_e5iftv4g66=%Ajv@1U04<=>OPfK73TBVAnE~%=ie^%2}yXu-+wT7lP zLK$ST_nx!u#Iz#Tf}D}$=22(kFI7z~Qq_kAs#=n(sxxy`b$^zsUdvR~e==0{3}Rr- z`w#Z3a=tT9Regz%B<}$EDbyK4y{**sr~Vr5>B61m=wmiM;4s!;E&KcFCAPAr9wat` zoB(ngGLIv5YBHZcbJoW6VpVO8STty4q3%O=V+*yRnT2ZB#6rz#YN3WTw@^b{TBw^_ zTd1$hE!1aiEL6X?7V7GD7V0%@f-UcJ*$*IQ53%#f;eXCiLrpEzpA9Y4t_>{I9d&uH zWufNQuuv_kTd3`-S*XpcTBvDNEL2O@5-efAQB4bVW^D`g2Jv;s>q1Yvs56jyJE>c~ zm4#~Hp4Hsv#=REYzX8LL%=;7eD`O}z$@EgY1+~dt$UHZxv!D6KQ}Z2t^};e7glQ8K zbqB6DHBmjAnW$Hro2cn6P1FXhP1JgAOw=0fOw{r1P1KbgOw>O*ny3{!nW*;o$@5S4 zs-iD3PQ>Q6GErx>Fi|g1YmKRi>fX>qonGHW{Zhw7-BZg%4XI(GF0Rg9)lAg(_ygy7 zk6^!QZ4R@$<;LNB#op%%I+P>N-$g;hsE>YslOBC<6CCZ@=B}%MCiL$prgctu7k{1i#cz!q+itK`3&`2{R&komV_#ceupZ5lnqllRt!^SR0&hoRSQ$L)eKY2 z>xL;N4Z@Vhjlz_-jl+~&(=f%cRVTlQ1Qb824IX$`SHnsqv&JRJoWPs$`^u zDof%*6|3k_#pF|{a{hg&GVfieGT?2faxExSQG-L3#v!3fbN0t`-Z&~$i6Z_dc~{AA zR~)LuP;UrzyU|M&_mt(nM%>$x9(KYB-*|6Df8FTwH8J7D?xBAR@&YS_DFe!dDOZ`# zi#fY8uP1zX-promdtW62>EXW0xR1U{lSp5sZj`U`*Ee6KcAT$LKhak)P4-oaQhk-s zbYI0P(^tvM@KtJJ1@AGO%^>F6cV8ut+;Hlc1p6xIp8G19PkfaFcYT!_*L{_JzP?I5 zZ(rr@X}4hSDu;;)jHTBYU*$A&O!(rfRQtqpgs*aoTF05S9;+v6 zFmKCiZpv&Z*WHxhn{LX@J8nw-dv40=M{Y`WKR3l&+>}#K-S~gL-IQ9d+?3O=-IOj^ z#B&&Xn>br*a8r&ya8uUba#Lnqc2lgq-4x3cZpwrMZc5W#Zi=|MDQ!2nDaTg3DIP1` zlpD+4l**{K!cEEh%T38(fAM-Z*-89w<|xlR(ae>`d@q>O1*cGk_vzIAvy-a)ftb#! zGOD|(%3lpdXm0Y&TUIV4*4t>2EW&y5K1DCiP(E>LvR8Y9;#r zYL@8d)@L;-(R;Kk(QoKdqF-rMqVHx?qVGDcM89QHiT7Um4r9YAOQ{VL6PyNZqKlL@Q z{M0YJ^i#j)+E0CtM?dwoLVxO&^q+b|onQLk-oNw#lhy&9G1 z+x95YucjYUJ9=^`(RZIi4|qPKM89ToiN4yH61}5RqHoZ@L|>*W{kJI5+mPo|yF~BD zoV_cR=*Kc^a#haQtJ=6kKj68Ge)lsMz53ckfA_76{#KZaJ|^8oZ&l)=*Ee?6x9aJt zHyh@vzhUpHpEk!;ALQt&&sgB9kDBJH-#FM+KdQB>eoLB*KIpcKK53hR_)j8E+1BO z-`lCW;>M~jbFivgpeQ=IK16rE|6pC2L@V9E>3wywtEcW<)h;@V*6nqF)oZ2O*s_W4 z$A$*FSuJYooQtdK;#*bHojh7j*X&&xT~>!Ox@V`#=zd0()AbIosPk)4O;`1EEnSCw z4RoeA&2-PyR=RyI?R7pay6F6!dg{C?^wp{52IwlL4bqt%9HP6vPti48sOr8SQ*}N) zRb9CwitdwxUN@@O5M6fmVBObEgLF-L57ccQJ3x2%xRtI;J1gDSx>h>#K~}mU8CJTO z+yOeX^D=>2?N}E_Q~!)$I&5eoioa%B81* z35N3J>s=+Zm4cwKGJFvNQakw;_Mn8TzlVGlZ_OGuW=OGkkHjGkl(BXK2fe zi$~ZQV&;SxdM^ku^g}bp5JQJ)A%5T43o#UT3o$h55@N`- z2r+!@5n{M8FvL)s^R)RPhEAJ9430h_hNzDr28-IEhV8Q2{eQFlJuAepj%RD;%jW#t&=A9ab4+EzJX4u5-&B@6n##D@recX3 z(@bUMWK&sXZz{@oQ>ik>RE~@?mBH9H&Q!7|nab-~rqXVism$9*&S6uzd(BjwgG^;i znyGBBZ6;59n90cTW@53-OdjkslY%p5()XsB)O}(m-TciY`mvb|zhx%D?8pCWCRt0( zr2RNE8BAXFI%aZ%I_-l^C6T(#sDI-hQyH<;R65e%H+s85UuEfQ6g@p-)#%H|&Q#`4 zF%`S%rgC$JshBgL33L4g1J5efu`kRQMQr$UYuWe8T6Vv&mI<$|zqFQr1FR*$ z&sv&1wwC+{)?#(vS~}db76+uW7yXR*P-|KF&06Z_S3 ze_$h@B5kBf*`acx#ZYqt$-NCuQhuY8{6w?$PU7m~B)Y$y zWco@c`MknO>@W?@d0$4(m@Q6Hw9iRyopF-M51i!c2PgTO?sb3~ndoPpGEtg4g`DLQ}wp4x-zjyyq zxkCOfb*U_--f`+)eD5T+?>otJPbaCn$4R0$JINvX*iAnZ>8k>s!h4mI1g&-wPtGnf z$0_FO_m7h_V6H_xzh*8I&K{6AV40H?zuqosZ?;R9pzTsGc)PsBtHAB@5Fa_Sf3;n_ zUT&BB*v0b>Vr#$ME@i^EOX%0_lAo|${Bqf==q^PK-DO~BceyjfU3N`!m#a?hQpwF- z?s>RN+C_J1`_Nr7pSw%#V0Y>H&Rv3o+@(J6Ummb>?nvAQ;!lz{gZxH%cd13aGL772 zbOm>Lo3mZ&ByN|G$nDbp{dPG@uX*&imA)M4&xAD_t?4lUX451D$(L)=b#$XAT!ISBXJU(dN5_mGOLw|Rf>$@4i6`EbcYdff4l z1L7g|Lp)@~HxH?t?;&++9hKqjj>-|;QSqF7RK~A3DtopcmGt9BWyNLA9v+o=|D&Ql zJ1XBaTt0A(s9g@+bEuU-XcP z-X4-fzvt=eIz6tY$5U9ydm+3z+lujMjCRaq!@0{b4>@|oLs}m8kSYEbr1{eeQt{aZ zi9#TD;}p)JEw*r8g>}h`3$pUf1$q09=Z_a;`S%Ornt4GEl=BfY6Casu;UleVd}QQo zANgmUkJLZnBjLAvq)M=l#Kif?hyouuQ`uKq)$x^xdcM+^bv^Ip3w>llypLGC^^s`e z-;?)_{57+DB-7eQ4pVn)Lmzp>J>#=3NLcIzX~+GO=;O!h3$lp5is-QhJ^Hd5pN%t^ zJ-;9w;LP&`+z+@Qw#<9U?}GgE_<{_0ctH|v@5t=Ycf?`L9XXG)ct7@zR7MJ>jJqSf zppCyH-Rr=ZO7#7^;t_CH{GZ{*rC$FB3=kiz9v^dW^r^8tpHiSR0P>mjJw&;4hYw z{AHhmztoxMFI|@UOMT2_xY^zm!Zr2WeFM+Z=I#6E6 z2g;Rz#Q1;dgl83E=#Lz!T)JZ||!Z}Em9tn~!kAq}be2~1a87y0S21~wuuyj}z zEIa=TmIbGSW#gq_DY+Ibo~-pfgQY6_3g=CT8%umE@;%+@Rh`>h72rBo$)WH6VG=V`K?i2iGv7?++|9gJuLGs? z%Rs68EKux|!=-O(xU5ePmrj}CQXxBBF5^i~xJ=Cr7t{Q32`>zn#wFphvV4S$t{x$a z8b-+awhqa(z=Z-jK={_FIyjDAkhS0nn{PM>S(_aS|sq5mt)aV{%dZZVfV^F=e~O6EQO zBV4YvX5*s8VMDZR@rf4K zx6xvf5iOM~$4F#@7&&AXBNI(xq;91cnVKFgCc%yn+AN9PxM`z{#!r!DvKX|l~&C4komUU z{VF$^H|fS#*>Ux&G`_+tx;Tj%5+~)=I2m9QC%!}DWaO|o*=idn<448Gt}$^^&5rY_ zapEy2PTZa19IC12@humwvJ5F}e$L1w*a*4j4O^K72^jT?aoS4z~?GbTeJ3LOhGS4*YIR5A) zPQK{lqyqDP92h6kKTg{9i<4y@NpkvFl59VbBnwX`iLYmpoH?5$yUr!aRi7luzLX^D zjU=(WpCp?Dl4M^<%SK4=d^SUr_zc<5 zK2ydl$Q12#rc4jZ6z7smIodo+*7wYkE&Z}2p+}aCZjmKL>>qodDJ?uRWi|1AI%LYo zgbWEklOYwU=S|(?Y3UNiJyzWJ_K$S=#Qm}K;Z8s6>FYB6nbYS2`aPbUChZf`&97P+AK>f>8n$l zELq(tOUlx3*MV75bwrlv9kQg}$}E|-J4>8yX334HEV)%TTP$p|#czAI^a{!r2a_E6 zIwMCOoXZh4HAf1&RGCM3+ECX}p`Q2O@eIQr8wn{j3~W zIWI@dmgGo%=NwtKDMvQ!&5<_r<}LK~Jx4Cp$dyY2bH#L3uDrOFE3=AorRmT-F+Z3m zB?);_yMMm)b<3Apf%(#_dVw4sTp;Zo3*@D1fmGU3AOl(3&nuAg?Cf#GgK1(@$giT0?*BSLMisKXc?ZeP5;j z9S%7%hk4dd%#l0Hw{2{W#4+zx+Z_3Oc#hn&$r0^*u6**zmE23Y^6+Y|eBhp${Froj zlq(w~S31AW7613SvN$$Zn&juoy;^ya)+JA_O~{j{oAUUYnkTvWdGc77FL(dVm+LY4 z641Xu7VR&PtkeP-q7=%&J%y6?zECFADw4Z>i=^uKBJrGBB&MthytiWCl=Ei03nfus zD3i$>zpp?%tqS-%h4W=Kb!BkAlq<*+OYY0xm?z%c??)d^Yv+l39{tkaSo&;Dzt#P7 zr84~=W)3grale);y)Ng1Vx}Q^ zQl5`{_fgz2F;Cjf&6Aod^W@2PW<8rHO_iBM` zs#hqk&V>^6p-^607D=sti=^N~kvwZyEI+hj`8umu$}cV!x7o!qjQ73lFOMja!#j&) z7V&?RH)2(xL{jHp>OC7@AT6kW)R)gC?)%y-Ja$E{DNJn`w6CoTBc9p}fjs3=#~|K_=T zo(!v;C;t36?{3J)pjDn+ASY1C6T>t<7A|@6)-z8Ihvi9Ft$e9tmoEd)iRw`(Wlj}JQuQKnS;>!iNRcdRS1iL96^qyLVtMnRSjIdrma`9w<TzPAoD~o3H^KM9K1U)CPVriW0nfcloVVfm&}T#&xB(6-^yYs*;p*~Ru#*I z@x}6u{U@9^TU8_riQhxs1M)4Xvz2;j5A&toxP0;8o^pIm=rBA_9J&7neN^?#mFDaD zoTk6?Bl-Luz|T?o?$I_^E;2`b=ILLbpTEp^f;sn8<;S^FuJo>uD+|i;bJ{gqR&L0a z1DmqNb8EKfcks1#Z?=3sk}cQ0vgP%SY#H)ATf(EVrGA+l>C-Vs2J>}gu}6+f;%lJs zz_c$C77RyFjk|FJBf=Z#{JbsK1N4-c)!AbCceV^#nJverWlGx2 zOzAW?Q)=*Q?}k4!#eZd{^jMcE%iJ?1#)Ds%uVu=lz)VTa%#?#Iv&434mYhAAB|WpU z#C}4y1o~x5qk%b6&X6MuNAvj;ohz02df{0yUuyi9FFR@$$U>I_$$noT2OAg445d)Y zjxCfFzSh+-Wo6%=Ukm;vZaVSKg=zX-1}WbzsgZnR0m&-<#NF z%FjD#vi4z`nE0j1vVb&s{VGjfg{DbjB;Pae{f`;nR}5>ME)V*p%cME!QtMEYK-<%=q(=z17!b~~G$41%0_XI7oh90Tlz;xn6npNw zx+z1>@I6r>efS?t7d!gu+&^7fH%gZuC25k)_doaPzb$i=d7UP;pQcF*<{SSYP5kes zN#omT(*I_fY`K;uH%=yt+nHp!d@fl`E+tE^o5|w(i0?07Cd>Sfe6N|7EQ713$Sl66 z^_iI>^^T^TIfvX_6*K&!ovgt8}^dC0#0R&yYl`OnI1^DV0xTNw|_N ztv+YVbG{!s{3=I^O!;1cuP4FN_;r;vf%l&5+jHLXQ?}e9-sO0f*pTl!AX8p%%aCu> zy~TWQxo0l-ZSzc(q1=CyKIVF)NLTtgO@F(qrARLQE~W3j^nZ*wIxx?6=4x<0S%xuZ zqf^P^e>_=wA5E6~N0OyV%S4H5ohTRDCrYobiIUzYQG9fXd>$uC#LPsgwmMOA40`glO5(DMl8pkCET8F;a8ZH_1x-CKdL7mz~rOY!oM-v*P6N zhj^LzG(im462;}<4=J}iNw)S$mIpD((r!tL?0TOfGaICeQ>Rq%W?jpB82f!WS1kFQ zT$Ut%9{Rz5^GTGPfCRDn7%zRY_155CFtxEQInAx3V~S5`cK zws0d_blan)Jbhb_kCv6p@s4?-J4Q!JNa=YwQvR|0D$U=2 zm7zQNvyREp64Nh60y=(^;T0r;-#lWoSdM}{C3o%?uPy`a*umPai4GaS4ro7 zJNjrdFj9WdmpA>Lq|dALYfsusX_MMD|>s}tE|k7N=T9@ zyNrs=P)KRq&r3_CC5o)h5$8y9aQcYwkVt z+I0`{xCaBxa)eCc8kLkIuTKT;+^@jU;QNRi`vA{eE3wb+A^uR);USOFW$qKG_&r6( zy;beQfKUFvojcn{kg@8JOb zyrr+Njkr#xc@OQ?=!a`%VSn!87T3!5>`TzgwS;RUB{)B{1krQL8{DdFKAQi^r|+B78y_#>d~m6zUmIU1rqxRizY>StY!Fd#S3t-oW>!hQ(20WK*r2$-9 zjpM%u1?X;c8`{fnV_xNLbe>)a!@q@4t`(tsSTV4*1T7AfA_RA^K;XgiVq7Bsv_FLyI;{|;4{oD;*=_i?xy^HJ0X9?jEB^wt zo>zdz^b!4?I0)V=^G%nIPBn}a#cb8ylqhsTK=tnZQo^HDiCvMdJ^ z59eUtog92O%Eb}iTm*f|#mvB4uVsn^lejpDPgj?LOM%Jitc!3r8iqsw#1x?YI9bv6bJ^TVL;E9^*n8uf@D$ zE$^b_>>V^Fo;7hh6Tg5wW63x1b3Wdy`! zKc_OhTgA2J(0h>O<>=D70?Yj>FeIh|AK12L{|3iX_`MBWeHYn`AD>qWC+1hA7U2f* zz7V&MIX$P}g8!;qJ~PjO5&7e(qw;VjvTARl<*J)FNu58acj7w180vpZA1U;+s}^`e ze^=?V5&Z_20~5;tlRLn_!>QO=K%Tz>v(13}2|jnJzKJDcvatO@Hdd_5#WIUL{I1N$rJc7i%en}Gmx>Xn zTZ%W`@4%>28S37YVeEvvFxYq(huC^mm*En}lR3XpuM~?IKf{K;nSX*fABk5++=$9- zIFRQa`I<)yRLNfypupi=25f$&!*)(OUQlO;y=iz$-9r5b=p&AP%;@VQ{jH$SRrFhL zUn>0erlMU~Dtt#J;mCv}OrM>E(v?Z@+nEHP^GR^6NWwXT>u{NS9bXHsqjY34@Ff|4 zl2hP+`Ud)Dq@vNkH2j;Jfni@11RoVFot25-uGt9F%SGE4w@?$Cj}L=yqhDqrrZy_Z z`(`CrR9=DyBTCU@O(~yKm7;w`2~wMvppNq&7^gAhHcp+)ha>Z2hy%B5lo7X@_*=*` zcwPp^sHEc<`G=gjftEK?ko7egRn%vH`#M6X_cnEF-%mo`xg>0)pL_J>OMe^bvjhD$ z8B_)>libF{DH*@9%Ke@#JcOe(M@9V=%m7&U{>;AUpx?38Tm=$(su_4Ba#SUx7J z+{Ol@Lg>^KvSy};_c=v~^e(~<_J47FtSXPMjMLD{!&>GAG5;xX)Mg5ziEBdqz}Qrj zeMv#s!er!<|9?6~-zi#wtMZZt} zUPjih%P{zH8F#Or#|1eL^OEx@dU+lZ^)8^&_5#Mvy#R~L7cioEJiPYBqt5*zqEs)z z_}e9{X_0_g^AoY>%@uebxQ1^_ufxqN1$VZj;{DllEV!qj-ETpVRu;TJX5&>{E}pf^ zL)h4Sw014P=Zpdz{7``9Y}MHRkK>!o^3axXL5x@GXW6f9YG z9jfGOLtanvpEF267wY*+UHz%A6?KMFZ_%X-aG?ID^x;K6%B%BmpudguX_|Z=h)6Q-O*?C4Wi zzCI3nDo&%<(z8hKejaOj#$(&EOVG;YXRP~Gyyr9h55rP$JvSAjtus(Q12{D0Ccf!r zVffN)tY4mki~L?6@0W|OY(MinwZc0a_MA6koWgh&=6z$n4{_!ZPo21bauU#iJZs4p z+wDB!z0aaKb%d>pL(!;H7*BnhsdE|i9-;0I)Sq!Dnzby^=tf_+=#LwVxlS03oG#Hg z*(n;QoucuzO%xtjMWJnvC{#{~g4fn4*k?rHvF=g)U2_x@)Q`b4;TRHkAIGj^Cvf6b zG}PzE;(UiwIA(kr_lC2U({plVBTk9y?rQaL+ zQ8=v=g&z&0u(shrG&4MizYYiSX4FBHZ$5}$%0Z|a9ztE{A^fyFi~;Wtqx|U+bYqT| z&ruk*K8`tR(YR(2i_1GsA*1aX_&z;{wMsk&UcQVu(N}OVfXCgQDKN}W#l0pO_)!ng z-Y+2Z~q%>rC zXCSBzaC*Gpbc>tF-02(M)iBQWwHPE(&Nbq=K7;fTOd>fi7n9RKL&>(p>8qQ9>6>E1XT zzg5Gr|My-veBX;9R-w@D9ty+Bp{(f(#qo6hYqA^NHtoj#CSl0G9fpT#drp)n>S;bE0{f_MId%F|?>_4GqW+(HyK$X|=P@BL*AGPhsrlvuJD<50mqk@lxw5rnS9}?$1*2 zs(Tt%wa-B3?Fv6Pfc+W3pKbLv1u-1A?vaMtsuZ{|z8&*=ox6-o;@l@*_li^KO#I2@ z(IlTCc^}8`N1|HiQWIiUjHmquxxvohW!5j8C(Jafg2T=IlVjgF9eMpG)cY zl;;k#8qBf2JJ9D^AUYNWBJFD+5-hi3>58o|so07pv$nz8U^_JHw)0uU4xHQ>j6J`ys}xk9 zO~u^!bR4!&c+UkKpA1;Bz0*;_j|=Gt<9rC?)ER%QCK17tE+LROeiu)pS!OKm5I>YW z1IQOXWG$u+ zKpZ|Ch@BCEaI*Hp=mCCMxYUpLZGMG>yI{iq^I<;Wc%gm(Ic10Oqx>k0{_%X`5QobX&%tfRMND!?#Kue4@Z@$fwyjFV z?E~rPPXBSOfhJ3Uc03+zqkjXASNNnNm2n?0UV}aJ1~7jraYi}D;Y(dK0xFK;@twms zR*%Q6mEjod9ERT1;Xpm+)RjbiQ}wsvEcHed24E=lcVFs{So(=O>jy{r1ATt5^26?y ze#qALLjw&zgl|}j>8Wc`*lHcxA6Wv_rj2TJ_18;9fW7AC};$qz){Up7`*B%ywc-gnxDXH!fU83PG;R# zDmx-&j5I{_Pf<1vFc-o)Fx@Hl3k zJpvE%bU(fqJ2!^n3i%iH-j2i6vyZwKP27wv)Y<6OT701H!NET0L?5r|$Ix;)deYxM z`kYF?cdjgj*Z!s0;J*~byXV8WYCd$vFTj^S3*i5JA##itV{qyc6jgd7ch_>%tMI^v4%|ceFsUN-QR2oWTcPi+h?SB4bz* zuGgjDciVKFj#ki^YYLwtZX)&jO|;l|6D!&0#x|TbO~+5hbsnCCGt67FKOR$wb3Zv2 zLx}q?^e}#sCzO0vOG2RL8HBS(0(t-Hk7X}6LQicS-n{m~1nMn*=#38POK{(4G1k$~ zEc%jh3-J5Ne7vV$o5}NW)qOrx+RjJp<{1cjFavL<&BW!F|3SC)Ec9D5o6qUzqU(|Q zShIH#=JfW)lp8Cc)?_VK58Z?w?g7}`WgD8F3`WtC-ALUWfx5gy_~?8LJ&(oWhpf-St9liybd+nRAjBqzyTw{w}zQ0am_;3;VcBP)!_C1RAa&Nb-YHhNyRtDTQDz` z`OUwc!KtIMXxHf&9EiWmF9IFN*NnXX$Ul%e(x~SybuCO;0ZZ21r|emT2pKI^ZDv42bq3a4oq(6ECt`N|M8s{H1e>Fiv83Hp z__v#mu2*Mb!Oz+7R$TzA7fXN`#% z-Txf+ce#uS5!Y~boe8&KV48>iY-h9IlH-Foug19PjNcu8 z4Lz7&M4Tza`;WMTM;(GEc^>)gg0VW!L$3mmx?v;kwO@s&MP8UieH*Q3VHWkCw4RLd zV<*78?Km7?HU({78a|Bo zb|bK;Y8158#=`Q$1WZt-V*Kt|n6zXe*Cm$Y*VOfxf7u`JY_?-oqg`m(H5@ZpZ~v3m z<0to=!qBJZvGi2}_HDn8W~b7y(n1jTBMXyNZ=s7D@AX3q;K6mwoosut|5@c0ns9y+ z<8n`?A)0w{%zyUeJjU%kg?q&H=6T=RBOJx#vmtNAC4a1j7Od=UJyhTx3r2>kZ<#O3S>_^;J;bl))->y5n;RlEk1^Zf8AZX1?<2tmr0aD)sy z3>DiG*!nLHt7GGF>h~2K=5^oVUR(?LnaR51TL6;Vxa!4^s*q&U%8foJK zWOEI-J>zCEe#i3^jQDc}FJj_hrE(fxHYX4<{4nYQ!r@#K0;AL05J>)wC2J65;*D|C z<=J{V*Ow>YNPs7{Q}w4fw~Tu{joTI5N>WBj=~;ekYh9%Yxwy(;I$YpAFje67eDk^ zw+(t$yI^J&!Oz7JsPSGqqWBC-3ol{H>LlFc-*RA|n+UVd#qF^LD330}u?D4>zwr** z{JF#ZId^cjVJUn$-eFt;wlZ!bJ1HN#0McwMYu$hmAtK|byo7o+X2J2=XE z?FGBaQKnacwQMKzxl~JzZ#F4rZF)X7Fn(`|LTxGNHZu`!#5+M;<88M|0_ zu#L}Jc|Eet2ct$Ugxa_nXh?lAmLt)qdJxjH`oi}_Ph6PZjrGx8u=Tq;#?s%*ov!e| z=YmfYTwtf^f||TeICa(@>(@9S;D95<+!+BqU9i8tJMJF#z%{d;n4HlMgLVx?{E0DG z(O?>Evgaf1_DVqA52+otW7U;V=UZ42DgC<-K`uNnVM2i~KN_$KeH9VbGj(5EW%ye;LhvK)i2rNlJ!z0{189V(P_0 z9CR` z1Dh*sAt*%OShWi{4xtnRkOzVX?8e&(FrcC+_?{+ zJHpT(<}M>Ktleas515DdLwt~J?T?zSJMbWR5B3KfMy+=YbSIui6N9UG?wbbt`Fuun zwE!~)@O!Ru9~W7lKjmN*y0VVmv)(Is?BPGQ-6yiHp5vW4AIrFk{&#TsN&ybd%fe&g zJZOFu)x?B_!^1iV0$IHP!=-@w(>q3*^-ihZk>5mlZUE0ze>hVr^P9K)4 z)>unlW9aV|eO8&aN5xVzEdJIG6Hm3n_Y7nBJT!sTB2(HFvtS9J% z9UnZPbEq%(h7QM;;7N!ooQL?WtB}$+05+w;=u;4mwYpI}E}p`an-{U_`E~S-<@2S- zw_v%v1f#oEK*#(s>bHE3#j3Aib+?-9$?wqGm~}EcSxZz_jny2FZt)yl8E3}$qGcu6 z&-_y{z&YX_B<@A;Q<&R03OmUcUJ{IjT1ViXIs@4bz-bQ#~V1#!^TOsqIA)@#5X0*l_HI3Ur-^d)R z2UufVk|Ubz>5Picz0vW*Fbt#r^{MlrwPX!4u5H1EtPp(du^$$Hk6{Fl(?NR^q42rW zJG*S`xmt*3!S`Ufi+eC)p0hrmHA89d5gPjuo^wA#`STf<*}i7qm*YJ-f0}U{8NZ)- z%dQoo192=3($Ji^{v~Iy;?FVo_1uqA@{UQ`f_UoKkTxIbJ0`*4<1o~I>5V60oe_V{ z5pDymv70_R>bAp3`tqkg_eX}jp60&gjFt%K*b=!JEwDGO8ShODV4&R+(Z>y8*TfhL zwzNZLM+;u}*<<2)H`FWah0e!@;Z*y{h#s+!*D>qyAaE;&wcL%t`iI~(G=_Cb7x0K{ z)y?97(Jk_^t(xb!x=JLvJjdHr)p&Zo27U6ru$JOG{6c;rwPP*rv)wA+xo7hW{<}~E zoz>ja;QAcu|0=QY?H&9y$j5Zz84-8>{tJjF&wlbXZnYaVTem_(Z9N9^_&>P=7gLT8 zgWv65XidEd)P2^{0?+89S5spgqp#8Qw}L*a>33PCKK2>v!!@QUvfAk3#>plK;h6bx z11y_oh^GTgknPn00gY^-*2fi39eUyOvtjr+X$p>QUySelHlp6e?Oc=JgYW^7nDh4( z{-tu=cU&rL5AwP2)lLfq&@1w!Xz* zoNN0FA2{EOaX%Pe$h<7(TTdy5Bk^92O~rEJudX|VspPwry9Zh1FQAT-L5neZ$`pK} zzTWn|aKE=JDyVzm(hitE&;(EC8*=Tz01dPB5f?NmF9QUWQUo$ z{3%tF`RoU9{2=EKGcJVj(QKet%-ZJ&owk?at)H^URZ235;xDxKq}W0oL2^5SnFK~ z9drSfpCp4n&Y8O-Vu5Jar7R_)aSQk3H{uf2$ke-m)er%xeLM6ccDHu*6yw7fj;ya)r}qG|ZiaNY~X!YQGgK zQ^UAdCK97Np2p5GSCF+pL4TJ591bkUOxtG|uK6BqKXK1S$zMbaQIi2JH6&GSb>l)d=BJ& zJ2?!ysbi_jYWU>L0)L+sclY;Zt&s~(%(p~tk_q&vwd7er8Zz0<~ML?7nub@Ad-1DxHYf$9lr@TK3<3xAa8 zQNNVQNk0{Dt6JsK$U0?bv>JwXt&dv{I_P(;8J^WQ=6)bcc&59e@%a8Y*Jc75{9c66 zCz}zlev{~X7UbN(gcT54*_C+5v%{?e@<5ktH~#I4U-qY#Y@ z93|f&^4k79fO!XYqQ%qAi2k(*n~f(RgE~7>?@j7HtYwV;SDT^8UI%{krAB}AN7pGP zHnqz772lL!C7+cS>YtP?@>xk*`BUlLScUuQG~m2X8|k8t|Lz&#iKZ3Q7Q170i$SQb zJ_YldF2gXbE!=Ck8~!6AF)Hy46t3@$d6J2bj-}|=@Cm-Vy@OBK5A?28ksU`hWbow% zvf4^V2CC~xcaNr$SEMg?H=9Xd^X4*$ZQEjfnaS}i&QE1rIOE?lZwB*gcmF^+@oqMF zf+FH?B2Oy$63Oc`G7{dCcH;~6tfelUdQ%W$FbFFbxpV)D6>=+#U_l>Y5!(3Nf}d^r z8%UoMvOX&@CqF1=DQ}h6@o$tPW$%=hZN4bC0)8u=ebo^=z9DN)^s%^&F)q1V!#B1I zHY^>A>YX#7dUq8Zg0|uPDqe%Boj}ItczkV`2HQsYXp>sOGR>D*JLC&~6xMP5yq`Cp1y?5jS6(s$;SlpHTuHmOX??3Lml&|$Bw#2Q{NS@p?De7h5M4Np>AS~PxP^! zeq#HnV{zbbj_%^e2?|v zzcAHEUDnUllC7AheX&V{B_LEa<`4`hlPH=u7 z<4WzCh!^v^Ge4g=D#UXou6xK6blp~hLc^Q5N?v2~KX`utAO7RBI_esnJO@#;M&g%O zH#~oDgR1++XwgC+)9I&wwkrPk{ZM-K|DZgW@k((SQ>FM^eWYkuS1L>9R4Hx#yiv-V ze^pLLsi4I#Ee!cjA2;;cBA3@u^YghL@?#7>ge}5tXMaTR3&r^KNSLfVhtw4*NL`uF1l%MpFJvPu#vXlZY8DCE2pIY#3=I+wK{Qv0@^zjoXSL z+pg^Q=Xej!hcPbYOEcNZyzR_CM;vS7%^+?^;@>CFO!7IA*JH&wB&S8Q&-6D zMc7N7G1OaPX%BtsH=U)ADEiq&Uk(Oem0Q2wC|>_nDPsyMl`}czO2v;drQpd!CEo3& zGPnOH<=NqXO3q^~cp5iDTQS9{kB*2+?~C15ldxECIow)rMQFQlc)mUXd%MfH5w9T3 zyci9+U)#Ex`z(|{uqde~e%BgG6u-~<@y*2UN=vC|)<(>WjivFswxYVQo$MZFCVvl@ zi67gX1?{BvZCeR1GM3+ryOY>bGA}ffQ0DI>&H&;KChlwEPag3Eb>wSF-c7cb;Xob6 z)brkOD?+GGta%@h(HBWIj+m{OBAh;YKhZ+pL;sY~{XQuP^!cdrp|aufJ!M??QYF}= zNcmY(p^W@frG)6$C>Ceylv7p>uzhHA*nTm?$lfj}AIvqvf79{aeJ##S+yP6?g9w-x zhYMYju*5wF=?3L+;k|6zdEAfXs3v!L9vd0fSQ_5dm&}zd<vxnZY*X3y<#>P2hcHfaQ5!kOJWJ-k-`!YD&S}Xp;trbso%Ob_aJ)r1 z^vPRE{_oT=m3od)*S0Qe(TwZ)t%nRk6Y9SE$qbF?qlSKbPuD5$n$#$d>2r5cg`!=r zRB7GfwlXW~mQv8|t`a=tiPCt}du6P5o#J2F07f+i*cM}s3GF)LrT1{WUNaXRw{Awt zhg~r2$$QW)7tn8C8gv>IVp#vjJlE79O{WgVO*DnSxgm!aHI?lrT8M>R8(F~5`N95X z(!8>RM3r?EpU##N*vLxGkG7H>Y_r%0$ER}s9OLdXegN~XFkh26ZXrA`>S@ZH#&xhI z&u{XXlXt}aG}w7uKpFMaQdiS$n^8@jr>M7)S!V=O|L6|}2&JEVuR6tG<9j8NKBsrT zt88IqN7krZrDS=g(!4{d;%Mf8oc#`uYBgC?+dyu4>q)Dg2GZ@n)?#wUL`?WO zKaprIFYGO4w!XD!&$W?FcDB-bnXT+*JB9sK9QSjulpTya&G=!=8^QcbJq)CZcteS+ zL43<|+>c7WpX8m;Jrhr;V_9J|9PjT%B=yaWSOQn-Z4l877b`8fHq{Exe>6nXnmQ$z z{?>JTq%^cBRd#;3rRcxEsl1BJP}D+-lxh1amCiliDYILs;Ns>+(1>h}jz?^9>0oc@ z?w*9t6Me95BHtxscn}ZxeBZYy8IP>;v2MvjxDDcd`4x3oSFS0>FLb2;@Mh9S%TRvn zn25!6GYL@XC=-8M%GL2UVs2_D&!^iWjVq3$03y!-^u#xtRD`UJf^IkH) zFL62&&x*K}_cSG9WgWZ+en2^SXIbZ?8FiSmKA+--YL5x=yP|G@_T!>a`<(IQet{TiCuGBxqaw?vi|rxWm0QZOkdX+LGi{g zdgF+;ZUgZ!bSCs~Za}@>yYTUR6bzrnW7;?u)QMmDvTXM^oU=evU#8#{?jzLU(?a27kZcZ{6m?KcPc%=vbV z^J2U?^Gun)gE;$%H=Vc#^BPKX8+D0k^c~yCn>mqt5`q=nc@mEY)MeFc7v9JQ+^62n z)NMokdoCKon0}7a*Rm7ulxOs*zV5ctZ0}8Fe7{sB>)$mc=uN)zwKK=BzE`#f@*45A z9^Un7hqgCeG0n5^sA^0$wB|j`!GEJ4w_&7vR=HtM5i~F z1{a#k=x42E>3CD=t8FgU^{k}pP+Lj=XfNYboaEPbXIZ_!lk9PEk=_ejWGh=O_Fr>YO6-|mL>zPCO((7&@#m6f*XO@@cRk%@C$cDWb zxhMb;P782<$#6V`D<1W2ht~8nlfII#yjS{rJW#g1&R6a=hB8PaS$X7|sFXg*Q=(Tt zP@?^76sHOGu%bX8)>F-qV%L?gcN`0&)yr_C|8`7hbP(Ng&hY$`iU);-aGFzvcfs6$ z|3ghI+G|VRxu()#V=L(%Y$88yx0jPcEv2cBtwb6*h;N9K9P;WUTZ>#|6yIRAXSkb; z_jQx4Y_-@A=ePyu4>N8h<9{@=l|<(27PgnJ#2ZRntMg6el9{%AA>U#0u9;nhSJV+h zJ+HFQAd&iZ4%m+RYnGv|$5{Nc>5A#}(Sm+HkFN)FzZzxv+6PMdqdetLUAl6?>YAdG zc~McFnWy-SuT&Oje^%7?X`;%y1@PAjy{vmE^J zk7G{NIJNx`CjQZsCC0kaGQ>dIyBW##CT23SwS`=nXd^lm_F{9!N$NlDBpN$iMN8bI zJltKz>2wx!>MT>(RCp-f_B<1TudU;X%Aui0EL8wmG~4roU|b@Zi4e*@?<(mPM-Jt$R) zJ9k;hd3HvzYm%?H>OE16=hP}QXEwsS878oJ?1DM3MquTdK+MiE@B7t=sUBy6?Uofy<9vaFu?V{nrQ!=r6eO6XjXce5L&K z4W(zJOUkOXamt9{1q_*Zg1zgZikIXtb<&B>nt~xyUMpQ?s7c7 zvpkFJA~BskRJxJZf&6Q!V+-|crLLyqF5}OWC`3~4Q|j)2VLaxTcf|zyG5(>8=)QG|+Se+@ zV?=?H8hk?uFOOH|d!1CmtqYZ!@R!QDKdRWJ*$i7_EfMptH*y-!fai$KXlbzrorlDt z+w|-3sx5%=nJTpV%l9hu){sL>8gs42Kq~(;mJz?&OWz19S?I>|*n1~go97~(-@Ea8 zsIzoi(M6USc!-IQhg>}AA)2Wk;w&Cwz;-$N&sTbg8|V8nZU*BUGcTC=hlw+VctON{ zQrliS%`z54@|`8``Cb|__s@51KV5~s)b(%bb+}Wf0rkEdz8N2=KS!kxD(Rc?@S`{j{KHpI6n#U{WGGmlR=Zlpl->Q{_{u-#=(-MdG*`wTRFyGs-0C&R!vE1eW z8rGe~w^JFInpB4BYt>w5{)YnIqdUxNDy@$j%J5gFl4M~aHZyHy=YB^qtL!A52D(Z0 zFL#M--bIkyMH=gPNE<5;xozzskJ@|4`?emE&UPpJ^*Fw#k%tsB&W-UGnKz61!Ndt8 zULbMPiEm1trsOjxuMPQ&?fzj0^%zmtRO&0I&P?jHr0z}H3-HQ&Ff8`lBQ&ffX42ot z@72nzGsQ~rvs7hL)&*tY=4hqn?j6OT%}2#ny#Y$1jIgg6pBws&#{SslsI_2CrEe6T zf4|Hcgd7ZweTcNG&-j+8E^4!NByXsJcn>s|8(lldNkeNH`^;Vpjyp@$d{i=vt7vkH;&Kce1FEZW&921b!Pr2;y4p; z6LF1OTZwQ^=165CZgKHkZaeb(wT)T`q{S9v#FB|9PA=vg50F? zqr2F~@t9Z9S<-lH!_dy6RJhCEW$sdJV`C)80xJx8hQHuY&!r#AKerfxs#|2$|mUeJ$jZ%Yh# z+yu6F|0v!)9x99MZYnQvQE97tLRk@9rI>e9!@hbgp!UQb-#-k+($-$Mv?B<6FGOOV zVFElS<|6sUW5n}4L3#HyBM=e7l&+mG%~*ealvES@K*z!bPr)ag)f$PA^jyT?ue11=ILnpYP7=*?dhC+!l>2%>G(lCkEkn!`rIevLWO#J$4BzLC#(rJ^g`E~p{V!N z9?=>t@Y6#L^>q>YOV*y1d|-aE>-00)^h&t4{9wv(wV?PM$44(uP}c$W{3V#zpd#;<2y zW9DxmPFLc&5O+559km_gKl0_1cLn+HJkpnm)bo|PVp_e1MVDf@QSV*q-b4LC^x;Q8 zA&uNH_-q^QFV{rR6R(xgoAZ?TONokJuW04*mKtS7XKj?1wu5GNcU;$>jukik(B^&w zjFz2+6JHf=JFo(J^FAVXvARsI*H{`|Z6OH*+lp^kM|rWzR+iN`ifTOY7}kjBBj*tnsO&BM(N%4hf=mk4+pncqM!Z%*3v9MpFO}N)6-Q z9IU8?5-*v!iNtp#&tmeOCGW{_Gs&ioebiGyT^p%y#JZ=rPrZYw+t2F= z;^-r;!2c34^tp7Wy)aL(F$_x$gHO&D&pGM&L{uTIm zCItHjMWd-(GRmrp@iOZT7KEut{8??eR@I#MpvGbnYc3^aHWI(wQ8sVrB%kWLN{p+k zymohy*8`ko`3VPE9BeD^`&dbLt&XymkR0p870pA1uPcc9BK>7J4O|iZbrwln-4SRa@^ioI?Z;FH8nP}x22^7weKMHJWVCxk&%@AG?cctT1rFj7BZ>UK%RTGkjd=# zs5O)n&NpFPA>;QjFPZr#W;w`f;*BKkTH+5R&nEI+Bd_*SOYs=kPLil+4|Nq&-$v>@ zO1-P7J7dln)-QykJNq%AEw)+>ntF&bE34K}bZX_LNc936ht!2Xv2g$kREMLrABijaX z3ZB%hk4yNS$oFHr2cpe_S@?9XCn{>&;l}L-=lfFP>M)Dr*EpZWxLC$tW8O~>BY8ud+Lsp6gSgMfIm*EO&N7R9|KrUizX5g3r=DBX zb>XX?%%aW*=3lUny1l7?7kw1cPd0tMroYF0o@0EtIec6GQQkc+Q^rSKQ}*_3gH_$S zpoQC1RQvj)V$%WmcfANNvs_#asKSDSzmavTfxIYdCR=tJ%ki`3a{sN3%(8KmRx_OC z`5tF^^TJVf?6Q-MMO?G`Z6@nx8AgFfPwWOU5v-C-WQ4Hj>(3W>QMrnZ*A_o>1~>llQl^qeN4O z7xgTmuA|gfM4bWDyNW{pHZ-vmR}DJ**D(8mAcl_-=Tx(^fi_tu?CWTv9UO0Xv&I_b$G>Vhl9hOo_% zb-kcIrwSc;OTBMv_&(o3#aKrlwe)k8zQ)jBK+8UOH_r@z6)iL^dZCPb4CS$bHJbVj z#TS(o$Zr)2i;E}G`3Co5?y11bd0$X$&pMrgCi1*(8}a43=EWdunP%c3TYQ|PGuJEk zZss+epN)(xF_$&jCh|U|g*?>NmFs4j@;r|3ef;tU8tM0;6<&bJPr0;MmyE|&SCOWq+eINC>?#$dxMC}!O7JEa)cWW+&)F7(uF*e$v2(6R-R5`t>-Al z-nKH9y7H*+-_ho>mU>I6dmZ&(rH>2rQ$=51^cO{+t?76CwvpQbO{KiOmJB=k3m5J^$8*+z_WYcJ{+4IZyo$dc5wZ(=`~vwZ*G*W| z%a?mXHer>2Ae!ykg`v;*+YyWt#yhh1vjy|lNB!dd7A;vw+z8_DAx}f{)sCT5N=tn;X=u7DDwfanG>vYAQxovRspDGqMu23{5 z^@E$qVmN&ZW=(qxOfRKEZ$LQ;U-P|+qcx?xM^mXMZN%NXg9KGuOVu}fndjvwF(Vvg zK!S~ID=?RhuZ<;WW^?%%)lgO#)uBtzmuR$s@2Wp?4TC*TU{&8xL{)FV)iblOW7$Z! zU+;_M+?SBZ_AmRc9Dn$B1Ew*qIpbS1ZGNpx?N^xS{maG?4phZROeEYK%8z#@ew(GqS_RbuG4mZVj z*5BpptD#Rx?sGe3eAvD)V=>ErKWU-VpZ)@tq2I?MS}n|5-{D z`O9(~q&M|+r!FJvE2GZA)T^?jq3odkBKo*MKf3hwlm5)<(}aG@wQTs_+QzWG^;SuV zo(OX_f0#t_y#N*oDEn3becLw}Rjn$My&KDkW38k{#Y`r3;2I>4x2r-Np$XU;2oIuNW!zABbeagkL5L@7sunW>bD(55}mAVuACN>oTv ziUyUI_O!l!FTcOeE2jAXS^zZe4tI*?v4BVtS#Gl4tf)0%kce{@iw}Xtu*qw$V;hc_;%v2M~{p7{P4;?(^ z#3z1J{TYw<^XC12pW!PS=W>ac8n4=dXICA(AYIYWTRJg$b@JgKmlB2_cbT4h=Xk=X z=+(*p)@SX#Ag#8_mZsiS<3rH*6#aety?HhCw%UH;opU?5vzoj(1-~!w{kFqU_<-Xn zcou_e06se=fU{5c0b(n-JA)n5M;$Mdx z-~$z%xu9U)O#^!~Boy(^CO>)OaCyS>ag?h@_J zR+24mog|mcZ%KM(IZ1Z^-74{q>Mlu~Sv}3}m4@_|BwV_~RDsVsKbwb|pXU>xHyye| z5FsV2nUaD&p^WG@te1$rt0C5_=m}XhBe7t* ziLl1&Zpu(&aVvL-xOGljv>aCv=LdBc5vBD!rr-^Cnh?%k$X(%=-OPDwdJn!R$U|EB z#x1!j?T6Wtt*(;kx*sIHUUp{Rt^2YkmTIgCYz)%w`jjnq(wW^YldYR}JxIyrSsh_?xfM{>1&J;J*DoF8p@o zD*kIuZ(MicB0V|2F6p4*B8eWol?0DaW@{2nnPbXAw&MGGHuJyDtfgi>8!i^I&;(QV z&RCiCo%vQWM1PUQc0pazo_rT+#O&VKZ)O#@{q4fXK>s&i>BUiR6P7xhu)U%EQ*_w5BcK7zi92@(j!_tIm}Bspr3z|!hn;KoF_jdbKOR>TkacJSnYSU|C;NMRpe*j4 zDc#QHS5N1Ao@w!KpI!I}y>RZ|^(C)zE#vAdJBba~6omz8i1!7$qI>F4QRrwaj+GmW z!T=-jH3Oe-u4stw!xcp0><&Kd+k0-iDje4Yoa4&1BYB_Dc^nG<&CflX+Q&uo-S~tl9ZF+dWyzUSRx&x#flswoToUl031B$Q(hO8EM~e<3*hz!L(lq2T)nPI>S)fO{qQosnZ5^0Xn>8RSz$ zP8a0umwuQp+c+0{6zlVdmRr1UYczLRnZ<+EYx%-+T}7AeeZ?PVE#a`*KztlOOjITq zi_c!eMNsk(kw02nco{2;EXA(E*RzbPH78_EK&iV2r&ye0fxB0&fy{-p+08k?tucY}8f7(mUGX z=gA==W!!LKxE}AZ9~lW#1ATnwq9MwD_Y`0B+IZZsw|pzRhwF~^a*wsWxt;P1>EA$O ziN*IWtWVks_JX;vlE06bjA0fVxw(?*9sGrV|E^RTCr9_x zJGe`4Yl|z$5s5se$aNk04BHa85%R9TeUa}_+Q7Yv$MMY{f_US=6s|16Ub?u3uZxF* z5VzFD-F3PmYT8g?^kldgw_~_iqG>1;)3t@NpR(xF+(qo$_mz+R56{oazrwHI8_o9? zc}q18-7#BYRUz46xrjAxc18Ypc0hb$zP3MDX0IM}HC~Ad!ZawdU;s@s)24Ea$(XOh z`du7fjyfOyiM>Hz0QyI7T*NMKsgR5~a>r~{iMRBAz-XQbza{W3fd3?L3Pe9k@sKx4M#B5GzSt71DgL{wAf9Wqafct7yt|4I-*;*) z?!~Nh3)TI@#9A$!W4UQ4cBkkFji)N&esWi_>iSn+lN!sb z4%u_>|Fn6`gN4$2WF@ITuFpEBUtl3?<5(Z#Qg-;AELq*_M`2}yDfQSGl9@l1-Y=U$ zW1Q!ZX~$fehA|BD!g*6^6^?V%O+dXR`s~p^Yh4^`dUb&{pU`J!bDIXy4qIdSfH_<-EAJTG-Q zKOK<7{bv{Aecw;6Jh!JiGV0P$KO22((f>uhm_2~r9q3lRkzx9$&YA7; zTga?0zqaZ|)huosM4S zEkic(sikRr%Byl7=^!Jz4(=`N@ZPettG>`kA1Vg;3=>N=hYB@WU9ozjnh4q4O*||w z2v;x5dY*-0OkobiFBj_*pH*7c{QN+aop38SuViz#rl zB?WfbL2G39Q{05Z)X&G7?qc-8JO}ID##qwt8;j{D>NlXTY;Ausgia~+;_Q6b2k0Mz z&+%QqllQ=P>&SEbuUQ;F2A-?nG6CO0a4rY0Jh;b!|3Bn-gggZXz47yq5p$4p81gQ8 z9LjI3bmm?vd%5*=+;cv!j{gXh6|x0N;zRWSv8&f$G37e0WhusIzPp1(tIq(j>PK%e z^6M}D^VeHmf69wLOqkCVeL|&0dsQTKREI75eT`+frLmM>|FQZJ8noPbBn{d!la5|o ziQnsO6n^+H*^WL*xqZ)3lYu?y<=Im;#(K=%u&!l&n8HyPiTZKqQ$_!N=ww1q2fELG zU1O2QbXevt6-mB#sC4Vo`Ft4m0_*hqE!PFtHSlfo9Uyvn4aRkJMnV<*Cy_&`W`Ovb zuOteQ?>llfOv~cC-5&8fM?LtkE5~_b)JJY1ZR0W_J%rABHL>ZQj(GTRh!~k;B%IF= z6`l6ziSf7|{lPOiku;-<8!f{gtKH7?C2xE4EB8E;Rl4L$+$Zc|i#%f3vSSTw;3gI7 zv)Y8-6wD-x*fq2~V-GFpV@GSv?CD9LEA-Ls8YNwJqHg1zNs2KG^9Zc(#_?IG8;<(T z=nF=FFX*U1PZ_#<-DB8=Nqbn&PWh7D0MFz@@E_d$Jl_JIQgCTKmlIdPIR?C2!Ciyb z!XV_ZLmmU;yo@*ePd%8zDw@tFSkfu!1WIMNwxK6OKJ~}V-xy%F&V`|X7pEq zyiN?Io?b-5hO8y8hW#|6=?pzOb(!RSooM;m8}ua3gVG=0qPd$r={-gl%qL;J+{cNe zsQb})hVG-!9Q_xda~*n?(6v=4WJ&O;JTQ(uf$zkleo`Ch0-gdM6L7_X&kvlx!Rrm~ z%i!;g9EHfU1-X2XZx?cY8TN&5%H(|Aru*FE51t_#TE#C!brG9q^c7#Dw8Sc0Uvb!H zs7QG*RLsx8dDVBCqW(cok%?y%*t~m=d!R0IiLny5dc8CGN^6ruYt319p(BMk4Cq2f zJL%Gxm}&I1Y!$6ocYwN|I!7&Q9jN~+S9+6ii`sU3(TqpFRD0i_a_0w78^!^cf5Ey7 zj!!~eI_kTjFB<&|%2&}y=oLXX5c>1rvjBbt@Lg8DGdTntzdu~$JHh1*J{NE<2k!=O zzXLxZhavI|exN1JBHt?H{D8dcG&6X!KK2#txXuFx;W;XC-NlVJxJPN9w%8FfSTyGj z73anqiem=`i5_cIg=6ooLNV_XfB!p>o2swigNp;D_fD*qI7ATBxDd=@BWl^&-~Fg% z=y-~2TS|5P_EF4(bJVB!8kM=b)4YPaWF6;E+f9RL+U8LDJ~Nz-eha5YjAJq1f%Qc= zz7uuTsPBos67&yI-$x11n{70n;-N1KpAz_8gzvj!t0k-`P#WKF1-A#+@Z3+_5S%5e zRfP+ zsAx=eTg_>h(N22jbe6XCa-<`@Zqi3rA5yv>NN<*elU?WoIuRdDx39)hwSL_H$LpB8 zVZ8{)wNa;V!-w{vZ#Viwp<@KSCg>XUGNx$wWWmn^zB=l^B{mYrLfrd`HNrV3S(&L#SG)L_tUKc7=A z#(rUmB*D6jTP#gQT@&j2p|3movuhow1bW+{n+*La@bQA*P54e6KZiN}nJ;+(o)~a- z1)m!@4}td$xHG{YfgI7uvl+Sm(1o5!7dhh)>ZLFA|hQuO6L*k^=T~zv;m%JdOAz?~>#{u`QB0ORln^HhIkVKwlbt z#grQMtt1h3ly;uELZw8ox1jF=pRMpKJeJ35;6Di*2LeixZEqWKxmzCm z3phuEHxS$h!5@nppONR2n}KjazW%sYx8;zmD8_ShqLcBA0F5YqeoGHQ%hg2UF&%L~ zYp@XK3&7^qk2)>X-$&m{^bd!Q1N2H?T%bayJ!Bd)j~M(m!8ZZ^y}+^Rv9`o$%qQtV zBOBfU&Rp<@g8TEC{vsJUN|DDNxk8a|1#%uo-tF^Sx%SXp{!1O-PmEL)Z;on+Nglc) z=?4C<+!`XBy6B4YHfrKmWmmCz$S3Sw?#~l4r}O;3rqYJhg_0E}m)X0Ze3o9JLJLn! zrs#qV^jCU@tQD`5Rh$>Sj0vIKUNN-!Ln0~Zq)@(mIxTwihEzvo(_QZzl2^zjXZu|0 zj`1+&J+VFj$FoqEubo00(N~Cm+1L=;3B9Axt%d$m_>6>K6?{$M-ya;Kz?0p1oV4)u zbl&mTkM{?!YE@Tp0Q@JA!x?$v(26Gl~tEE<3p8F0&>>ZwopL-`CS*$QvB~;2F3& z+-wH;%5WavLHz|Ebm9lszmI#4kwYDMjv!YV^7$gCG4gg4cNV6-D)_9EX?%bLpN|R$ ziY49#qQhZ`F#Mt?bTlMtet$*`?dekM*Qp;3|o&1{a2EV8AuRhXK>1Ps;imCGP7xFLv zN@;USDF~zNlP^?+^>`egin?&rKSSRE^s7MU5cIY{Hxl}(y@BJ-~+y& z#C^vP`wkMDj|>)>k%NULHaBg;8U61+B+=6pn8~-4WC$q3>V+W$3Jjo;`H)pzjBt)$lt7Un}?z zY5&Xoz_T=6k4-qSCut@)`+|26xSxQ(3^}GDPa1M9MZRFkK{#TdOIIJ;s1Un?mZl9AOiLc5Nou0i~jrYTv46pe8|Y-+$iXUR4((Zq~Fbb?Cgdtw$ERM z%qphStiC(R;n5|kGWMiX?}F)#LmWNXz-j!ZOd9kchwfN^BI8dbq}aEdBF!r4;`M4e zsQsNb$=1>li(35rYAGD^n^qV?`S{AUMDSl3v$FGj~#M(Bi{++^g-V0&Amj$pTGQ4a0!>l z4-h-d^@aMK!6F{t9pv}d5R13ViL)wS`LnNqJo12qE95Cios8tz(^N0^wzPwlogPX% zI+oFg)|1pw>O!tR{V8I=BN|IdBzH8Ow!M2#IkumO0=`hW%{MYJsV3g=oi$fJ2jd6KmtnmO$A_UV5B1a0_XPd3pd$-CedwNpe$qc5_}zr>arh^KV=Z|0 zg6prL99vbSAWc0e;p4%r1%7knXhfci$o2C!{(X^C5qUqB_7(SKx{6MHs<3Y?KHr!b zh@p7~xKCF{{OzJ5mK1gpnlf*A7WOvGIM|J^yZ4`&Tlr!(cXTSt>D-qpPfnxHKX%d| z^DDHV@D4q<38$Ie5=h=Yjk?KZ)4i|)8gl;&O*E(=|4lV?!MTpU6*tg^lTGyMYBPNq z)Iz(LwETa(jrnw}zs7M7)NMokDD)Yi|0#6->GguH0rZRE(+hq@@O=RPr{K5;o|I9k zEU<(k&OOLG*;rNNed{hBZg1oV=M553 zj}1iSF@13o-=B9M)Jr^UZNNSCcn*sDHa_0_x|AdtlBab}?9|>`*7c16d0$vczp_tJ z=R-H>y32i9dncBToD$R|c}rhA<>Pg_gq{tmpyY-cnsKe3e1pV3N1vMuxibuy@bg1$WT_bxA?3h4DFXX$0oI%K&F-~2a2xyw_)y0Rs-9_e!BK|7Emmm3S%)e2268~e&q*bxZ)37IHKbu6ePVXQVdxhH8 zd69o}ByCv!oMMk>(60lzWNukZ4u`(cw~8A2{=1%zH#AY>pBCD<4p}F*lQglNzWi;c z%N+0(@S;uLZtS;lBqQa?dAG z1h~wqW0}fdW2P{oJn3X76P^kFyU6hxc@`npGvuocG7wHVy5eiQrqG+xTTI69$Fo{J z@mbD5Xr*e4A$H2*c;;U`Gd-QFT3_Nzx0gv}Rvwn<#@et-X(78-piNg37SZx0Cu!^Z z8*^_5(oEkkwo=vaHcI{6PHrnYXs1gD znTB-GdqM_UWdp5m?Pb~ai!1p5jUBK}c zJXzo>=7sDvIQOhRELpj;Ou88SrpRHBJmZi{7x@y9vlMxKw+|5VUP>bBO;@qgQD4+% z>4`I&v;>P#6zPifc&=y+|E{r`|DC!(DmmYUDX01~)1h6+>hV}QAGMXTnl4i<-j~g{ zctGZk$rQWp4b6O&Pa|5t(p%+fO59pcGA7OR^KmO}pW9A7^gHOnxej{n(Ls{S9i(X8 zK~}pvs3*pwm}g@>9LIm_b&xOWJJ45e+)US?69+vt=&pnQQ}}F!-xB!hKK7zAa0G%U z7+enEOG)u(v%y<4eSy?mb2X1Zj#}iAAlEMB>+((y*WT)j&L_1+Z;tDxedI)Wh`tDJ zA0!SkbuqYicj0{dGk1u?vo5;maBIIEX7j_$*_Hw6EH}O%l?KnH1!s@bi{3XVXGswG zPK&1K%LjVat%RnSR??eQb=31o6Fv5BrO?&w^l@|tweIVnzjHfiLVG)Tdbd;Y z`gU46tDPz_zQKGN)`!l+|A&8ds1HG(C;F=%H&F!iG@LuoK`wZ2{mUI19q61g1;C!B9TWAxy+HT204?F_m>IY6Zor(^f?Nmexkm3XpQf6 zHYp3INq@0_!b{$DBlb2vo+i!N7$|X;zsB08H?fx|hts2*mekbq67_4oOTBJCp!Vld za-NYz(Z-)C(X*U3B!0(x;zkOb+Dfk`wNw0{4w^l^gKP@g>ETHHejjP0^6XYxYt>3w z(^_dK#x~6Bu^xxx%TY(D|JU~%{g0tD5_%TUy$Jnk_^gMYOp=uTdOo18;3xpkb#QG3 zpA$IefH!bcprqI)O*$1h?juhma{b<@EEdiSB0juJExU z?z^hvE3Q4_uM;eIgIix|nzbeyDSgDYpI4+NO9{E`K1|iaUFex}Ao-k*r?iw;RAZh; zNd{kOuT3@mINLyZjxBVkzK!PnX{YH59hAAdoi1%{qp_x~WTwSD$8?&A37&)l== zHve@WKY5S}dlqzdaVQP0M;$sv=w@Imh@OdS^kX-gxP?~2Q zEj`st3J2O~{mXXR_qCnQs_qQw(&e5WJhgT~kwM z_7pibB2PMU9Y(&E#p=QodHdqtmCU8u;+L$Nm^>S=1^4isX7yiwZ`(^wL1+2XE^$)R z)Ktm5zc-oBG8yu#8%L+Kc9X^kCmNG@kLr`3P+}AQ4Mu*X>!(WTjLdiPZ*8RDP_Un9 zr%fsCWLMEfzwkRQr`=3{-!+iYtvX_X-|3h^4ZRy(OaK59Oc40mf>$*7JC%b|A zqy9Jg?xEirIzG@#gKi4+C&4Eje#hY(0{;$hECP=SxE_H|t9~4%fL8+UO(IpIi5!N= z^9;FWBHzmZWibbNqt@$*2^l)VBw1bLEz}YhM)VTLL+ZI{%p)GOWdYxy|2g@&jS&kh zd(LdrRLMDgK7D+7nws=)k#(O)dN5f^PTSwnsMAHHbfc1zv+D^DAE1dccGU~jxjQ%8)(JRcKVtpcxZ$zC!U_Du) z?=$*$L8nIuULT=b4E^))nFGJE@C}FmQ*bzgr=P(s8uaQkJp|`z@ID5&0{A!UeNKLb zJZ;gB_zC1&jhsoy`@>CF_`Mq>Ive#D6Mfaie|6o(^T&le%lr-$ojtah0|g_|cwjk7>)4S7bByBi%MAqph`gZTi$i)n;w9`eZvT>1d-V z{ab06c@sHS*HNbhH8eV@f}WizqmtAwG(WDG0{0bB@ZUn5%PXP-m{(%G1IGxzq$28dB(Rm2Ot5Ax}o&M)A)L*2|M>5E@^lH%#U?AQ73 zv~c`%>b2$wJ<@ig6D*7*E=lw;E{j$;7Ez)`6)9h6APv=4s?cwzQ~ld1cv~wq9LE3U z@Hz@RP))`CzR{!FFQhc8h-RpMA|2^RdT5bH{c>{uKbm4b9_yoV+!uBAsEy#yFW#C zUq%b-?5UyJi;OH{Xyd&!TJDoao`z*)y`+}hmNe7L<89vA|g5_NA;KLCBZ(eDSH z3D8>x-M!FnfzKNF?SStM_|F6ffu|?9UV%>woL9hm6Wo>Hk3bIBr76j}$n^mEE+MB0 z@>*9ZhSXwToJ*xNAENTi!;IvCS0dT~9;otI70z8Clg8QQ@$B zTJYgL{T%X^l$T{t*r}IfCVfGjhrJ*P#*3J@EzO_|9AAw(Khz&Vp9T82LMIe@8PHXS zehYl2!|xP)$G|@b92dc}4P5WR_Z*xP!21c@vYqSM1>_i=J6N(#Ta9z%Ye&xa$Q!Mp zA-bkxua0&dkk1{Ei|?_j$33YL-@}@3IZ2nl-y+9552(@* z=cLBu(gdecIx?-6)*o!9Z+Pz#@~Dl{@3v6Lg9eI0L}wcEXzkf|yy=9zySrR#KdPYu3iF6gCJ>~Y#h`91>Rr%hh5qT#@r2%M=vF}gE_{B& z?-+a+!hbF}27)IKToK@l0H-^63&C9iehuW%USB9Fw2G0AN4`Q_w@`3cyYx!?Ru zYG*3RC#ZqM+*W!aZKLaFTIp}+Cc2jKoj$JqM$ZQq(;DAAvNp`3HOjB3F_7c$(=&4O zdqT@r#?rfG(exqc5oKUpgLw$npX2x+)NMfhY4jaJ|6}MhL+>th$8>3;!|-W`UkQBO z;cpC%aPYK(YaRIV!6^l=C%84ie+M~^BadBOe)0@Ren zj1R=#>UWE^xb7ZJ$po1*?2<<_D}OVVhQ8ZJQAMsar7Vovg_It5%Apl2zEVH@ow_)) znHJA)qo@&WM0=a*%;h>Nl&hqY10|F@=_5Jkyv4cgH2PDWMEd_dq3gY(Y18aTQhgap zM+1Xtc6|^X!8jiCvsizIug&a+t&M?Kjnv!+Mr;eQ3$h*N#UR(;*5V^B;L|E}Vu6Z5b z12Z8_U6U)>*Lt5>{Z^!V=jYsM4r`3j4NF+QBwD z`K*OfZ5oJ;sHRPO@Uw}{r)l!z0Nb_0i~iiT|9Bd}bs2n0 z;QW2>E_Dw&OEw>ch@5AUH$ws6YgDNU8NB}I-hIaV&e*^mHY!S| z%p1-6=47(7yL9P@sU=;II*^BcAVuzdM&fT4-8xZ1DhV|-B&UfiySI`2(^it~Y9gz9 z-|67bGV1^96AdxWCgq+lDW}tO^4=9gQ{2NSF)4uZG`(nh=1uabzd@3lE~N10IxWX| z6Z0)tPsj0Fs2h!XNAx{JzbtfAq4($KCz=oa3-IX;zvg(nw}QVKIJ$wy8C)*lyAIBa z;B^G|CGhV<4prpIL9V~Zmx-Lukhjn6AKZ0!U))=$CDMa}c&ol4-;h>krkiNX)K0Xp zRh`C@dFCP773NMyEuzTuN-E{Q$RmgNa$35mo(_b!P{E)!8m8Mq^TyRvP*Vk2<28F$ zLJoZ@c|~p?pOc$H3@ytIA=A^o6vJ*&;zAe7PIVw-=_PVne1SsF*i!&T1 zQP&gov(e{?{-@B%gx+lEE`k0t_~^h-9lp=uzY`quz;hH_f#4ep&b{E>3GVISUyB@j zk%y3LwWu?jYGBAqkax=ADxPhmATCbP6eGJ_=RY5Pk}h%;l4k({EO%os3JaP~Q8&+% zbfpg^^nXJ3dao(2zL18qYC5giMEfFJX^Lel9SmtCL%kXrxBV;C^!Z2&VlqiNKbg9; z$I$H~A*2%KO>+y~=oDR}Ah`>aa`O~jnsb!=%?{Erm;Lk@<2cMiu)Ye%ol!Rk^}W%@ z(LV_~O3>Q{-FoPcg3oaH?T4=x{7-=63U~&BD+GMO;7kSY6L3EQe+qIaZ|X&NkgL0k zkjNnCM&vE=$>TQ%brXHRsEKVm_VS(W5^3UGLw0z1IvXa{C5yQmDcawW9OHxO(eY&B zBXg*Be<>yP#JLT%7W(v|mAs!e(>jYf3LW!}io1QL&>c7rP$wu!`!P8k3? zO0v%`(^=osWD&|oDc>{?FR7H1J*lcM$mZBgZ1-S%F+zkOb6~B)W`o2j(eQKZE0msC$h1IP{sKUky4H&{KtODfFG- z({m5bEyMR9{6oNT20Yin)ds#$aQ*;qCAclYzZN-eAWx61MplD-{omM`MIvuhWhBqT z9_x9(6vfCdzom=g?@Pw?^=8>^3RF@upN=}(lai7jmG68?3lrax(y@xZ%ucOT9Tvde4MwR zOvlfTpm}!(Q^E8>H1UWwMPrP_JP_-4IDQ*-zfkXoK8F5a=q!L7g2A-`e6rx21>Rra?g{=w$Ptb_9mr*id>4@uo1jWp%iiKO3yXNTm7Lgd z)n00MQJs~XPGK8q5M>|QNN27&(X(}-BQ zqEO9ZGTNC<*+wb!t!pfmU%gKsrg+fWiF0`DbZtR2wXR4f>-f*KMXs7w=`~TZNh=xSXZ}91mi8Vj zqfV9|Y4wzJT9fpYR$L3Etz&PK+Ssd~RVA6hvgGljf%#?? zv6JQR*%`Aewguxp%(r2^#z2-DmZ%c$pQpFzdw*&*)j~%Jdh?+>6#D1kGZ=nN>p#*~ z_{V~y3wT1oH41!Y;9LvdKj01ke=TzOAx{W$Ek?c=_l<11_naiB%k%h*m5+IGWGgqG zVJpen>CTF?dXTl*Jo@2cPqHO`6rPtraTngvMfR1Z#MRQ`+-92pxrO#mY@{idtLUa( z5p{gXqCrcg)DZlD*4g?{>?$WZa>$nE{aQ;s%cjw6Z+&t-p-2gvn%U)7*{s`&c;-4V zh^;Zc%X(b8#TH-`1A>@zc|1#gna%#y^+Nq5^bJFQHgt|d?>TfQK>r7PI>WCHzK7v& z1dbKpnE&U`x*EkJuODA803pZP7h`)@u^kfgI4(QeUaEVYF8Il zbvl7rbR9^s!jhg{cA`YhFgk_T+!XDPUgJ-iy|_oEhC6V^;0deo7XO&NYBd9?KE zOR{+Un0`0m_rB-`708~W9&wwf(;*2NX6RAN;2yNay_9`OO<)PCci32;vusA8CCikX z%XV-H^Tv4flO-F7bp;$Ze38K1-AY*r`c9$W5juL%`vcwVfBNvTgx@mw4uJnQa1?@P zBe;4+x6oj4hJ!a0+$aC>BgZG?u|+OT7i! zN&Tci$<2@$370966kZ~2R>tPhJjZJ+!5ekh8z{hGZVRNk+0jHudFNb?#X|j92B*Y_j{O({cNwW1zlz7 z`Nvt*C-OYa&+wz~2NTK1C7axLm6EYr9eMt0rdRHg*5&JH`+IaLj|?a%{Bb4EXjYA^Ze?fq^i z+YEf4T&!_BIfBVgU-G)2B<08hNj2(*U)#abCI>Kg^!J0#An0j9_dE1;;WHS1Y49z8 z|340E@QeYMEBIW&ISahk!F>(z(oP+ijW)`er=Tb|quzx4u)vK0$8`bj_fD9X=WGON8$! z`1b@yKk$@+>lpYrI46Pk47fAFKMXlck*5*4z93&EaxOw%ne1$d?Tu3Ds&4LFOEH(t zS!YargZEQ!%bWDy`)JB-&!B4sMf3;X;jXL1`K+J#&i-OOS&aBbQ=j28sp3m&=n_jA zHokOrj00`uyQ$w0MuE1P^s=a$<#Y;So}HI5^$Wmwwta#mwwymSnKZGDgrj2few_Eg8%x20jbn z$KhKG|99Xp0M86?MS#y7oU_2IfbWj)fd3_OR3gtn z=QfM}F!uxr4X!#%FF*OxdzOgvXxZe~xs1%Z))RebrcRMfWYi1i=)+3Lc>OyvRY;5OQ>4$JLN|-(J=5z!2J~ba>$X2Jg<>!0P<}?&RxhGYI%%l zu2eBAu%F3Q+>{8-%j`{oj!g(AKTY~ z7tF+SYmO%K>W^8x<8}d`^RbXeV4Rnqg?kZ_dDN^Bu7|py3G=xo`bK$oO)fDkkyt}- z4s^dj-vK^H;P(i=>)@XQj&0y6UQ$3x;2Qg^$62YYd=$AhWQfnD_XraSHb`iK@h&Y%jpVhT3;PO12O9f9v-1{|v;y~|(e z+i+Z)wlbMs%7o!>gB#6kv!UB5b15mmKdoI+!kkN-*yNjXtgnyS^o+Dh>1?O9{Lw>C zo@xA?*PZ#q6rF6fCv*J&BUsPtj6!A4*%CK$Z8hDNwzPMr+pNoMkgjv}q(=rz+ZW z?lW~U%b?dOarC~oFTLuE&vi35(5+Sz^4`#i`n(Bdr&3K=w)zLNWohrF(WfkVw_rc6 zxjB=YKCkE1a^1w-{63=F5p~h!roqt`P46Zq|GIKr2dDksu`JXF!7(FD5^mIBHSwv=dt&4DKBJ=4@MLr ztl}H;9UD*e(S8){a+#V=tfyi{BkH`dfj!T5VfiT)66>|2rAo0=xVoY@=O;dJ`y-vj z`b}!W;OSuT`so<)_SRH!``irCm`7v%5{}P9of_&-p>L1v2c88T z1L(!CA1(C}6_QB!JloK~a^Snxc0GB4<12VF!F2+BN5OdoygR`i2L4&du@8AxBUcRa ztwc^AL`CFQ!++ryu;Z;JX<9wGtbW15aOY-36ZwI6c8T5!|D}{}VahAx{)?^+CQ!_K|Mjy@O0`1FlhOBD!2l^(J4b{oFe`cq5Tk zpSed9wp}IpJC<}}sv&*Y`;8gwKg$vuu1P$*WJ<^1KZ9p1rE){jN%Xp@DSjLpDfE4q z__KAf*rKpr9Qa@*Tw8XFd)N2l@u&wx490rQZ(zNX;(DQCwHSK`G4Tz3%^sQpJ9Y?b z=q-Y7p=_q~MbkCOzI|tzD}0B;|JQ9xG6v5Sa7_bW6*#|ucNVy(fxjnmJVKru$Tbi7 zh9KuTI-YJSr&{tuxCpD$|ODwR*R1D8cZsYrLJ%wJcQ6lj3 zOtGu?N-?ivt5{ODU;OQ7Bl@SG6vJK5h-&S#VkE|sn9Hl%h*BIMg*pe+JEAXNag=zA z>lJdLw-~x+(9gBil>X9FVY}e_yG4OMfFl$an<LJLK>| zo^Qx?0Qoi{=ThXoJas>P|87W|ZClSrU!+&!vnB6|{VJ1C8p@MAj?6)5QOZ zDY4--xi5K4A8^g#y;)}|ee)cu8{dmgYzt!tY&x-vS=Xdb58-*Bac_8^E2dfzg@4}(y{OxkITQ%4PV2oW1e~ehH39cZ z@S7mVJme`vu8GK3jGVs6Yv*>BI?GKZ$M4_SDeEv|Z7(Q(|7SY;p_=6G;9TOv21>+t zYfi88DKsFJ=1z#9);BJsJ98(^4m74mo6A{a#dbDno`;$3%u!tEJ>(7#{_^*Ob%jC1 zbkXg}DzQ3vFZQfFCGPFNDAayBh*xIU#n4?hM5w#Fh|c#AeP`Shff)Bjv&ca1!$9%RvABkGn-3^j<^P75WnRM8I!buW0GP!X`huBBLyH=Gx9|t=LY1>>*7wA&8$d0a{zgmCsO;J9GWKkjg+3&)3C#hl)kfu zVrLbRa*qr;RQZVf#^0ne*F#hjGnQ()*Rd6*d)cjzQ_a%;5}(za#3zLI5Z8N{h>95t z#M8*_*e~a#Q2u&Jyd3E)0z0{jQI|c1ddFR1a@4D%CHhxiMJi_liM*m^xe1u-VnF~Y(^mXC$1%6#S&ETrN_ov5Iv zHF-1}mjtLe2UE%}2U=gaiuA)YXw zWgi!@{V$0XTb;$vE*@g^#5=-nijOd#?k~3Nz9)3Q+!xJ{1BLc~fuath1oJakzlq}) zP`3y5&FIrX|0U>HLoWcjx~?RSz$XiS|9mCh$V0C46=GxOgfSHj(u$-(WhFndGM7Mreu-l z+<02) zu>^VgAeRF29Y9WhFySz?aqm-y&Q$s zb$79G-d(Z5-cQJ#xi5a%1dH2#q2i}rxY(@|A>Kzuh^WsIq7jwWO`8esaaeY`Xa7_=>qf~IVfY%hHh8y#`xE#(``~XL^1Mba z6Xe^7oGr*3Y?DlTlKg2~;u#v`Qb=uEYv@!=15ManOaFEHN|McQDJ?6G`ba&={J>%Q zzIG(l<>#|{M`JeW$~{Wq_&8YXTiE)n;F4~e61FAAk+F5>v^+oBS$X^{&9 z@pnE{O#T`!blyh_O@jx*Lgu09fBB(kdHYb9e|soovmT1Bm@mS5uKt7n*Y!jFG4xfS zUvIddI1N30=-NQv1wK;v{f4h1{1<@320Y|s%;XF5*+X!efOj~!pMrk|a(qXgImmSf z`C5>381jadrO{ZgFgkq%*9@-xO0iGze|{sbtJ_pfW7dDBCEU846LWSu2}_GxqP&~0Ff6+-K2E~# z`^pG${Y;b?*ziE?&3`D2tR4xi=trXN<0GL`_DHm0RKt8S*4N;;3F^+FUU5}~keeJT z#zUtOdN$Ctg#KyxsC!z8V)*96e*-u|9LI7sa0T4(XGg&~6}%h3eIER$k;4Re5|Qf~ z^7$g?8{}R6@(ul`6+_4W+@Sme-{=Udr_-Zw9kSgwx>ost`rLp1KaS2juBY#h160A0Fp%&pqe; zdOlO1&%O6^Kj%ubZ1zjZ${VHc@vhQ}ryu!~U6c9H@%HkQ4Lih>tv^KCsqW;K=uXCu zSJ6O?tz0@IaH7+fpO${X^`Igau>E-100NxfA6x*+YRykJCdHh?6 zf7#epZ&yLvuzv`~jl=jD%zKRaHK_9e_5MTM&Zyr7Ig^pM8o7^<{~jE%Av?sS3HI{x zn8|z^IP1Zi3hqGgo5LdwKJ($V4t~?%c?G`9P0`o)xO8jyUa9rjmy+$*X35^>jdb?J zBdK@ZB`MUQ5^Gl^Nbw&QNv*GRk~Y;G$4S{`*4WQf+?X$mvJXZiW^fwtdI7ES3nRtU z7|Pt7LRRB5XqtW=jj}7E>rEwOlwU@Dv&-p~c?D@YSJ2xY6=a}WLDHvkGJS>rceLHH z{T=-yu>U#64Z(Ob%*)06-?l~MfqEvWn~3@@$ni$r5af28$!QNbYl70=(mb9lfNjI-GOFk~Iq(Hm7 z(zNW;(kROUslP*%G|Bcq$veB1G;l*WpQW5w)tnY0Vo!Y#hB@7+seUrO`?;1zD7Mk% zZ;4c^w2x*T&LPv#LUK4#LI)0&QR4Y>>f}&C;r12O`ExmSO)aMvqs!@_K{+k@Q$|H- zo3U+%{=wM4?B6(y|2J;~=BuNQCh84C-5k`vkDS)Xdx%`ytCO)FWjFEw&qHvze@m#k z3eHgQ+JJiy_@}~S6@0wmP|s@h8!K_{ka#8XJ;%TGjK$L=VW7rIP~

5a7L{v$~* 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

S~LX&^3T@#!Q}SU=3}m zsiCsZ%!z5!|F5w3nm+TEUCnGN<3?$-&%|QR;C;)%GO!dZ0rNrL)N_Ih0JlFNpOwpR z<=mkK0hRS8Ztpj_nDZF_?_m1v^UOPp(YE`ho-vbW%=B|t-a~SMY#IBEl3U0SvF%P&KRN38rEj8lyTaohRQri zZ^ez;YfPO{(_@rxv5%NNsDD&%Vi}QVKX+s;NePmJB)|p41-FVG48Ch|ANs)Jp{4h1 z`UrN+8phbe`p1AiZKJf!EYnMk3Ewd>+QjtI<~&nlcC^21Y?Cu@8lSUogPY(6xB@N! zC%41FaV-)+NqpdJ#i%YIjuoSy5F`SO(>5{FXXY?Ro64M-ZSqFrSve|a8Q;vKat7l@ zS!ZU`HooaKv(3AV^VS~)4hMe*AHi!-`j^Py@}Qihm7oSs*cAblF=KndR)ESlRV-yK zbEe14u-CBGj<$TN={5cABW8}-!|Y<}7^lsCvm0wj1xtBj8Q=j5gWO=)=v%>~Eyh8~ zSPSnMK6B%3Fi)TL#->iNU_E^@$%+{@HAXqdZcl{In3-X0;M3p&I1eJhC2-NQ>0bfNodZ#Txy#@zV4iWtSZDeu?+nT&G=-=obOmA%}NwwY_*q;F=JJIrpJL+GCZ$1EIBKTvAk=imxJ0Z*VUyNc!4 zfYK(FZFW`5W}iLB8s-F;9A^^7snud&W>rVryun>)vzB@0IFm5WIgFXNSwl>oZ?lHJ z7bpiRg5sbkC3-A*B23}dbwyE8IgE)y9 zqi^CS$60T!xIM?b!5(KVcQ85H#^)@?>2p`(v)}B@oSDZMeJbzcoln6-5Dl(@B?;3w zmVsZu3a}C^w{+DX=vphb8fx;!USrE~4q*-R%$Yjs{|?i~w@jVsH!)MgINvbv2EsmL zQ+eZ3umH>lv%wVba*3lQ(hW+w-Q*)Wr1dag*bF=FLww z+vJV1hIzs{W_Np@HE#iLqw>z@;4!!du7f5&<#MzE?JPP$JAjUs8lT$5iqr0F*{r3_ z9JQ;ZCeJynC(Ie8ZRiG@{Zz(in|WprGtbPjze$^M*4pdnw+Fn9H?{`NKqF8OR0GS> zxjR;YwO~D12R4EYmQ8;%U~ZEI^IL2_^IL5(`Z4qD*$z0vo}@_}TvChmgwdNfvYguP%%r5keZQe3(Q0?!qj<@l~Am9)7fh}M)_;$UV!}&%z zhYLtzkr7~ydOf79w&l*#2pK0HjwagjiUA*rT;Jr`4ZEz7(-&xa96Ew7F z0wv8XjNKG|3ybD9yQNiUVpR6Bp0?p<*z7kxl`$%37@KN*V{@MAH9MFXYYdE;eQ0y{ z#-IVH2Wo@LzzY-ydBN-P4IJ;mckm53O=#eV3!H7lgPj1lSjt=?KxK?11g3^L`thw8 zZPqZ(*{q{Y5(9fLbH=A8v3&C;^MtcGlX2RtHScj>#;LsN3-}5?0N(ouTnA@Cu8Kc9 z+(0o<5|jX?Kp9XLlmitk-xFHiicy)TRsvo&n{|va$6jMIUeUtDsf^jxyuo_DPi5Y` z#d@=s>0{2oJa^+g=BDDX-9c{P3X*}(6@GS9J>AYx7c>VgL2J+gv;u7`zXP-#=ma`~ z_7-g|pLxa!YnZ3aJZ;8kn>n1tIF)g#v5n6?Si>DSi+RR4%hXailXdK4otex18Kd&H zCZIm30xE&RAPY!RxSJyr$O5u~Tp%yVZzCV(lJmn z5{w6CD? z_)N=Yd_G_w)!6K%%{tm|-5$`)o6xJ-j0VI6wf{2R4GmU<&94p8n+Rcn#iyPv9GHs`Vdyx9m9Z z;{j@1%che5UCz{S9_t8g6Q|PWY})ZHrO!Bh(`VNo*vrhY&ouj*xxD2I_zK>GH-Puv z0a4&MSdns~V?Edewt?Ni7wodw3*8U)*)Tqpc|S|{ST=puGjDRHhczZ=pKZ69r*G8c zjGDN84sFin{;Z>tofaFRymL8N2quH!pu*hAj>_OCi@MPIpaEzM8iMAQ{WG*FXlBzU zu$iYaZfw?>TIP&T+rWC$XJ(o@zHRF4G1f6h%zV>p_B6fRy&k9yY61<)gW@0`$N*Y} zPIYtyJwad49}EP;zz_@CqrgZ&n~Vm-!3e;(U0KT<<967`I@4!tQ$ydt8iRS;&cy9H z8Sx1=CRw(rVcyJO%+%U#`1H~n zf|+0*SOAuQrC^1{O6Y2^%7Q*&p1Q`u*sL`$w-zu*HE}9qRMv2|QS%Ocd!Fx^e%8>o z$Ckq#v%y3#3iJc*L0zyX-z-N6I1EmKbKpF<46cAEa1C6yl(zA&!X`HX z^LE%r{{~>*)ZDV-%$XQ#%v`%|YGTItrl~jYP>I?366^~g5}XFd0Pp0zTfrhQ5yU$@ z%aIyn069QjkPoJ~ACl1LMJH zFa-1koj^n21@eO<)A8#B+yr;QBk%(J23~=W-~)IIzFEGBeS-fEd;zps$C}SJpK)Sh z>?J0~9%AzLxas|B)tf%nGiUap&pqA)V{`Y{;0bsLt^(f4d;P##Fcnl?In&V)v;mz# zFVG(h2E)J*FcORaW5F0O8Zb88^2b}sn5h{Bn?0;GJ&e((8cd!(>rCx9*rt{-<1=RB zjG38cN4`n8|3J_ObOUWbQ&0tz1ld3wkRi(qhZ`sb$^b7=71RWEL2XbUGz5)66B~^! zyBV~p1#4(C7gO2GxS7M5W-jBj8vtXQepAo>nDvY?Z)S1ldY}&A4FWttDc}w=0cY?$ z^9;usuetXXJu$Fl$-y;vf1Hj#HgDc=1I0_DcZD1)F3)+FbB_}%$gEQbdxCfqs7vK$e z2R?#N;4}DQ+4O0Xua+|Q9en!(-_*0#)Ek@i_88x>V`ehOI{Ic8Q}-S*!hNYP!4trH zu7PtP6zl@4z(mjyh-18?G3Wq#f{|b}@CK6rKAUpP1arVVfZtIabHQxOr@a6$KHrM5 zj&YMSK6?mfnR%>J{AlDeL-8$4DjBiu&a$Z;K_|dk(iLz9 zZBy6W@;Qs~_LjDSni|evoHcxh?=wfAO245+Jt%js49WsGkQIE!eGdTdodRlrcpyWw zfsV4EF&F|Sf?vQ&unYKt0N@WoERH}=fC!7@&_f^?oCVB>1ICVlqn2%yb?j#iVGU=q zkF$b+A7FndIBePU&0N+y0CzY5*s~XK=Y3!`SPrIxp`aBg4U&Q@O$R!bfZE{DkFJhq zAd^!!M@di(v<2P30E=0m0D2Tab?&9!24pfdq$nIqEx>n8)| z%sj^Fb0+(!oMU|M!8zP%BA`DCj0M9$7tjsxp3;E#y~ds4zz5U?PM~}GmX4Jm96SWC zLGlbO9hpE5P!JRam4GLxXyIkW1X>eN8PkBZRe;ISR-ihl3^<2%oW&ettYr;*s{z&$ z)-hMrQr0kL<{K@ISaDDSxPdGnJ@|loqCqHF4SItL;9)xWAjQ1ej;f$5m;zRVgWxbY z3vPk?;HAYosNpqi@)mpq-z+u$d&~a}{Q&4QXYZr^6|kQ<)^NVPhB15m6MWXbvh+Fh z0eA?mgKOX%@CUqS4(JK0faKuv+}e&wpd@JCwW4Dd*aE`T1N;OUfu^9gMO{l9K#k3Q*0P^=b-)=W&K&0v##zT+^9J**HSY^-1JJ1I7bEI5+?f0)OBDL4Yx1vu+zJeN1cBfHU=3}u3$UIs*0GN>Y5M`b z%^cy|>%cO=U6+8-pa-Z43W9hb5;yvQy5Ms%+y+)&iQ~8goTK76N`jw3e=r8j2OGd< zu-768dH{q02RH<-X+pbzK-+Jm-$wOs&X4FLOD(+e>FGvI7$ zSHL;lE$H(dQ^WV`fXcuN6abk(LU13u`+>f|9bCYDwZSReTN(_%buoCyO}y*7KKLW( zvul0uNswdr`rxi$EZ7OogE!y>NU>*qa9WTKqygDM7LWtv0R=!Akl&&pv?!qF1Gxa} zsEnnz?7~nFz?vd}H5mctv5xP!1Lo;x1KfkL#Na!2`v9JUGvGLw0|tSt;2!Q80v_^C zaO79?0k@s&gIyc83$EIzU2v|(?ShjwX%`#_ernn-cmQ|;$~J2k+z$)^zTgr#3*Lj5 z;3N17UW1g)+XcUaz6C!3b1sO{XZq264cy>J^-J=9dOg~Ih*e=eihsX zPrzw#9vlSwz*Nu;)CTcE1a@x+R^pyapdIcjjXSg9-fQ*S1vjjRo;uj4V|388F3~{^ zyF~}p=@A{YqGxo_v0l+ZO?yWN#px3r6ah;1jSdanJ#*1~b7HuoN@` zD?v*zA8Z26z#uRROb6XSVbB@O1?*W1CV>f{38)L^f%;$wXa)KK_EZGSrv-1Y+k8*~ z+{3;-!3FGp8g~rGJ>_thH}0#7JCk*a4rtb;V{3(w16wPs zUa_@;@06_-<__3eAxD?373w$NTA^KSP<3mCewDXYNLdbb<)NjvR=8bqYlUKEps>ez zY^~r_0=>ncMNyC3xFS%*`XLrl2tCl%sH+ctgFP2HRT?pn5;4wT?Qf{1)-V1agL7f-?)^cuoerKv|F%xPk}s z<9H^9K84bL4kEx+@EKeH7r`A63GM;r9)p|U1BiouXFxv+JOO6`>s|r&yar+5EqDZ2 z{}oEo!R8KCKqoLA>;;YdvdEBuOdx6>d<$NKC*T423f_Ys;2mJ>H*g!g0QW3q4{hdOg8Sem;G8#rwI412uBEK!yPu(P zp{YO?PztmFy})|#1mw2viI3YBfik%B0oWLQ$aCc#+;;bn=P>Q;l?|K}XRk@121pHF zD8#@XunJ5AnyZ%lUl2KNtkK(@d}wYzGnG5lD_ZYJxG~ILL%M$AasiMP+BNOQ6GfcQ0qKH`3j! zGsp+topbj(4mN@bUVcY|22h}kMNudzWNC3IDGZ8Pl!Uqi55OGjOIyB)QCUx| z2+TarDFRr}Ijr#lo}e0N2)cp3U_4j_BESn!5chNdz92sC>;}$)Y8TwSP6E%V^}V8| z)c2YS%7Ay1>w5(OAJ7Zb0L4KbkQ~GX&fxPT)LJ}%z5w^ZQ}7Zz0*wC#o&n|=dv4jU zp&!9xo6q`tfHU3#_7T>;0jy`7Grxl`fV-px`9Vog6?6nM!G7=wc;K#a;1*E0cRO&K zR^Mv_C|-G}*I_->s|UyeF3C_YAJ89E23dd$c&UbZ-3FJyMQ{{^f)L;j0>NIe2LxO8 zekgOh!9mNW9ssO~uws7DAj@VgdpMVI)&+nt!1w6WJ`PTS^WXt^1rl5PmWH+i^Fb6y zZ`~ILEsT48K(;DFy%qsS|2bYWK`T%UBmvL*&GEVjLcvb32FwT3!Bj8-j0VHO2rv)~ zvvd%YaVq0OzyM24j5-*wXDG18*<*6FIg9=%z*_c<1bm-+Oa!xm4_FDdfdCK*o`Mv( zrwC{ccxN#90Ll%(ToAo>otG~d4H|#~ATfBeW}Vk{a0DC#JHQ680xSUw!5lCjOb2r< zr9B(W1T(DIRLh?Pod)O=)*72S8BDQarjB!Ho0(L;#djBjWnewn4gx?pxCCBIaQ z@B|e=Sxad%RvwfFjQ=mT?mVie?G60+z2>0|Aw-lRvkaMi&iN!Il~mFtgl>{dAsI3x zRLDFJ6`4YY%%pRUWR@Wzl6e-AdAR)E`}aMqW&PIgkJozk+56egex7x|>-+uQ)pZlG z6WXCG#97g~h($T#t~eLp28c89-3_9qh|X|_Cq!Q(Ao`3(91i0)#5`iI0a%2K`0FS& zF%jX3pJf>pkh|%#ydU#05-w;9D^$ea__6V`{24Eij~94`CwPdvdc02*?&AE7q;Wnbzz6ndj@mH6Z>^;9GxG5Sk8mHiaR)bX1KG&JRXtw+BZ_sAYpjK$J4O5A zzStMvV*US~i+6+^+(I56;31yl9X>(yTaLah&qEK|F=)5RjoQYgg&CNse~UO>C-y~-_!j#`#GY7-^P;;&vFJ>ki}f5t>cqP-5PdAg zYQ$p~M86uY;t_tL5_2_&8v+rDI2=UdDGiJ!D45*9n2qDuj%A3zIE;WV`obMO;D~OJ z&;#;0#yv!2k?L2xed@ z5^)08A?B;doK4UTeh9(e{aPAd{L|8S0ei3v5g37g&;wmzizcu}ZJ47PDx;FFBC$M7 z|4@c?IeovZ9>urVH_^XK6UCk&a*FcBc?JAgPrPRa@pdiLg$-Jx6P(c(qW^HL#$H^& zOB82b3)sN{%inh}PKF=4!v>Y{y`Y2fDQ@5b(r_5Nk%Udyi1oU)#MM}dHCUy`o6a)F%6*@jbIE%fFAvcgZ1wr#DDc~u@?Ix7Oh3D zI2X|mLs66?_C!q)iv+S3Zv{cT8-j@lMaQ%#J-59^z~Vy zZ~@}&>$rn^cm~nmU-Vi5R%nM_2!@zz1H_zJc!AIOj@>JLjVmw>1JE6fQ5C;d_!^(% z2F^jjLF~a+Y{CYt!8$C*O02*tJuV{(Vy|dj^ey(pouV^wx9D5k7j*@(zZT-Hcx=RG zh&~QM^mPGu@C@HjQJ+a|VN7%y|4*GWwJ&JviBlg8w+!Mr|o^btx2WxK(gy=)`GYNCB0$Xtu zSMdU+nWqlAqCdpE<1ray(W&`ZqYWzJW3#cw9B9~#`Iw9V^nnWuutf_rfE8+?rXJ0S zLUmL{MO1 z_y`aFh>uwp;U|LscYR+!7yBajslG41MZM?9hj>H0_Z8n!njTD18_m!O?g)VBe>D!{ z5}x2cm@;owl*cc=H?N&yT#dOHi{9vr+9-)vYo-`;aSo|S#wM)6BFw{l%)xZsEaEhM zE#g#6{)6}y!ZA~iQ~p1DGuVs3Y>0Paun>!}8lsOKIE=Hnh1V!X-w`!5&T!^SVIH+ZI)h%`#SoDgMHjqqB@J zaTiySj>Fi4L?mD}mSQm$U=E@Xi3mLwt!MtRSG1na-YlKS7mCh`zQwtyA?k|yF_@27 zEW~oGK^(SWH;&>IGLZ{0hnPpqRUT%jii#+O51f^n8)+#!0F5resiK)7xyaUv$`F_btS6ZCHp1+gd2iWKGinJ?C&))a_(B2I&NZw{gmi{;n= z(cfN(eor9_k09px3^89>h(UUhjJ_{o(Vo~ZukVW}>Q#WKFI0mC>Y_fHpe+n=f)@s16vQ0S zSPU`eMm;Vk&O#6_q(>UBAQNYC0x3wsN-RJm#$gQp#X$JL6K?1MM_o6fJ!G9kEYg*= z0U`=r^luUWB<>m^Vo^OO*5Vx(xWgO17=mDgVjAXSHMV0PPT(BGjMs4y=@4hjU}GC; zv_*3?L0wcq8GLIQX?%-E$i_8fAPt9+f@JK#R&2&5T|7~U!v@`Y;yQ>Z_QaW37ZH16 zE%p=}|H=^aG)6PDL{nIx6rPfoX+6t$3pa5c z7m<#`*oO63j5(N!aTt!F7>K^;0}r^tMdwO%LJvJU6CKfAk78Z4C)OfY5bK^gQA^Ym zwM2dKjt|7ULlA_~n1tC_3eoR&i2fCbd2S#ZV#dojfvpfTeQPt*SgGwyV-=X7BtEj2 zgA>?<`3S=R^hOsngEgw^$`gOLo@xAnFZhY?_^K28g?Nu|`uYR0DCe`jFYQeW zK^TE)n2QbAkMqdGPgKz7Fc4*k*+jI$2hJ7v!?Jmpu`_z<9Et54w@lC{VWfw(gnVTgoyN4zKA-3bL}@dW?D zj6NH~9)mFkGZBGEjDj=DB7=OLJ@)q`nOn*!B~j(2#osUJ6Kfkr<7x_)UHadgAw_p+>vOLyf&L6u$65FSJ4pl*SYCZbL;9HedlFF%CiS zhcEh}H$3424}I-K?2ex30rBksM?H#fu`kZW9WM;RKm=j}!V!bD*os5Aj#ntA_uGi* z3Qr8e5dFIgu_Q9c4Z(k5LyeCP_cK}@>1XT;cl3rEoY4l=Q5-MGyNwe_!fMRLM2v+W z`ojyZaE1#UVGjp%Kvx*hO^;pvpD6R)9qtfy`oI$b7>P*`@24UxTf}ti#0pGC0Qx|NEtq9@!DgfJ{aGDM%E-&gn# zC7GjwKGSo)#rXns!foCO#O-C>jdhoIH+o<=CSeLjVj$dMhuZiH@5#T3bR0rF)?yiE zU;;uh8X*{oQ3%Ee4A$c?qSzO4EP^0%M4Sk5cQU3U21_8`*?}X-z%9H&Df+5~4(JKd z_gI8t8iL>gk@JN7*=UL#OS>DZ+jlbd!xHR722^O+hXh1nJiOtAX0SkMyft<*KF4ER zgr-Z=;~}DuiX+(nhr_H>a1_TNzC~1V8s~5cH<5>z_=!@yTMcc{1)~3vn2$Bsg*1p+ zgl&jHUx<6+9Uq*RI~f-=YGJ&C3XNMB?a&);@PY%{pbpC6jh>r9+=n#~_opKqf$&8i z_`nq|=m9r<-JNKJhz@Yp*S&~N5V^hKje!_~e=!~rScEw2#z|!39ZKnawjese1AQRo zX$di34tX(Xid35xMq6C=v@$mLvNHN(F?Qh)j$;otAQt1`2PfE~9?IZ5_g~@xa&R6h zG#thO9K=5CMGCfI50W7Ex9i`@dOSi*g{YAU(c@h_#}|lq>!2yRVE`s$0oFmxAo@Ro z9axAFXn}0*hv2J+m2ouUE}0pBp>w90aU|wqEjD76{ymKtfZk}2x~Pf|+3>&wWF#d+qwi3qah{JxI!~yKYM$AVT{(%G9!yJ|IgZnS= z3^#EZ7jO#4aTFT%;fNknh8JhKKEcB z(y3b24%`eP}wh2cET8p zgrEN1j93g=>{RmuxJ$;2?AC6)VB2Wi|I1eD-gc?(sb3gMgs^KdSG53`(SMinSEUzxF zTUlK$v%0!Gbxn17<=X19&${aJZ8TV4UG9c{_|EqOgrWu>kv9q!(6|?aA*g_7)Vhle zh(ZXaAqWFugbWE@a6UG5qJV71a3&Ks5qL0ByhZ#LO!wzCCdhLq2 zU#ng}LDB=uC1q!lNVq>~v*(uXri(wcKglI4XY$sey# z;$o7tlo*1I=aZyN&ifl9nM5|Dplf<6U}^^b?;^ zn(qo|3s+3RHJEXKD6YYjy7$qRcdlU?Z?@PduUwA^$`fUi4>g0PBHK zhFD?)pP5&=hI}c? z5Q>2Y>J$5QjWX0=-4%AYgjVctM04`Ry%XFY)s5b$choV;FvmH{kjy(D-FT08&+-0G z`e;s{U+FWM-h$|J4bhoz6ZS(r|2I!QH6!ep#U{#-Q;ogiQHG3yxrXld<{BEGbXB?^ zbye=|b5-1Tx+<5qxGEPmx+?qQT$P{eU6sZ-v(8mnf=aB-H@GSpoPCYwjJ$1|T$P=P zu1YEH6{6}kS7ko#YeoSc-kQg|b>T=KrWi+0HBcYZ(U!gToZlh095o8EsKb0V%o)zS4Ik$#LC^D* z^w;^ygE5`d!=pN>>B0P7ixHjFWy3qE0fC*=F~isk=%j|B4O*i)#$X4^b8f_Od`1`U ztjAYaP-74VBLZ8n0~$WyA(r5-|(j=!TlOMZM*4Ls_iht!^lV<@B)voneLs zh`}w0{$0rrqeeOA%3;0}%=wXdrE#6qp%bVzksh~%t1T15)!G}w)sXmb_1uPVb=~@K zb;Y`HHG6HiIvizSg^z2()vm-T*o{i$OhE>IB9MEB@DcT>(*Qw;K{%3d97nL{kGPKY z1kAw@Sm84@?jaHWu)rDd2CQ(L_s60SO5i)ai9REchk4|La_2m?yqT*b^L1j*aOOR^ zEnIz-6t0@>3Rlbh8?U||8n3<@60ep(!@=?DOuu;b)1Y{DC0t;Es`v{w5PR;Jg@ee$ zZ#3ne4<=$hR^TX3;W%<|ACK`I&u|Y}$iM|0hPXEgVemjR6vutuNWl{L!H7oq8>L|m zAFM$k!nxC&c|PJjvlTLD73S?45U=(c7O(aTj8`i;DeANCih9pMQ6F_v)JpKNSJdN1 zMID4?LF_~tUf>O?aOR3IBp?mXP>lPQu!9%=#UM<@Bt-oog7r9z!ccgl6{^4t zFR7P~Wf%u%G{QIDzk}1*g=CyTMb3sogLt<*^W-9%`SO_am!qQE^ib4}&WhTpr=qs$ zm#0?zCr|y>H%~3_&QmSn+b2&w zP~Gq~ke zi&`hK977<%9N&5WCa&NVuHzTFb9MwRxqAo8nP)C@&0xM*=1gJUoXJ1b^eI2oO;hQ= zn~ApH-b8DJO-2)~F;2-QS`g~vy=0=DMlv>IH4APHttPJwun)7TWT#Mp|h^PHd$87urahhDz8sp^?@PR(LVKk#-q}u@5OY zj+4m2TYN!Pa$BGaJkTFO7>f`@U=Ct1TaQu1nFvEDM80^}4V}>#7WfNqc;g1rA>Q8u z(bF+pfauebyunCDd3~Or#6`?kkvZ3*+9ck@Wt=Kwr*(!>+D_|;gQe`W?zmIZPMe4B zD2-<&?6k|ebHr1~!V`Rj$gzfmo*05agkvTmu?(xQ4lA(+Vs8cXr=lJ6?17k1g}s;;X1TKF0p-4n=7{t6 zT(k~2n&+Z*LFQc-Z6X@uajuJY7)ud_IS4}})*unba0&PE24%Td4Gqu|t)#!Tb`W_@P#M3e@m{Z&Nj!)xSb@2ifpA1)G4|mhnsRp%uEB{pj-wNEokn-&RMGAM zz2hKySM00Zs?b;Kk4)3Pnj11r`fB~~yL?}57Xr`%B~VbVul5#CQHbK~l}A0;!5Q8d zk6BoN)mR5nD;ayR7yGdr8xer301H&W58f0#i1%ON8%nb;`tygVE&3ir z|2N^xJXg_|`LfZId5_>dEGzZZY7Ozzwhi{vnqrHepJs&(gZ#9{I6u%&8;{0#HNa2P z5Qp_xjd<+FL0mu{-r*Oj==WL^yTTFP@IgP_06q35c1LH}!3t&&bza~ePU8R)QS`p( zMeLnIK5V!%4j0gz{;Kb zo3Re_F&V+|L3bEn1rvz3fAOwRhIJ!2U>pvi9Q_928hSFvIe0SHWf+-L%=;FVdQuO* zH$t^9*`eAZm}ABDP^~f+XN77_kZ~dx_12GCy z5REO^kJGq{=lFmEl%|deEMSUCIxjH`V7EzNc4Xe_ROQ91M@AyO_ZoW-^gX{FkqI}0AGA(X(urkjwp`H{by;b;g3$J zhTr{WY430kIk=5nJis&L;|q$DTMiXrj+&^CM!LqtCa{4u>O$n1p)7t;%`HTk4=Zrz{hYQ5s_)oL7p)jH7_tmWuiCM;FwG8A_u#{?e5qR)RS!(Fm>49tPOMN#{azMGrVYoOePSh0#(rv-7pOEa2W5=jJ}6sBl2L*Tr&JH4x{0Y1~7lb9C-h5wssE7 zF$i_>@WE_tD?(t8YACorTe}Gb2e1=K*pA&eiZqE9DkIE);;#b-Q1Dk32biPQ{egNk@E znE#%@7<9&Ozep_|%i#}uSfdy|4~o>D;yxbWF_Sp*Ad03)NwU z@+bjuuR!;N_y~Cr^~HOlc3JX74bjg~EW#Oxe!I|jC=TK&Dlu1Uh&hGE_{RPbB!7+2 zQm_-T2t<2)`4XX>LJXWy9Zx?;Xz55qBtj5?et#IqdN4*noQGnjZXR&~mSYK)ViCmt zTueh4#$p)!(I0)`g8>k=#kt5&z$u8ns?eVs=0f!S10D2v77>f)+`)P|2B9+Y+-GRN z@CF%}fri-KYlh~CY_}PjC!V>^&>~=m_bxNE!-zx(`l2^_!4v&38dG$Oi7T)TyKn%< zaS$okj(9A@987{gL|swS9`5kQ5Qv;)yhTU7uNOoQ`aA~*`aXdA%rOn;@D<;XgDq%* zdE^XuG(}s4*%*%cxcqR67Km>TrfAV9hjsU-Xl-%t-V|*W`oR(v@Fs7H_7L~*7%%Vz zB{?e#3p9isI-@Ikzz7LqzcFf{3QD1n`_FJ6xp;!t_>DT8yJIDuL-ZAglJq$fA2EXd z#T*ToCjj#hjs7slQqG=|_v%onW^*`HYla%Q#di#vf&)mzN{Bi!n2%Vj!a-bv3GcgO zJAPs){bj?2epAtc{^#K?%$TbYtniZWr6_^Elh-J1`E-arOEb?O%M!8l%mE z8Ma;9YPzN@U;ecKkfB^U-7$fxWK|~MqKqoXsT~yK4BG!a> z#|`4${dfmg`bkCw`kRBF7*5~U(SkX`kboqt#sIwK%ojV!ZQ3(P8-ul2uCHy0na)Al zI26a?9zj}t9CQlOyzw`Z9D}sUkWnAyQ39s;TmP;`Y=PEj3n#e43!XY>Vh^-OD~MX= zD2rkcwJM+t8bIWSA{C$U54{{iE&3CEn$YiL+(K*S2*DB*&DNatKF&m5Kl1&ohH5+U zQg@TM9unTx9jZ-38N}Bas)|& zA(%U}1N&>)uo~W9YmKJ(&UYgI!HZ%2wMnReH39v#j=1}8e{C*2P!SLP`)lWM4EvFU z!$`v^T*d`Fz+=3@3%tZ@+{aaj9R6lgJAiH2rN^Vhbcp=&yyuL0$cBM_Hli&3Mc_63 z=z9-}F-tvkMk738ZyJ7(j=Z+$%e|QsoVBlT40YB*F$*Km1P|Dc zLPcyG@2s`J#c|GBFsk8nh_kjB17M39D2ZZvEU(AP#Oi2-=4ge^Xrr&25G`PaDlozC zvCdivVmZ`=0sS!#87Ra1Bawx+^tA=1^f?vx(Uks&V+y9j2jBJcSL8N^Gk4#4%i83= z{EP;bd}Pf6Cb-A(>$Y{YDI##_$jpcayDx8vtX?X=vSc3K$f;MuKq+HQoS zKits;?a&sTA;SPS^us@T^d)*h+%ZDrbwnq$hPdmBK+MH1i1(WC?hM?8BmIcJEa)!^ z?_i+ssaOj!&wM!G31|JVmHgY>{Tr<-H`gx1Xx3a?jGaiqS`0)b?Bl#A9#(3uje{8y zDmK^ppggWr;2Tph8s2b2H+aAk?(oGh-4J2`{4fATIS#CQz^L!}6K5bE7g34#d~gV5 z=wlilz=i$}q9*+YU^(_c%o77UoaS7@O!AYse~o%oO4rkNqfME5S`_vo4O=iB?Qom( zsi=s=QuX+`YCY{r$$I>}ww~4kA4=5I(yBO9ewPD8GVJ~Av)9VY;1#q1DJ`1*v@%PcylL|`>UzvcD}my z7@-%cYd2tevASjh6MW(OF#Pd1){)l&m(Eq!hQbsF&Q{mPK}Jn{JX2lE!v&ngDP%yy zC7i=GWa0`$-dUW1iW7PicW>Z1OsU%xp*V!{ygL#ZkmzS4{=;y3Jc$2Ljs9Dp60-Ro zg9qd|Vh;D>sdJp#rG}YmThSrVRNIEPs50D?|Lru@zOp`nQK*F7aAl1YVFhl zwZVx3^#R<|3e+H-6)~EU)1N;iub ziiHq&4&fqxLex8sPSoFmzP$Gt3h!^EkJ0qgh`t_U3VnVRIic!d(&HHR;bq8@cO zp*rtmBZ_x>^S&j0e8X<~@}j?I7^L?-mS}iHD4-Nh93-%BZ~V`hr_5rZzLsB>4=K2UOkBfFJjF*;<4zmQ#8bFa_X7NQrylP;!d~A0mp=N? z&sWT+KMVStf`#yfn4>4WIImCcG48ojV=XoBWu&Mcr|7#)oa$v4r(SOtrl7sVoPk9U>m2q> zQH-1m?+li{we}?(I=g+&1dH)eE(9aP1dI|^n9IrDaZe;H$=gH&^;Lcg@J5x*H zjn8jm)RAxKrA@GUw_UKB-7#35-z8XmZ3tE`$-!!>F<4Er4_2Qe8D(J3-Z?2)9Y)Rz za$PzHs|xp8cL-KLQ{#}u=<^vjgi$ln7@v}Y6a|S70h4bV08}G;5g!tf@Ivl z3$!9P7Uj8r4~f*fN8S6>_v4-Yyl2n5cX>a6KFZT;ZThmKzx{ZGOnhYT5odSFNhkL^ z_byW-k(!%qgH?y-!Kz!6U^Q>NgZgTsgZg2zgQ|r)s7^B+)b0@uYS1hPbw;Fv`W&a> z1~2wHbCyYt?{o+CB>AnTI;i`(>pRIoZ5igEmZ6q?sDnCWf`eKK!>C^yS>qhkdx*d? zD2T%$sL)W7ydk&*Kkgc-^9x4m&Zqt*-f6^palGry`?u-Cjeh*-E0F&7;5??`0DI!B z9XUVAuR4WDZ*kP#G1fti9_66k7(rf!rJ8ZpQhj~FQazq&sSdhosSe2E`?{t2E!$E} zykV)%LuW{Qj}rUWEY;=Yy}N9w`f=yWMN2h``;O-=)mG;$)o#?AcE(bDf0}xD#T!Sk z4&HFXKn#K(R$v@Hz@7X{7{Yx|>R3_Fm%2Bo|B82lc<&DHj^X{1^yx%D!|6+=zo|OW zcPV&r-j2NI-1*J@&(srdEjnqbE>$e~|2vlIi}{60v89E|)YXNGU0k6uKCw_)v8_;X z+F7VfNiI~p_WaMY-GxeHzMrsPBdJj7w6#zvLGIi5LZ#b=LZ#B$LM3ulp)zAdp&~6S zR6Z^#RK_eWR3Xc1MW@WejDnTQ7?+R zd#HbI{r}$kZxeU7VEh023`;IlcG8~}y%rK@@h#EkROT?J$EM5`%6vY|d6#(;=M*Yc zW)>=&rWPt$b&{3!4U!edX35IOR>_J@`($Np=VT>NN>*yyCo4@Il9h0b#ACkq8#n2Zc z@B&|uM^1MAWF?0?b-4eiQLb-E=hSKvsG zFNtmJsY{O&nByz?Czz`S^Bt$|ZRVX*BUx!*g?bf|m3z-Tluz$Hl&8Nulr`l%mAzFx zm8*3;l|D9}%FO1TN)M?n#!;$0WsH*eyp$SpmUzgv4ME8sk}$zaO9W`CfclZTR1f zPYcG|y!f>JSo1JGE$0z;p2w$seI1|XT^OHc^DRCts(3ciLYe=eNYiQce*09ap)=)dZ)-ZXptzqgyTf>7*wuY?zoGZ45 z?iXwgt+H$lE_t?wNB78iY-^Z-XHRSm>CbEpF^_BwH~G%bwKdGYZEJXO#n#|=+SYLO z2zR#I8hlpR8iJ=$Yox6qs2ATIYz@=u+8VZ$u{F4TYiWpl+R`wYzM9kDlSh2NYiYRr zH#Mwm4F{PcKA87b+Zue&*&0rKvo#djFmLZxhE|hW8Nyb!GR&!GE!D1WEzKxzE%p0t zC5`%EC5?DtC9Qd2C3VfQlGj?_ORm|9CaA)=vzwKS6$1v3+C z>1sJ^sRX$rO0xdjT59m0m6ZI|O6pl?C0%`EB^5lgk|y)sSKd9%`+Mjk>avxzg}yA| zMsJ_lYewHU?^{WOn4>-Oxc#z{tav-a+**od-oKkOb31EkKo@Jt|8BHYa5Gx^a5Y-` zaUojD%!rn@sL@ir)M)9+;b^JS!Dy+>{%C34zGx{PRZ^m*aeTY7AIJHUW6_euiD;=I zca)RS(iHAjKN~G|I3F!drJnIpv@|)B`&ZN+pl-y+2jj zxF=Qe-IXde-;pZq-<~RsL>J6RN|l^;rb?m7sgn2JRB2&Ks+4swRf;^4D*Zl|Ds81k zypk&Q)KaDFlc`cK>IP<{N*{6LbgE>2CRLJ95iPKfJwG($d|o<{J7MbodbxKzReDI> z>(p;bPcrX4=G`>jPp6MK`jIhT^hTc%^m~}TpOBlwJ(sjpDVdsu%s1n5s$|N%vvX3V z&UbnLUaGWcWWMwzFkiYpBwuPUAYbayH(zq`$d|6UpUon@x#t!+?68io@etYh&rj9wa{~DSvonX$n%==?(zT`b2UwRXoFYU}Ukz+2J z$O-36&*IKz#YE0kP2{`OFi$s;uOWt-jWbN-rAWXVIPz9iSR#+_h4@OIJ7T$W z;G~JXoEra7t0XnYP`@Spr19P=-rdXlKj>o?cIf>yV1F6sqsU9A7yb!vxheJD^M=NJ z%bD{y^X6PJ;rFJR$PKfYW1qD=Xpgm=y31NFOtO~!wpq)*o2})q8?EJtcx%~vgSDKs zj`OwFat)kWV=X^IEbBn_z1LgI8_BsHXD!!Fu$BjMFLIN$yeQFHK1rRGTdd_aTd56S zYKLJXzTz=1;vgj4C8q`axwDA76RBZIt#i~3qL-4qk;;1;csGgno8maV)F*z#J@zNl z%MfyJi~gzMPF;%~)^Y~(EoaVf<{i7=THbfST3&LHd-v?*Z+GqFRd?*q+Y-4cJeM1qHMOE+#3O~LMt3+KM?8UKIe`p3h7}Lwd|-_liF)} zXC}Q2=gld+-w5aFWfak#y%c$^MQ`^1SX&ay`6q zaFTt97qN-`qnz*Q;UwpfTaG&}u1>O>o0Duu4I6hSITY5NhHm z=T_)Q{=eMqNR49Dn$y!sUQF%UykWyz^?1{px6N^zUJ{AZ*el>{KY0)7p%3>TQ|kuv zL@`$j=3B>{k<5E*kdyq#&q?+g>?D6};UhO}?jsj#>LZV6mzTd z?IVZt1JMOFeB_JfKJpWOnCOh3Rej_R#F-e%e#h!Qa!Ya?Yx>Be$=_PbM_$6+{+2$n zT^%1e8%wE|2R|#;h{r7)#!5`aH_k_35&0T-7gHmYT7}dt1C4iX^Ik~|t}F8DtTq#z`a`BuST7t)Z9<)INsRL zTNRMR+lz1l)A(M?SwHez$zR9aGSuD695Kve!Cae|FPS+J5^rH~D#DfsIVqS>+&z%stMox%qeltYwlN}=0xgH|FyA~q<3lBuY zA}d6W!wJq-lH+wFL>@zaqgx^Jd(7wlpxYsG4mwkBDZauaH$-j?KU79T%wxY4W|N!F zy(8RDqt+s7PNepH-bm!FhWN&bl4Wzxyg&9&jUEj=3Jr z@6ryJuU`$9E%D$=xNJ`J$9-g73zwI2-kiMZ+2L{~HgK=O&2aez`cY>)9>X^$T%L`s z$i{jkL&AB^hhQUjGPr+@I_IdlhuWWbqaM43COMf$IPM?=*+@YwL|!5H7N9B8 zsW}TSXoR{5z!8k*ytlYRp9AQ31ATwo6(t*)BZ7H0G1pq=^JmU1nC*{}zZ1pm4gQ-a zpZ+;dHb=7`^W-Yu=gH|`=gB+2%#+II8VOyex6(z;RW;LR(SGm zo}7Vey1&?KhcKkz9;%aXgW(v71nk3GRG?-Zc)|sv;SD>OKp|%wY9fnTYcLa|A>O!% z)|~yoJ@P-$=ii^^$!_$$k^XNpM|I|@&s=8Ar=bb+c1Jy2=ex@KSozGlSh*!U))Lpm z%B5Gw%J)~s$_rP-%Eslfa`|Pk^5Z43^0&pYvNdKbij~cgv@lkliJ|yEES-5g7613g z%a%$iQPws~8!f2BoO3AcN{cq#R*IrXD{c3a63G%RTBH?~L@LFd(VmDlOCf}`A+#^} zo$urE`{Vg|zvsNq`@CPT%Zxj7ubJ7*3MP=b%49R;OgqkN&x~YjnKg`@$zZA&HLhpR zxHA6CW@aTLV+@#2oO6oV%uHjZFteDgOgS@;a|W_DtpodM6ziPAdJnPgYS!O{eT-v2 zcI>M+`@6w(WxrZXJ@>tslhNU{lBtLarw+{T$Z+}*5l&ab!)bL`I1Sn#PEGs5DJvwL z9tVfhtG(f5!o&uJlRi_rC!CHlA2|A#qa#Aq|Um=Vkt<}>5LT2xq54fBEZX}ssPS+5`KzQX#8*+(P$ zdCI=z?C%ew$6h-yPq?qnxZH@OSxm$AND`R;u0>K%S|m-n8cDxWBI(AJNb*mPq#2hZ z$u=pH%q~UJsEZuC5J~lkku;yFNQk5hj6G8wA4w+}C#E0s@_Z!aGa1Y!<}6ddsPWiX z<|3oU^R_WrOmD8|!z3{!OdF%cYxQLunRU!dW)5q3%N$}ofvoEo>$}1_8(6PFY9!5O z{kz!5F7`8zeSKyYvd<+9GIhK?m>JL@A4$H|k)+COtcs-XZzE|hqs63GMv{yf!Kg6> z6_NCqX<~-*_73I)vzl}NH_w~re_`CXRwkp(wHGl_%w47fuQ`{=VVqgRH|8emQLwHz ztgoJRj;iA|S$7ocSFn#n_OqIO)iaaX=NLwniQ?^#Ja&p-zjVfr>A-B{*K{SnzT5eA zx8k2i#ra5zI~U34#F1ouCX%0n&Oetp)_XFNjF?HWkrZ}zEA2n8yw>z`1XkNS%U_z zp5otE)^mt;-C})(tW%eNpNMs@WBozwV>nRDF& zWr7z1MXbA(j|0u@A&31eXJ7fuXXYid zjLGA8KPDxUkCRLuvx6zl2&Zn$s9WLG^JX{|-Uz4A>)|x`S~xXa4X3zNK8~h@(~v9S zRCSq;%}L>O@e&{ZFNV|B3w*3k45!Tr;j}KE`{()CevWg_hLacb=}b5kFl7w#_5)@k zk1yi9Wt{KE^Oo`aRa|Ek*Bj1tyL0_4CXm+}mCS#Om}FktlQk@4EwfpZe;WU7V0}fb zvz7H)uNiA)Y-$=i>aL>^1wocEmDjpt3{`G>oOQ4QC6&ox`P zcC~gG74kYac)bW-cZ+Tq$ykFYYgx^j4jA&ia9Q6^)@jr?jPD1_by$CA_VJvtWnX)l z&5R>+i(`G6VVn~(G?YFw{}=@`p1ExiO8MrYBn=6r(}P3FV-SxI2&F@2p`>OSN@t8i zd2T4h_YI{1eL^YRD3tUKLn%N%lyY@L$-Y-8-P8%C8QP)rPAin2cMqjM-9pKgX~*

}9aP7;zL+KH(^M=eB$^|oW}znN?7!{dK`m@mv5CWvXrEaov+&T*&?rmM_rCWTqSlvM>& z%iCb8s|=?6ieQQ<52lfCf+@2!n0l53)4^9fUKC8S!e9!08BF({2UC}4!DN^pOuA2k zX?RXBxn>8`m50I9aG%?I!F2pC*S*8_nXODClfbcZ9-I7tw>kG5&++HE^Z!?aYt=pt zCO@uS@gkUp7Vtb??+mY7RUAxOuY<{fwX9@K7s`U^GwbWZS}j?#?b~4L#~!Mf0QPg2 zDf$0)3b#2N@6Pn(y#7;z=p^%$$!1P4>dcHOeEu*wi1a4~5#QI9{u>`ezsK?U$Jii> zwGX0d45FS8MD6T?sKq9T>P7`oqBYO63?hx;LG;>!&u514xzC^=nm&-vH_U>lGqc;2 z&u^Gc+<(UqkJWR|OU`ZLIemHV6t1y}YwhBiOS!h5Z4f2#I+na%G_U)Mg2>i^$63ow z*3^}?O`8xzTPE^39P5o_-5#vpf_)@1mCQTlK4ZyT;;}N$`N6pd*9MZIPaw@=JeWyL z2FC_4{niB1Pp?2ytPZ3ps{$#@BaoC=29lY3AlWYuB84{KU1V&zPaG3@>^kRso*GD-c#aa! z-OV+$xYqjlft2bRNRlj&;uZ(eN?xxUuN&jW`K)0oYl&n{|2zX}By03xt$Vx!$(wcC zvHqXT0`?QYtY^kD=Q*azSa8l9&P|&mr^$2Ww2w()!WdoVD910j$SH8PoE&D!`DRga z8tg15*J*N^K2=VmCd;XKf}9pO%ITT|kE5KP*~v+=k<-mGO&=-%fWK$ zG)PWo2gs>2^TkX~QQY1hD5tGFcGH||axU?l5j?jq*XYl+O8M@=q4sjBAItIaaqFLMXnR4pNT6?kP?@R{kpTj;bGil6OW+9Ws@%GFx&Y8ve zvE2fwxqATZ)(W5!WJmVKodU?CLjd(q2_R?}Km~vO$^553Wi|QJ zf;#Tk`qQ2Yf4cX|pRPamr}H`fwELbv-BK_&{Ap^cKUrV)r|%d1X=kE85tGdQg-QOj zCdHq2T=%EIOn>Ts*PrUM{i*n=Kb05x(||WTUd{79_|xwC8PQ(XH1 z+QORVvbOq;0W?!1fF@}MP!sE3!ycxym%9J=b(-5Ej$LG5KRL&jeeLZWK$5|F5{%YU zH3PlZQwK)D{ga04sTYr_=&h$sJ=fE?9_vZ!x}IV>ucsQd^>nNKdb;-4k1l=pqvH*J z1n>Q5O$E2bex&=EHCDH>rNq@3J55yx>Pi68vZ$)0F5(jhFoBe2O1= z-SDHOq95J6&$XWTk)ptlo|pO2uo^#F^4X7an*6B#ryn`A_)#0LJC4^M+G#z-bzz;| z)>9B`8^aoXbl1}g{q@w$x?R{qBeRqJxG)XeZ)HzUc&r`g?C0E#JZDs?7iE=s(Y`lc z^n>}u+UwhHzA}{iI=|xFTc{|sO3?6!s?`<#objyocuX>T@B`+#E<3$BW zy~s1ti#qT1qO04z=<^0Iy6EjiCsue-pQT>ZQRYP_7kH5yW4O?Zb}i<4ZeFx;wHGb) z^P=kkUX&B)MMJ~9$o;Sv^^Ef(hXmG=>_w5+y(m7@i@x%@84tY3EXRx9=6R9&b1&*p z;6;V3@y#nQDk}l6)FY3g()rDSko38^~FLtMq zOWf%Jqq5YU9&-DZV`CTbb#A^!{D7|;Z=d5%`}kUNB41Y?%-5LJ_`37YG49kd!ku~# za;LIB?sP!Mot}5%YufGHY4dkC+WXm!RI1!ay~K^KzH}p{r*35N#Eq(Q-6$u|jm|!I zqsU@6GOuu>ZXetzxyg-s|8t{4HFvV_?oJ*CeBIoHud!RW(-&)ZdS&NMFL>R7y#8Ol zR^D-jJ1w8h*TMPP_M!#uR{Zeg|>hUe4b=&z;;Z z%P1>ZMip0Nl$s(VtyCFZ{bl87Y~{=!=1j&S}eNTvr+O){xP)&N6z^MMmaY zytb~4y!y##?GPCq93>;4u`&vuE+cnW8MUsEQH-yQW(LSeEl@^9`($+bfQ%0D`qxg# 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 Date: Tue, 26 Mar 2024 17:29:56 +0000 Subject: [PATCH 30/37] Move model configs inside SimulationConfig --- gprMax/config.py | 206 ++++++++++++++++++++++++++------------ gprMax/contexts.py | 23 ++--- gprMax/model_build_run.py | 53 ++++++---- 3 files changed, 189 insertions(+), 93 deletions(-) diff --git a/gprMax/config.py b/gprMax/config.py index a406e81a..ab1b45e3 100644 --- a/gprMax/config.py +++ b/gprMax/config.py @@ -20,7 +20,7 @@ import logging import sys import warnings from pathlib import Path -from typing import List, Union +from typing import List, Optional, Union import cython import numpy as np @@ -42,10 +42,11 @@ class ModelConfig: N.B. Multiple models can exist within a simulation """ - def __init__(self): + def __init__(self, model_num): self.mode = "3D" self.grids = [] self.ompthreads = None + self.model_num = model_num # Store information for CUDA or OpenCL solver # dev: compute device object. @@ -70,7 +71,11 @@ class ModelConfig: except: deviceID = 0 - self.device = {"dev": sim_config.set_model_device(deviceID), "deviceID": deviceID, "snapsgpu2cpu": False} + self.device = { + "dev": sim_config.get_model_device(deviceID), + "deviceID": deviceID, + "snapsgpu2cpu": False, + } # Total memory usage for all grids in the model. Starts with 50MB overhead. self.mem_overhead = 65e6 @@ -79,11 +84,18 @@ class ModelConfig: self.reuse_geometry = False # String to print at start of each model run - s = f"\n--- Model {model_num + 1}/{sim_config.model_end}, " f"input file: {sim_config.input_file_path}" - self.inputfilestr = Fore.GREEN + f"{s} {'-' * (get_terminal_width() - 1 - len(s))}\n" + Style.RESET_ALL + s = ( + f"\n--- Model {model_num + 1}/{sim_config.model_end}, " + f"input file: {sim_config.input_file_path}" + ) + self.inputfilestr = ( + Fore.GREEN + f"{s} {'-' * (get_terminal_width() - 1 - len(s))}\n" + Style.RESET_ALL + ) # Output file path and name for specific model - self.appendmodelnumber = "" if sim_config.args.n == 1 else str(model_num + 1) # Indexed from 1 + self.appendmodelnumber = ( + "" if sim_config.args.n == 1 else str(model_num + 1) + ) # Indexed from 1 self.set_output_file_path() # Numerical dispersion analysis parameters @@ -111,9 +123,12 @@ class ModelConfig: "crealfunc": None, } + def reuse_geometry(self): + return self.model_num != 0 and sim_config.args.geometry_fixed + def get_scene(self): try: - return sim_config.scenes[model_num] + return sim_config.scenes[self.model_num] except: return None @@ -121,7 +136,7 @@ class ModelConfig: """Namespace only used with #python blocks which are deprecated.""" tmp = { "number_model_runs": sim_config.model_end, - "current_model_run": model_num + 1, + "current_model_run": self.model_num + 1, "inputfile": sim_config.input_file_path.resolve(), } return dict(**sim_config.em_consts, **tmp) @@ -177,6 +192,14 @@ class SimulationConfig: N.B. A simulation can consist of multiple models. """ + # TODO: Make this an enum + em_consts = { + "c": c, # Speed of light in free space (m/s) + "e0": e0, # Permittivity of free space (F/m) + "m0": m0, # Permeability of free space (H/m) + "z0": np.sqrt(m0 / e0), # Impedance of free space (Ohms) + } + def __init__(self, args): """ Args: @@ -185,14 +208,38 @@ class SimulationConfig: self.args = args - if self.args.taskfarm and self.args.geometry_fixed: + self.geometry_fixed: bool = args.geometry_fixed + self.geometry_only: bool = args.geometry_only + self.gpu: Union[List[str], bool] = args.gpu + self.mpi: List[int] = args.mpi + self.number_of_models: int = args.n + self.opencl: Union[List[str], bool] = args.opencl + self.output_file_path: str = args.outputfile + self.taskfarm: bool = args.taskfarm + self.write_processed_input_file: bool = ( + args.write_processed + ) # For depreciated Python blocks + + if self.taskfarm and self.geometry_fixed: logger.exception("The geometry fixed option cannot be used with MPI taskfarm.") raise ValueError - if self.args.gpu and self.args.opencl: + if self.gpu and self.opencl: logger.exception("You cannot use both CUDA and OpenCl simultaneously.") raise ValueError + if self.mpi and self.args.subgrid: + logger.exception("You cannot use subgrids with MPI.") + raise ValueError + + # Each model in a simulation is given a unique number when the instance of ModelConfig is created + self.current_model = 0 + + # Instances of ModelConfig that hold model configuration parameters. + # TODO: Consider if this would be better as a dictionary. + # Or maybe a non fixed length list (i.e. append each config) + self.model_configs: List[Optional[ModelConfig]] = [None] * self.number_of_models + # General settings for the simulation # solver: cpu, cuda, opencl. # precision: data type for electromagnetic field output (single/double). @@ -200,25 +247,25 @@ class SimulationConfig: # progressbars when logging level is greater than # info (20) - self.general = {"solver": "cpu", "precision": "single", "progressbars": args.log_level <= 20} - - self.em_consts = { - "c": c, # Speed of light in free space (m/s) - "e0": e0, # Permittivity of free space (F/m) - "m0": m0, # Permeability of free space (H/m) - "z0": np.sqrt(m0 / e0), # Impedance of free space (Ohms) + self.general = { + "solver": "cpu", + "precision": "single", + "progressbars": args.log_level <= 20, } # Store information about host machine self.hostinfo = get_host_info() # CUDA - if self.args.gpu is not None: + if self.gpu is not None: self.general["solver"] = "cuda" # Both single and double precision are possible on GPUs, but single # provides best performance. self.general["precision"] = "single" - self.devices = {"devs": [], "nvcc_opts": None} # pycuda device objects; nvcc compiler options + self.devices = { + "devs": [], + "nvcc_opts": None, + } # pycuda device objects; nvcc compiler options # Suppress nvcc warnings on Microsoft Windows if sys.platform == "win32": self.devices["nvcc_opts"] = ["-w"] @@ -227,10 +274,13 @@ class SimulationConfig: self.devices["devs"] = detect_cuda_gpus() # OpenCL - if self.args.opencl is not None: + if self.opencl is not None: self.general["solver"] = "opencl" self.general["precision"] = "single" - self.devices = {"devs": [], "compiler_opts": None} # pyopencl device device(s); compiler options + self.devices = { + "devs": [], + "compiler_opts": None, + } # pyopencl device device(s); compiler options # Suppress CompilerWarning (sub-class of UserWarning) warnings.filterwarnings("ignore", category=UserWarning) @@ -250,12 +300,16 @@ class SimulationConfig: self.general["subgrid"] and self.general["solver"] == "opencl" ): logger.exception( - "You cannot currently use CUDA or OpenCL-based " "solvers with models that contain sub-grids." + "You cannot currently use CUDA or OpenCL-based solvers with models that contain sub-grids." ) raise ValueError else: self.general["subgrid"] = False + self.autotranslate_subgrid_coordinates = True + if hasattr(self.args, "autotranslate"): + self.autotranslate_subgrid_coordinates: bool = args.autotranslate + # Scenes parameter may not exist if user enters via CLI try: self.scenes = args.scenes if args.scenes is not None else [] @@ -267,26 +321,6 @@ class SimulationConfig: self._set_input_file_path() self._set_model_start_end() - def set_model_device(self, deviceID): - """Specify pycuda/pyopencl object for model. - - Args: - deviceID: int of requested deviceID of compute device. - - Returns: - dev: requested pycuda/pyopencl device object. - """ - - found = False - for ID, dev in self.devices["devs"].items(): - if ID == deviceID: - found = True - return dev - - if not found: - logger.exception(f"Compute device with device ID {deviceID} does " "not exist.") - raise ValueError - def _set_precision(self): """Data type (precision) for electromagnetic field output. @@ -325,6 +359,15 @@ class SimulationConfig: elif self.general["solver"] == "opencl": self.dtypes["C_complex"] = "cdouble" + def _set_input_file_path(self): + """Sets input file path for CLI or API.""" + # API + if self.args.inputfile is None: + self.input_file_path = Path(self.args.outputfile) + # API/CLI + else: + self.input_file_path = Path(self.args.inputfile) + def _set_model_start_end(self): """Sets range for number of models to run (internally 0 index).""" if self.args.i: @@ -337,30 +380,69 @@ class SimulationConfig: self.model_start = modelstart self.model_end = modelend - def _set_input_file_path(self): - """Sets input file path for CLI or API.""" - # API - if self.args.inputfile is None: - self.input_file_path = Path(self.args.outputfile) - # API/CLI - else: - self.input_file_path = Path(self.args.inputfile) + def get_model_device(self, deviceID): + """Specify pycuda/pyopencl object for model. + + Args: + deviceID: int of requested deviceID of compute device. + + Returns: + dev: requested pycuda/pyopencl device object. + """ + + found = False + for ID, dev in self.devices["devs"].items(): + if ID == deviceID: + found = True + return dev + + if not found: + logger.exception(f"Compute device with device ID {deviceID} does " "not exist.") + raise ValueError + + def get_model_config(self, model_num: Optional[int] = None) -> ModelConfig: + """Return ModelConfig instance for specific model. + + Args: + model_num: number of the model. If None, returns the config for the current model + + Returns: + model_config: requested model config + """ + if model_num is None: + model_num = self.current_model + + model_config = self.model_configs[model_num] + if model_config is None: + logger.exception(f"Cannot get ModelConfig for model {model_num}. It has not been set.") + raise ValueError + + return model_config + + def set_model_config(self, model_config: ModelConfig, model_num: Optional[int] = None) -> None: + """Set ModelConfig instace for specific model. + + Args: + model_num: number of the model. If None, sets the config for the current model + """ + if model_num is None: + model_num = self.current_model + + self.model_configs[model_num] = model_config + + def set_current_model(self, model_num: int) -> None: + """Set the current model by it's unique identifier + + Args: + model_num: unique identifier for the current model + """ + self.current_model = model_num # Single instance of SimConfig to hold simulation configuration parameters. sim_config: SimulationConfig = None -# Instances of ModelConfig that hold model configuration parameters. -model_configs: Union[ModelConfig, List[ModelConfig]] = [] - -# Each model in a simulation is given a unique number when the instance of -# ModelConfig is created -model_num: int = 0 - def get_model_config() -> ModelConfig: - """Return ModelConfig instace for specific model.""" - if isinstance(model_configs, ModelConfig): - return model_configs - else: - return model_configs[model_num] + """Return ModelConfig instance for specific model.""" + return sim_config.get_model_config() diff --git a/gprMax/contexts.py b/gprMax/contexts.py index 24b5f21a..2dfabec9 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -28,6 +28,7 @@ from colorama import Fore, Style, init init() import gprMax.config as config +from gprMax.config import ModelConfig from ._version import __version__, codename from .model_build_run import ModelBuildRun @@ -81,10 +82,6 @@ class Context: self._start_simulation() - # Clear list of model configs. It can be retained when gprMax is - # called in a loop, and want to avoid this. - config.model_configs = [] - for i in self.model_range: self._run_model(i) @@ -99,8 +96,9 @@ class Context: model_num: index of model to be run """ - config.model_num = model_num - self._set_model_config() + config.sim_config.set_current_model(model_num) + model_config = self._create_model_config(model_num) + config.sim_config.set_model_config(model_config) # Always create a grid for the first model. The next model to run # only gets a new grid if the geometry is not re-used. @@ -124,10 +122,9 @@ class Context: gc.collect() - def _set_model_config(self) -> None: + def _create_model_config(self, model_num: int) -> ModelConfig: """Create model config and save to global config.""" - model_config = config.ModelConfig() - config.model_configs.append(model_config) + return ModelConfig(model_num) def print_logo_copyright(self) -> None: """Prints gprMax logo, version, and copyright/licencing information.""" @@ -193,12 +190,12 @@ class TaskfarmContext(Context): self.rank = self.comm.rank self.TaskfarmExecutor = TaskfarmExecutor - def _set_model_config(self) -> None: + def _create_model_config(self, model_num: int) -> ModelConfig: """Create model config and save to global config. Set device in model config according to MPI rank. """ - model_config = config.ModelConfig() + model_config = super()._create_model_config(model_num) # Set GPU deviceID according to worker rank if config.sim_config.general["solver"] == "cuda": model_config.device = { @@ -206,7 +203,7 @@ class TaskfarmContext(Context): "deviceID": self.rank - 1, "snapsgpu2cpu": False, } - config.model_configs = model_config + return model_config def _run_model(self, **work) -> None: """Process for running a single model. @@ -261,4 +258,4 @@ class TaskfarmContext(Context): if executor.is_master(): self._end_simulation() - return results + return results diff --git a/gprMax/model_build_run.py b/gprMax/model_build_run.py index e20164f9..058227a0 100644 --- a/gprMax/model_build_run.py +++ b/gprMax/model_build_run.py @@ -73,12 +73,15 @@ class ModelBuildRun: # Normal model reading/building process; bypassed if geometry information to be reused self.reuse_geometry() if config.get_model_config().reuse_geometry else self.build_geometry() - logger.info(f"\nOutput directory: {config.get_model_config().output_file_path.parent.resolve()}") + logger.info( + f"\nOutput directory: {config.get_model_config().output_file_path.parent.resolve()}" + ) # Adjust position of simple sources and receivers if required if G.srcsteps[0] != 0 or G.srcsteps[1] != 0 or G.srcsteps[2] != 0: + model_num = config.sim_config.current_model for source in itertools.chain(G.hertziandipoles, G.magneticdipoles): - if config.model_num == 0: + if model_num == 0: if ( source.xcoord + G.srcsteps[0] * config.sim_config.model_end < 0 or source.xcoord + G.srcsteps[0] * config.sim_config.model_end > G.nx @@ -87,14 +90,17 @@ class ModelBuildRun: or source.zcoord + G.srcsteps[2] * config.sim_config.model_end < 0 or source.zcoord + G.srcsteps[2] * config.sim_config.model_end > G.nz ): - logger.exception("Source(s) will be stepped to a position outside the domain.") + logger.exception( + "Source(s) will be stepped to a position outside the domain." + ) raise ValueError - source.xcoord = source.xcoordorigin + config.model_num * G.srcsteps[0] - source.ycoord = source.ycoordorigin + config.model_num * G.srcsteps[1] - source.zcoord = source.zcoordorigin + config.model_num * G.srcsteps[2] + source.xcoord = source.xcoordorigin + model_num * G.srcsteps[0] + source.ycoord = source.ycoordorigin + model_num * G.srcsteps[1] + source.zcoord = source.zcoordorigin + model_num * G.srcsteps[2] if G.rxsteps[0] != 0 or G.rxsteps[1] != 0 or G.rxsteps[2] != 0: + model_num = config.sim_config.current_model for receiver in G.rxs: - if config.model_num == 0: + if model_num == 0: if ( receiver.xcoord + G.rxsteps[0] * config.sim_config.model_end < 0 or receiver.xcoord + G.rxsteps[0] * config.sim_config.model_end > G.nx @@ -103,11 +109,13 @@ class ModelBuildRun: or receiver.zcoord + G.rxsteps[2] * config.sim_config.model_end < 0 or receiver.zcoord + G.rxsteps[2] * config.sim_config.model_end > G.nz ): - logger.exception("Receiver(s) will be stepped to a position outside the domain.") + logger.exception( + "Receiver(s) will be stepped to a position outside the domain." + ) raise ValueError - receiver.xcoord = receiver.xcoordorigin + config.model_num * G.rxsteps[0] - receiver.ycoord = receiver.ycoordorigin + config.model_num * G.rxsteps[1] - receiver.zcoord = receiver.zcoordorigin + config.model_num * G.rxsteps[2] + receiver.xcoord = receiver.xcoordorigin + model_num * G.rxsteps[0] + receiver.ycoord = receiver.ycoordorigin + model_num * G.rxsteps[1] + receiver.zcoord = receiver.zcoordorigin + model_num * G.rxsteps[2] # Write files for any geometry views and geometry object outputs gvs = G.geometryviews + [gv for sg in G.subgrids for gv in sg.geometryviews] @@ -204,7 +212,8 @@ class ModelBuildRun: results = dispersion_analysis(gb.grid) if results["error"]: logger.warning( - f"\nNumerical dispersion analysis [{gb.grid.name}] " f"not carried out as {results['error']}" + f"\nNumerical dispersion analysis [{gb.grid.name}] " + f"not carried out as {results['error']}" ) elif results["N"] < config.get_model_config().numdispersion["mingridsampling"]: logger.exception( @@ -218,7 +227,8 @@ class ModelBuildRun: raise ValueError elif ( results["deltavp"] - and np.abs(results["deltavp"]) > config.get_model_config().numdispersion["maxnumericaldisp"] + and np.abs(results["deltavp"]) + > config.get_model_config().numdispersion["maxnumericaldisp"] ): logger.warning( f"\n[{gb.grid.name}] has potentially significant " @@ -295,7 +305,7 @@ class ModelBuildRun: # Print information about and check OpenMP threads if config.sim_config.general["solver"] == "cpu": logger.basic( - f"\nModel {config.model_num + 1}/{config.sim_config.model_end} " + f"\nModel {config.sim_config.current_model + 1}/{config.sim_config.model_end} " f"on {config.sim_config.hostinfo['hostname']} " f"with OpenMP backend using {config.get_model_config().ompthreads} thread(s)" ) @@ -308,7 +318,10 @@ class ModelBuildRun: elif config.sim_config.general["solver"] in ["cuda", "opencl"]: if config.sim_config.general["solver"] == "opencl": solvername = "OpenCL" - platformname = " ".join(config.get_model_config().device["dev"].platform.name.split()) + " with " + platformname = ( + " ".join(config.get_model_config().device["dev"].platform.name.split()) + + " with " + ) devicename = ( f'Device {config.get_model_config().device["deviceID"]}: ' f'{" ".join(config.get_model_config().device["dev"].name.split())}' @@ -322,7 +335,7 @@ class ModelBuildRun: ) logger.basic( - f"\nModel {config.model_num + 1}/{config.sim_config.model_end} " + f"\nModel {config.sim_config.current_model + 1}/{config.sim_config.model_end} " f"solving on {config.sim_config.hostinfo['hostname']} " f"with {solvername} backend using {platformname}{devicename}" ) @@ -353,9 +366,13 @@ class ModelBuildRun: elif config.sim_config.general["solver"] == "opencl": mem_str = f" host + unknown for device" - logger.info(f"\nMemory used (estimated): " + f"~{humanize.naturalsize(self.p.memory_full_info().uss)}{mem_str}") logger.info( - f"Time taken: " + f"{humanize.precisedelta(datetime.timedelta(seconds=solver.solvetime), format='%0.4f')}" + f"\nMemory used (estimated): " + + f"~{humanize.naturalsize(self.p.memory_full_info().uss)}{mem_str}" + ) + logger.info( + f"Time taken: " + + f"{humanize.precisedelta(datetime.timedelta(seconds=solver.solvetime), format='%0.4f')}" ) From f94b11626f5e34a191296588938dea700134a646 Mon Sep 17 00:00:00 2001 From: nmannall Date: Tue, 26 Mar 2024 18:11:17 +0000 Subject: [PATCH 31/37] Begin refactoring Scene creation --- gprMax/config.py | 45 +++++++++++++++++++++++++++++---------- gprMax/contexts.py | 41 ++++++++++++++++++++++++----------- gprMax/model_build_run.py | 21 +----------------- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/gprMax/config.py b/gprMax/config.py index ab1b45e3..d1d5385c 100644 --- a/gprMax/config.py +++ b/gprMax/config.py @@ -26,6 +26,8 @@ import cython import numpy as np from colorama import Fore, Style, init +from gprMax.scene import Scene + init() from scipy.constants import c from scipy.constants import epsilon_0 as e0 @@ -81,8 +83,6 @@ class ModelConfig: self.mem_overhead = 65e6 self.mem_use = self.mem_overhead - self.reuse_geometry = False - # String to print at start of each model run s = ( f"\n--- Model {model_num + 1}/{sim_config.model_end}, " @@ -127,10 +127,7 @@ class ModelConfig: return self.model_num != 0 and sim_config.args.geometry_fixed def get_scene(self): - try: - return sim_config.scenes[self.model_num] - except: - return None + return sim_config.get_scene(self.model_num) def get_usernamespace(self): """Namespace only used with #python blocks which are deprecated.""" @@ -307,14 +304,15 @@ class SimulationConfig: self.general["subgrid"] = False self.autotranslate_subgrid_coordinates = True - if hasattr(self.args, "autotranslate"): + if hasattr(args, "autotranslate"): self.autotranslate_subgrid_coordinates: bool = args.autotranslate # Scenes parameter may not exist if user enters via CLI - try: - self.scenes = args.scenes if args.scenes is not None else [] - except AttributeError: - self.scenes = [] + self.scenes: List[Optional[Scene]] + if hasattr(args, "scenes") and args.scenes is not None: + self.scenes = args.scenes + else: + self.scenes = [None] * self.number_of_models # Set more complex parameters self._set_precision() @@ -438,6 +436,31 @@ class SimulationConfig: """ self.current_model = model_num + def get_scene(self, model_num: Optional[int] = None) -> Optional[Scene]: + """Return Scene instance for specific model. + + Args: + model_num: number of the model. If None, returns the scene for the current model + + Returns: + scene: requested scene + """ + if model_num is None: + model_num = self.current_model + + return self.scenes[model_num] + + def set_scene(self, scene: Scene, model_num: Optional[int] = None) -> None: + """Set Scene instace for specific model. + + Args: + model_num: number of the model. If None, sets the scene for the current model + """ + if model_num is None: + model_num = self.current_model + + self.scenes[model_num] = scene + # Single instance of SimConfig to hold simulation configuration parameters. sim_config: SimulationConfig = None diff --git a/gprMax/contexts.py b/gprMax/contexts.py index 2dfabec9..18cc8a5e 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -25,6 +25,9 @@ from typing import Any, Dict, List, Optional import humanize from colorama import Fore, Style, init +from gprMax.hash_cmds_file import parse_hash_commands +from gprMax.scene import Scene + init() import gprMax.config as config @@ -100,25 +103,22 @@ class Context: model_config = self._create_model_config(model_num) config.sim_config.set_model_config(model_config) - # Always create a grid for the first model. The next model to run - # only gets a new grid if the geometry is not re-used. - if model_num != 0 and config.sim_config.args.geometry_fixed: - config.get_model_config().reuse_geometry = True - else: - G = create_G() + if not model_config.reuse_geometry(): + scene = self._get_scene(model_num) + model = self._create_model() + scene.create_internal_objects(model.G) - model = ModelBuildRun(G) model.build() - if not config.sim_config.args.geometry_only: - solver = create_solver(G) + if not config.sim_config.geometry_only: + solver = create_solver(model.G) model.solve(solver) - del solver, model + del solver - if not config.sim_config.args.geometry_fixed: + if not config.sim_config.geometry_fixed: # Manual garbage collection required to stop memory leak on GPUs # when using pycuda - del G + del model.G, model gc.collect() @@ -126,6 +126,23 @@ class Context: """Create model config and save to global config.""" return ModelConfig(model_num) + def _get_scene(self, model_num: int) -> Scene: + # API for multiple scenes / model runs + scene = config.sim_config.get_scene(model_num) + + # If there is no scene, process the hash commands + if scene is None: + scene = Scene() + config.sim_config.set_scene(scene, model_num) + # Parse the input file into user objects and add them to the scene + scene = parse_hash_commands(scene) + + return scene + + def _create_model(self) -> ModelBuildRun: + grid = create_G() + return ModelBuildRun(grid) + def print_logo_copyright(self) -> None: """Prints gprMax logo, version, and copyright/licencing information.""" logo_copyright = logo(f"{__version__} ({codename})") diff --git a/gprMax/model_build_run.py b/gprMax/model_build_run.py index 058227a0..3ec66166 100644 --- a/gprMax/model_build_run.py +++ b/gprMax/model_build_run.py @@ -71,7 +71,7 @@ class ModelBuildRun: self.p = psutil.Process() # Normal model reading/building process; bypassed if geometry information to be reused - self.reuse_geometry() if config.get_model_config().reuse_geometry else self.build_geometry() + self.reuse_geometry() if config.get_model_config().reuse_geometry() else self.build_geometry() logger.info( f"\nOutput directory: {config.get_model_config().output_file_path.parent.resolve()}" @@ -146,9 +146,6 @@ class ModelBuildRun: logger.info(config.get_model_config().inputfilestr) - # Build objects in the scene and check memory for building - self.build_scene() - # Print info on any subgrids for sg in G.subgrids: sg.print_info() @@ -263,22 +260,6 @@ class ModelBuildRun: grid.iteration = 0 # Reset current iteration number grid.reset_fields() - def build_scene(self): - # API for multiple scenes / model runs - scene = config.get_model_config().get_scene() - - # If there is no scene, process the hash commands - if not scene: - scene = Scene() - config.sim_config.scenes.append(scene) - # Parse the input file into user objects and add them to the scene - scene = parse_hash_commands(scene) - - # Creates the internal simulation objects - scene.create_internal_objects(self.G) - - return scene - def write_output_data(self): """Writes output data, i.e. field data for receivers and snapshots to file(s). From 0001deafff1e14d2d280d7e5d5fd9753dcd53683 Mon Sep 17 00:00:00 2001 From: nmannall Date: Wed, 27 Mar 2024 13:37:33 +0000 Subject: [PATCH 32/37] Rename ModelBuildRun to Model --- gprMax/contexts.py | 6 +++--- gprMax/{model_build_run.py => model.py} | 4 +--- gprMax/taskfarm.py | 10 +++++++--- tests/updates/test_cpu_updates.py | 4 ++-- 4 files changed, 13 insertions(+), 11 deletions(-) rename gprMax/{model_build_run.py => model.py} (97%) diff --git a/gprMax/contexts.py b/gprMax/contexts.py index 18cc8a5e..dc40064a 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -34,7 +34,7 @@ import gprMax.config as config from gprMax.config import ModelConfig from ._version import __version__, codename -from .model_build_run import ModelBuildRun +from .model import Model from .solvers import create_G, create_solver from .utilities.host_info import print_cuda_info, print_host_info, print_opencl_info from .utilities.utilities import get_terminal_width, logo, timer @@ -139,9 +139,9 @@ class Context: return scene - def _create_model(self) -> ModelBuildRun: + def _create_model(self) -> Model: grid = create_G() - return ModelBuildRun(grid) + return Model(grid) def print_logo_copyright(self) -> None: """Prints gprMax logo, version, and copyright/licencing information.""" diff --git a/gprMax/model_build_run.py b/gprMax/model.py similarity index 97% rename from gprMax/model_build_run.py rename to gprMax/model.py index 3ec66166..0b864f28 100644 --- a/gprMax/model_build_run.py +++ b/gprMax/model.py @@ -37,10 +37,8 @@ from .cython.yee_cell_build import build_electric_components, build_magnetic_com from .fields_outputs import write_hdf5_outputfile from .geometry_outputs import save_geometry_views from .grid.fdtd_grid import dispersion_analysis -from .hash_cmds_file import parse_hash_commands from .materials import process_materials from .pml import CFS, build_pml, print_pml_info -from .scene import Scene from .snapshots import save_snapshots from .utilities.host_info import mem_check_build_all, mem_check_run_all, set_omp_threads from .utilities.utilities import get_terminal_width @@ -48,7 +46,7 @@ from .utilities.utilities import get_terminal_width logger = logging.getLogger(__name__) -class ModelBuildRun: +class Model: """Builds and runs (solves) a model.""" def __init__(self, G): diff --git a/gprMax/taskfarm.py b/gprMax/taskfarm.py index b9fde6b3..84f779d5 100644 --- a/gprMax/taskfarm.py +++ b/gprMax/taskfarm.py @@ -51,7 +51,7 @@ class TaskfarmExecutor(object): `gprMax` models in parallel is given below. >>> from mpi4py import MPI >>> from gprMax.taskfarm import TaskfarmExecutor - >>> from gprMax.model_build_run import run_model + >>> from gprMax.model import run_model >>> # choose an MPI.Intracomm for communication (MPI.COMM_WORLD by default) >>> comm = MPI.COMM_WORLD >>> # choose a target function @@ -269,7 +269,9 @@ class TaskfarmExecutor(object): for i, worker in enumerate(self.workers): if self.comm.Iprobe(source=worker, tag=Tags.DONE): job_idx, result = self.comm.recv(source=worker, tag=Tags.DONE) - logger.debug(f"({self.comm.name}) - Received finished job {job_idx} from worker {worker:d}.") + logger.debug( + f"({self.comm.name}) - Received finished job {job_idx} from worker {worker:d}." + ) results[job_idx] = result self.busy[i] = False elif self.comm.Iprobe(source=worker, tag=Tags.READY): @@ -277,7 +279,9 @@ class TaskfarmExecutor(object): self.comm.recv(source=worker, tag=Tags.READY) self.busy[i] = True job_idx = num_jobs - len(my_jobs) - logger.debug(f"({self.comm.name}) - Sending job {job_idx} to worker {worker:d}.") + logger.debug( + f"({self.comm.name}) - Sending job {job_idx} to worker {worker:d}." + ) self.comm.send((job_idx, my_jobs.pop(0)), dest=worker, tag=Tags.START) elif self.comm.Iprobe(source=worker, tag=Tags.EXIT): logger.debug(f"({self.comm.name}) - Worker on rank {worker:d} has terminated.") diff --git a/tests/updates/test_cpu_updates.py b/tests/updates/test_cpu_updates.py index 67d4a6c5..73fb93fb 100644 --- a/tests/updates/test_cpu_updates.py +++ b/tests/updates/test_cpu_updates.py @@ -7,7 +7,7 @@ from pytest import MonkeyPatch from gprMax import config, gprMax from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.materials import create_built_in_materials -from gprMax.model_build_run import GridBuilder +from gprMax.model import GridBuilder from gprMax.pml import CFS from gprMax.updates.cpu_updates import CPUUpdates @@ -43,7 +43,7 @@ def config_mock(monkeypatch: MonkeyPatch): return config.SimulationConfig(args) def _mock_model_config() -> config.ModelConfig: - model_config = config.ModelConfig() + model_config = config.ModelConfig(1) model_config.ompthreads = 1 return model_config From 204ba261ad2e2149fdd07bd8ae17dda4a16d01c5 Mon Sep 17 00:00:00 2001 From: nmannall Date: Wed, 27 Mar 2024 13:43:12 +0000 Subject: [PATCH 33/37] Move create_G() to Model class --- gprMax/contexts.py | 5 ++--- gprMax/model.py | 24 +++++++++++++++++++++--- gprMax/solvers.py | 17 ----------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/gprMax/contexts.py b/gprMax/contexts.py index dc40064a..396dbad0 100644 --- a/gprMax/contexts.py +++ b/gprMax/contexts.py @@ -35,7 +35,7 @@ from gprMax.config import ModelConfig from ._version import __version__, codename from .model import Model -from .solvers import create_G, create_solver +from .solvers import create_solver from .utilities.host_info import print_cuda_info, print_host_info, print_opencl_info from .utilities.utilities import get_terminal_width, logo, timer @@ -140,8 +140,7 @@ class Context: return scene def _create_model(self) -> Model: - grid = create_G() - return Model(grid) + return Model() def print_logo_copyright(self) -> None: """Prints gprMax logo, version, and copyright/licencing information.""" diff --git a/gprMax/model.py b/gprMax/model.py index 0b864f28..91468498 100644 --- a/gprMax/model.py +++ b/gprMax/model.py @@ -26,6 +26,9 @@ import numpy as np import psutil from colorama import Fore, Style, init +from gprMax.grid.cuda_grid import CUDAGrid +from gprMax.grid.opencl_grid import OpenCLGrid + init() from terminaltables import SingleTable @@ -36,7 +39,7 @@ import gprMax.config as config from .cython.yee_cell_build import build_electric_components, build_magnetic_components from .fields_outputs import write_hdf5_outputfile from .geometry_outputs import save_geometry_views -from .grid.fdtd_grid import dispersion_analysis +from .grid.fdtd_grid import FDTDGrid, dispersion_analysis from .materials import process_materials from .pml import CFS, build_pml, print_pml_info from .snapshots import save_snapshots @@ -49,8 +52,8 @@ logger = logging.getLogger(__name__) class Model: """Builds and runs (solves) a model.""" - def __init__(self, G): - self.G = G + def __init__(self): + self.G = self._create_grid() # Monitor memory usage self.p = None @@ -60,6 +63,21 @@ class Model: # later for use with CPU solver. config.get_model_config().ompthreads = set_omp_threads(config.get_model_config().ompthreads) + def _create_grid(self) -> FDTDGrid: + """Create grid object according to solver. + + Returns: + grid: FDTDGrid class describing a grid in a model. + """ + if config.sim_config.general["solver"] == "cpu": + grid = FDTDGrid() + elif config.sim_config.general["solver"] == "cuda": + grid = CUDAGrid() + elif config.sim_config.general["solver"] == "opencl": + grid = OpenCLGrid() + + return grid + def build(self): """Builds the Yee cells for a model.""" diff --git a/gprMax/solvers.py b/gprMax/solvers.py index 7cb9c11f..91dab762 100644 --- a/gprMax/solvers.py +++ b/gprMax/solvers.py @@ -29,23 +29,6 @@ from .updates.opencl_updates import OpenCLUpdates from .updates.updates import Updates -def create_G() -> FDTDGrid: - """Create grid object according to solver. - - Returns: - G: FDTDGrid class describing a grid in a model. - """ - - if config.sim_config.general["solver"] == "cpu": - G = FDTDGrid() - elif config.sim_config.general["solver"] == "cuda": - G = CUDAGrid() - elif config.sim_config.general["solver"] == "opencl": - G = OpenCLGrid() - - return G - - class Solver: """Generic solver for Update objects""" From fadcf4641ebee79b91b9a4acbe10ddc22c661903 Mon Sep 17 00:00:00 2001 From: nmannall Date: Wed, 27 Mar 2024 14:58:02 +0000 Subject: [PATCH 34/37] Update reframe tests to use PrgEnv-gnu as recommended by ARCHER2 docs --- reframe_tests/base_tests.py | 56 ++++++++++++++----- reframe_tests/job_scripts/archer2_tests.slurm | 2 + 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/reframe_tests/base_tests.py b/reframe_tests/base_tests.py index bcbb73de..46b05792 100644 --- a/reframe_tests/base_tests.py +++ b/reframe_tests/base_tests.py @@ -5,7 +5,14 @@ from shutil import copyfile import reframe as rfm import reframe.utility.sanity as sn -from reframe.core.builtins import performance_function, require_deps, run_after, run_before, sanity_function, variable +from reframe.core.builtins import ( + performance_function, + require_deps, + run_after, + run_before, + sanity_function, + variable, +) from reframe.utility import udeps from utilities.deferrable import path_join @@ -16,15 +23,16 @@ PATH_TO_PYENV = os.path.join(".venv", "bin", "activate") @rfm.simple_test class CreatePyenvTest(rfm.RunOnlyRegressionTest): valid_systems = ["generic", "archer2:login"] - valid_prog_environs = ["builtin", "PrgEnv-cray"] + valid_prog_environs = ["builtin", "PrgEnv-gnu"] modules = ["cray-python"] prerun_cmds = [ "python -m venv --system-site-packages --prompt gprMax .venv", f"source {PATH_TO_PYENV}", - f"pip install -r {os.path.join(GPRMAX_ROOT_DIR, 'requirements.txt')}", + f"CC=cc CXX=CC FC=ftn python -m pip install --upgrade pip')", + f"CC=cc CXX=CC FC=ftn python -m pip install -r {os.path.join(GPRMAX_ROOT_DIR, 'requirements.txt')}", ] - executable = f"pip install -e {GPRMAX_ROOT_DIR}" + executable = f"CC=cc CXX=CC FC=ftn python -m pip install -e {GPRMAX_ROOT_DIR}" @sanity_function def check_requirements_installed(self): @@ -33,8 +41,12 @@ class CreatePyenvTest(rfm.RunOnlyRegressionTest): Check gprMax installed successfully and no other errors thrown """ return ( - sn.assert_found(r"Successfully installed (?!gprMax)", self.stdout, "Failed to install requirements") - and sn.assert_found(r"Successfully installed gprMax", self.stdout, "Failed to install gprMax") + sn.assert_found( + r"Successfully installed (?!gprMax)", self.stdout, "Failed to install requirements" + ) + and sn.assert_found( + r"Successfully installed gprMax", self.stdout, "Failed to install gprMax" + ) and sn.assert_not_found(r"finished with status 'error'", self.stdout) and sn.assert_not_found(r"ERROR:", self.stderr) ) @@ -42,7 +54,8 @@ class CreatePyenvTest(rfm.RunOnlyRegressionTest): class GprMaxBaseTest(rfm.RunOnlyRegressionTest): valid_systems = ["archer2:compute"] - valid_prog_environs = ["PrgEnv-cray"] + valid_prog_environs = ["PrgEnv-gnu"] + modules = ["cray-python"] executable = "time -p python -m gprMax --log-level 25" exclusive_access = True @@ -76,13 +89,19 @@ class GprMaxBaseTest(rfm.RunOnlyRegressionTest): """Check simulation completed successfully""" # TODO: Check for correctness/regression rather than just completing return sn.assert_not_found( - r"(?i)error", self.stderr, f"An error occured. See '{path_join(self.stagedir, self.stderr)}' for details." - ) and sn.assert_found(r"=== Simulation completed in ", self.stdout, "Simulation did not complete") + r"(?i)error", + self.stderr, + f"An error occured. See '{path_join(self.stagedir, self.stderr)}' for details.", + ) and sn.assert_found( + r"=== Simulation completed in ", self.stdout, "Simulation did not complete" + ) @performance_function("s", perf_key="run_time") def extract_run_time(self): """Extract total runtime from the last task to complete""" - return sn.extractsingle(r"real\s+(?P\S+)", self.stderr, "run_time", float, self.num_tasks - 1) + return sn.extractsingle( + r"real\s+(?P\S+)", self.stderr, "run_time", float, self.num_tasks - 1 + ) @performance_function("s", perf_key="simulation_time") def extract_simulation_time(self): @@ -91,7 +110,9 @@ class GprMaxBaseTest(rfm.RunOnlyRegressionTest): # sn.extractall throws an error if a group has value None. # Therefore have to handle the < 1 min, >= 1 min and >= 1 hour cases separately. timeframe = sn.extractsingle( - r"=== Simulation completed in \S+ (?Phour|minute|second)", self.stdout, "timeframe" + r"=== Simulation completed in \S+ (?Phour|minute|second)", + self.stdout, + "timeframe", ) if timeframe == "hour": simulation_time = sn.extractall( @@ -117,7 +138,10 @@ class GprMaxBaseTest(rfm.RunOnlyRegressionTest): hours = 0 minutes = 0 seconds = sn.extractsingle( - r"=== Simulation completed in (?P\S+) seconds? =*", self.stdout, "seconds", float + r"=== Simulation completed in (?P\S+) seconds? =*", + self.stdout, + "seconds", + float, ) return hours * 3600 + minutes * 60 + seconds @@ -146,7 +170,9 @@ class GprMaxRegressionTest(GprMaxBaseTest): Create reference file from the test output if it does not exist. """ if sn.path_exists(self.reference_file): - h5diff_output = sn.extractsingle(f"{self.h5diff_header}\n(?P[\S\s]*)", self.stdout, "h5diff") + h5diff_output = sn.extractsingle( + f"{self.h5diff_header}\n(?P[\S\s]*)", self.stdout, "h5diff" + ) return ( self.test_simulation_complete() and sn.assert_found(self.h5diff_header, self.stdout, "Failed to find h5diff header") @@ -161,7 +187,9 @@ class GprMaxRegressionTest(GprMaxBaseTest): ) else: copyfile(self.output_file, self.reference_file) - return sn.assert_true(False, f"No reference file exists. Creating... '{self.reference_file}'") + return sn.assert_true( + False, f"No reference file exists. Creating... '{self.reference_file}'" + ) class GprMaxMpiTest(GprMaxBaseTest): diff --git a/reframe_tests/job_scripts/archer2_tests.slurm b/reframe_tests/job_scripts/archer2_tests.slurm index 63d36498..f3958fd2 100644 --- a/reframe_tests/job_scripts/archer2_tests.slurm +++ b/reframe_tests/job_scripts/archer2_tests.slurm @@ -11,6 +11,8 @@ # using threading. export OMP_NUM_THREADS=1 +module load cray-python + source ../.venv/bin/activate reframe -C configuration/archer2_settings.py -c reframe_tests.py -c base_tests.py -r "$@" From a7d713a98ab8b8db9c104d45b2bc9542ee41e420 Mon Sep 17 00:00:00 2001 From: nmannall Date: Thu, 28 Mar 2024 17:12:49 +0000 Subject: [PATCH 35/37] Fix pip update step in CreatePyenvTest --- reframe_tests/base_tests.py | 20 +++++++++++++------- requirements.txt | 1 - 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/reframe_tests/base_tests.py b/reframe_tests/base_tests.py index 46b05792..534c3dd6 100644 --- a/reframe_tests/base_tests.py +++ b/reframe_tests/base_tests.py @@ -29,7 +29,7 @@ class CreatePyenvTest(rfm.RunOnlyRegressionTest): prerun_cmds = [ "python -m venv --system-site-packages --prompt gprMax .venv", f"source {PATH_TO_PYENV}", - f"CC=cc CXX=CC FC=ftn python -m pip install --upgrade pip')", + "CC=cc CXX=CC FC=ftn python -m pip install --upgrade pip", f"CC=cc CXX=CC FC=ftn python -m pip install -r {os.path.join(GPRMAX_ROOT_DIR, 'requirements.txt')}", ] executable = f"CC=cc CXX=CC FC=ftn python -m pip install -e {GPRMAX_ROOT_DIR}" @@ -42,13 +42,20 @@ class CreatePyenvTest(rfm.RunOnlyRegressionTest): """ return ( sn.assert_found( - r"Successfully installed (?!gprMax)", self.stdout, "Failed to install requirements" + r"(Successfully installed pip)|(Requirement already satisfied: pip.*\n(?!Collecting pip))", + self.stdout, + "Failed to update pip", + ) + and sn.assert_found( + r"Successfully installed (?!(gprMax)|(pip))", + self.stdout, + "Failed to install requirements", ) and sn.assert_found( r"Successfully installed gprMax", self.stdout, "Failed to install gprMax" ) and sn.assert_not_found(r"finished with status 'error'", self.stdout) - and sn.assert_not_found(r"ERROR:", self.stderr) + and sn.assert_not_found(r"(ERROR|error):", self.stderr) ) @@ -69,8 +76,8 @@ class GprMaxBaseTest(rfm.RunOnlyRegressionTest): self.prerun_cmds.append("unset SLURM_MEM_PER_NODE") self.prerun_cmds.append("unset SLURM_MEM_PER_CPU") - # Set HOME environment variable to the work filesystem - self.env_vars["HOME"] = "${HOME/home/work}" + # Set the matplotlib cache to the work filesystem + self.env_vars["MPLCONFIGDIR"] = "${HOME/home/work}/.config/matplotlib" # TODO: Change CreatePyenvTest to a fixture instead of a test dependency @run_after("init") @@ -147,8 +154,6 @@ class GprMaxBaseTest(rfm.RunOnlyRegressionTest): class GprMaxRegressionTest(GprMaxBaseTest): - modules = ["cray-hdf5"] - input_file = variable(str) output_file = variable(str) @@ -157,6 +162,7 @@ class GprMaxRegressionTest(GprMaxBaseTest): @run_before("run", always_last=True) def setup_regression_check(self): """Build reference file path and add h5diff command to run after the test""" + self.modules.append("cray-hdf5") self.reference_file = Path("regression_checks", self.unique_name).with_suffix(".h5") self.reference_file = os.path.abspath(self.reference_file) if os.path.exists(self.reference_file): diff --git a/requirements.txt b/requirements.txt index 646b4e9a..fcd838ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,6 @@ matplotlib numpy numpy-stl pandas -pip pre-commit psutil # pycuda From b23ab58f598700ecee3c74eb0f2623287836047b Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 15 Apr 2024 14:04:25 +0100 Subject: [PATCH 36/37] Move GridBuilder functionality into FDTDGrid --- gprMax/gprMax.py | 1 - gprMax/grid/fdtd_grid.py | 316 +++++++++++++++++++++++++++++++++++---- gprMax/model.py | 205 +------------------------ gprMax/subgrids/grid.py | 21 +++ 4 files changed, 314 insertions(+), 229 deletions(-) diff --git a/gprMax/gprMax.py b/gprMax/gprMax.py index 122fb3d7..bc3fec28 100644 --- a/gprMax/gprMax.py +++ b/gprMax/gprMax.py @@ -15,7 +15,6 @@ # # You should have received a copy of the GNU General Public License # along with gprMax. If not, see . - import argparse import gprMax.config as config diff --git a/gprMax/grid/fdtd_grid.py b/gprMax/grid/fdtd_grid.py index 7156a8c9..38b24640 100644 --- a/gprMax/grid/fdtd_grid.py +++ b/gprMax/grid/fdtd_grid.py @@ -17,13 +17,31 @@ # along with gprMax. If not, see . import decimal +import itertools +import logging +import sys from collections import OrderedDict +from typing import Iterable, List, Union +import humanize import numpy as np +from terminaltables import SingleTable +from tqdm import tqdm from gprMax import config -from gprMax.pml import PML -from gprMax.utilities.utilities import fft_power, round_value +from gprMax.cython.yee_cell_build import build_electric_components, build_magnetic_components + +# from gprMax.geometry_outputs import GeometryObjects, GeometryView +from gprMax.materials import Material, process_materials +from gprMax.pml import CFS, PML, build_pml, print_pml_info +from gprMax.receivers import Rx +from gprMax.sources import HertzianDipole, MagneticDipole, Source, VoltageSource + +# from gprMax.subgrids.grid import SubGridBaseGrid +from gprMax.utilities.host_info import mem_check_build_all, mem_check_run_all +from gprMax.utilities.utilities import fft_power, get_terminal_width, round_value + +logger = logging.getLogger(__name__) class FDTDGrid: @@ -60,23 +78,218 @@ class FDTDGrid: # corrections will be different. self.pmls["thickness"] = OrderedDict((key, 10) for key in PML.boundaryIDs) - self.materials = [] + # TODO: Add type information. + # Currently importing GeometryObjects, GeometryView, and + # SubGridBaseGrid cause cyclic dependencies + self.materials: List[Material] = [] self.mixingmodels = [] self.averagevolumeobjects = True self.fractalvolumes = [] self.geometryviews = [] self.geometryobjectswrite = [] self.waveforms = [] - self.voltagesources = [] - self.hertziandipoles = [] - self.magneticdipoles = [] + self.voltagesources: List[VoltageSource] = [] + self.hertziandipoles: List[HertzianDipole] = [] + self.magneticdipoles: List[MagneticDipole] = [] self.transmissionlines = [] - self.rxs = [] - self.srcsteps = [0, 0, 0] - self.rxsteps = [0, 0, 0] + self.rxs: List[Rx] = [] + self.srcsteps: List[float] = [0, 0, 0] + self.rxsteps: List[float] = [0, 0, 0] self.snapshots = [] self.subgrids = [] + def build(self) -> None: + # Print info on any subgrids + for subgrid in self.subgrids: + subgrid.print_info() + + # Combine available grids + grids = [self] + self.subgrids + + # Check for dispersive materials (and specific type) + if config.get_model_config().materials["maxpoles"] != 0: + # TODO: This sets materials["drudelorentz"] based only the + # last grid/subgrid. Is that correct? + for grid in grids: + config.get_model_config().materials["drudelorentz"] = any( + [m for m in grid.materials if "drude" in m.type or "lorentz" in m.type] + ) + + # Set data type if any dispersive materials (must be done before memory checks) + config.get_model_config().set_dispersive_material_types() + + # Check memory requirements to build model/scene (different to memory + # requirements to run model when FractalVolumes/FractalSurfaces are + # used as these can require significant additional memory) + total_mem_build, mem_strs_build = mem_check_build_all(grids) + + # Check memory requirements to run model + total_mem_run, mem_strs_run = mem_check_run_all(grids) + + if total_mem_build > total_mem_run: + logger.info( + f'\nMemory required (estimated): {" + ".join(mem_strs_build)} + ' + f"~{humanize.naturalsize(config.get_model_config().mem_overhead)} " + f"overhead = {humanize.naturalsize(total_mem_build)}" + ) + else: + logger.info( + f'\nMemory required (estimated): {" + ".join(mem_strs_run)} + ' + f"~{humanize.naturalsize(config.get_model_config().mem_overhead)} " + f"overhead = {humanize.naturalsize(total_mem_run)}" + ) + + # Build grids + for grid in grids: + # Set default CFS parameter for PMLs if not user provided + if not grid.pmls["cfs"]: + grid.pmls["cfs"] = [CFS()] + logger.info(print_pml_info(grid)) + if not all(value == 0 for value in grid.pmls["thickness"].values()): + grid._build_pmls() + if grid.averagevolumeobjects: + grid._build_components() + grid._tm_grid_update() + grid._update_voltage_source_materials() + grid.initialise_field_arrays() + grid.initialise_std_update_coeff_arrays() + if config.get_model_config().materials["maxpoles"] > 0: + grid.initialise_dispersive_arrays() + grid.initialise_dispersive_update_coeff_array() + grid._build_materials() + + # Check to see if numerical dispersion might be a problem + results = dispersion_analysis(grid) + if results["error"]: + logger.warning( + f"\nNumerical dispersion analysis [{grid.name}] " + f"not carried out as {results['error']}" + ) + elif results["N"] < config.get_model_config().numdispersion["mingridsampling"]: + logger.exception( + f"\nNon-physical wave propagation in [{grid.name}] " + f"detected. Material '{results['material'].ID}' " + f"has wavelength sampled by {results['N']} cells, " + f"less than required minimum for physical wave " + f"propagation. Maximum significant frequency " + f"estimated as {results['maxfreq']:g}Hz" + ) + raise ValueError + elif ( + results["deltavp"] + and np.abs(results["deltavp"]) + > config.get_model_config().numdispersion["maxnumericaldisp"] + ): + logger.warning( + f"\n[{grid.name}] has potentially significant " + f"numerical dispersion. Estimated largest physical " + f"phase-velocity error is {results['deltavp']:.2f}% " + f"in material '{results['material'].ID}' whose " + f"wavelength sampled by {results['N']} cells. " + f"Maximum significant frequency estimated as " + f"{results['maxfreq']:g}Hz" + ) + elif results["deltavp"]: + logger.info( + f"\nNumerical dispersion analysis [{grid.name}]: " + f"estimated largest physical phase-velocity error is " + f"{results['deltavp']:.2f}% in material '{results['material'].ID}' " + f"whose wavelength sampled by {results['N']} cells. " + f"Maximum significant frequency estimated as " + f"{results['maxfreq']:g}Hz" + ) + + def _build_pmls(self) -> None: + pbar = tqdm( + total=sum(1 for value in self.pmls["thickness"].values() if value > 0), + desc=f"Building PML boundaries [{self.name}]", + ncols=get_terminal_width() - 1, + file=sys.stdout, + disable=not config.sim_config.general["progressbars"], + ) + for pml_id, thickness in self.pmls["thickness"].items(): + if thickness > 0: + build_pml(self, pml_id, thickness) + pbar.update() + pbar.close() + + def _build_components(self) -> None: + # Build the model, i.e. set the material properties (ID) for every edge + # of every Yee cell + logger.info("") + pbar = tqdm( + total=2, + desc=f"Building Yee cells [{self.name}]", + ncols=get_terminal_width() - 1, + file=sys.stdout, + disable=not config.sim_config.general["progressbars"], + ) + build_electric_components(self.solid, self.rigidE, self.ID, self) + pbar.update() + build_magnetic_components(self.solid, self.rigidH, self.ID, self) + pbar.update() + pbar.close() + + def _tm_grid_update(self) -> None: + if config.get_model_config().mode == "2D TMx": + self.tmx() + elif config.get_model_config().mode == "2D TMy": + self.tmy() + elif config.get_model_config().mode == "2D TMz": + self.tmz() + + def _update_voltage_source_materials(self): + # Process any voltage sources (that have resistance) to create a new + # material at the source location + for voltagesource in self.voltagesources: + voltagesource.create_material(self) + + def _build_materials(self) -> None: + # Process complete list of materials - calculate update coefficients, + # store in arrays, and build text list of materials/properties + materialsdata = process_materials(self) + materialstable = SingleTable(materialsdata) + materialstable.outer_border = False + materialstable.justify_columns[0] = "right" + + logger.info(f"\nMaterials [{self.name}]:") + logger.info(materialstable.table) + + def _update_positions( + self, items: Iterable[Union[Source, Rx]], step_size: List[float], step_number: int + ) -> None: + if step_size[0] != 0 or step_size[1] != 0 or step_size[2] != 0: + for item in items: + if step_number == 0: + if ( + item.xcoord + self.srcsteps[0] * config.sim_config.model_end < 0 + or item.xcoord + self.srcsteps[0] * config.sim_config.model_end > self.nx + or item.ycoord + self.srcsteps[1] * config.sim_config.model_end < 0 + or item.ycoord + self.srcsteps[1] * config.sim_config.model_end > self.ny + or item.zcoord + self.srcsteps[2] * config.sim_config.model_end < 0 + or item.zcoord + self.srcsteps[2] * config.sim_config.model_end > self.nz + ): + raise ValueError + item.xcoord = item.xcoordorigin + step_number * step_size[0] + item.ycoord = item.ycoordorigin + step_number * step_size[1] + item.zcoord = item.zcoordorigin + step_number * step_size[2] + + def update_simple_source_positions(self, step: int = 0) -> None: + try: + self._update_positions( + itertools.chain(self.hertziandipoles, self.magneticdipoles), self.srcsteps, step + ) + except ValueError as e: + logger.exception("Source(s) will be stepped to a position outside the domain.") + raise ValueError from e + + def update_receiver_positions(self, step: int = 0) -> None: + try: + self._update_positions(self.rxs, self.rxsteps, step) + except ValueError as e: + logger.exception("Receiver(s) will be stepped to a position outside the domain.") + raise ValueError from e + def within_bounds(self, p): if p[0] < 0 or p[0] > self.nx: raise ValueError("x") @@ -124,30 +337,67 @@ class FDTDGrid: def initialise_field_arrays(self): """Initialise arrays for the electric and magnetic field components.""" - self.Ex = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Ey = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Ez = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hx = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hy = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) - self.Hz = np.zeros((self.nx + 1, self.ny + 1, self.nz + 1), dtype=config.sim_config.dtypes["float_or_double"]) + self.Ex = np.zeros( + (self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.sim_config.dtypes["float_or_double"], + ) + self.Ey = np.zeros( + (self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.sim_config.dtypes["float_or_double"], + ) + self.Ez = np.zeros( + (self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.sim_config.dtypes["float_or_double"], + ) + self.Hx = np.zeros( + (self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.sim_config.dtypes["float_or_double"], + ) + self.Hy = np.zeros( + (self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.sim_config.dtypes["float_or_double"], + ) + self.Hz = np.zeros( + (self.nx + 1, self.ny + 1, self.nz + 1), + dtype=config.sim_config.dtypes["float_or_double"], + ) def initialise_std_update_coeff_arrays(self): """Initialise arrays for storing update coefficients.""" - self.updatecoeffsE = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) - self.updatecoeffsH = np.zeros((len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"]) + self.updatecoeffsE = np.zeros( + (len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"] + ) + self.updatecoeffsH = np.zeros( + (len(self.materials), 5), dtype=config.sim_config.dtypes["float_or_double"] + ) def initialise_dispersive_arrays(self): """Initialise field arrays when there are dispersive materials present.""" self.Tx = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + ( + config.get_model_config().materials["maxpoles"], + self.nx + 1, + self.ny + 1, + self.nz + 1, + ), dtype=config.get_model_config().materials["dispersivedtype"], ) self.Ty = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + ( + config.get_model_config().materials["maxpoles"], + self.nx + 1, + self.ny + 1, + self.nz + 1, + ), dtype=config.get_model_config().materials["dispersivedtype"], ) self.Tz = np.zeros( - (config.get_model_config().materials["maxpoles"], self.nx + 1, self.ny + 1, self.nz + 1), + ( + config.get_model_config().materials["maxpoles"], + self.nx + 1, + self.ny + 1, + self.nz + 1, + ), dtype=config.get_model_config().materials["dispersivedtype"], ) @@ -284,14 +534,21 @@ class FDTDGrid: def calculate_dt(self): """Calculate time step at the CFL limit.""" if config.get_model_config().mode == "2D TMx": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dy**2) + (1 / self.dz**2))) + self.dt = 1 / ( + config.sim_config.em_consts["c"] * np.sqrt((1 / self.dy**2) + (1 / self.dz**2)) + ) elif config.get_model_config().mode == "2D TMy": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dz**2))) + self.dt = 1 / ( + config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dz**2)) + ) elif config.get_model_config().mode == "2D TMz": - self.dt = 1 / (config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2))) + self.dt = 1 / ( + config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2)) + ) else: self.dt = 1 / ( - config.sim_config.em_consts["c"] * np.sqrt((1 / self.dx**2) + (1 / self.dy**2) + (1 / self.dz**2)) + config.sim_config.em_consts["c"] + * np.sqrt((1 / self.dx**2) + (1 / self.dy**2) + (1 / self.dz**2)) ) # Round down time step to nearest float with precision one less than @@ -351,7 +608,8 @@ def dispersion_analysis(G): try: freqthres = ( np.where( - power[freqmaxpower:] < -config.get_model_config().numdispersion["highestfreqthres"] + power[freqmaxpower:] + < -config.get_model_config().numdispersion["highestfreqthres"] )[0][0] + freqmaxpower ) @@ -370,7 +628,8 @@ def dispersion_analysis(G): # If waveform is truncated don't do any further analysis else: results["error"] = ( - "waveform does not fit within specified " + "time window and is therefore being truncated." + "waveform does not fit within specified " + + "time window and is therefore being truncated." ) else: results["error"] = "no waveform detected." @@ -418,7 +677,10 @@ def dispersion_analysis(G): results["N"] = minwavelength / delta # Check grid sampling will result in physical wave propagation - if int(np.floor(results["N"])) >= config.get_model_config().numdispersion["mingridsampling"]: + if ( + int(np.floor(results["N"])) + >= config.get_model_config().numdispersion["mingridsampling"] + ): # Numerical phase velocity vp = np.pi / (results["N"] * np.arcsin((1 / S) * np.sin((np.pi * S) / results["N"]))) diff --git a/gprMax/model.py b/gprMax/model.py index 91468498..67adce00 100644 --- a/gprMax/model.py +++ b/gprMax/model.py @@ -94,44 +94,9 @@ class Model: ) # Adjust position of simple sources and receivers if required - if G.srcsteps[0] != 0 or G.srcsteps[1] != 0 or G.srcsteps[2] != 0: - model_num = config.sim_config.current_model - for source in itertools.chain(G.hertziandipoles, G.magneticdipoles): - if model_num == 0: - if ( - source.xcoord + G.srcsteps[0] * config.sim_config.model_end < 0 - or source.xcoord + G.srcsteps[0] * config.sim_config.model_end > G.nx - or source.ycoord + G.srcsteps[1] * config.sim_config.model_end < 0 - or source.ycoord + G.srcsteps[1] * config.sim_config.model_end > G.ny - or source.zcoord + G.srcsteps[2] * config.sim_config.model_end < 0 - or source.zcoord + G.srcsteps[2] * config.sim_config.model_end > G.nz - ): - logger.exception( - "Source(s) will be stepped to a position outside the domain." - ) - raise ValueError - source.xcoord = source.xcoordorigin + model_num * G.srcsteps[0] - source.ycoord = source.ycoordorigin + model_num * G.srcsteps[1] - source.zcoord = source.zcoordorigin + model_num * G.srcsteps[2] - if G.rxsteps[0] != 0 or G.rxsteps[1] != 0 or G.rxsteps[2] != 0: - model_num = config.sim_config.current_model - for receiver in G.rxs: - if model_num == 0: - if ( - receiver.xcoord + G.rxsteps[0] * config.sim_config.model_end < 0 - or receiver.xcoord + G.rxsteps[0] * config.sim_config.model_end > G.nx - or receiver.ycoord + G.rxsteps[1] * config.sim_config.model_end < 0 - or receiver.ycoord + G.rxsteps[1] * config.sim_config.model_end > G.ny - or receiver.zcoord + G.rxsteps[2] * config.sim_config.model_end < 0 - or receiver.zcoord + G.rxsteps[2] * config.sim_config.model_end > G.nz - ): - logger.exception( - "Receiver(s) will be stepped to a position outside the domain." - ) - raise ValueError - receiver.xcoord = receiver.xcoordorigin + model_num * G.rxsteps[0] - receiver.ycoord = receiver.ycoordorigin + model_num * G.rxsteps[1] - receiver.zcoord = receiver.zcoordorigin + model_num * G.rxsteps[2] + model_num = config.sim_config.current_model + G.update_simple_source_positions(step=model_num) + G.update_receiver_positions(step=model_num) # Write files for any geometry views and geometry object outputs gvs = G.geometryviews + [gv for sg in G.subgrids for gv in sg.geometryviews] @@ -158,109 +123,8 @@ class Model: logger.info("") def build_geometry(self): - G = self.G - logger.info(config.get_model_config().inputfilestr) - - # Print info on any subgrids - for sg in G.subgrids: - sg.print_info() - - # Combine available grids - grids = [G] + G.subgrids - - # Check for dispersive materials (and specific type) - for grid in grids: - if config.get_model_config().materials["maxpoles"] != 0: - config.get_model_config().materials["drudelorentz"] = any( - [m for m in grid.materials if "drude" in m.type or "lorentz" in m.type] - ) - - # Set data type if any dispersive materials (must be done before memory checks) - if config.get_model_config().materials["maxpoles"] != 0: - config.get_model_config().set_dispersive_material_types() - - # Check memory requirements to build model/scene (different to memory - # requirements to run model when FractalVolumes/FractalSurfaces are - # used as these can require significant additional memory) - total_mem_build, mem_strs_build = mem_check_build_all(grids) - - # Check memory requirements to run model - total_mem_run, mem_strs_run = mem_check_run_all(grids) - - if total_mem_build > total_mem_run: - logger.info( - f'\nMemory required (estimated): {" + ".join(mem_strs_build)} + ' - f"~{humanize.naturalsize(config.get_model_config().mem_overhead)} " - f"overhead = {humanize.naturalsize(total_mem_build)}" - ) - else: - logger.info( - f'\nMemory required (estimated): {" + ".join(mem_strs_run)} + ' - f"~{humanize.naturalsize(config.get_model_config().mem_overhead)} " - f"overhead = {humanize.naturalsize(total_mem_run)}" - ) - - # Build grids - gridbuilders = [GridBuilder(grid) for grid in grids] - for gb in gridbuilders: - # Set default CFS parameter for PMLs if not user provided - if not gb.grid.pmls["cfs"]: - gb.grid.pmls["cfs"] = [CFS()] - logger.info(print_pml_info(gb.grid)) - if not all(value == 0 for value in gb.grid.pmls["thickness"].values()): - gb.build_pmls() - if gb.grid.averagevolumeobjects: - gb.build_components() - gb.tm_grid_update() - gb.update_voltage_source_materials() - gb.grid.initialise_field_arrays() - gb.grid.initialise_std_update_coeff_arrays() - if config.get_model_config().materials["maxpoles"] > 0: - gb.grid.initialise_dispersive_arrays() - gb.grid.initialise_dispersive_update_coeff_array() - gb.build_materials() - - # Check to see if numerical dispersion might be a problem - results = dispersion_analysis(gb.grid) - if results["error"]: - logger.warning( - f"\nNumerical dispersion analysis [{gb.grid.name}] " - f"not carried out as {results['error']}" - ) - elif results["N"] < config.get_model_config().numdispersion["mingridsampling"]: - logger.exception( - f"\nNon-physical wave propagation in [{gb.grid.name}] " - f"detected. Material '{results['material'].ID}' " - f"has wavelength sampled by {results['N']} cells, " - f"less than required minimum for physical wave " - f"propagation. Maximum significant frequency " - f"estimated as {results['maxfreq']:g}Hz" - ) - raise ValueError - elif ( - results["deltavp"] - and np.abs(results["deltavp"]) - > config.get_model_config().numdispersion["maxnumericaldisp"] - ): - logger.warning( - f"\n[{gb.grid.name}] has potentially significant " - f"numerical dispersion. Estimated largest physical " - f"phase-velocity error is {results['deltavp']:.2f}% " - f"in material '{results['material'].ID}' whose " - f"wavelength sampled by {results['N']} cells. " - f"Maximum significant frequency estimated as " - f"{results['maxfreq']:g}Hz" - ) - elif results["deltavp"]: - logger.info( - f"\nNumerical dispersion analysis [{gb.grid.name}]: " - f"estimated largest physical phase-velocity error is " - f"{results['deltavp']:.2f}% in material '{results['material'].ID}' " - f"whose wavelength sampled by {results['N']} cells. " - f"Maximum significant frequency estimated as " - f"{results['maxfreq']:g}Hz" - ) + self.G.build() def reuse_geometry(self): s = ( @@ -371,64 +235,3 @@ class Model: f"Time taken: " + f"{humanize.precisedelta(datetime.timedelta(seconds=solver.solvetime), format='%0.4f')}" ) - - -class GridBuilder: - def __init__(self, grid): - self.grid = grid - - def build_pmls(self): - pbar = tqdm( - total=sum(1 for value in self.grid.pmls["thickness"].values() if value > 0), - desc=f"Building PML boundaries [{self.grid.name}]", - ncols=get_terminal_width() - 1, - file=sys.stdout, - disable=not config.sim_config.general["progressbars"], - ) - for pml_id, thickness in self.grid.pmls["thickness"].items(): - if thickness > 0: - build_pml(self.grid, pml_id, thickness) - pbar.update() - pbar.close() - - def build_components(self): - # Build the model, i.e. set the material properties (ID) for every edge - # of every Yee cell - logger.info("") - pbar = tqdm( - total=2, - desc=f"Building Yee cells [{self.grid.name}]", - ncols=get_terminal_width() - 1, - file=sys.stdout, - disable=not config.sim_config.general["progressbars"], - ) - build_electric_components(self.grid.solid, self.grid.rigidE, self.grid.ID, self.grid) - pbar.update() - build_magnetic_components(self.grid.solid, self.grid.rigidH, self.grid.ID, self.grid) - pbar.update() - pbar.close() - - def tm_grid_update(self): - if config.get_model_config().mode == "2D TMx": - self.grid.tmx() - elif config.get_model_config().mode == "2D TMy": - self.grid.tmy() - elif config.get_model_config().mode == "2D TMz": - self.grid.tmz() - - def update_voltage_source_materials(self): - # Process any voltage sources (that have resistance) to create a new - # material at the source location - for voltagesource in self.grid.voltagesources: - voltagesource.create_material(self.grid) - - def build_materials(self): - # Process complete list of materials - calculate update coefficients, - # store in arrays, and build text list of materials/properties - materialsdata = process_materials(self.grid) - materialstable = SingleTable(materialsdata) - materialstable.outer_border = False - materialstable.justify_columns[0] = "right" - - logger.info(f"\nMaterials [{self.grid.name}]:") - logger.info(materialstable.table) diff --git a/gprMax/subgrids/grid.py b/gprMax/subgrids/grid.py index b0e51563..1ac04fa3 100644 --- a/gprMax/subgrids/grid.py +++ b/gprMax/subgrids/grid.py @@ -17,6 +17,7 @@ # along with gprMax. If not, see . import logging +from abc import abstractmethod from gprMax.grid.fdtd_grid import FDTDGrid @@ -62,3 +63,23 @@ class SubGridBaseGrid(FDTDGrid): self.n_boundary_cells_z = d_to_pml + self.pmls["thickness"]["z0"] self.interpolation = kwargs["interpolation"] + + @abstractmethod + def update_magnetic_is(self, precursors): + pass + + @abstractmethod + def update_electric_is(self, precursors): + pass + + @abstractmethod + def update_electric_os(self, main_grid): + pass + + @abstractmethod + def update_magnetic_os(self, main_grid): + pass + + @abstractmethod + def print_info(self): + pass From 38d25e53776f3e0e463f50cd1357fc5065571569 Mon Sep 17 00:00:00 2001 From: nmannall Date: Mon, 15 Apr 2024 14:05:19 +0100 Subject: [PATCH 37/37] Fix CreatePyenvTest failing on ARCHER2 --- reframe_tests/base_tests.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/reframe_tests/base_tests.py b/reframe_tests/base_tests.py index 534c3dd6..9cf78c5b 100644 --- a/reframe_tests/base_tests.py +++ b/reframe_tests/base_tests.py @@ -34,6 +34,20 @@ class CreatePyenvTest(rfm.RunOnlyRegressionTest): ] executable = f"CC=cc CXX=CC FC=ftn python -m pip install -e {GPRMAX_ROOT_DIR}" + @run_after("init") + def install_system_specific_dependencies(self): + """Install additional dependencies for specific systems""" + if self.current_system.name == "archer2": + """ + Needed to prevent a pip install error. + dask 2022.2.1 (installed) requires cloudpickle>=1.1.1, which + is not installed and is missed by the pip dependency checks. + + Not necessary for gprMax, but any error message is picked up + by the sanity checks. + """ + self.prerun_cmds.insert(3, "CC=cc CXX=CC FC=ftn python -m pip install cloudpickle") + @sanity_function def check_requirements_installed(self): """