diff --git a/gprMax/__init__.py b/gprMax/__init__.py index b97fa2dc..ac917412 100644 --- a/gprMax/__init__.py +++ b/gprMax/__init__.py @@ -10,28 +10,29 @@ Electromagnetic wave propagation simulation software. import gprMax.config as config from ._version import __version__ -from .cmds_geometry.add_grass import AddGrass -from .cmds_geometry.add_surface_roughness import AddSurfaceRoughness -from .cmds_geometry.add_surface_water import AddSurfaceWater -from .cmds_geometry.box import Box -from .cmds_geometry.cone import Cone -from .cmds_geometry.cylinder import Cylinder -from .cmds_geometry.cylindrical_sector import CylindricalSector -from .cmds_geometry.edge import Edge -from .cmds_geometry.ellipsoid import Ellipsoid -from .cmds_geometry.fractal_box import FractalBox -from .cmds_geometry.geometry_objects_read import GeometryObjectsRead -from .cmds_geometry.plate import Plate -from .cmds_geometry.sphere import Sphere -from .cmds_geometry.triangle import Triangle -from .cmds_multiuse import ( +from .gprMax import run as run +from .scene import Scene +from .subgrids.user_objects import SubGridHSG +from .user_objects.cmds_geometry.add_grass import AddGrass +from .user_objects.cmds_geometry.add_surface_roughness import AddSurfaceRoughness +from .user_objects.cmds_geometry.add_surface_water import AddSurfaceWater +from .user_objects.cmds_geometry.box import Box +from .user_objects.cmds_geometry.cone import Cone +from .user_objects.cmds_geometry.cylinder import Cylinder +from .user_objects.cmds_geometry.cylindrical_sector import CylindricalSector +from .user_objects.cmds_geometry.edge import Edge +from .user_objects.cmds_geometry.ellipsoid import Ellipsoid +from .user_objects.cmds_geometry.fractal_box import FractalBox +from .user_objects.cmds_geometry.geometry_objects_read import GeometryObjectsRead +from .user_objects.cmds_geometry.plate import Plate +from .user_objects.cmds_geometry.sphere import Sphere +from .user_objects.cmds_geometry.triangle import Triangle +from .user_objects.cmds_multiuse import ( PMLCFS, AddDebyeDispersion, AddDrudeDispersion, AddLorentzDispersion, ExcitationFile, - GeometryObjectsWrite, - GeometryView, HertzianDipole, MagneticDipole, Material, @@ -41,12 +42,12 @@ from .cmds_multiuse import ( RxArray, Snapshot, SoilPeplinski, - Subgrid, TransmissionLine, VoltageSource, Waveform, ) -from .cmds_singleuse import ( +from .user_objects.cmds_output import GeometryObjectsWrite, GeometryView +from .user_objects.cmds_singleuse import ( Discretisation, Domain, OMPThreads, @@ -58,8 +59,5 @@ from .cmds_singleuse import ( TimeWindow, Title, ) -from .gprMax import run as run -from .scene import Scene -from .subgrids.user_objects import SubGridHSG __name__ = "gprMax" diff --git a/gprMax/cmds_singleuse.py b/gprMax/cmds_singleuse.py deleted file mode 100644 index e39bdf29..00000000 --- a/gprMax/cmds_singleuse.py +++ /dev/null @@ -1,409 +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 abc import ABC, abstractmethod - -import numpy as np - -import gprMax.config as config -from gprMax.grid.mpi_grid import MPIGrid -from gprMax.model import Model -from gprMax.user_inputs import MainGridUserInput - -from .pml import PML -from .utilities.host_info import set_omp_threads - -logger = logging.getLogger(__name__) - - -class Properties: - pass - - -class UserObjectSingle(ABC): - """Object that can only occur a single time in a model.""" - - def __init__(self, **kwargs): - # Each single command has an order to specify the order in which - # the commands are constructed, e.g. discretisation must be - # created before the domain - self.order = 0 - self.kwargs = kwargs - self.props = Properties() - self.autotranslate = True - - for k, v in kwargs.items(): - setattr(self.props, k, v) - - @abstractmethod - def build(self, model: Model, uip: MainGridUserInput): - pass - - # TODO: Check if this is actually needed - def rotate(self, axis, angle, origin=None): - pass - - -class Title(UserObjectSingle): - """Includes a title for your model. - - Attributes: - name: string for model title. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 1 - - def build(self, model, uip): - try: - title = self.kwargs["name"] - model.title = title - logger.info(f"Model title: {model.title}") - except KeyError: - pass - - -class Discretisation(UserObjectSingle): - """Specifies the discretization of space in the x, y, and z directions. - - Attributes: - p1: tuple of floats to specify spatial discretisation in x, y, z direction. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 2 - - def build(self, model, uip): - try: - model.dl = np.array(self.kwargs["p1"], dtype=np.float64) - except KeyError: - logger.exception(f"{self.__str__()} discretisation requires a point") - raise - - if model.dl[0] <= 0: - logger.exception( - f"{self.__str__()} discretisation requires the " - f"x-direction spatial step to be greater than zero" - ) - raise ValueError - if model.dl[1] <= 0: - logger.exception( - f"{self.__str__()} discretisation requires the " - f"y-direction spatial step to be greater than zero" - ) - raise ValueError - if model.dl[2] <= 0: - logger.exception( - f"{self.__str__()} discretisation requires the " - f"z-direction spatial step to be greater than zero" - ) - raise ValueError - - logger.info(f"Spatial discretisation: {model.dl[0]:g} x {model.dl[1]:g} x {model.dl[2]:g}m") - - -class Domain(UserObjectSingle): - """Specifies the size of the model. - - Attributes: - p1: tuple of floats specifying extent of model domain (x, y, z). - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 3 - - def build(self, model, uip): - try: - model.nx, model.ny, model.nz = uip.discretise_point(self.kwargs["p1"]) - # TODO: Remove when distribute full build for MPI - if isinstance(model.G, MPIGrid): - model.G.nx = model.nx - model.G.ny = model.ny - model.G.nz = model.nz - - except KeyError: - logger.exception(f"{self.__str__()} please specify a point") - raise - - if model.nx == 0 or model.ny == 0 or model.nz == 0: - logger.exception(f"{self.__str__()} requires at least one cell in every dimension") - raise ValueError - - logger.info( - f"Domain size: {self.kwargs['p1'][0]:g} x {self.kwargs['p1'][1]:g} x " - + f"{self.kwargs['p1'][2]:g}m ({model.nx:d} x {model.ny:d} x {model.nz:d} = " - + f"{(model.nx * model.ny * model.nz):g} cells)" - ) - - # Calculate time step at CFL limit; switch off appropriate PMLs for 2D - G = model.G - if model.nx == 1: - config.get_model_config().mode = "2D TMx" - G.pmls["thickness"]["x0"] = 0 - G.pmls["thickness"]["xmax"] = 0 - elif model.ny == 1: - config.get_model_config().mode = "2D TMy" - G.pmls["thickness"]["y0"] = 0 - G.pmls["thickness"]["ymax"] = 0 - elif model.nz == 1: - config.get_model_config().mode = "2D TMz" - G.pmls["thickness"]["z0"] = 0 - G.pmls["thickness"]["zmax"] = 0 - else: - config.get_model_config().mode = "3D" - G.calculate_dt() - - logger.info(f"Mode: {config.get_model_config().mode}") - - # Sub-grids cannot be used with 2D models. There would typically be - # minimal performance benefit with sub-gridding and 2D models. - if "2D" in config.get_model_config().mode and config.sim_config.general["subgrid"]: - logger.exception("Sub-gridding cannot be used with 2D models") - raise ValueError - - logger.info(f"Time step (at CFL limit): {G.dt:g} secs") - - -class TimeStepStabilityFactor(UserObjectSingle): - """Factor by which to reduce the time step from the CFL limit. - - Attributes: - f: float for factor to multiply time step. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 4 - - def build(self, model, uip): - try: - f = self.kwargs["f"] - except KeyError: - logger.exception(f"{self.__str__()} requires exactly one parameter") - raise - - if f <= 0 or f > 1: - logger.exception( - f"{self.__str__()} requires the value of the time " - f"step stability factor to be between zero and one" - ) - raise ValueError - - model.dt_mod = f - model.dt *= model.dt_mod - - logger.info(f"Time step (modified): {model.dt:g} secs") - - -class TimeWindow(UserObjectSingle): - """Specifies the total required simulated time. - - Attributes: - time: float of required simulated time in seconds. - iterations: int of required number of iterations. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 5 - - def build(self, model, uip): - # If number of iterations given - # The +/- 1 used in calculating the number of iterations is to account for - # the fact that the solver (iterations) loop runs from 0 to < G.iterations - try: - iterations = int(self.kwargs["iterations"]) - model.timewindow = (iterations - 1) * model.dt - model.iterations = iterations - except KeyError: - pass - - try: - tmp = float(self.kwargs["time"]) - if tmp > 0: - model.timewindow = tmp - model.iterations = int(np.ceil(tmp / model.dt)) + 1 - else: - logger.exception(self.__str__() + " must have a value greater than zero") - raise ValueError - except KeyError: - pass - - if not model.timewindow: - logger.exception(self.__str__() + " specify a time or number of iterations") - raise ValueError - - logger.info(f"Time window: {model.timewindow:g} secs ({model.iterations} iterations)") - - -class OMPThreads(UserObjectSingle): - """Controls how many OpenMP threads (usually the number of physical CPU - cores available) are used when running the model. - - Attributes: - n: int for number of threads. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 6 - - def build(self, model, uip): - try: - n = self.kwargs["n"] - except KeyError: - logger.exception( - f"{self.__str__()} requires exactly one parameter " - f"to specify the number of CPU OpenMP threads to use" - ) - raise - if n < 1: - logger.exception( - f"{self.__str__()} requires the value to be an " f"integer not less than one" - ) - raise ValueError - - config.get_model_config().ompthreads = set_omp_threads(n) - - -class PMLProps(UserObjectSingle): - """Specifies the formulation used and thickness (number of cells) of PML - that are used on the six sides of the model domain. Current options are - to use the Higher Order RIPML (HORIPML) - https://doi.org/10.1109/TAP.2011.2180344, - or Multipole RIPML (MRIPML) - https://doi.org/10.1109/TAP.2018.2823864. - - Attributes: - formulation: string specifying formulation to be used for all PMLs - either 'HORIPML' or 'MRIPML'. - thickness or x0, y0, z0, xmax, ymax, zmax: ints for thickness of PML - on all 6 sides or individual - sides of the model domain. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 7 - - def build(self, model, uip): - G = model.G - try: - G.pmls["formulation"] = self.kwargs["formulation"] - if G.pmls["formulation"] not in PML.formulations: - logger.exception( - self.__str__() - + f" requires the value to be " - + f"one of {' '.join(PML.formulations)}" - ) - except KeyError: - pass - - try: - thickness = self.kwargs["thickness"] - for key in G.pmls["thickness"].keys(): - G.pmls["thickness"][key] = int(thickness) - - except KeyError: - try: - G.pmls["thickness"]["x0"] = int(self.kwargs["x0"]) - G.pmls["thickness"]["y0"] = int(self.kwargs["y0"]) - G.pmls["thickness"]["z0"] = int(self.kwargs["z0"]) - G.pmls["thickness"]["xmax"] = int(self.kwargs["xmax"]) - G.pmls["thickness"]["ymax"] = int(self.kwargs["ymax"]) - G.pmls["thickness"]["zmax"] = int(self.kwargs["zmax"]) - except KeyError: - logger.exception(f"{self.__str__()} requires either one or six parameter(s)") - raise - - if ( - 2 * G.pmls["thickness"]["x0"] >= G.nx - or 2 * G.pmls["thickness"]["y0"] >= G.ny - or 2 * G.pmls["thickness"]["z0"] >= G.nz - or 2 * G.pmls["thickness"]["xmax"] >= G.nx - or 2 * G.pmls["thickness"]["ymax"] >= G.ny - or 2 * G.pmls["thickness"]["zmax"] >= G.nz - ): - logger.exception(f"{self.__str__()} has too many cells for the domain size") - raise ValueError - - -class SrcSteps(UserObjectSingle): - """Moves the location of all simple sources. - - Attributes: - p1: tuple of float increments (x,y,z) to move all simple sources. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 8 - - def build(self, model, uip): - try: - model.srcsteps = uip.discretise_point(self.kwargs["p1"]) - except KeyError: - logger.exception(f"{self.__str__()} requires exactly three parameters") - raise - - logger.info( - f"Simple sources will step {model.srcsteps[0] * model.dx:g}m, " - f"{model.srcsteps[1] * model.dy:g}m, {model.srcsteps[2] * model.dz:g}m " - "for each model run." - ) - - -class RxSteps(UserObjectSingle): - """Moves the location of all receivers. - - Attributes: - p1: tuple of float increments (x,y,z) to move all receivers. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 9 - - def build(self, model, uip): - try: - model.rxsteps = uip.discretise_point(self.kwargs["p1"]) - except KeyError: - logger.exception(f"{self.__str__()} requires exactly three parameters") - raise - - logger.info( - f"All receivers will step {model.rxsteps[0] * model.dx:g}m, " - f"{model.rxsteps[1] * model.dy:g}m, {model.rxsteps[2] * model.dz:g}m " - "for each model run." - ) - - -class OutputDir(UserObjectSingle): - """Controls the directory where output file(s) will be stored. - - Attributes: - dir: string of file path to directory. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 10 - - def build(self, grid, uip): - config.get_model_config().set_output_file_path(self.kwargs["dir"]) diff --git a/gprMax/grid/fdtd_grid.py b/gprMax/grid/fdtd_grid.py index 60004e7f..0813da0f 100644 --- a/gprMax/grid/fdtd_grid.py +++ b/gprMax/grid/fdtd_grid.py @@ -61,6 +61,9 @@ class FDTDGrid: self.dl = np.ones(3, dtype=np.float64) self.dt = 0.0 + self.iterations = 0 # Total number of iterations + self.timewindow = 0.0 + # Field Arrays self.Ex: npt.NDArray[np.float32] self.Ey: npt.NDArray[np.float32] @@ -362,7 +365,7 @@ class FDTDGrid: logger.info(f"Materials [{self.name}]:\n{materialstable.table}\n") def _update_positions( - self, items: Iterable[Union[Source, Rx]], step_size: List[int], step_number: int + self, items: Iterable[Union[Source, Rx]], step_size: npt.NDArray[np.int32], step_number: int ) -> None: """Update the grid positions of the provided items. @@ -387,11 +390,11 @@ class FDTDGrid: or item.zcoord + step_size[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] + item.coord = item.coordorigin + step_number * step_size - def update_simple_source_positions(self, step_size: List[int], step: int = 0) -> None: + def update_simple_source_positions( + self, step_size: npt.NDArray[np.int32], step: int = 0 + ) -> None: """Update the positions of sources in the grid. Move hertzian dipole and magnetic dipole sources. Transmission @@ -414,7 +417,7 @@ class FDTDGrid: logger.exception("Source(s) will be stepped to a position outside the domain.") raise ValueError from e - def update_receiver_positions(self, step_size: List[int], step: int = 0) -> None: + def update_receiver_positions(self, step_size: npt.NDArray[np.int32], step: int = 0) -> None: """Update the positions of receivers in the grid. Args: diff --git a/gprMax/hash_cmds_geometry.py b/gprMax/hash_cmds_geometry.py index 1d53b980..1cb068d3 100644 --- a/gprMax/hash_cmds_geometry.py +++ b/gprMax/hash_cmds_geometry.py @@ -20,20 +20,20 @@ import logging import numpy as np -from .cmds_geometry.add_grass import AddGrass -from .cmds_geometry.add_surface_roughness import AddSurfaceRoughness -from .cmds_geometry.add_surface_water import AddSurfaceWater -from .cmds_geometry.box import Box -from .cmds_geometry.cmds_geometry import check_averaging -from .cmds_geometry.cone import Cone -from .cmds_geometry.cylinder import Cylinder -from .cmds_geometry.cylindrical_sector import CylindricalSector -from .cmds_geometry.edge import Edge -from .cmds_geometry.ellipsoid import Ellipsoid -from .cmds_geometry.fractal_box import FractalBox -from .cmds_geometry.plate import Plate -from .cmds_geometry.sphere import Sphere -from .cmds_geometry.triangle import Triangle +from .user_objects.cmds_geometry.add_grass import AddGrass +from .user_objects.cmds_geometry.add_surface_roughness import AddSurfaceRoughness +from .user_objects.cmds_geometry.add_surface_water import AddSurfaceWater +from .user_objects.cmds_geometry.box import Box +from .user_objects.cmds_geometry.cmds_geometry import check_averaging +from .user_objects.cmds_geometry.cone import Cone +from .user_objects.cmds_geometry.cylinder import Cylinder +from .user_objects.cmds_geometry.cylindrical_sector import CylindricalSector +from .user_objects.cmds_geometry.edge import Edge +from .user_objects.cmds_geometry.ellipsoid import Ellipsoid +from .user_objects.cmds_geometry.fractal_box import FractalBox +from .user_objects.cmds_geometry.plate import Plate +from .user_objects.cmds_geometry.sphere import Sphere +from .user_objects.cmds_geometry.triangle import Triangle from .utilities.utilities import round_value logger = logging.getLogger(__name__) @@ -57,8 +57,7 @@ def process_geometrycmds(geometry): tmp = object.split() if tmp[0] == "#geometry_objects_read:": - from .cmds_geometry.geometry_objects_read import \ - GeometryObjectsRead + from .user_objects.cmds_geometry.geometry_objects_read import GeometryObjectsRead if len(tmp) != 6: logger.exception("'" + " ".join(tmp) + "'" + " requires exactly five parameters") @@ -126,7 +125,14 @@ def process_geometrycmds(geometry): # Isotropic case with user specified averaging elif len(tmp) == 13: averaging = check_averaging(tmp[12].lower()) - triangle = Triangle(p1=p1, p2=p2, p3=p3, thickness=thickness, material_id=tmp[11], averaging=averaging) + triangle = Triangle( + p1=p1, + p2=p2, + p3=p3, + thickness=thickness, + material_id=tmp[11], + averaging=averaging, + ) # Uniaxial anisotropic case elif len(tmp) == 14: @@ -330,7 +336,9 @@ def process_geometrycmds(geometry): # Isotropic case with user specified averaging elif len(tmp) == 9: averaging = check_averaging(tmp[8].lower()) - ellipsoid = Ellipsoid(p1=p1, xr=xr, yr=yr, zr=zr, material_id=tmp[7], averaging=averaging) + ellipsoid = Ellipsoid( + p1=p1, xr=xr, yr=yr, zr=zr, material_id=tmp[7], averaging=averaging + ) # Uniaxial anisotropic case elif len(tmp) == 8: @@ -346,7 +354,9 @@ def process_geometrycmds(geometry): # Default is no dielectric smoothing for a fractal box if len(tmp) < 14: - logger.exception("'" + " ".join(tmp) + "'" + " requires at least thirteen parameters") + logger.exception( + "'" + " ".join(tmp) + "'" + " requires at least thirteen parameters" + ) raise ValueError p1 = (float(tmp[1]), float(tmp[2]), float(tmp[3])) @@ -402,7 +412,9 @@ def process_geometrycmds(geometry): if tmp[0] == "#add_surface_roughness:": if len(tmp) < 13: - logger.exception("'" + " ".join(tmp) + "'" + " requires at least twelve parameters") + logger.exception( + "'" + " ".join(tmp) + "'" + " requires at least twelve parameters" + ) raise ValueError p1 = (float(tmp[1]), float(tmp[2]), float(tmp[3])) @@ -432,14 +444,18 @@ def process_geometrycmds(geometry): seed=int(tmp[13]), ) else: - logger.exception("'" + " ".join(tmp) + "'" + " too many parameters have been given") + logger.exception( + "'" + " ".join(tmp) + "'" + " too many parameters have been given" + ) raise ValueError scene_objects.append(asr) if tmp[0] == "#add_surface_water:": if len(tmp) != 9: - logger.exception("'" + " ".join(tmp) + "'" + " requires exactly eight parameters") + logger.exception( + "'" + " ".join(tmp) + "'" + " requires exactly eight parameters" + ) raise ValueError p1 = (float(tmp[1]), float(tmp[2]), float(tmp[3])) @@ -452,7 +468,9 @@ def process_geometrycmds(geometry): if tmp[0] == "#add_grass:": if len(tmp) < 12: - logger.exception("'" + " ".join(tmp) + "'" + " requires at least eleven parameters") + logger.exception( + "'" + " ".join(tmp) + "'" + " requires at least eleven parameters" + ) raise ValueError p1 = (float(tmp[1]), float(tmp[2]), float(tmp[3])) @@ -482,7 +500,9 @@ def process_geometrycmds(geometry): seed=int(tmp[12]), ) else: - logger.exception("'" + " ".join(tmp) + "'" + " too many parameters have been given") + logger.exception( + "'" + " ".join(tmp) + "'" + " too many parameters have been given" + ) raise ValueError scene_objects.append(grass) diff --git a/gprMax/hash_cmds_multiuse.py b/gprMax/hash_cmds_multiuse.py index c933b7c4..3bb2ecee 100644 --- a/gprMax/hash_cmds_multiuse.py +++ b/gprMax/hash_cmds_multiuse.py @@ -18,14 +18,12 @@ import logging -from .cmds_multiuse import ( +from .user_objects.cmds_multiuse import ( PMLCFS, AddDebyeDispersion, AddDrudeDispersion, AddLorentzDispersion, ExcitationFile, - GeometryObjectsWrite, - GeometryView, HertzianDipole, MagneticDipole, Material, @@ -39,6 +37,7 @@ from .cmds_multiuse import ( VoltageSource, Waveform, ) +from .user_objects.cmds_output import GeometryObjectsWrite, GeometryView logger = logging.getLogger(__name__) diff --git a/gprMax/hash_cmds_singleuse.py b/gprMax/hash_cmds_singleuse.py index 2ff9eaf5..08b57b97 100644 --- a/gprMax/hash_cmds_singleuse.py +++ b/gprMax/hash_cmds_singleuse.py @@ -18,9 +18,18 @@ import logging -from .cmds_singleuse import (Discretisation, Domain, OMPThreads, OutputDir, - PMLProps, RxSteps, SrcSteps, - TimeStepStabilityFactor, TimeWindow, Title) +from .user_objects.cmds_singleuse import ( + Discretisation, + Domain, + OMPThreads, + OutputDir, + PMLProps, + RxSteps, + SrcSteps, + TimeStepStabilityFactor, + TimeWindow, + Title, +) logger = logging.getLogger(__name__) @@ -54,7 +63,9 @@ def process_singlecmds(singlecmds): if singlecmds[cmd] is not None: tmp = tuple(int(x) for x in singlecmds[cmd].split()) if len(tmp) != 1: - logger.exception(f"{cmd} requires exactly one parameter to specify the number of CPU OpenMP threads to use") + logger.exception( + f"{cmd} requires exactly one parameter to specify the number of CPU OpenMP threads to use" + ) raise ValueError omp_threads = OMPThreads(n=tmp[0]) @@ -144,7 +155,12 @@ def process_singlecmds(singlecmds): pml_props = PMLProps(thickness=int(tmp[0])) else: pml_props = PMLProps( - x0=int(tmp[0]), y0=int(tmp[1]), z0=int(tmp[2]), xmax=int(tmp[3]), ymax=int(tmp[4]), zmax=int(tmp[5]) + x0=int(tmp[0]), + y0=int(tmp[1]), + z0=int(tmp[2]), + xmax=int(tmp[3]), + ymax=int(tmp[4]), + zmax=int(tmp[5]), ) scene_objects.append(pml_props) diff --git a/gprMax/model.py b/gprMax/model.py index 82add10c..8d7012f5 100644 --- a/gprMax/model.py +++ b/gprMax/model.py @@ -56,11 +56,9 @@ class Model: self.dt_mod = 1.0 # Time step stability factor self.iteration = 0 # Current iteration number - self.iterations = 0 # Total number of iterations - self.timewindow = 0.0 - self.srcsteps: List[int] = [0, 0, 0] - self.rxsteps: List[int] = [0, 0, 0] + self.srcsteps = np.zeros(3, dtype=np.int32) + self.rxsteps = np.zeros(3, dtype=np.int32) self.G = self._create_grid() self.subgrids: List[SubGridBaseGrid] = [] @@ -141,6 +139,22 @@ class Model: def dt(self, value: float): self.G.dt = value + @property + def iterations(self) -> int: + return self.G.iterations + + @iterations.setter + def iterations(self, value: int): + self.G.iterations = value + + @property + def timewindow(self) -> float: + return self.G.timewindow + + @timewindow.setter + def timewindow(self, value: float): + self.G.timewindow = value + def _create_grid(self) -> FDTDGrid: """Create grid object according to solver. diff --git a/gprMax/scene.py b/gprMax/scene.py index 5ca94d95..5f592812 100644 --- a/gprMax/scene.py +++ b/gprMax/scene.py @@ -16,22 +16,24 @@ # You should have received a copy of the GNU General Public License # along with gprMax. If not, see . import logging -from typing import List, Optional, Union +from typing import List, Sequence -from gprMax import config -from gprMax.cmds_geometry.add_grass import AddGrass -from gprMax.cmds_geometry.add_surface_roughness import AddSurfaceRoughness -from gprMax.cmds_geometry.add_surface_water import AddSurfaceWater -from gprMax.cmds_geometry.cmds_geometry import UserObjectGeometry -from gprMax.cmds_geometry.fractal_box import FractalBox -from gprMax.cmds_multiuse import UserObjectMulti -from gprMax.cmds_singleuse import Discretisation, Domain, TimeWindow, UserObjectSingle from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.materials import create_built_in_materials from gprMax.model import Model -from gprMax.subgrids.grid import SubGridBaseGrid from gprMax.subgrids.user_objects import SubGridBase as SubGridUserBase -from gprMax.user_inputs import MainGridUserInput, SubgridUserInput +from gprMax.user_objects.cmds_geometry.add_grass import AddGrass +from gprMax.user_objects.cmds_geometry.add_surface_roughness import AddSurfaceRoughness +from gprMax.user_objects.cmds_geometry.add_surface_water import AddSurfaceWater +from gprMax.user_objects.cmds_geometry.fractal_box import FractalBox +from gprMax.user_objects.cmds_singleuse import Discretisation, Domain, TimeWindow +from gprMax.user_objects.user_objects import ( + GeometryUserObject, + GridUserObject, + ModelUserObject, + OutputUserObject, + UserObject, +) logger = logging.getLogger(__name__) @@ -39,128 +41,85 @@ logger = logging.getLogger(__name__) class Scene: """Scene stores all of the user created objects.""" - def __init__(self): - self.multiple_cmds: List[UserObjectMulti] = [] - self.single_cmds: List[UserObjectSingle] = [] - self.geometry_cmds: List[UserObjectGeometry] = [] - self.multiple_cmds: List[UserObjectMulti] = [] - self.single_cmds: List[UserObjectSingle] = [] - self.geometry_cmds: List[UserObjectGeometry] = [] - self.essential_cmds = [Domain, TimeWindow, Discretisation] + ESSENTIAL_CMDS = [Domain, TimeWindow, Discretisation] - def add(self, user_object): + def __init__(self): + self.single_use_objects: List[ModelUserObject] = [] + self.grid_objects: List[GridUserObject] = [] + self.geometry_objects: List[GeometryUserObject] = [] + self.output_objects: List[OutputUserObject] = [] + self.subgrid_objects: List[SubGridUserBase] = [] + + def add(self, user_object: UserObject): """Add the user object to the scene. Args: user_object: user object to add to the scene. For example, - :class:`gprMax.cmds_single_use.Domain` + `gprMax.user_objects.cmds_singleuse.Domain` """ - if isinstance(user_object, UserObjectMulti): - self.multiple_cmds.append(user_object) - elif isinstance(user_object, UserObjectGeometry): - self.geometry_cmds.append(user_object) - elif isinstance(user_object, UserObjectSingle): - self.single_cmds.append(user_object) + # Check for + if isinstance(user_object, SubGridUserBase): + self.subgrid_objects.append(user_object) + elif isinstance(user_object, ModelUserObject): + self.single_use_objects.append(user_object) + elif isinstance(user_object, GeometryUserObject): + self.geometry_objects.append(user_object) + elif isinstance(user_object, GridUserObject): + self.grid_objects.append(user_object) + elif isinstance(user_object, OutputUserObject): + self.output_objects.append(user_object) else: - logger.exception("This object is unknown to gprMax") - raise ValueError + raise TypeError(f"Object of type '{type(user_object)}' is unknown to gprMax") - def build_grid_obj(self, obj: UserObjectGeometry, grid: FDTDGrid): - """Builds objects in FDTDGrids. - - Args: - obj: user object - grid: FDTDGrid class describing a grid in a model. - """ - uip = create_user_input_points(grid, obj) - try: - obj.build(grid, uip) - except ValueError: - logger.exception("Error creating user input object") - raise - - def build_model_obj( - self, - obj: Union[UserObjectSingle, UserObjectMulti], - model: Model, - subgrid: Optional[FDTDGrid] = None, - ): + def build_model_objects(self, objects: Sequence[ModelUserObject], model: Model): """Builds objects in models. Args: obj: user object model: Model being built """ - - grid = model.G if subgrid is None else subgrid - uip = create_user_input_points(grid, obj) - try: - obj.build(model, uip) + for model_user_object in sorted(objects): + model_user_object.build(model) except ValueError: - logger.exception("Error creating user input object") + logger.exception(f"Error creating user object '{model_user_object}'") raise - def process_subgrid_cmds(self, model: Model): - """Process all commands in any sub-grids.""" + def build_grid_objects(self, objects: Sequence[GridUserObject], grid: FDTDGrid): + """Builds objects in FDTDGrids. - # Subgrid user objects - subgrid_cmds = [ - sg_cmd for sg_cmd in self.multiple_cmds if isinstance(sg_cmd, SubGridUserBase) - ] - subgrid_cmds = [ - sg_cmd for sg_cmd in self.multiple_cmds if isinstance(sg_cmd, SubGridUserBase) - ] + Args: + objects: user object + grid: FDTDGrid class describing a grid in a model. + """ + try: + for grid_user_object in sorted(objects): + grid_user_object.build(grid) + except ValueError: + logger.exception(f"Error creating user object '{grid_user_object}'") + raise - # Iterate through the user command objects under the subgrid user object - for sg_cmd in subgrid_cmds: - # When the subgrid is created its reference is attached to its user - # object. This reference allows the multi and geo user objects - # to build in the correct subgrid. - sg = sg_cmd.subgrid - self.process_cmds(sg_cmd.children_multiple, model, sg) - self.process_geocmds(sg_cmd.children_geometry, sg) - - def process_cmds( - self, - commands: Union[List[UserObjectMulti], List[UserObjectSingle]], - model: Model, - subgrid: Optional[SubGridBaseGrid] = None, + def build_output_objects( + self, objects: Sequence[OutputUserObject], model: Model, grid: FDTDGrid ): - """Process list of commands.""" - cmds_sorted = sorted(commands, key=lambda cmd: cmd.order) - for obj in cmds_sorted: - self.build_model_obj(obj, model, subgrid) + try: + for output_user_object in sorted(objects): + output_user_object.build(model, grid) + except ValueError: + logger.exception(f"Error creating user object '{output_user_object}'") + raise - return self - - def process_geocmds(self, commands, grid): - # Check for fractal boxes and modifications and pre-process them first - proc_cmds = [] - for obj in commands: - if isinstance(obj, (FractalBox, AddGrass, AddSurfaceRoughness, AddSurfaceWater)): - self.build_grid_obj(obj, grid) - if isinstance(obj, (FractalBox)): - proc_cmds.append(obj) - else: - proc_cmds.append(obj) - - # Process all geometry commands - for obj in proc_cmds: - self.build_grid_obj(obj, grid) - - return self - - def process_singlecmds(self, model: Model): + def process_single_use_objects(self, model: Model): # Check for duplicate commands and warn user if they exist - cmds_unique = list(set(self.single_cmds)) - if len(cmds_unique) != len(self.single_cmds): + # TODO: Test this works + unique_commands = list(set(self.single_use_objects)) + if len(unique_commands) != len(self.single_use_objects): logger.exception("Duplicate single-use commands exist in the input.") raise ValueError # Check essential commands and warn user if missing - for cmd_type in self.essential_cmds: - d = any(isinstance(cmd, cmd_type) for cmd in cmds_unique) + for cmd_type in self.ESSENTIAL_CMDS: + d = any(isinstance(cmd, cmd_type) for cmd in unique_commands) if not d: logger.exception( "Your input file is missing essential commands " @@ -169,7 +128,39 @@ class Scene: ) raise ValueError - self.process_cmds(cmds_unique, model) + self.build_model_objects(unique_commands, model) + + def process_multi_use_objects(self, model: Model): + self.build_grid_objects(self.grid_objects, model.G) + self.build_output_objects(self.output_objects, model, model.G) + self.build_model_objects(self.subgrid_objects, model) + + def process_geometry_objects(self, geometry_objects: List[GeometryUserObject], grid: FDTDGrid): + # Check for fractal boxes and modifications and pre-process them first + # TODO: Can this be removed in favour of sorting geometry objects? + objects_to_be_built: List[GeometryUserObject] = [] + for obj in geometry_objects: + if isinstance(obj, (FractalBox, AddGrass, AddSurfaceRoughness, AddSurfaceWater)): + self.build_grid_objects([obj], grid) + if isinstance(obj, (FractalBox)): + objects_to_be_built.append(obj) + else: + objects_to_be_built.append(obj) + + # Process all geometry commands + self.build_grid_objects(objects_to_be_built, grid) + + def process_subgrid_objects(self, model: Model): + """Process all commands in any sub-grids.""" + # Iterate through the user command objects under the subgrid user object + for subgrid_object in self.subgrid_objects: + # When the subgrid is created its reference is attached to its user + # object. This reference allows the multi and geo user objects + # to build in the correct subgrid. + subgrid = subgrid_object.subgrid + self.build_grid_objects(subgrid_object.children_grid, subgrid) + self.build_output_objects(subgrid_object.children_output, model, subgrid) + self.process_geometry_objects(subgrid_object.children_geometry, subgrid) def create_internal_objects(self, model: Model): """Calls the UserObject.build() function in the correct way - API @@ -181,36 +172,17 @@ class Scene: create_built_in_materials(model.G) # Process commands that can only have a single instance - self.process_singlecmds(model) + self.process_single_use_objects(model) - # Process main grid multiple commands - self.process_cmds(self.multiple_cmds, model) + # Process multiple commands + self.process_multi_use_objects(model) # Initialise geometry arrays for main and subgrids for grid in [model.G] + model.subgrids: grid.initialise_geometry_arrays() # Process the main grid geometry commands - self.process_geocmds(self.geometry_cmds, model.G) + self.process_geometry_objects(self.geometry_objects, model.G) # Process all the commands for subgrids - self.process_subgrid_cmds(model) - - -def create_user_input_points( - grid: FDTDGrid, user_obj: Union[UserObjectSingle, UserObjectMulti, UserObjectGeometry] -) -> Union[MainGridUserInput, SubgridUserInput]: - """Returns a point checker class based on the grid supplied.""" - - if isinstance(grid, SubGridBaseGrid): - # Local object configuration trumps. User can turn off autotranslate for - # specific objects. - if not user_obj.autotranslate and config.sim_config.args.autotranslate: - return MainGridUserInput(grid) - - if config.sim_config.args.autotranslate: - return SubgridUserInput(grid) - else: - return MainGridUserInput(grid) - else: - return MainGridUserInput(grid) + self.process_subgrid_objects(model) diff --git a/gprMax/snapshots.py b/gprMax/snapshots.py index 69278fc4..48e93770 100644 --- a/gprMax/snapshots.py +++ b/gprMax/snapshots.py @@ -110,7 +110,7 @@ class Snapshot: filename: str, fileext: str, outputs: Dict[str, bool], - grid_dl: npt.NDArray[np.float32], + grid_dl: npt.NDArray[np.float64], grid_dt: float, ): """ @@ -358,7 +358,7 @@ class MPISnapshot(Snapshot): filename: str, fileext: str, outputs: Dict[str, bool], - grid_dl: npt.NDArray[np.float32], + grid_dl: npt.NDArray[np.float64], grid_dt: float, ): super().__init__( diff --git a/gprMax/subgrids/user_objects.py b/gprMax/subgrids/user_objects.py index aa7c8ee0..992f3b7e 100644 --- a/gprMax/subgrids/user_objects.py +++ b/gprMax/subgrids/user_objects.py @@ -25,33 +25,42 @@ import numpy as np from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.model import Model from gprMax.subgrids.grid import SubGridBaseGrid +from gprMax.subgrids.subgrid_hsg import SubGridHSG as SubGridHSGUser from gprMax.user_inputs import MainGridUserInput - -from ..cmds_geometry.cmds_geometry import UserObjectGeometry -from ..cmds_multiuse import UserObjectMulti -from .subgrid_hsg import SubGridHSG as SubGridHSGUser +from gprMax.user_objects.user_objects import ( + GeometryUserObject, + GridUserObject, + ModelUserObject, + OutputUserObject, + UserObject, +) logger = logging.getLogger(__name__) -class SubGridBase(UserObjectMulti): +class SubGridBase(ModelUserObject): """Allows UserObjectMulti and UserObjectGeometry to be nested in SubGrid type user objects. """ + @property + def is_single_use(self) -> bool: + return False + def __init__(self, **kwargs): super().__init__(**kwargs) - self.children_multiple: List[UserObjectMulti] = [] - self.children_geometry: List[UserObjectGeometry] = [] - self.children_multiple: List[UserObjectMulti] = [] - self.children_geometry: List[UserObjectGeometry] = [] + self.children_grid: List[GridUserObject] = [] + self.children_geometry: List[GeometryUserObject] = [] + self.children_output: List[OutputUserObject] = [] - def add(self, node: Union[UserObjectMulti, UserObjectGeometry]): + def add(self, node: UserObject): """Adds other user objects. Geometry and multi only.""" - if isinstance(node, UserObjectMulti): - self.children_multiple.append(node) - elif isinstance(node, UserObjectGeometry): + if isinstance(node, GeometryUserObject): self.children_geometry.append(node) + elif isinstance(node, GridUserObject): + self.children_grid.append(node) + elif isinstance(node, OutputUserObject): + self.children_output.append(node) else: logger.exception(f"{str(node)} this Object can not be added to a sub grid") raise ValueError @@ -92,11 +101,12 @@ class SubGridBase(UserObjectMulti): """Sets number of iterations that will take place in the subgrid.""" sg.iterations = model.iterations * sg.ratio - def setup(self, sg: SubGridBaseGrid, model: Model, uip: MainGridUserInput): + def setup(self, sg: SubGridBaseGrid, model: Model): """ "Common setup to both all subgrid types.""" p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] + uip = self._create_uip(model.G) p1, p2 = uip.check_box_points(p1, p2, self.__str__()) self.set_discretisation(sg, model.G) @@ -170,6 +180,14 @@ class SubGridHSG(SubGridBase): stability. Defaults to True. """ + @property + def order(self): + return 18 + + @property + def hash(self): + return "#subgrid_hsg" + def __init__( self, p1=None, @@ -197,10 +215,8 @@ class SubGridHSG(SubGridBase): kwargs["filter"] = filter super().__init__(**kwargs) - self.order = 18 - self.hash = "#subgrid_hsg" - def build(self, model: Model, uip: MainGridUserInput) -> SubGridHSGUser: + def build(self, model: Model) -> SubGridHSGUser: sg = SubGridHSGUser(**self.kwargs) - self.setup(sg, model, uip) + self.setup(sg, model) return sg diff --git a/gprMax/cmds_geometry/__init__.py b/gprMax/user_objects/cmds_geometry/__init__.py similarity index 100% rename from gprMax/cmds_geometry/__init__.py rename to gprMax/user_objects/cmds_geometry/__init__.py diff --git a/gprMax/cmds_geometry/add_grass.py b/gprMax/user_objects/cmds_geometry/add_grass.py similarity index 92% rename from gprMax/cmds_geometry/add_grass.py rename to gprMax/user_objects/cmds_geometry/add_grass.py index fa263173..f6d9e098 100644 --- a/gprMax/cmds_geometry/add_grass.py +++ b/gprMax/user_objects/cmds_geometry/add_grass.py @@ -20,15 +20,19 @@ import logging import numpy as np -from ..fractals import FractalSurface, Grass -from ..materials import create_grass -from ..utilities.utilities import round_value -from .cmds_geometry import UserObjectGeometry, rotate_2point_object +from gprMax.fractals import FractalSurface, Grass +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import create_grass +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject +from gprMax.utilities.utilities import round_value + +from .cmds_geometry import rotate_2point_object logger = logging.getLogger(__name__) -class AddGrass(UserObjectGeometry): +class AddGrass(RotatableMixin, GeometryUserObject): """Adds grass with roots to a FractalBox class in the model. Attributes: @@ -46,25 +50,21 @@ class AddGrass(UserObjectGeometry): grass should be applied to. """ + @property + def hash(self): + return "#add_grass" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#add_grass" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Perform rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): """Add Grass to fractal box.""" try: p1 = self.kwargs["p1"] @@ -88,7 +88,7 @@ class AddGrass(UserObjectGeometry): seed = None if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) # Get the correct fractal volume volumes = [volume for volume in grid.fractalvolumes if volume.ID == fractal_box_id] @@ -98,6 +98,7 @@ class AddGrass(UserObjectGeometry): logger.exception(f"{self.__str__()} cannot find FractalBox {fractal_box_id}") raise + uip = self._create_uip(grid) p1, p2 = uip.check_box_points(p1, p2, self.__str__()) xs, ys, zs = p1 xf, yf, zf = p2 diff --git a/gprMax/cmds_geometry/add_surface_roughness.py b/gprMax/user_objects/cmds_geometry/add_surface_roughness.py similarity index 92% rename from gprMax/cmds_geometry/add_surface_roughness.py rename to gprMax/user_objects/cmds_geometry/add_surface_roughness.py index a127a1ae..2e49ab4f 100644 --- a/gprMax/cmds_geometry/add_surface_roughness.py +++ b/gprMax/user_objects/cmds_geometry/add_surface_roughness.py @@ -20,14 +20,18 @@ import logging import numpy as np -from ..fractals import FractalSurface -from ..utilities.utilities import round_value -from .cmds_geometry import UserObjectGeometry, rotate_2point_object +from gprMax.fractals import FractalSurface +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject +from gprMax.utilities.utilities import round_value + +from .cmds_geometry import rotate_2point_object logger = logging.getLogger(__name__) -class AddSurfaceRoughness(UserObjectGeometry): +class AddSurfaceRoughness(RotatableMixin, GeometryUserObject): """Adds surface roughness to a FractalBox class in the model. Attributes: @@ -47,25 +51,21 @@ class AddSurfaceRoughness(UserObjectGeometry): number generator used to create the fractals. """ + @property + def hash(self): + return "#add_surface_roughness" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#add_surface_roughness" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Perform rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -88,7 +88,7 @@ class AddSurfaceRoughness(UserObjectGeometry): seed = None if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) # Get the correct fractal volume volumes = [volume for volume in grid.fractalvolumes if volume.ID == fractal_box_id] @@ -98,6 +98,7 @@ class AddSurfaceRoughness(UserObjectGeometry): logger.exception(f"{self.__str__()} cannot find FractalBox {fractal_box_id}") raise ValueError + uip = self._create_uip(grid) p1, p2 = uip.check_box_points(p1, p2, self.__str__()) xs, ys, zs = p1 xf, yf, zf = p2 diff --git a/gprMax/cmds_geometry/add_surface_water.py b/gprMax/user_objects/cmds_geometry/add_surface_water.py similarity index 89% rename from gprMax/cmds_geometry/add_surface_water.py rename to gprMax/user_objects/cmds_geometry/add_surface_water.py index 79a6af72..3f335d7d 100644 --- a/gprMax/cmds_geometry/add_surface_water.py +++ b/gprMax/user_objects/cmds_geometry/add_surface_water.py @@ -20,14 +20,18 @@ import logging import numpy as np -from ..materials import create_water -from ..utilities.utilities import round_value -from .cmds_geometry import UserObjectGeometry, rotate_2point_object +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import create_water +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject +from gprMax.utilities.utilities import round_value + +from .cmds_geometry import rotate_2point_object logger = logging.getLogger(__name__) -class AddSurfaceWater(UserObjectGeometry): +class AddSurfaceWater(RotatableMixin, GeometryUserObject): """Adds surface water to a FractalBox class in the model. Attributes: @@ -42,25 +46,21 @@ class AddSurfaceWater(UserObjectGeometry): surface water should be applied to. """ + @property + def hash(self): + return "#add_surface_water" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#add_surface_water" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Perform rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): """ "Create surface water on fractal box.""" try: p1 = self.kwargs["p1"] @@ -72,7 +72,7 @@ class AddSurfaceWater(UserObjectGeometry): raise if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) if volumes := [volume for volume in grid.fractalvolumes if volume.ID == fractal_box_id]: volume = volumes[0] @@ -80,6 +80,7 @@ class AddSurfaceWater(UserObjectGeometry): logger.exception(f"{self.__str__()} cannot find FractalBox {fractal_box_id}") raise ValueError + uip = self._create_uip(grid) p1, p2 = uip.check_box_points(p1, p2, self.__str__()) xs, ys, zs = p1 xf, yf, zf = p2 diff --git a/gprMax/cmds_geometry/box.py b/gprMax/user_objects/cmds_geometry/box.py similarity index 88% rename from gprMax/cmds_geometry/box.py rename to gprMax/user_objects/cmds_geometry/box.py index 564a4132..6b79e759 100644 --- a/gprMax/cmds_geometry/box.py +++ b/gprMax/user_objects/cmds_geometry/box.py @@ -21,15 +21,18 @@ import logging import numpy as np import gprMax.config as config +from gprMax.cython.geometry_primitives import build_box +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject -from ..cython.geometry_primitives import build_box -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging, rotate_2point_object +from .cmds_geometry import rotate_2point_object logger = logging.getLogger(__name__) -class Box(UserObjectGeometry): +class Box(RotatableMixin, GeometryUserObject): """Introduces an orthogonal parallelepiped with specific properties into the model. @@ -42,25 +45,21 @@ class Box(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#box" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#box" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Perform rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -69,7 +68,7 @@ class Box(UserObjectGeometry): raise if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) # Check materials have been specified # Isotropic case @@ -91,6 +90,7 @@ class Box(UserObjectGeometry): # Otherwise go with the grid default averagebox = grid.averagevolumeobjects + uip = self._create_uip(grid) p3, p4 = uip.check_box_points(p1, p2, self.__str__()) # Find nearest point on grid without translation p5 = uip.round_to_grid_static_point(p1) diff --git a/gprMax/cmds_geometry/build_templates.py b/gprMax/user_objects/cmds_geometry/build_templates.py similarity index 100% rename from gprMax/cmds_geometry/build_templates.py rename to gprMax/user_objects/cmds_geometry/build_templates.py diff --git a/gprMax/cmds_geometry/cmds_geometry.py b/gprMax/user_objects/cmds_geometry/cmds_geometry.py similarity index 84% rename from gprMax/cmds_geometry/cmds_geometry.py rename to gprMax/user_objects/cmds_geometry/cmds_geometry.py index 41b6b778..13af63dd 100644 --- a/gprMax/cmds_geometry/cmds_geometry.py +++ b/gprMax/user_objects/cmds_geometry/cmds_geometry.py @@ -26,44 +26,6 @@ import gprMax.config as config logger = logging.getLogger(__name__) -class UserObjectGeometry: - """Specific Geometry object.""" - - def __init__(self, **kwargs): - self.kwargs = kwargs - self.hash = "#example" - self.autotranslate = True - self.do_rotate = False - - def __str__(self): - """Readable string of parameters given to object.""" - s = "" - for _, v in self.kwargs.items(): - if isinstance(v, (tuple, list)): - v = " ".join([str(el) for el in v]) - s += f"{str(v)} " - - return f"{self.hash}: {s[:-1]}" - - def build(self, grid, uip): - """Creates object and adds it to the grid.""" - pass - - def rotate(self, axis, angle, origin=None): - """Rotates object - specialised for each object.""" - pass - - def grid_name(self, grid): - """Returns subgrid name for use with logging info. Returns an empty - string if the grid is the main grid. - """ - - if config.sim_config.general["subgrid"] and grid.name != "main_grid": - return f"[{grid.name}] " - else: - return "" - - def check_averaging(averaging): """Check and set material averaging value. diff --git a/gprMax/cmds_geometry/cone.py b/gprMax/user_objects/cmds_geometry/cone.py similarity index 94% rename from gprMax/cmds_geometry/cone.py rename to gprMax/user_objects/cmds_geometry/cone.py index 54f0e2c3..fffa1729 100644 --- a/gprMax/cmds_geometry/cone.py +++ b/gprMax/user_objects/cmds_geometry/cone.py @@ -20,14 +20,15 @@ import logging import numpy as np -from ..cython.geometry_primitives import build_cone -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging +from gprMax.cython.geometry_primitives import build_cone +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.user_objects import GeometryUserObject logger = logging.getLogger(__name__) -class Cone(UserObjectGeometry): +class Cone(GeometryUserObject): """Introduces a circular cone into the model. The difference with the cylinder is that the faces of the cone can have different radii and one of them can be zero. @@ -44,11 +45,14 @@ class Cone(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#cone" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#cone" - def build(self, grid, uip): + def build(self, grid: FDTDGrid) -> None: try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -78,6 +82,7 @@ class Cone(UserObjectGeometry): logger.exception(f"{self.__str__()} no materials have been specified") raise + uip = self._create_uip(grid) p3 = uip.round_to_grid_static_point(p1) p4 = uip.round_to_grid_static_point(p2) diff --git a/gprMax/cmds_geometry/cylinder.py b/gprMax/user_objects/cmds_geometry/cylinder.py similarity index 91% rename from gprMax/cmds_geometry/cylinder.py rename to gprMax/user_objects/cmds_geometry/cylinder.py index 583645dd..7bcb6c15 100644 --- a/gprMax/cmds_geometry/cylinder.py +++ b/gprMax/user_objects/cmds_geometry/cylinder.py @@ -20,14 +20,15 @@ import logging import numpy as np -from ..cython.geometry_primitives import build_cylinder -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging +from gprMax.cython.geometry_primitives import build_cylinder +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.user_objects import GeometryUserObject logger = logging.getLogger(__name__) -class Cylinder(UserObjectGeometry): +class Cylinder(GeometryUserObject): """Introduces a circular cylinder into the model. Attributes: @@ -42,11 +43,14 @@ class Cylinder(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#cylinder" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#cylinder" - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -75,6 +79,7 @@ class Cylinder(UserObjectGeometry): logger.exception(f"{self.__str__()} no materials have been specified") raise + uip = self._create_uip(grid) p3 = uip.round_to_grid_static_point(p1) p4 = uip.round_to_grid_static_point(p2) diff --git a/gprMax/cmds_geometry/cylindrical_sector.py b/gprMax/user_objects/cmds_geometry/cylindrical_sector.py similarity index 93% rename from gprMax/cmds_geometry/cylindrical_sector.py rename to gprMax/user_objects/cmds_geometry/cylindrical_sector.py index a9d1924d..6159f8c0 100644 --- a/gprMax/cmds_geometry/cylindrical_sector.py +++ b/gprMax/user_objects/cmds_geometry/cylindrical_sector.py @@ -20,14 +20,15 @@ import logging import numpy as np -from ..cython.geometry_primitives import build_cylindrical_sector -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging +from gprMax.cython.geometry_primitives import build_cylindrical_sector +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.user_objects import GeometryUserObject logger = logging.getLogger(__name__) -class CylindricalSector(UserObjectGeometry): +class CylindricalSector(GeometryUserObject): """Introduces a cylindrical sector (shaped like a slice of pie) into the model. Attributes: @@ -51,11 +52,14 @@ class CylindricalSector(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#cylindrical_sector" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#cylindrical_sector" - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: normal = self.kwargs["normal"].lower() ctr1 = self.kwargs["ctr1"] @@ -158,6 +162,7 @@ class CylindricalSector(UserObjectGeometry): numIDy = materials[1].numID numIDz = materials[2].numID + uip = self._create_uip(grid) # yz-plane cylindrical sector if normal == "x": level, ctr1, ctr2 = uip.round_to_grid((extent1, ctr1, ctr2)) diff --git a/gprMax/cmds_geometry/edge.py b/gprMax/user_objects/cmds_geometry/edge.py similarity index 83% rename from gprMax/cmds_geometry/edge.py rename to gprMax/user_objects/cmds_geometry/edge.py index 45acca04..f1fb5398 100644 --- a/gprMax/cmds_geometry/edge.py +++ b/gprMax/user_objects/cmds_geometry/edge.py @@ -20,14 +20,17 @@ import logging import numpy as np -from ..cython.geometry_primitives import (build_edge_x, build_edge_y, - build_edge_z) -from .cmds_geometry import UserObjectGeometry, rotate_2point_object +from gprMax.cython.geometry_primitives import build_edge_x, build_edge_y, build_edge_z +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject + +from .cmds_geometry import rotate_2point_object logger = logging.getLogger(__name__) -class Edge(UserObjectGeometry): +class Edge(RotatableMixin, GeometryUserObject): """Introduces a wire with specific properties into the model. Attributes: @@ -37,25 +40,21 @@ class Edge(UserObjectGeometry): to material that has already been defined. """ + @property + def hash(self): + return "#edge" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#edge" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): """Creates edge and adds it to the grid.""" try: p1 = self.kwargs["p1"] @@ -66,8 +65,9 @@ class Edge(UserObjectGeometry): raise if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) + uip = self._create_uip(grid) p3 = uip.round_to_grid_static_point(p1) p4 = uip.round_to_grid_static_point(p2) diff --git a/gprMax/cmds_geometry/ellipsoid.py b/gprMax/user_objects/cmds_geometry/ellipsoid.py similarity index 93% rename from gprMax/cmds_geometry/ellipsoid.py rename to gprMax/user_objects/cmds_geometry/ellipsoid.py index 2919c41d..464c351f 100644 --- a/gprMax/cmds_geometry/ellipsoid.py +++ b/gprMax/user_objects/cmds_geometry/ellipsoid.py @@ -20,14 +20,15 @@ import logging import numpy as np -from ..cython.geometry_primitives import build_ellipsoid -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging +from gprMax.cython.geometry_primitives import build_ellipsoid +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.user_objects import GeometryUserObject logger = logging.getLogger(__name__) -class Ellipsoid(UserObjectGeometry): +class Ellipsoid(GeometryUserObject): """Introduces an ellipsoidal object with specific parameters into the model. Attributes: @@ -41,11 +42,14 @@ class Ellipsoid(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#ellipsoid" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#ellipsoid" - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] xr = self.kwargs["xr"] @@ -77,6 +81,7 @@ class Ellipsoid(UserObjectGeometry): raise # Centre of ellipsoid + uip = self._create_uip(grid) p2 = uip.round_to_grid_static_point(p1) xc, yc, zc = uip.discretise_point(p1) diff --git a/gprMax/cmds_geometry/fractal_box.py b/gprMax/user_objects/cmds_geometry/fractal_box.py similarity index 96% rename from gprMax/cmds_geometry/fractal_box.py rename to gprMax/user_objects/cmds_geometry/fractal_box.py index af5d9bba..b6837bd1 100644 --- a/gprMax/cmds_geometry/fractal_box.py +++ b/gprMax/user_objects/cmds_geometry/fractal_box.py @@ -21,16 +21,18 @@ import logging import numpy as np import gprMax.config as config -from gprMax.cmds_geometry.cmds_geometry import UserObjectGeometry, rotate_2point_object +from gprMax.cython.geometry_primitives import build_voxels_from_array, build_voxels_from_array_mask from gprMax.fractals import FractalVolume +from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.materials import ListMaterial - -from ..cython.geometry_primitives import build_voxels_from_array, build_voxels_from_array_mask +from gprMax.user_objects.cmds_geometry.cmds_geometry import rotate_2point_object +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject logger = logging.getLogger(__name__) -class FractalBox(UserObjectGeometry): +class FractalBox(RotatableMixin, GeometryUserObject): """Introduces an orthogonal parallelepiped with fractal distributed properties which are related to a mixing model or normal material into the model. @@ -54,26 +56,22 @@ class FractalBox(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#fractal_box" + def __init__(self, **kwargs): super().__init__(**kwargs) self.do_pre_build = True - self.hash = "#fractal_box" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def pre_build(self, grid, uip): + def pre_build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -97,7 +95,7 @@ class FractalBox(UserObjectGeometry): seed = None if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) # Check averaging try: @@ -108,6 +106,7 @@ class FractalBox(UserObjectGeometry): # a fractal box. averagefractalbox = False + uip = self._create_uip(grid) p3 = uip.round_to_grid_static_point(p1) p4 = uip.round_to_grid_static_point(p2) @@ -188,9 +187,9 @@ class FractalBox(UserObjectGeometry): ) grid.fractalvolumes.append(self.volume) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): if self.do_pre_build: - self.pre_build(grid, uip) + self.pre_build(grid) self.do_pre_build = False else: if self.volume.fractalsurfaces: diff --git a/gprMax/cmds_geometry/geometry_objects_read.py b/gprMax/user_objects/cmds_geometry/geometry_objects_read.py similarity index 91% rename from gprMax/cmds_geometry/geometry_objects_read.py rename to gprMax/user_objects/cmds_geometry/geometry_objects_read.py index 46b7c243..789a569e 100644 --- a/gprMax/cmds_geometry/geometry_objects_read.py +++ b/gprMax/user_objects/cmds_geometry/geometry_objects_read.py @@ -22,24 +22,24 @@ from pathlib import Path import h5py import gprMax.config as config - -from ..cython.geometry_primitives import build_voxels_from_array -from ..hash_cmds_file import get_user_objects -from ..utilities.utilities import round_value -from .cmds_geometry import UserObjectGeometry +from gprMax.cython.geometry_primitives import build_voxels_from_array +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.hash_cmds_file import get_user_objects +from gprMax.user_objects.user_objects import GeometryUserObject +from gprMax.utilities.utilities import round_value logger = logging.getLogger(__name__) -class GeometryObjectsRead(UserObjectGeometry): +class GeometryObjectsRead(GeometryUserObject): + @property + def hash(self): + return "#geometry_objects_read" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#geometry_objects_read" - def rotate(self, axis, angle, origin=None): - pass - - def build(self, grid, uip): + def build(self, grid: FDTDGrid): """Creates the object and adds it to the grid.""" try: p1 = self.kwargs["p1"] @@ -52,6 +52,7 @@ class GeometryObjectsRead(UserObjectGeometry): # Discretise the point using uip object. This has different behaviour # depending on the type of uip object. So we can use it for # the main grid or the subgrid. + uip = self._create_uip(grid) xs, ys, zs = uip.discretise_point(p1) # See if material file exists at specified path and if not try input @@ -82,7 +83,7 @@ class GeometryObjectsRead(UserObjectGeometry): scene.add(material_obj) # Creates the internal simulation objects - scene.process_cmds(material_objs, grid) + scene.build_grid_objects(material_objs, grid) # Update material type for material in grid.materials: diff --git a/gprMax/cmds_geometry/plate.py b/gprMax/user_objects/cmds_geometry/plate.py similarity index 87% rename from gprMax/cmds_geometry/plate.py rename to gprMax/user_objects/cmds_geometry/plate.py index 90557b0e..5de480e9 100644 --- a/gprMax/cmds_geometry/plate.py +++ b/gprMax/user_objects/cmds_geometry/plate.py @@ -20,14 +20,17 @@ import logging import numpy as np -from ..cython.geometry_primitives import (build_face_xy, build_face_xz, - build_face_yz) -from .cmds_geometry import UserObjectGeometry, rotate_2point_object +from gprMax.cython.geometry_primitives import build_face_xy, build_face_xz, build_face_yz +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject + +from .cmds_geometry import rotate_2point_object logger = logging.getLogger(__name__) -class Plate(UserObjectGeometry): +class Plate(RotatableMixin, GeometryUserObject): """Introduces a plate with specific properties into the model. Attributes: @@ -38,25 +41,21 @@ class Plate(UserObjectGeometry): material_ids: list of material identifiers in the x, y, z directions. """ + @property + def hash(self): + return "#plate" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#plate" - def rotate(self, axis, angle, origin=None): - """Set parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" pts = np.array([self.kwargs["p1"], self.kwargs["p2"]]) rot_pts = rotate_2point_object(pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) self.kwargs["p2"] = tuple(rot_pts[1, :]) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -76,8 +75,9 @@ class Plate(UserObjectGeometry): raise if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) + uip = self._create_uip(grid) p3 = uip.round_to_grid_static_point(p1) p4 = uip.round_to_grid_static_point(p2) diff --git a/gprMax/cmds_geometry/sphere.py b/gprMax/user_objects/cmds_geometry/sphere.py similarity index 90% rename from gprMax/cmds_geometry/sphere.py rename to gprMax/user_objects/cmds_geometry/sphere.py index d3577b34..ef2db9cd 100644 --- a/gprMax/cmds_geometry/sphere.py +++ b/gprMax/user_objects/cmds_geometry/sphere.py @@ -20,14 +20,15 @@ import logging import numpy as np -from ..cython.geometry_primitives import build_sphere -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging +from gprMax.cython.geometry_primitives import build_sphere +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.user_objects import GeometryUserObject logger = logging.getLogger(__name__) -class Sphere(UserObjectGeometry): +class Sphere(GeometryUserObject): """Introduces a spherical object with specific parameters into the model. Attributes: @@ -39,11 +40,14 @@ class Sphere(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#sphere" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#sphere" - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] r = self.kwargs["r"] @@ -72,6 +76,7 @@ class Sphere(UserObjectGeometry): raise # Centre of sphere + uip = self._create_uip(grid) p2 = uip.round_to_grid_static_point(p1) xc, yc, zc = uip.discretise_point(p1) diff --git a/gprMax/cmds_geometry/triangle.py b/gprMax/user_objects/cmds_geometry/triangle.py similarity index 90% rename from gprMax/cmds_geometry/triangle.py rename to gprMax/user_objects/cmds_geometry/triangle.py index 17d4abd7..4a752d74 100644 --- a/gprMax/cmds_geometry/triangle.py +++ b/gprMax/user_objects/cmds_geometry/triangle.py @@ -20,14 +20,18 @@ import logging import numpy as np -from ..cython.geometry_primitives import build_triangle -from ..materials import Material -from .cmds_geometry import UserObjectGeometry, check_averaging, rotate_point +from gprMax.cython.geometry_primitives import build_triangle +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.materials import Material +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GeometryUserObject + +from .cmds_geometry import rotate_point logger = logging.getLogger(__name__) -class Triangle(UserObjectGeometry): +class Triangle(RotatableMixin, GeometryUserObject): """Introduces a triangular patch or a triangular prism with specific properties into the model. @@ -43,18 +47,14 @@ class Triangle(UserObjectGeometry): averaging: string (y or n) used to switch on and off dielectric smoothing. """ + @property + def hash(self): + return "#triangle" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.hash = "#triangle" - def rotate(self, axis, angle, origin=None): - """Sets parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" p1 = rotate_point(self.kwargs["p1"], self.axis, self.angle, self.origin) p2 = rotate_point(self.kwargs["p2"], self.axis, self.angle, self.origin) @@ -63,7 +63,7 @@ class Triangle(UserObjectGeometry): self.kwargs["p2"] = tuple(p2) self.kwargs["p3"] = tuple(p3) - def build(self, grid, uip): + def build(self, grid: FDTDGrid): try: up1 = self.kwargs["p1"] up2 = self.kwargs["p2"] @@ -74,7 +74,7 @@ class Triangle(UserObjectGeometry): raise if self.do_rotate: - self._do_rotate() + self._do_rotate(grid) # Check averaging try: @@ -96,6 +96,7 @@ class Triangle(UserObjectGeometry): logger.exception(f"{self.__str__()} no materials have been specified") raise + uip = self._create_uip(grid) p4 = uip.round_to_grid_static_point(up1) p5 = uip.round_to_grid_static_point(up2) p6 = uip.round_to_grid_static_point(up3) diff --git a/gprMax/cmds_multiuse.py b/gprMax/user_objects/cmds_multiuse.py similarity index 78% rename from gprMax/cmds_multiuse.py rename to gprMax/user_objects/cmds_multiuse.py index 55cc64fb..fc34a347 100644 --- a/gprMax/cmds_multiuse.py +++ b/gprMax/user_objects/cmds_multiuse.py @@ -19,7 +19,9 @@ import inspect import logging from abc import ABC, abstractmethod +from os import PathLike from pathlib import Path +from typing import Optional, Union import numpy as np import numpy.typing as npt @@ -28,123 +30,81 @@ from scipy import interpolate import gprMax.config as config from gprMax.grid.fdtd_grid import FDTDGrid from gprMax.grid.mpi_grid import MPIGrid +from gprMax.materials import DispersiveMaterial as DispersiveMaterialUser +from gprMax.materials import ListMaterial as ListMaterialUser +from gprMax.materials import Material as MaterialUser +from gprMax.materials import PeplinskiSoil as PeplinskiSoilUser +from gprMax.materials import RangeMaterial as RangeMaterialUser from gprMax.model import Model -from gprMax.user_inputs import MainGridUserInput - -from .cmds_geometry.cmds_geometry import ( - UserObjectGeometry, +from gprMax.pml import CFS, CFSParameter +from gprMax.receivers import Rx as RxUser +from gprMax.snapshots import MPISnapshot as MPISnapshotUser +from gprMax.snapshots import Snapshot as SnapshotUser +from gprMax.sources import HertzianDipole as HertzianDipoleUser +from gprMax.sources import MagneticDipole as MagneticDipoleUser +from gprMax.sources import TransmissionLine as TransmissionLineUser +from gprMax.sources import VoltageSource as VoltageSourceUser +from gprMax.subgrids.grid import SubGridBaseGrid +from gprMax.user_objects.cmds_geometry.cmds_geometry import ( rotate_2point_object, rotate_polarisation, ) -from .geometry_outputs import GeometryObjects as GeometryObjectsUser -from .geometry_outputs import MPIGeometryObjects as MPIGeometryObjectsUser -from .materials import DispersiveMaterial as DispersiveMaterialUser -from .materials import ListMaterial as ListMaterialUser -from .materials import Material as MaterialUser -from .materials import PeplinskiSoil as PeplinskiSoilUser -from .materials import RangeMaterial as RangeMaterialUser -from .pml import CFS, CFSParameter -from .receivers import Rx as RxUser -from .snapshots import MPISnapshot as MPISnapshotUser -from .snapshots import Snapshot as SnapshotUser -from .sources import HertzianDipole as HertzianDipoleUser -from .sources import MagneticDipole as MagneticDipoleUser -from .sources import TransmissionLine as TransmissionLineUser -from .sources import VoltageSource as VoltageSourceUser -from .subgrids.grid import SubGridBaseGrid -from .utilities.utilities import round_value -from .waveforms import Waveform as WaveformUser +from gprMax.user_objects.rotatable import RotatableMixin +from gprMax.user_objects.user_objects import GridUserObject +from gprMax.utilities.utilities import round_value +from gprMax.waveforms import Waveform as WaveformUser logger = logging.getLogger(__name__) -class UserObjectMulti(ABC): - """Object that can occur multiple times in a model.""" +class ExcitationFile(GridUserObject): + """Specify file containing amplitude values of custom waveforms. - def __init__(self, **kwargs): - self.kwargs = kwargs - self.order = 0 - self.hash = None - self.autotranslate = True - self.do_rotate = False - - def __str__(self): - """Readable user string as per hash commands.""" - s = "" - for _, v in self.kwargs.items(): - if isinstance(v, (tuple, list)): - v = " ".join([str(el) for el in v]) - s += f"{str(v)} " - - return f"{self.hash}: {s[:-1]}" - - @abstractmethod - def build(self, model: Model, uip: MainGridUserInput): - """Creates object and adds it to model.""" - pass - - # TODO: Make _do_rotate not use a grid object - def rotate(self, axis, angle, origin=None): - """Rotates object (specialised for each object).""" - pass - - def params_str(self): - """Readable string of parameters given to object.""" - return f"{self.hash}: {str(self.kwargs)}" - - def grid_name(self, grid: FDTDGrid) -> str: - """Returns subgrid name for use with logging info. Returns an empty - string if the grid is the main grid. - """ - if isinstance(grid, SubGridBaseGrid): - return f"[{grid.name}] " - else: - return "" - - def model_name(self, model: Model) -> str: - """Returns model name for use with logging info.""" - return f"[{model.title}] " - - -class ExcitationFile(UserObjectMulti): - """An ASCII file that contains columns of amplitude values that specify - custom waveform shapes that can be used with sources in the model. + The file should be an ASCII file, and the custom waveform shapes can + be used with sources in the model. Attributes: - filepath: string of excitation file path. - kind: string or int specifying interpolation kind passed to - scipy.interpolate.interp1d. - fill_value: float or 'extrapolate' passed to scipy.interpolate.interp1d. + filepath (str | PathLike): Excitation file path. + kind (int | str | None): Optional interpolation kind passed to + scipy.interpolate.interp1d. + fill_value (float | str | None): Optional float value or + 'extrapolate' passed to scipy.interpolate.interp1d. """ - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 1 - self.hash = "#excitation_file" + @property + def order(self): + return 1 - def build(self, model, uip): - try: - kwargs = {} - excitationfile = self.kwargs["filepath"] - kwargs["kind"] = self.kwargs["kind"] - kwargs["fill_value"] = self.kwargs["fill_value"] + @property + def hash(self): + return "#excitation_file" - except KeyError: - try: - excitationfile = self.kwargs["filepath"] - fullargspec = inspect.getfullargspec(interpolate.interp1d) - kwargs = dict(zip(reversed(fullargspec.args), reversed(fullargspec.defaults))) - except KeyError: - logger.exception(f"{self.__str__()} requires either one or three parameter(s)") - raise + def __init__( + self, + filepath: Union[str, PathLike], + kind: Optional[Union[int, str]] = None, + fill_value: Optional[Union[float, str]] = None, + ): + """Create an ExcitationFile user object. + Args: + filepath: Excitation file path. + kind: Optional interpolation kind passed to + scipy.interpolate.interp1d. Default None. + fill_value: Optional float value or 'extrapolate' passed to + scipy.interpolate.interp1d. Default None. + """ + super().__init__(filepath=filepath, kind=kind, fill_value=fill_value) + self.filepath = filepath + self.kind = kind + self.fill_value = fill_value + + def build(self, grid: FDTDGrid): # See if file exists at specified path and if not try input file directory - excitationfile = Path(excitationfile) - # excitationfile = excitationfile.resolve() + excitationfile = Path(self.filepath) if not excitationfile.exists(): excitationfile = Path(config.sim_config.input_file_path.parent, excitationfile) - grid = uip.grid logger.info(self.grid_name(grid) + f"Excitation file: {excitationfile}") # Get waveform names @@ -162,13 +122,12 @@ class ExcitationFile(UserObjectMulti): waveformvalues = waveformvalues[:, 1:] timestr = "user-defined time array" else: - waveformtime = np.arange(0, model.timewindow + grid.dt, grid.dt) + waveformtime = np.arange(0, grid.timewindow + grid.dt, grid.dt) timestr = "simulation time array" for i, waveformID in enumerate(waveformIDs): if any(x.ID == waveformID for x in grid.waveforms): - logger.exception(f"Waveform with ID {waveformID} already exists") - raise ValueError + raise ValueError(f"Waveform with ID {waveformID} already exists") w = WaveformUser() w.ID = waveformID w.type = "user" @@ -191,38 +150,61 @@ class ExcitationFile(UserObjectMulti): ) # Interpolate waveform values - w.userfunc = interpolate.interp1d(waveformtime, singlewaveformvalues, **kwargs) + if self.kind is None and self.fill_value is None: + w.userfunc = interpolate.interp1d(waveformtime, singlewaveformvalues) + elif self.kind is not None and self.fill_value is not None: + w.userfunc = interpolate.interp1d( + waveformtime, singlewaveformvalues, kind=self.kind, fill_value=self.fill_value + ) + else: + raise ValueError(f"{self} requires either one or three parameter(s)") logger.info( self.grid_name(grid) + f"User waveform {w.ID} created using {timestr} and, if " - f"required, interpolation parameters (kind: {kwargs['kind']}, " - f"fill value: {kwargs['fill_value']})." + f"required, interpolation parameters (kind: {self.kind}, " + f"fill value: {self.fill_value})." ) grid.waveforms.append(w) -class Waveform(UserObjectMulti): - """Specifies waveforms to use with sources in the model. +class Waveform(GridUserObject): + """Create waveform to use with sources in the model. Attributes: - wave_type: string required to specify waveform type. - amp: float to scale maximum amplitude of waveform. - freq: float to specify centre frequency (Hz) of waveform. - id: string required for identifier of waveform. - user_values: optional 1D array of amplitude values to use with - user waveform. - user_time: optional 1D array of time values to use with user waveform. - kind: optional string or int, see scipy.interpolate.interp1d - https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html#scipy-interpolate-interp1d - fill_value: optional array or 'extrapolate', see scipy.interpolate.interp1d - https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html#scipy-interpolate-interp1d + wave_type (str): Waveform type. Can should be one of 'gaussian', + 'gaussiandot', 'gaussiandotnorm', 'gaussiandotdot', + 'gaussiandotdotnorm', 'ricker', 'gaussianprime', + 'gaussiandoubleprime', 'sine', 'contsine'. + amp (float): Factor to scale the maximum amplitude of the + waveform by. (For a #hertzian_dipole the units will be Amps, + for a #voltage_source or #transmission_line the units will + be Volts). + freq: Centre frequency (Hz) of the waveform. In the case of the + Gaussian waveform it is related to the pulse width. + id (str): Identifier of the waveform. + user_values: Optional 1D array of amplitude values to use with + user waveform. + user_time: Optional 1D array of time values to use with user + waveform. + kind (int | str | None): Optional string or int, see + scipy.interpolate.interp1d - https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html#scipy-interpolate-interp1d + fill_value: Optional array or 'extrapolate', see + scipy.interpolate.interp1d - https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html#scipy-interpolate-interp1d """ + @property + def order(self): + return 2 + + @property + def hash(self): + return "#waveform" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 2 - self.hash = "#waveform" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: wavetype = self.kwargs["wave_type"].lower() except KeyError: @@ -236,7 +218,6 @@ class Waveform(UserObjectMulti): ) raise ValueError - grid = uip.grid if wavetype != "user": try: amp = self.kwargs["amp"] @@ -288,7 +269,7 @@ class Waveform(UserObjectMulti): if "user_time" in self.kwargs: waveformtime = self.kwargs["user_time"] else: - waveformtime = np.arange(0, model.timewindow + grid.dt, grid.dt) + waveformtime = np.arange(0, grid.timewindow + grid.dt, grid.dt) # Set args for interpolation if given by user if "kind" in self.kwargs: @@ -310,7 +291,7 @@ class Waveform(UserObjectMulti): grid.waveforms.append(w) -class VoltageSource(UserObjectMulti): +class VoltageSource(RotatableMixin, GridUserObject): """Specifies a voltage source at an electric field location. Attributes: @@ -323,19 +304,18 @@ class VoltageSource(UserObjectMulti): stop: float optional to time (secs) to remove source. """ + @property + def order(self): + return 3 + + @property + def hash(self): + return "#voltage_source" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 3 - self.hash = "#voltage_source" - def rotate(self, axis, angle, origin=None): - """Sets parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self, grid): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" rot_pol_pts, self.kwargs["polarisation"] = rotate_polarisation( self.kwargs["p1"], self.kwargs["polarisation"], self.axis, self.angle, grid @@ -343,7 +323,7 @@ class VoltageSource(UserObjectMulti): rot_pts = rotate_2point_object(rot_pol_pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] polarisation = self.kwargs["polarisation"].lower() @@ -353,7 +333,6 @@ class VoltageSource(UserObjectMulti): logger.exception(self.params_str() + (" requires at least six parameters.")) raise - grid = uip.grid if self.do_rotate: self._do_rotate(grid) @@ -380,6 +359,7 @@ class VoltageSource(UserObjectMulti): logger.exception(self.params_str() + (" polarisation must be z in 2D TMz mode.")) raise ValueError + uip = self._create_uip(grid) xcoord, ycoord, zcoord = uip.check_src_rx_point(p1, self.params_str()) p2 = uip.round_to_grid_static_point(p1) @@ -435,15 +415,14 @@ class VoltageSource(UserObjectMulti): ) raise ValueError v.start = start - v.stop = min(stop, model.timewindow) + v.stop = min(stop, grid.timewindow) startstop = f" start time {v.start:g} secs, finish time {v.stop:g} secs " except KeyError: v.start = 0 - v.stop = model.timewindow + v.stop = grid.timewindow startstop = " " - iterations = grid.iterations if isinstance(grid, SubGridBaseGrid) else model.iterations - v.calculate_waveform_values(iterations, grid.dt) + v.calculate_waveform_values(grid.iterations, grid.dt) logger.info( f"{self.grid_name(grid)}Voltage source with polarity " @@ -456,7 +435,7 @@ class VoltageSource(UserObjectMulti): grid.voltagesources.append(v) -class HertzianDipole(UserObjectMulti): +class HertzianDipole(RotatableMixin, GridUserObject): """Specifies a current density term at an electric field location. The simplest excitation, often referred to as an additive or soft source. @@ -469,19 +448,18 @@ class HertzianDipole(UserObjectMulti): stop: float optional to time (secs) to remove source. """ + @property + def order(self): + return 4 + + @property + def hash(self): + return "#hertzian_dipole" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 4 - self.hash = "#hertzian_dipole" - def rotate(self, axis, angle, origin=None): - """Sets parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self, grid): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" rot_pol_pts, self.kwargs["polarisation"] = rotate_polarisation( self.kwargs["p1"], self.kwargs["polarisation"], self.axis, self.angle, grid @@ -489,7 +467,7 @@ class HertzianDipole(UserObjectMulti): rot_pts = rotate_2point_object(rot_pol_pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: polarisation = self.kwargs["polarisation"].lower() p1 = self.kwargs["p1"] @@ -498,7 +476,6 @@ class HertzianDipole(UserObjectMulti): logger.exception(f"{self.params_str()} requires at least 3 parameters.") raise - grid = uip.grid if self.do_rotate: self._do_rotate(grid) @@ -525,6 +502,7 @@ class HertzianDipole(UserObjectMulti): logger.exception(self.params_str() + " polarisation must be z in 2D TMz mode.") raise ValueError + uip = self._create_uip(grid) xcoord, ycoord, zcoord = uip.check_src_rx_point(p1, self.params_str()) p2 = uip.round_to_grid_static_point(p1) @@ -575,15 +553,14 @@ class HertzianDipole(UserObjectMulti): ) raise ValueError h.start = start - h.stop = min(stop, model.timewindow) + h.stop = min(stop, grid.timewindow) startstop = f" start time {h.start:g} secs, finish time {h.stop:g} secs " except KeyError: h.start = 0 - h.stop = model.timewindow + h.stop = grid.timewindow startstop = " " - iterations = grid.iterations if isinstance(grid, SubGridBaseGrid) else model.iterations - h.calculate_waveform_values(iterations, grid.dt) + h.calculate_waveform_values(grid.iterations, grid.dt) if config.get_model_config().mode == "2D": logger.info( @@ -605,7 +582,7 @@ class HertzianDipole(UserObjectMulti): grid.hertziandipoles.append(h) -class MagneticDipole(UserObjectMulti): +class MagneticDipole(RotatableMixin, GridUserObject): """Simulates an infinitesimal magnetic dipole. Often referred to as an additive or soft source. @@ -618,19 +595,18 @@ class MagneticDipole(UserObjectMulti): stop: float optional to time (secs) to remove source. """ + @property + def order(self): + return 5 + + @property + def hash(self): + return "#magnetic_dipole" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 5 - self.hash = "#magnetic_dipole" - def rotate(self, axis, angle, origin=None): - """Sets parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self, grid): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" rot_pol_pts, self.kwargs["polarisation"] = rotate_polarisation( self.kwargs["p1"], self.kwargs["polarisation"], self.axis, self.angle, grid @@ -638,7 +614,7 @@ class MagneticDipole(UserObjectMulti): rot_pts = rotate_2point_object(rot_pol_pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: polarisation = self.kwargs["polarisation"].lower() p1 = self.kwargs["p1"] @@ -647,7 +623,6 @@ class MagneticDipole(UserObjectMulti): logger.exception(f"{self.params_str()} requires at least five parameters.") raise - grid = uip.grid if self.do_rotate: self._do_rotate(grid) @@ -674,6 +649,7 @@ class MagneticDipole(UserObjectMulti): logger.exception(self.params_str() + " polarisation must be z in 2D TMz mode.") raise ValueError + uip = self._create_uip(grid) xcoord, ycoord, zcoord = uip.check_src_rx_point(p1, self.params_str()) p2 = uip.round_to_grid_static_point(p1) @@ -725,15 +701,14 @@ class MagneticDipole(UserObjectMulti): ) raise ValueError m.start = start - m.stop = min(stop, model.timewindow) + m.stop = min(stop, grid.timewindow) startstop = f" start time {m.start:g} secs, finish time {m.stop:g} secs " except KeyError: m.start = 0 - m.stop = model.timewindow + m.stop = grid.timewindow startstop = " " - iterations = grid.iterations if isinstance(grid, SubGridBaseGrid) else model.iterations - m.calculate_waveform_values(iterations, grid.dt) + m.calculate_waveform_values(grid.iterations, grid.dt) logger.info( f"{self.grid_name(grid)}Magnetic dipole with polarity " @@ -745,7 +720,7 @@ class MagneticDipole(UserObjectMulti): grid.magneticdipoles.append(m) -class TransmissionLine(UserObjectMulti): +class TransmissionLine(RotatableMixin, GridUserObject): """Specifies a one-dimensional transmission line model at an electric field location. @@ -759,19 +734,18 @@ class TransmissionLine(UserObjectMulti): stop: float optional to time (secs) to remove source. """ + @property + def order(self): + return 6 + + @property + def hash(self): + return "#transmission_line" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 6 - self.hash = "#transmission_line" - def rotate(self, axis, angle, origin=None): - """Sets parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self, grid): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" rot_pol_pts, self.kwargs["polarisation"] = rotate_polarisation( self.kwargs["p1"], self.kwargs["polarisation"], self.axis, self.angle, grid @@ -779,7 +753,7 @@ class TransmissionLine(UserObjectMulti): rot_pts = rotate_2point_object(rot_pol_pts, self.axis, self.angle, self.origin) self.kwargs["p1"] = tuple(rot_pts[0, :]) - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: polarisation = self.kwargs["polarisation"].lower() p1 = self.kwargs["p1"] @@ -789,7 +763,6 @@ class TransmissionLine(UserObjectMulti): logger.exception(f"{self.params_str()} requires at least six parameters.") raise - grid = uip.grid if self.do_rotate: self._do_rotate(grid) @@ -825,6 +798,7 @@ class TransmissionLine(UserObjectMulti): logger.exception(self.params_str() + (" polarisation must be z in " "2D TMz mode.")) raise ValueError + uip = self._create_uip(grid) xcoord, ycoord, zcoord = uip.check_src_rx_point(p1, self.params_str()) p2 = uip.round_to_grid_static_point(p1) @@ -843,8 +817,7 @@ class TransmissionLine(UserObjectMulti): ) raise ValueError - iterations = grid.iterations if isinstance(grid, SubGridBaseGrid) else model.iterations - t = TransmissionLineUser(iterations, grid.dt) + t = TransmissionLineUser(grid.iterations, grid.dt) t.polarisation = polarisation t.xcoord = xcoord t.ycoord = ycoord @@ -885,14 +858,14 @@ class TransmissionLine(UserObjectMulti): ) raise ValueError t.start = start - t.stop = min(stop, model.timewindow) + t.stop = min(stop, grid.timewindow) startstop = f" start time {t.start:g} secs, finish time {t.stop:g} secs " except KeyError: t.start = 0 - t.stop = model.timewindow + t.stop = grid.timewindow startstop = " " - t.calculate_waveform_values(iterations, grid.dt) + t.calculate_waveform_values(grid.iterations, grid.dt) t.calculate_incident_V_I(grid) logger.info( @@ -906,7 +879,7 @@ class TransmissionLine(UserObjectMulti): grid.transmissionlines.append(t) -class Rx(UserObjectMulti): +class Rx(RotatableMixin, GridUserObject): """Specifies output points in the model. These are locations where the values of the electric and magnetic field @@ -919,20 +892,20 @@ class Rx(UserObjectMulti): selection from Ex, Ey, Ez, Hx, Hy, Hz, Ix, Iy, or Iz. """ + @property + def order(self): + return 7 + + @property + def hash(self): + return "#rx" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 7 - self.hash = "#rx" + # TODO: Can this be removed? self.constructor = RxUser - def rotate(self, axis, angle, origin=None): - """Sets parameters for rotation.""" - self.axis = axis - self.angle = angle - self.origin = origin - self.do_rotate = True - - def _do_rotate(self, grid): + def _do_rotate(self, grid: FDTDGrid): """Performs rotation.""" new_pt = ( self.kwargs["p1"][0] + grid.dx, @@ -953,17 +926,17 @@ class Rx(UserObjectMulti): except KeyError: pass - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] except KeyError: logger.exception(self.params_str()) raise - grid = uip.grid if self.do_rotate: self._do_rotate(grid) + uip = self._create_uip(grid) p = uip.check_src_rx_point(p1, self.params_str()) p2 = uip.round_to_grid_static_point(p1) @@ -971,8 +944,6 @@ class Rx(UserObjectMulti): r.xcoord, r.ycoord, r.zcoord = p r.xcoordorigin, r.ycoordorigin, r.zcoordorigin = p - iterations = grid.iterations if isinstance(grid, SubGridBaseGrid) else model.iterations - try: r.ID = self.kwargs["id"] outputs = self.kwargs["outputs"] @@ -981,7 +952,7 @@ class Rx(UserObjectMulti): r.ID = f"{r.__class__.__name__}({str(r.xcoord)},{str(r.ycoord)},{str(r.zcoord)})" for key in RxUser.defaultoutputs: r.outputs[key] = np.zeros( - iterations, dtype=config.sim_config.dtypes["float_or_double"] + grid.iterations, dtype=config.sim_config.dtypes["float_or_double"] ) else: outputs.sort() @@ -994,7 +965,7 @@ class Rx(UserObjectMulti): for field in outputs: if field in allowableoutputs: r.outputs[field] = np.zeros( - iterations, dtype=config.sim_config.dtypes["float_or_double"] + grid.iterations, dtype=config.sim_config.dtypes["float_or_double"] ) else: logger.exception( @@ -1016,7 +987,7 @@ class Rx(UserObjectMulti): return r -class RxArray(UserObjectMulti): +class RxArray(GridUserObject): """Defines multiple output points in the model. Attributes: @@ -1025,12 +996,18 @@ class RxArray(UserObjectMulti): dl: tuple required for receiver spacing dx, dy, dz. """ + @property + def order(self): + return 8 + + @property + def hash(self): + return "#rx_array" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 8 - self.hash = "#rx_array" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: p1 = self.kwargs["p1"] p2 = self.kwargs["p2"] @@ -1039,6 +1016,7 @@ class RxArray(UserObjectMulti): logger.exception(f"{self.params_str()} requires exactly 9 parameters") raise + uip = self._create_uip(grid) xs, ys, zs = uip.check_src_rx_point(p1, self.params_str(), "lower") xf, yf, zf = uip.check_src_rx_point(p2, self.params_str(), "upper") p3 = uip.round_to_grid_static_point(p1) @@ -1078,7 +1056,6 @@ class RxArray(UserObjectMulti): ) raise ValueError - grid = uip.grid logger.info( f"{self.grid_name(grid)}Receiver array " f"{p3[0]:g}m, {p3[1]:g}m, {p3[2]:g}m, to " @@ -1086,8 +1063,6 @@ class RxArray(UserObjectMulti): f"{dx * grid.dx:g}m, {dy * grid.dy:g}m, {dz * grid.dz:g}m" ) - iterations = grid.iterations if isinstance(grid, SubGridBaseGrid) else model.iterations - for x in range(xs, xf + 1, dx): for y in range(ys, yf + 1, dy): for z in range(zs, zf + 1, dz): @@ -1105,7 +1080,7 @@ class RxArray(UserObjectMulti): r.ID = f"{r.__class__.__name__}({str(x)},{str(y)},{str(z)})" for key in RxUser.defaultoutputs: r.outputs[key] = np.zeros( - iterations, dtype=config.sim_config.dtypes["float_or_double"] + grid.iterations, dtype=config.sim_config.dtypes["float_or_double"] ) logger.info( f" Receiver at {p5[0]:g}m, {p5[1]:g}m, " @@ -1115,7 +1090,7 @@ class RxArray(UserObjectMulti): grid.rxs.append(r) -class Snapshot(UserObjectMulti): +class Snapshot(GridUserObject): """Obtains information about the electromagnetic fields within a volume of the model at a given time instant. @@ -1136,10 +1111,17 @@ class Snapshot(UserObjectMulti): selection from Ex, Ey, Ez, Hx, Hy, or Hz. """ + # TODO: Make this an output user object + @property + def order(self): + return 9 + + @property + def hash(self): + return "#snapshot" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 9 - self.hash = "#snapshot" def _calculate_upper_bound( self, start: npt.NDArray, step: npt.NDArray, size: npt.NDArray @@ -1147,9 +1129,7 @@ class Snapshot(UserObjectMulti): # upper_bound = p2 + dl - ((snapshot_size - 1) % dl) - 1 return start + step * np.ceil(size / step) - def build(self, model, uip): - grid = uip.grid - + def build(self, grid: FDTDGrid): if isinstance(grid, SubGridBaseGrid): logger.exception(f"{self.params_str()} do not add snapshots to subgrids.") raise ValueError @@ -1162,6 +1142,7 @@ class Snapshot(UserObjectMulti): logger.exception(f"{self.params_str()} requires exactly 11 parameters.") raise + uip = self._create_uip(grid) dl = np.array(uip.discretise_static_point(dl)) try: @@ -1242,7 +1223,7 @@ class Snapshot(UserObjectMulti): logger.exception(f"{self.params_str()} time value must be greater than zero.") raise ValueError - if iterations <= 0 or iterations > model.iterations: + if iterations <= 0 or iterations > grid.iterations: logger.exception(f"{self.params_str()} time value is not valid.") raise ValueError @@ -1322,7 +1303,7 @@ class Snapshot(UserObjectMulti): grid.snapshots.append(s) -class Material(UserObjectMulti): +class Material(GridUserObject): """Specifies a material in the model described by a set of constitutive parameters. @@ -1334,12 +1315,18 @@ class Material(UserObjectMulti): id: string used as identifier for material. """ + @property + def order(self): + return 10 + + @property + def hash(self): + return "#material" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 10 - self.hash = "#material" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: er = self.kwargs["er"] se = self.kwargs["se"] @@ -1373,7 +1360,6 @@ class Material(UserObjectMulti): logger.exception(f"{self.params_str()} requires a positive value for magnetic loss.") raise ValueError - grid = uip.grid if any(x.ID == material_id for x in grid.materials): logger.exception(f"{self.params_str()} with ID {material_id} already exists") raise ValueError @@ -1391,7 +1377,7 @@ class Material(UserObjectMulti): m.er = er logger.info( - f"{self.model_name(model)}Material {m.ID} with eps_r={m.er:g}, " + f"{self.grid_name(grid)}Material {m.ID} with eps_r={m.er:g}, " f"sigma={m.se:g} S/m; mu_r={m.mr:g}, sigma*={m.sm:g} Ohm/m " f"created." ) @@ -1399,7 +1385,7 @@ class Material(UserObjectMulti): grid.materials.append(m) -class AddDebyeDispersion(UserObjectMulti): +class AddDebyeDispersion(GridUserObject): """Adds dispersive properties to already defined Material based on a multi-pole Debye formulation. @@ -1413,12 +1399,18 @@ class AddDebyeDispersion(UserObjectMulti): properties. """ + @property + def order(self): + return 11 + + @property + def hash(self): + return "#add_dispersion_debye" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 11 - self.hash = "#add_dispersion_debye" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: poles = self.kwargs["poles"] er_delta = self.kwargs["er_delta"] @@ -1433,7 +1425,6 @@ class AddDebyeDispersion(UserObjectMulti): raise ValueError # Look up requested materials in existing list of material instances - grid = uip.grid materials = [y for x in material_ids for y in grid.materials if y.ID == x] if len(materials) != len(material_ids): @@ -1475,7 +1466,7 @@ class AddDebyeDispersion(UserObjectMulti): ) -class AddLorentzDispersion(UserObjectMulti): +class AddLorentzDispersion(GridUserObject): """Adds dispersive properties to already defined Material based on a multi-pole Lorentz formulation. @@ -1490,12 +1481,18 @@ class AddLorentzDispersion(UserObjectMulti): properties. """ + @property + def order(self): + return 12 + + @property + def hash(self): + return "#add_dispersion_lorentz" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 12 - self.hash = "#add_dispersion_lorentz" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: poles = self.kwargs["poles"] er_delta = self.kwargs["er_delta"] @@ -1511,7 +1508,6 @@ class AddLorentzDispersion(UserObjectMulti): raise ValueError # Look up requested materials in existing list of material instances - grid = uip.grid materials = [y for x in material_ids for y in grid.materials if y.ID == x] if len(materials) != len(material_ids): @@ -1558,7 +1554,7 @@ class AddLorentzDispersion(UserObjectMulti): ) -class AddDrudeDispersion(UserObjectMulti): +class AddDrudeDispersion(GridUserObject): """Adds dispersive properties to already defined Material based on a multi-pole Drude formulation. @@ -1570,12 +1566,18 @@ class AddDrudeDispersion(UserObjectMulti): properties. """ + @property + def order(self): + return 13 + + @property + def hash(self): + return "#add_dispersion_drude" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 13 - self.hash = "#add_dispersion_drude" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: poles = self.kwargs["poles"] omega = self.kwargs["omega"] @@ -1590,7 +1592,6 @@ class AddDrudeDispersion(UserObjectMulti): raise ValueError # Look up requested materials in existing list of material instances - grid = uip.grid materials = [y for x in material_ids for y in grid.materials if y.ID == x] if len(materials) != len(material_ids): @@ -1634,7 +1635,7 @@ class AddDrudeDispersion(UserObjectMulti): ) -class SoilPeplinski(UserObjectMulti): +class SoilPeplinski(GridUserObject): """Mixing model for soils proposed by Peplinski et al. (http://dx.doi.org/10.1109/36.387598) @@ -1650,12 +1651,18 @@ class SoilPeplinski(UserObjectMulti): id: string used as identifier for soil. """ + @property + def order(self): + return 14 + + @property + def hash(self): + return "#soil_peplinski" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 14 - self.hash = "#soil_peplinski" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: sand_fraction = self.kwargs["sand_fraction"] clay_fraction = self.kwargs["clay_fraction"] @@ -1698,7 +1705,6 @@ class SoilPeplinski(UserObjectMulti): "fraction." ) raise ValueError - grid = uip.grid if any(x.ID == ID for x in grid.mixingmodels): logger.exception(f"{self.params_str()} with ID {ID} already exists") raise ValueError @@ -1725,7 +1731,7 @@ class SoilPeplinski(UserObjectMulti): grid.mixingmodels.append(s) -class MaterialRange(UserObjectMulti): +class MaterialRange(GridUserObject): """Creates varying material properties for stochastic models. Attributes: @@ -1740,12 +1746,18 @@ class MaterialRange(UserObjectMulti): id: string used as identifier for this variable material. """ + @property + def order(self): + return 15 + + @property + def hash(self): + return "#material_range" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 15 - self.hash = "#material_range" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: er_lower = self.kwargs["er_lower"] er_upper = self.kwargs["er_upper"] @@ -1803,7 +1815,6 @@ class MaterialRange(UserObjectMulti): logger.exception( f"{self.params_str()} requires a positive value for the upper range of magnetic loss." ) - grid = uip.grid if any(x.ID == ID for x in grid.mixingmodels): logger.exception(f"{self.params_str()} with ID {ID} already exists") raise ValueError @@ -1826,7 +1837,7 @@ class MaterialRange(UserObjectMulti): grid.mixingmodels.append(s) -class MaterialList(UserObjectMulti): +class MaterialList(GridUserObject): """Creates varying material properties for stochastic models. Attributes: @@ -1834,19 +1845,24 @@ class MaterialList(UserObjectMulti): id: string used as identifier for this variable material. """ + @property + def order(self): + return 15 + + @property + def hash(self): + return "#material_range" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 16 - self.hash = "#material_list" - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: list_of_materials = self.kwargs["list_of_materials"] ID = self.kwargs["id"] except KeyError: logger.exception(f"{self.params_str()} requires at at least 2 parameters.") raise - grid = uip.grid if any(x.ID == ID for x in grid.mixingmodels): logger.exception(f"{self.params_str()} with ID {ID} already exists") raise ValueError @@ -1860,163 +1876,7 @@ class MaterialList(UserObjectMulti): grid.mixingmodels.append(s) -class GeometryView(UserObjectMulti): - """Outputs to file(s) information about the geometry (mesh) of model. - - The geometry information is saved in Visual Toolkit (VTK) formats. - - Attributes: - p1: tuple required for lower left (x,y,z) coordinates of volume of - geometry view in metres. - p2: tuple required for upper right (x,y,z) coordinates of volume of - geometry view in metres. - dl: tuple required for spatial discretisation of geometry view in metres. - output_tuple: string required for per-cell 'n' (normal) or per-cell-edge - 'f' (fine) geometry views. - filename: string required for filename where geometry view will be - stored in the same directory as input file. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 17 - self.hash = "#geometry_view" - - def geometry_view_constructor(self, output_type): - """Selects appropriate class for geometry view dependent on geometry - view type, i.e. normal or fine. - """ - - if output_type == "n": - from .geometry_outputs import GeometryViewVoxels as GeometryViewUser - else: - from .geometry_outputs import GeometryViewLines as GeometryViewUser - - return GeometryViewUser - - def build(self, model, uip): - try: - p1 = self.kwargs["p1"] - p2 = self.kwargs["p2"] - dl = self.kwargs["dl"] - output_type = self.kwargs["output_type"].lower() - filename = self.kwargs["filename"] - except KeyError: - logger.exception(f"{self.params_str()} requires exactly eleven parameters.") - raise - - GeometryViewUser = self.geometry_view_constructor(output_type) - - try: - p3 = uip.round_to_grid_static_point(p1) - p4 = uip.round_to_grid_static_point(p2) - p1, p2 = uip.check_box_points(p1, p2, self.params_str()) - except ValueError: - logger.exception(f"{self.params_str()} point is outside the domain.") - raise - xs, ys, zs = p1 - xf, yf, zf = p2 - - grid = uip.grid - dx, dy, dz = uip.discretise_static_point(dl) - - if dx < 0 or dy < 0 or dz < 0: - logger.exception(f"{self.params_str()} the step size should not be less than zero.") - raise ValueError - if dx > grid.nx or dy > grid.ny or dz > grid.nz: - logger.exception( - f"{self.params_str()} the step size should be less than the domain size." - ) - raise ValueError - if dx < 1 or dy < 1 or dz < 1: - logger.exception( - f"{self.params_str()} the step size should not be less than the spatial discretisation." - ) - raise ValueError - if output_type not in ["n", "f"]: - logger.exception( - f"{self.params_str()} requires type to be either n (normal) or f (fine)." - ) - raise ValueError - if output_type == "f" and ( - dx * grid.dx != grid.dx or dy * grid.dy != grid.dy or dz * grid.dz != grid.dz - ): - logger.exception( - f"{self.params_str()} requires the spatial " - "discretisation for the geometry view to be the " - "same as the model for geometry view of " - "type f (fine)" - ) - raise ValueError - - g = GeometryViewUser(xs, ys, zs, xf, yf, zf, dx, dy, dz, filename, grid) - - logger.info( - f"{self.grid_name(grid)}Geometry view from {p3[0]:g}m, " - f"{p3[1]:g}m, {p3[2]:g}m, to {p4[0]:g}m, {p4[1]:g}m, " - f"{p4[2]:g}m, discretisation {dx * grid.dx:g}m, " - f"{dy * grid.dy:g}m, {dz * grid.dz:g}m, with filename " - f"base {g.filename} created." - ) - - model.geometryviews.append(g) - - -class GeometryObjectsWrite(UserObjectMulti): - """Writes geometry generated in a model to file which can be imported into - other models. - - Attributes: - p1: tuple required for lower left (x,y,z) coordinates of volume of - output in metres. - p2: tuple required for upper right (x,y,z) coordinates of volume of - output in metres. - filename: string required for filename where output will be stored in - the same directory as input file. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.order = 18 - self.hash = "#geometry_objects_write" - - def build(self, model, uip): - grid = uip.grid - if isinstance(grid, SubGridBaseGrid): - logger.exception(f"{self.params_str()} do not add geometry objects to subgrids.") - raise ValueError - try: - p1 = self.kwargs["p1"] - p2 = self.kwargs["p2"] - basefilename = self.kwargs["filename"] - except KeyError: - logger.exception(f"{self.params_str()} requires exactly seven parameters.") - raise - - p1, p2 = uip.check_box_points(p1, p2, self.params_str()) - x0, y0, z0 = p1 - x1, y1, z1 = p2 - - if isinstance(grid, MPIGrid): - geometry_object_type = MPIGeometryObjectsUser - else: - geometry_object_type = GeometryObjectsUser - - g = geometry_object_type(x0, y0, z0, x1, y1, z1, basefilename) - - logger.info( - f"Geometry objects in the volume from {p1[0] * grid.dx:g}m, " - f"{p1[1] * grid.dy:g}m, {p1[2] * grid.dz:g}m, to " - f"{p2[0] * grid.dx:g}m, {p2[1] * grid.dy:g}m, " - f"{p2[2] * grid.dz:g}m, will be written to " - f"{g.filename_hdf5}, with materials written to " - f"{g.filename_materials}" - ) - - model.geometryobjects.append(g) - - -class PMLCFS(UserObjectMulti): +class PMLCFS(GridUserObject): """Controls parameters that are used to build each order of PML. Default values are set in pml.py @@ -2041,11 +1901,18 @@ class PMLCFS(UserObjectMulti): sigmamax: float required for maximum value for the CFS sigma parameter. """ + @property + def order(self): + return 19 + + @property + def hash(self): + return "#pml_cfs" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.order = 19 - def build(self, model, uip): + def build(self, grid: FDTDGrid): try: alphascalingprofile = self.kwargs["alphascalingprofile"] alphascalingdirection = self.kwargs["alphascalingdirection"] @@ -2131,7 +1998,6 @@ class PMLCFS(UserObjectMulti): f"{cfssigma.min:g}, max: {cfssigma.max}) created." ) - grid = uip.grid grid.pmls["cfs"].append(cfs) if len(grid.pmls["cfs"]) > 2: @@ -2141,11 +2007,17 @@ class PMLCFS(UserObjectMulti): raise ValueError +""" +TODO: Can this be removed? class Subgrid(UserObjectMulti): - """""" def __init__(self, **kwargs): super().__init__(**kwargs) + logger.warning( + "Subgrid user object is deprecated and may be removed in" + " future releases of gprMax. Use the SubGridHSG user object" + " instead." + ) self.children_multiple = [] self.children_geometry = [] @@ -2157,3 +2029,4 @@ class Subgrid(UserObjectMulti): else: logger.exception("This object is unknown to gprMax.") raise ValueError +""" diff --git a/gprMax/user_objects/cmds_output.py b/gprMax/user_objects/cmds_output.py new file mode 100644 index 00000000..6a9ca251 --- /dev/null +++ b/gprMax/user_objects/cmds_output.py @@ -0,0 +1,180 @@ +import logging + +from gprMax.geometry_outputs import GeometryObjects as GeometryObjectsUser +from gprMax.geometry_outputs import MPIGeometryObjects as MPIGeometryObjectsUser +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.grid.mpi_grid import MPIGrid +from gprMax.model import Model +from gprMax.subgrids.grid import SubGridBaseGrid +from gprMax.user_objects.user_objects import OutputUserObject + +logger = logging.getLogger(__name__) + + +class GeometryView(OutputUserObject): + """Outputs to file(s) information about the geometry (mesh) of model. + + The geometry information is saved in Visual Toolkit (VTK) formats. + + Attributes: + p1: tuple required for lower left (x,y,z) coordinates of volume of + geometry view in metres. + p2: tuple required for upper right (x,y,z) coordinates of volume of + geometry view in metres. + dl: tuple required for spatial discretisation of geometry view in metres. + output_tuple: string required for per-cell 'n' (normal) or per-cell-edge + 'f' (fine) geometry views. + filename: string required for filename where geometry view will be + stored in the same directory as input file. + """ + + @property + def order(self): + return 17 + + @property + def hash(self): + return "#geometry_view" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def geometry_view_constructor(self, output_type): + """Selects appropriate class for geometry view dependent on geometry + view type, i.e. normal or fine. + """ + + if output_type == "n": + from gprMax.geometry_outputs import GeometryViewVoxels as GeometryViewUser + else: + from gprMax.geometry_outputs import GeometryViewLines as GeometryViewUser + + return GeometryViewUser + + def build(self, model: Model, grid: FDTDGrid): + try: + p1 = self.kwargs["p1"] + p2 = self.kwargs["p2"] + dl = self.kwargs["dl"] + output_type = self.kwargs["output_type"].lower() + filename = self.kwargs["filename"] + except KeyError: + logger.exception(f"{self.params_str()} requires exactly eleven parameters.") + raise + + GeometryViewUser = self.geometry_view_constructor(output_type) + + uip = self._create_uip(grid) + try: + p3 = uip.round_to_grid_static_point(p1) + p4 = uip.round_to_grid_static_point(p2) + p1, p2 = uip.check_box_points(p1, p2, self.params_str()) + except ValueError: + logger.exception(f"{self.params_str()} point is outside the domain.") + raise + xs, ys, zs = p1 + xf, yf, zf = p2 + + dx, dy, dz = uip.discretise_static_point(dl) + + if dx < 0 or dy < 0 or dz < 0: + logger.exception(f"{self.params_str()} the step size should not be less than zero.") + raise ValueError + if dx > grid.nx or dy > grid.ny or dz > grid.nz: + logger.exception( + f"{self.params_str()} the step size should be less than the domain size." + ) + raise ValueError + if dx < 1 or dy < 1 or dz < 1: + logger.exception( + f"{self.params_str()} the step size should not be less than the spatial discretisation." + ) + raise ValueError + if output_type not in ["n", "f"]: + logger.exception( + f"{self.params_str()} requires type to be either n (normal) or f (fine)." + ) + raise ValueError + if output_type == "f" and ( + dx * grid.dx != grid.dx or dy * grid.dy != grid.dy or dz * grid.dz != grid.dz + ): + logger.exception( + f"{self.params_str()} requires the spatial " + "discretisation for the geometry view to be the " + "same as the model for geometry view of " + "type f (fine)" + ) + raise ValueError + + g = GeometryViewUser(xs, ys, zs, xf, yf, zf, dx, dy, dz, filename, grid) + + logger.info( + f"{self.grid_name(grid)}Geometry view from {p3[0]:g}m, " + f"{p3[1]:g}m, {p3[2]:g}m, to {p4[0]:g}m, {p4[1]:g}m, " + f"{p4[2]:g}m, discretisation {dx * grid.dx:g}m, " + f"{dy * grid.dy:g}m, {dz * grid.dz:g}m, with filename " + f"base {g.filename} created." + ) + + model.geometryviews.append(g) + + +class GeometryObjectsWrite(OutputUserObject): + """Writes geometry generated in a model to file which can be imported into + other models. + + Attributes: + p1: tuple required for lower left (x,y,z) coordinates of volume of + output in metres. + p2: tuple required for upper right (x,y,z) coordinates of volume of + output in metres. + filename: string required for filename where output will be stored in + the same directory as input file. + """ + + @property + def order(self): + return 18 + + @property + def hash(self): + return "#geometry_objects_write" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def build(self, model: Model, grid: FDTDGrid): + if isinstance(grid, SubGridBaseGrid): + logger.exception(f"{self.params_str()} do not add geometry objects to subgrids.") + raise ValueError + try: + p1 = self.kwargs["p1"] + p2 = self.kwargs["p2"] + basefilename = self.kwargs["filename"] + except KeyError: + logger.exception(f"{self.params_str()} requires exactly seven parameters.") + raise + + uip = self._create_uip(grid) + p1, p2 = uip.check_box_points(p1, p2, self.params_str()) + x0, y0, z0 = p1 + x1, y1, z1 = p2 + + # TODO: Remove these when add parallel build + if isinstance(grid, MPIGrid): + geometry_object_type = MPIGeometryObjectsUser + else: + geometry_object_type = GeometryObjectsUser + + g = geometry_object_type(x0, y0, z0, x1, y1, z1, basefilename) + + logger.info( + f"Geometry objects in the volume from {p1[0] * grid.dx:g}m, " + f"{p1[1] * grid.dy:g}m, {p1[2] * grid.dz:g}m, to " + f"{p2[0] * grid.dx:g}m, {p2[1] * grid.dy:g}m, " + f"{p2[2] * grid.dz:g}m, will be written to " + f"{g.filename_hdf5}, with materials written to " + f"{g.filename_materials}" + ) + + model.geometryobjects.append(g) diff --git a/gprMax/user_objects/cmds_singleuse.py b/gprMax/user_objects/cmds_singleuse.py new file mode 100644 index 00000000..8ac2a6ba --- /dev/null +++ b/gprMax/user_objects/cmds_singleuse.py @@ -0,0 +1,594 @@ +# 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 typing import Optional, Tuple, Union + +import numpy as np +import numpy.typing as npt + +from gprMax import config +from gprMax.grid.mpi_grid import MPIGrid +from gprMax.model import Model +from gprMax.pml import PML +from gprMax.user_objects.user_objects import ModelUserObject +from gprMax.utilities.host_info import set_omp_threads + +logger = logging.getLogger(__name__) + + +class Title(ModelUserObject): + """Title of the model. + + Attributes: + title (str): Model title. + """ + + @property + def order(self): + return 1 + + @property + def hash(self): + return "#title" + + def __init__(self, name: str): + """Create a Title user object. + + Args: + name: Title of the model. + """ + super().__init__(name=name) + self.title = name + + def build(self, model: Model): + model.title = self.title + logger.info(f"Model title: {model.title}") + + +class Discretisation(ModelUserObject): + """Spatial discretisation of the model in the x, y, and z dimensions. + + Attributes: + discretisation (np.array): Spatial discretisation of the model + (x, y, z) + """ + + @property + def order(self): + return 2 + + @property + def hash(self): + return "#dx_dy_dz" + + def __init__(self, p1: Tuple[float, float, float]): + """Create a Discretisation user object. + + Args: + p1: Spatial discretisation in the x, y, and z dimensions. + """ + super().__init__(p1=p1) + self.discretisation = p1 + + def build(self, model: Model): + if any(self.discretisation) <= 0: + raise ValueError( + f"{self} discretisation requires the spatial step to be" + " greater than zero in all dimensions" + ) + + model.dl = np.array(self.discretisation, dtype=np.float64) + logger.info(f"Spatial discretisation: {model.dl[0]:g} x {model.dl[1]:g} x {model.dl[2]:g}m") + + +class Domain(ModelUserObject): + """Size of the model. + + Attributes: + domain_size (tuple): Extent of the model domain (x, y, z). + """ + + @property + def order(self): + return 3 + + @property + def hash(self): + return "#domain" + + def __init__(self, p1: Tuple[float, float, float]): + """Create a Domain user object. + + Args: + p1: Model extent in the x, y, and z dimensions. + """ + super().__init__(p1=p1) + self.domain_size = p1 + + def build(self, model: Model): + uip = self._create_uip(model.G) + model.nx, model.ny, model.nz = uip.discretise_point(self.domain_size) + # TODO: Remove when distribute full build for MPI + if isinstance(model.G, MPIGrid): + model.G.nx = model.nx + model.G.ny = model.ny + model.G.nz = model.nz + + if model.nx == 0 or model.ny == 0 or model.nz == 0: + raise ValueError(f"{self} requires at least one cell in every dimension") + + logger.info( + f"Domain size: {self.domain_size[0]:g} x {self.domain_size[1]:g} x " + + f"{self.domain_size[2]:g}m ({model.nx:d} x {model.ny:d} x {model.nz:d} = " + + f"{(model.nx * model.ny * model.nz):g} cells)" + ) + + # Set mode and switch off appropriate PMLs for 2D models + grid = model.G + if model.nx == 1: + config.get_model_config().mode = "2D TMx" + grid.pmls["thickness"]["x0"] = 0 + grid.pmls["thickness"]["xmax"] = 0 + elif model.ny == 1: + config.get_model_config().mode = "2D TMy" + grid.pmls["thickness"]["y0"] = 0 + grid.pmls["thickness"]["ymax"] = 0 + elif model.nz == 1: + config.get_model_config().mode = "2D TMz" + grid.pmls["thickness"]["z0"] = 0 + grid.pmls["thickness"]["zmax"] = 0 + else: + config.get_model_config().mode = "3D" + + logger.info(f"Mode: {config.get_model_config().mode}") + + # Sub-grids cannot be used with 2D models. There would typically be + # minimal performance benefit with sub-gridding and 2D models. + if "2D" in config.get_model_config().mode and config.sim_config.general["subgrid"]: + raise ValueError("Sub-gridding cannot be used with 2D models") + + # Calculate time step at CFL limit + grid.calculate_dt() + + logger.info(f"Time step (at CFL limit): {grid.dt:g} secs") + + +class TimeStepStabilityFactor(ModelUserObject): + """Factor by which to reduce the time step from the CFL limit. + + Attributes: + stability_factor (flaot): Factor to multiply time step by. + """ + + @property + def order(self): + return 4 + + @property + def hash(self): + return "#time_step_stability_factor" + + def __init__(self, f: float): + """Create a TimeStepStabilityFactor user object. + + Args: + f: Factor to multiply the model time step by. + """ + super().__init__(f=f) + self.stability_factor = f + + def build(self, model: Model): + if self.stability_factor <= 0 or self.stability_factor > 1: + raise ValueError( + f"{self} requires the value of the time step stability" + " factor to be between zero and one" + ) + + model.dt_mod = self.stability_factor + model.dt *= model.dt_mod + + logger.info(f"Time step (modified): {model.dt:g} secs") + + +class TimeWindow(ModelUserObject): + """Specifies the total required simulated time. + + Either time or iterations must be specified. If both are specified, + time takes precedence. + + Attributes: + time: float of required simulated time in seconds. + iterations: int of required number of iterations. + """ + + @property + def order(self): + return 5 + + @property + def hash(self): + return "#time_window" + + def __init__(self, time: Optional[float] = None, iterations: Optional[int] = None): + """Create a TimeWindow user object. + + Args: + time: Optional simulation time in seconds. Default None. + iterations: Optional number of iterations. Default None. + """ + super().__init__(time=time, iterations=iterations) + self.time = time + self.iterations = iterations + + def build(self, model: Model): + if self.time is not None: + if self.time > 0: + model.timewindow = self.time + model.iterations = int(np.ceil(self.time / model.dt)) + 1 + else: + raise ValueError(f"{self} must have a value greater than zero") + elif self.iterations is not None: + # The +/- 1 used in calculating the number of iterations is + # to account for the fact that the solver (iterations) loop + # runs from 0 to < G.iterations + model.timewindow = (self.iterations - 1) * model.dt + model.iterations = self.iterations + else: + raise ValueError(f"{self} specify a time or number of iterations") + + if self.time is not None and self.iterations is not None: + logger.warning( + f"{self.params_str()} Time and iterations were both specified, using 'time'" + ) + + logger.info(f"Time window: {model.timewindow:g} secs ({model.iterations} iterations)") + + +class OMPThreads(ModelUserObject): + """Set the number of OpenMP threads to use when running the model. + + Usually this should match the number of physical CPU cores + available. + + Attributes: + omp_threads (int): Number of OpenMP threads. + """ + + @property + def order(self): + return 6 + + @property + def hash(self): + return "#num_threads" + + def __init__(self, n: int): + """Create an OMPThreads user object. + + Args: + n: Number of OpenMP threads. + """ + super().__init__(n=n) + self.omp_threads = n + + def build(self, model: Model): + if self.omp_threads < 1: + raise ValueError(f"{self} requires the value to be an integer not less than one") + + config.get_model_config().ompthreads = set_omp_threads(self.omp_threads) + + logger.info(f"Simulation will use {config.get_model_config().ompthreads} OpenMP threads") + + +class PMLFormulation(ModelUserObject): + """Set the formulation of the PMLs. + + Current options are to use the Higher Order RIPML (HORIPML) - + https://doi.org/10.1109/TAP.2011.2180344, or Multipole RIPML + (MRIPML) - https://doi.org/10.1109/TAP.2018.2823864. + + Attributes: + formulation (str): Formulation to be used for all PMLs. Either + 'HORIPML' or 'MRIPML'. + """ + + @property + def order(self): + return 7 + + @property + def hash(self): + return "#pml_formulation" + + def __init__(self, formulation: str): + """Create a PMLFormulation user object. + + Args: + formulation: Formulation to be used for all PMLs. Either + 'HORIPML' or 'MRIPML'. + """ + super().__init__(formulation=formulation) + self.formulation = formulation + + def build(self, model: Model): + if self.formulation not in PML.formulations: + logger.exception(f"{self} requires the value to be one of {' '.join(PML.formulations)}") + + model.G.pmls["formulation"] = self.formulation + + logger.info(f"PML formulation set to {model.G.pmls['formulation']}") + + +class PMLThickness(ModelUserObject): + """Set the thickness of the PMLs. + + The thickness can be set globally, or individually for each of the + six sides of the model domain. Either thickness must be set, or all + of x0, y0, z0, xmax, ymax, zmax. + + Attributes: + thickness (int | Tuple[int]): Thickness of the PML on all 6 + sides or individual sides of the model domain. + """ + + @property + def order(self): + return 7 + + @property + def hash(self): + return "#pml_cells" + + def __init__(self, thickness: Union[int, Tuple[int, int, int, int, int, int]]): + """Create a PMLThickness user object. + + Args: + thickness: Thickness of the PML on all 6 sides or individual + sides of the model domain. + """ + super().__init__(thickness=thickness) + self.thickness = thickness + + def build(self, model: Model): + grid = model.G + + if isinstance(self.thickness, int) or len(self.thickness) == 1: + for key in grid.pmls["thickness"].keys(): + grid.pmls["thickness"][key] = int(self.thickness) + elif len(self.thickness) == 6: + grid.pmls["thickness"]["x0"] = int(self.thickness[0]) + grid.pmls["thickness"]["y0"] = int(self.thickness[1]) + grid.pmls["thickness"]["z0"] = int(self.thickness[2]) + grid.pmls["thickness"]["xmax"] = int(self.thickness[3]) + grid.pmls["thickness"]["ymax"] = int(self.thickness[4]) + grid.pmls["thickness"]["zmax"] = int(self.thickness[5]) + else: + raise ValueError(f"{self} requires either one or six parameter(s)") + + # Check each PML does not take up more than half the grid + if ( + 2 * grid.pmls["thickness"]["x0"] >= grid.nx + or 2 * grid.pmls["thickness"]["y0"] >= grid.ny + or 2 * grid.pmls["thickness"]["z0"] >= grid.nz + or 2 * grid.pmls["thickness"]["xmax"] >= grid.nx + or 2 * grid.pmls["thickness"]["ymax"] >= grid.ny + or 2 * grid.pmls["thickness"]["zmax"] >= grid.nz + ): + raise ValueError(f"{self} has too many cells for the domain size") + + thickness = model.G.pmls["thickness"] + + logger.info( + f"PML thickness: x0={thickness['x0']}, y0={thickness['y0']}," + f" z0={thickness['z0']}, xmax={thickness['xmax']}," + f" ymax={thickness['ymax']}, zmax={thickness['zmax']}" + ) + + +class PMLProps(ModelUserObject): + """Specify the formulation and thickness of the PMLs. + + A PML can be set on each of the six sides of the model domain. + Current options are to use the Higher Order RIPML (HORIPML) - + https://doi.org/10.1109/TAP.2011.2180344, or Multipole RIPML + (MRIPML) - https://doi.org/10.1109/TAP.2018.2823864. + + Deprecated: PMLProps is deprecated and may be removed in future + releases of gprMax. Use the new PMLFormulation and PMLThickness + user objects instead. + + Attributes: + pml_formulation (PMLFormulation): User object to set the PML + formulation. + pml_thickness (PMLThickness): User object to set the PML + thickness. + """ + + @property + def order(self): + return 7 + + @property + def hash(self): + return "#pml_properties" + + def __init__( + self, + formulation: Optional[str] = None, + thickness: Optional[int] = None, + x0: Optional[int] = None, + y0: Optional[int] = None, + z0: Optional[int] = None, + xmax: Optional[int] = None, + ymax: Optional[int] = None, + zmax: Optional[int] = None, + ): + """Create a PMLProps user object. + + If 'thickness' is set, it will take precendence over any + individual thicknesses set. Additionally, if 'thickness' is not + set, the individual thickness must be set for all six sides of + the model domain. + + Deprecated: PMLProps is deprecated and may be removed in future + releases of gprMax. Use the new PMLFormulation and PMLThickness + user objects instead. + + Args: + formulation (str): Formulation to be used for all PMLs. Either + 'HORIPML' or 'MRIPML'. + thickness: Optional thickness of the PML on all 6 sides of + the model domain. Default None. + x0, y0, z0, xmax, ymax, zmax: Optional thickness of the PML + on individual sides of the model domain. Default None. + """ + super().__init__() + + logger.warning( + "PMLProps is deprecated and may be removed in future" + " releases of gprMax. Use the new PMLFormulation and" + " PMLThickness user objects instead." + ) + + if formulation is not None: + self.pml_formulation = PMLFormulation(formulation) + else: + self.pml_formulation = None + + if thickness is not None: + self.pml_thickness = PMLThickness(thickness) + elif ( + x0 is not None + and y0 is not None + and z0 is not None + and xmax is not None + and ymax is not None + and zmax is not None + ): + self.pml_thickness = PMLThickness((x0, y0, z0, xmax, ymax, zmax)) + else: + self.pml_thickness = None + + if self.pml_formulation is None and self.pml_thickness is None: + raise ValueError( + "Must set PML formulation or thickness. Thickness can be set by specifying all of x0, y0, z0, xmax, ymax, zmax." + ) + + def build(self, model): + if self.pml_formulation is not None: + self.pml_formulation.build(model) + + if self.pml_thickness is not None: + self.pml_thickness.build(model) + + +class SrcSteps(ModelUserObject): + """Move the location of all simple sources. + + Attributes: + step_size (Tuple[float]): Increment (x, y, z) to move all + simple sources by for each step. + """ + + @property + def order(self): + return 8 + + @property + def hash(self): + return "#src_steps" + + def __init__(self, p1: Tuple[float, float, float]): + """Create a SrcSteps user object. + + Args: + p1: Increment (x, y, z) to move all simple sources by for + each step. + """ + super().__init__(p1=p1) + self.step_size = p1 + + def build(self, model: Model): + uip = self._create_uip(model.G) + model.srcsteps = np.array(uip.discretise_point(self.step_size), dtype=np.int32) + + logger.info( + f"Simple sources will step {model.srcsteps[0] * model.dx:g}m, " + f"{model.srcsteps[1] * model.dy:g}m, {model.srcsteps[2] * model.dz:g}m " + "for each model run." + ) + + +class RxSteps(ModelUserObject): + """Move the location of all receivers. + + Attributes: + step_size (Tuple[float]): Increment (x, y, z) to move all + receivers by for each step. + """ + + @property + def order(self): + return 9 + + @property + def hash(self): + return "#rx_steps" + + def __init__(self, p1: Tuple[float, float, float]): + """Create a RxSteps user object. + + Args: + p1: Increment (x, y, z) to move all receivers by for each + step. + """ + super().__init__(p1=p1) + self.step_size = p1 + + def build(self, model: Model): + uip = self._create_uip(model.G) + model.rxsteps = np.array(uip.discretise_point(self.step_size), dtype=np.int32) + + logger.info( + f"All receivers will step {model.rxsteps[0] * model.dx:g}m, " + f"{model.rxsteps[1] * model.dy:g}m, {model.rxsteps[2] * model.dz:g}m " + "for each model run." + ) + + +class OutputDir(ModelUserObject): + """Set the directory where output file(s) will be stored. + + Attributes: + output_dir (str): File path to directory. + """ + + @property + def order(self): + return 10 + + @property + def hash(self): + return "#output_dir" + + def __init__(self, dir: str): + super().__init__(dir=dir) + self.output_dir = dir + + def build(self, model: Model): + config.get_model_config().set_output_file_path(self.output_dir) diff --git a/gprMax/user_objects/rotatable.py b/gprMax/user_objects/rotatable.py new file mode 100644 index 00000000..19f2492d --- /dev/null +++ b/gprMax/user_objects/rotatable.py @@ -0,0 +1,46 @@ +from abc import ABC, abstractmethod +from typing import Optional, Tuple + +from gprMax.grid.fdtd_grid import FDTDGrid + + +class RotatableMixin(ABC): + """Stores parameters and defines an interface for rotatable objects. + + Attributes: + axis (str): Defines the axis about which to perform the + rotation. Must have value "x", "y", or "z". Default x. + angle (int): Specifies the angle of rotation (degrees). + Default 0. + origin (tuple | None): Optional point about which to perform the + rotation (x, y, z). Default None. + do_rotate (bool): True if the object should be rotated. False + otherwise. Default False. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # Forward all unused arguments + self.axis = "x" + self.angle = 0 + self.origin = None + self.do_rotate = False + + def rotate(self, axis: str, angle: int, origin: Optional[Tuple[float, float, float]] = None): + """Sets parameters for rotation. + + Args: + axis: Defines the axis about which to perform the rotation. + Must have value "x", "y", or "z". + angle: Specifies the angle of rotation (degrees). + origin: Optional point about which to perform the rotation + (x, y, z). Default None. + """ + self.axis = axis + self.angle = angle + self.origin = origin + self.do_rotate = True + + @abstractmethod + def _do_rotate(self, grid: FDTDGrid): + """Performs the rotation.""" + pass diff --git a/gprMax/user_objects/user_objects.py b/gprMax/user_objects/user_objects.py new file mode 100644 index 00000000..b4c4c905 --- /dev/null +++ b/gprMax/user_objects/user_objects.py @@ -0,0 +1,154 @@ +from abc import ABC, abstractmethod +from typing import List, Union + +from gprMax import config +from gprMax.grid.fdtd_grid import FDTDGrid +from gprMax.model import Model +from gprMax.subgrids.grid import SubGridBaseGrid +from gprMax.user_inputs import MainGridUserInput, SubgridUserInput + + +class UserObject(ABC): + """User defined object. + + Attributes: + order (int): Specifies the order user objects should be + constructed in. + hash (str): gprMax hash command used to create the user object + in an input file. + kwargs (dict): Keyword arguments used to construct the user + object. + autotranslate (bool): TODO + is_single_use (bool): True if the object can only appear once in a + given model. False otherwise. Default True. + is_geometry_object (bool): True if the object adds geometry to the + model. False otherwise. Default False. + """ + + @property + @abstractmethod + def order(self) -> int: + pass + + @property + @abstractmethod + def hash(self) -> str: + pass + + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + self.autotranslate = True + + def __lt__(self, obj: "UserObject"): + return self.order < obj.order + + def __str__(self) -> str: + """Readable user object as per hash commands.""" + args: List[str] = [] + for value in self.kwargs.values(): + if isinstance(value, (tuple, list)): + for element in value: + args.append(str(element)) + else: + args.append(str(value)) + + return f"{self.hash}: {' '.join(args)}" + + def params_str(self) -> str: + """Readable string of parameters given to object.""" + return f"{self.hash}: {str(self.kwargs)}" + + def _create_uip(self, grid: FDTDGrid) -> Union[SubgridUserInput, MainGridUserInput]: + """Returns a point checker class based on the grid supplied. + + Args: + grid: Grid to get a UserInput object for. + + Returns: + uip: UserInput object for the grid provided. + """ + + # If autotranslate is set as True globally, local object + # configuration trumps. I.e. User can turn off autotranslate for + # specific objects. + if ( + isinstance(grid, SubGridBaseGrid) + and config.sim_config.args.autotranslate + and self.autotranslate + ): + return SubgridUserInput(grid) + else: + return MainGridUserInput(grid) + + +class ModelUserObject(UserObject): + """User defined object to add to the model.""" + + @abstractmethod + def build(self, model: Model): + """Build user object and set model properties. + + Args: + model: Model to set the properties of. + """ + pass + + +class GridUserObject(UserObject): + """User defined object to add to a grid.""" + + @abstractmethod + def build(self, grid: FDTDGrid): + pass + + def grid_name(self, grid: FDTDGrid) -> str: + """Format grid name for use with logging info. + + Returns an empty string if the grid is the main grid. + + Args: + grid: Grid to get the name of. + + Returns: + grid_name: Formatted version of the grid name. + """ + if isinstance(grid, SubGridBaseGrid): + return f"[{grid.name}] " + else: + return "" + + +class OutputUserObject(UserObject): + """User defined object that controls the output of data.""" + + @abstractmethod + def build(self, model: Model, grid: FDTDGrid): + pass + + def grid_name(self, grid: FDTDGrid) -> str: + """Format grid name for use with logging info. + + Returns an empty string if the grid is the main grid. + + Args: + grid: Grid to get the name of. + + Returns: + grid_name: Formatted version of the grid name. + """ + if isinstance(grid, SubGridBaseGrid): + return f"[{grid.name}] " + else: + return "" + + +class GeometryUserObject(GridUserObject): + """User defined object that adds geometry to a grid.""" + + @property + def order(self): + """Geometry Objects do not have an ordering. + + They should be built in the order they were added to the scene. + """ + return 1