Source code for struphy.simulation.sim

# third party imports
import glob
import json
import logging
import os
import pickle
import shutil
import sysconfig
import time

import cunumpy as xp
import h5py
import pyvista as pv
import yaml
from feectools.ddm.mpi import MockMPI
from feectools.ddm.mpi import mpi as MPI
from feectools.linalg.stencil import StencilVector
from line_profiler import profile
from pyevtk.hl import gridToVTK
from scope_profiler import ProfileManager
from tqdm import tqdm

# api imports
from struphy import (
    BaseUnits,
    DerhamOptions,
    EnvironmentOptions,
    PlottingData,
    PostProcessor,
    Time,
    domains,
    equils,
    grids,
    set_logging_level,
)

# core imports
from struphy.feec.basis_projection_ops import BasisProjectionOperators
from struphy.feec.mass import WeightedMassOperators
from struphy.feec.psydac_derham import Derham
from struphy.fields_background.base import (
    FluidEquilibrium,
    FluidEquilibriumWithB,
    MHDequilibrium,
    NumericalMHDequilibrium,
)
from struphy.fields_background.projected_equils import (
    ProjectedFluidEquilibrium,
    ProjectedFluidEquilibriumWithB,
    ProjectedMHDequilibrium,
)
from struphy.geometry.base import Domain
from struphy.io.output_handling import DataContainer
from struphy.models import Maxwell
from struphy.models.base import StruphyModel
from struphy.models.species import (
    DiagnosticSpecies,
    FieldSpecies,
    FluidSpecies,
    ParticleSpecies,
    Species,
)
from struphy.models.variables import FEECVariable, PICVariable, SPHVariable
from struphy.physics.physics import Units
from struphy.pic.base import Particles
from struphy.propagators.base import Propagator
from struphy.simulation.base import SimulationBase
from struphy.utils.clone_config import CloneConfig
from struphy.utils.utils import dict_to_yaml, ruff_autofix_and_format

logger = logging.getLogger("struphy")


[docs] class Simulation(SimulationBase): """Top-level class to configure and run a Struphy simulation. The `Simulation` class wraps model setup, MPI configuration, output management, normalization (units), memory allocation and time stepping. It initializes the model's variables and propagators, prepares runtime metadata and output folders, and provides the main `run()` entry point to execute the simulation. Parameters ---------- model : StruphyModel Physics model that provides species, propagators and variables. name : str, optional Name of the simulation. description : str, optional Description of the simulation. params_path : str, optional Path to a Python parameter file to save alongside outputs. env : EnvironmentOptions Runtime and output environment options. time_opts : Time Time-stepping options (dt, Tend, split algorithm, ...). domain : Domain Computational domain description. equil : FluidEquilibrium, optional Initial fluid equilibrium (may be None). grid : TensorProductGrid Spatial grid used for FEEC variables. derham_opts : DerhamOptions Options for discrete differential operators. """ def __init__( self, model: StruphyModel, name: str = "", description: str = "", params_path: str = None, env: EnvironmentOptions = EnvironmentOptions(), time_opts: Time = Time(), domain: Domain = domains.Cuboid(), equil: FluidEquilibrium = None, grid: grids.TensorProductGrid = grids.TensorProductGrid(), derham_opts: DerhamOptions = DerhamOptions(), logging_level: int | None = None, ): if logging_level is not None: set_logging_level(logging_level) self._name = name self._description = description self._model = model self._params_path = params_path self._env = env self._time_opts = time_opts self._setup_domain_and_equil(domain, equil) self._grid = grid self._derham_opts = derham_opts # setup profiling agent ProfileManager.setup( profiling_activated=env.profiling_activated, time_trace=env.profiling_trace, use_likwid=False, file_path=os.path.join( env.out_folders, env.sim_folder, "profiling_data.h5", ), ) # mpi info if isinstance(MPI, MockMPI): self.comm = None self.rank = 0 self.comm_size = 1 self.Barrier = lambda: None else: self.comm = MPI.COMM_WORLD self.rank = self.comm.Get_rank() self.comm_size = self.comm.Get_size() self.Barrier = self.comm.Barrier logger.info(f"\nMPI comm: {self.comm}") logger.info(f"MPI size: {self.comm_size} processes") logger.info(f"MPI rank: {self.rank}") if logger.level <= logging.INFO and self.rank == 0: self.show_parameters() self.model.units.show_units() self.model.show_equation_params() # synchronize MPI processes to set same start time of simulation for all processes self.Barrier() self.start_time = time.time() # check model assert hasattr(model, "propagators"), "Attribute 'self.propagators' must be set in model __init__!" self.model_name = model.__class__.__name__ # meta-data path_out = env.path_out num_clones = env.num_clones # creating output folders self._setup_folders() # save parameter file if self.rank == 0: # save python param file if self.params_path is not None: assert self.params_path[-3:] == ".py" try: shutil.copy2( self.params_path, os.path.join(path_out, "parameters.py"), ) except shutil.SameFileError: pass # pickle struphy objects else: with open(os.path.join(path_out, "env.bin"), "wb") as f: pickle.dump(env, f, pickle.HIGHEST_PROTOCOL) with open(os.path.join(path_out, "time_opts.bin"), "wb") as f: pickle.dump(time_opts, f, pickle.HIGHEST_PROTOCOL) with open(os.path.join(path_out, "domain.bin"), "wb") as f: # WORKAROUND: cannot pickle pyccelized classes at the moment tmp_dct = {"name": domain.__class__.__name__, "params": domain.params} pickle.dump(tmp_dct, f, pickle.HIGHEST_PROTOCOL) with open(os.path.join(path_out, "equil.bin"), "wb") as f: # WORKAROUND: cannot pickle pyccelized classes at the moment if equil is not None: tmp_dct = {"name": equil.__class__.__name__, "params": equil.params} else: tmp_dct = {} pickle.dump(tmp_dct, f, pickle.HIGHEST_PROTOCOL) with open(os.path.join(path_out, "grid.bin"), "wb") as f: pickle.dump(grid, f, pickle.HIGHEST_PROTOCOL) with open(os.path.join(path_out, "derham_opts.bin"), "wb") as f: pickle.dump(derham_opts, f, pickle.HIGHEST_PROTOCOL) with open(os.path.join(path_out, "model_class.bin"), "wb") as f: pickle.dump(model.__class__, f, pickle.HIGHEST_PROTOCOL) # config clones if self.comm is None: clone_config = None else: if num_clones == 1: clone_config = None else: # Setup domain cloning communicators # MPI.COMM_WORLD : comm # within a clone: : sub_comm # between the clones : inter_comm clone_config = CloneConfig(comm=self.comm, params=None, num_clones=num_clones) clone_config.print_clone_config() if model.particle_species: clone_config.print_particle_config() self.clone_config = model.clone_config = clone_config self.Barrier() # ---------------- # Abstract methods # ---------------- def show_parameters(self): """Print the current simulation configuration to stdout. Only the MPI rank 0 prints to avoid clutter from multiple processes. """ print(f"\nNew instance of Simulation from file\n{self.params_path}\n") print(self.model) print("") print(self.env) print("") print(self.time_opts) print("") print(self.domain) print("") print(self.equil) print("") print(self.grid) print("") print(self.derham_opts) def show_propagator_options(self): # Display propagator options and intial conditions: if MPI.COMM_WORLD.Get_rank() == 0: print("\nPROPAGATOR OPTIONS:") for prop in self.model.prop_list: assert isinstance(prop, Propagator) print(prop) def show_initial_conditions(self): if MPI.COMM_WORLD.Get_rank() == 0: print("\nINITIAL CONDITIONS:") for species in self.model.species.values(): assert isinstance(species, Species) for variable in species.variables.values(): if isinstance(variable, FEECVariable) or isinstance(variable, SPHVariable): variable.show_backgrounds() variable.show_perturbations() elif isinstance(variable, PICVariable): variable.show_backgrounds() variable.show_perturbations() variable.show_initial_condition() def allocate(self): """Allocate FEEC structures, model variables and propagators. This prepares FEEC operators, allocates variable storage for all species (fields, fluids, particles) and passes allocation info to propagators. Prints progress on MPI rank 0. """ logger.debug("\nAllocating simulation data ...") # feec self._allocate_feec(self.grid, self.derham_opts) # allocate model variables self._allocate_variables() # pass info to propagators self._allocate_propagators() # allocate helper fields and perform initial solves if needed self.model.allocate_helpers() logger.debug("... Done.") def save_geometry_and_equil_vtk(self): """Write a VTK file with geometry and (projected) equilibrium fields. Only executed on MPI rank 0. Outputs basic diagnostic fields such as jacobian determinant, pressure and |B| when available. """ # store geometry vtk if self.rank == 0: grids_log = [ xp.linspace(1e-6, 1.0, 32), xp.linspace(0.0, 1.0, 32), xp.linspace(0.0, 1.0, 32), ] tmp = self.domain(*grids_log) grids_phy = [tmp[0], tmp[1], tmp[2]] pointData = {} det_df = self.domain.jacobian_det(*grids_log) pointData["det_df"] = det_df if self.equil is not None: p0 = self.equil.p0(*grids_log) pointData["p0"] = p0 n0 = self.equil.n0(*grids_log) pointData["n0"] = n0 if isinstance(self.equil, FluidEquilibriumWithB): absB0 = self.equil.absB0(*grids_log) pointData["absB0"] = absB0 gridToVTK(os.path.join(self.env.path_out, "geometry"), *grids_phy, pointData=pointData) def create_geometry_mesh( self, nx: int = 32, ny: int = 32, nz: int = 32, ): """Create a PyVista mesh with geometry and (projected) equilibrium fields. Returns a StructuredGrid mesh with basic diagnostic fields such as jacobian determinant, pressure and |B| when available. Returns ------- pyvista.StructuredGrid Mesh containing geometry and equilibrium field data. """ grids_log = [ xp.linspace(1e-6, 1.0, nx), xp.linspace(0.0, 1.0, ny), xp.linspace(0.0, 1.0, nz), ] tmp = self.domain(*grids_log) grids_phy = [tmp[0], tmp[1], tmp[2]] # Create PyVista structured grid mesh = pv.StructuredGrid(grids_phy[0], grids_phy[1], grids_phy[2]) # Add point data det_df = self.domain.jacobian_det(*grids_log) mesh["det_df"] = det_df.ravel(order="F") if self.equil is not None: p0 = self.equil.p0(*grids_log) mesh["p0"] = p0.ravel(order="F") if isinstance(self.equil, FluidEquilibriumWithB): absB0 = self.equil.absB0(*grids_log) mesh["absB0"] = absB0.ravel(order="F") return mesh def show_domain( self, scalars: list | str | None = None, nx: int = 32, ny: int = 32, nz: int = 32, window_size: tuple | None = None, zoom_factor: int = 1.0, ) -> pv.Plotter: """Visualize the geometry and (projected) equilibrium fields using PyVista.""" if self.rank == 0: mesh = self.create_geometry_mesh(nx=nx, ny=ny, nz=nz) pv.set_jupyter_backend("static") if scalars: if isinstance(scalars, str): scalars_to_plot = [scalars] else: scalars_to_plot = scalars else: scalar_names = mesh.array_names scalars_to_plot = scalar_names[:3] if len(scalar_names) >= 3 else scalar_names if window_size is None: window_size = (len(scalars_to_plot) * 500, 250) # Create a plotter with three subplots side by side plotter = pv.Plotter(shape=(1, len(scalars_to_plot)), window_size=window_size) for idx, scalar_name in enumerate(scalars_to_plot): plotter.subplot(0, idx) plotter.add_mesh( mesh, scalars=scalar_name, show_edges=False, cmap="jet", scalar_bar_args={ "title": scalar_name, "vertical": True, "title_font_size": 12, "label_font_size": 10, "height": 0.8, }, ) plotter.view_isometric() plotter.camera.zoom(zoom_factor) plotter.show() return plotter return None def initialize_data_storage(self): """Create the `DataContainer` and register time datasets. Initializes `time_state` arrays (normalized and physical time and index) and registers them with the output `DataContainer` so they are saved during the run (and on restart). """ # data object for saving (will either create new hdf5 files if restart==False or open existing files if restart==True) # use MPI.COMM_WORLD as communicator when storing the outputs self.data = DataContainer(self.env.path_out, comm=self.comm) # time quantities (current time value, value in seconds and index) self.time_state = {} self.time_state["value"] = xp.zeros(1, dtype=float) self.time_state["value_sec"] = xp.zeros(1, dtype=float) self.time_state["index"] = xp.zeros(1, dtype=int) # add time quantities to data object for saving for key, val in self.time_state.items(): key_time = "time/" + key key_time_restart = "restart/time/" + key self.data.add_data({key_time: val}) self.data.add_data({key_time_restart: val}) def run(self, one_time_step: bool = False): """Main entry point to execute the simulation time loop. Responsibilities include allocation (when not restarting), initialization of output storage, handling restarts, running the main time-stepping loop, saving data at intervals, and finalizing profiling and metadata. Prints progress on MPI rank 0. Parameters ---------- one_time_step : bool If True, only perform one time step (useful for testing). """ logger.warning(f"\nStarting run for model {self.model_name} ...") if self.name != "": logger.info(f"Simulation name: {self.name}") if self.description != "": logger.info(f"Description: {self.description}") self._remove_existing_output_files() # equation paramters self.allocate() # output self.initialize_data_storage() # peek view into geometry self.save_geometry_and_equil_vtk() # plasma parameters self.compute_plasma_params() # print info on mpi procs if self.comm_size < 32: if self.derham is not None: logger.info(f"\nderham.domain_array:\n{self.derham.domain_array}") else: for _, species in self.model.species.items(): for _, variable in species.variables.items(): if isinstance(variable, (PICVariable, SPHVariable)): logger.info(f"\nparticle domain_array:\n{variable.particles.domain_array}") break if self.rank < 32: logger.debug("") logger.debug(f"Rank {self.rank}: executing run() for model {self.model_name} ...") if self.comm_size > 32 and self.rank == 32: logger.debug(f"Ranks > 31: executing run() for model {self.model_name} ...") # retrieve time parameters dt = self.time_opts.dt if one_time_step: Tend = dt else: Tend = self.time_opts.Tend split_algo = self.time_opts.split_algo # set initial conditions for all variables if self.env.restart: self._initialize_from_restart(self.data) with h5py.File(self.data.file_path, "a") as file: self.time_state["value"][0] = file["restart/time/value"][-1] self.time_state["value_sec"][0] = file["restart/time/value_sec"][-1] self.time_state["index"][0] = file["restart/time/index"][-1] start_step = file["restart/time/index"][-1] total_steps = int(round((Tend - self.time_state["value"][0]) / dt)) logger.info(f"""\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! RESTARTing from: {self.time_state["value"][0]=} {self.time_state["value_sec"][0]=} {self.time_state["index"][0]=} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! """) else: total_steps = int(round(Tend / dt)) start_step = 0 total_steps_str = str(total_steps) # compute initial scalars and kinetic data, pass time state to all propagators self.model.update_scalar_quantities() self.model.update_markers_to_be_saved() self.model.update_distr_functions() self._add_time_state(self.time_state["value"]) # add all variables to be saved to data object save_keys_all, save_keys_end = self._initialize_hdf5_datasets(self.data, self.comm_size) # ======================== main time loop ====================== self.model.update_scalar_quantities() if logger.level <= logging.INFO and self.rank == 0: print("\nINITIAL SCALAR QUANTITIES:") self.model.print_scalar_quantities() print(f"START TIME STEPPING WITH '{split_algo}' SPLITTING:") # time loop run_time_now = 0.0 show_progress_bar = logger.getEffectiveLevel() <= logging.WARNING and self.rank == 0 pbar = tqdm(total=total_steps, disable=not show_progress_bar, desc="Time stepping", unit="step") while True: self.Barrier() # stop time loop? break_cond_1 = self.time_state["value"][0] >= Tend break_cond_2 = run_time_now > self.env.max_runtime if break_cond_1 or break_cond_2: # save restart data (other data already saved below) self.data.save_data(keys=save_keys_end) end_time = time.time() logger.info(f"\nTime steps done: {self.time_state['index'][0]}") logger.info(f"wall-clock time of simulation [sec]: {end_time - self.start_time}") logger.info("") break if self.env.sort_step and self.time_state["index"][0] % self.env.sort_step == 0: t0 = time.time() for key, val in self.model.pointer.items(): if isinstance(val, Particles): val.do_sort() t1 = time.time() message = "Particles sorted | wall clock [s]: {0:8.4f} | sorting duration [s]: {1:8.4f}".format( run_time_now * 60, t1 - t0, ) logger.info(message) logger.info("") # update time and index (round time to 10 decimals for a clean time grid!) self.time_state["value"][0] = round(self.time_state["value"][0] + dt, 14) self.time_state["value_sec"][0] = round(self.time_state["value_sec"][0] + dt * self.model.units.t, 14) self.time_state["index"][0] += 1 # perform one time step dt t0 = time.time() with ProfileManager.profile_region("model.integrate"): self.model.integrate(dt, split_algo) t1 = time.time() run_time_now = (time.time() - self.start_time) / 60 # update diagnostics data and save data if self.time_state["index"][0] % self.env.save_step == 0: # compute scalars and kinetic data self.model.update_scalar_quantities() self.model.update_markers_to_be_saved() self.model.update_distr_functions() # extract FEEC coefficients feec_species = self.model.field_species | self.model.fluid_species | self.model.diagnostic_species for species, val in feec_species.items(): assert isinstance(val, Species) for variable, subval in val.variables.items(): assert isinstance(subval, FEECVariable) spline = subval.spline # in-place extraction of FEM coefficients from field.vector --> field.vector_stencil! spline.extract_coeffs(update_ghost_regions=False) # save data (everything but restart data) self.data.save_data(keys=save_keys_all) # print current time and scalar quantities to screen step = str(self.time_state["index"][0]).zfill(len(total_steps_str)) message = "time step:".ljust(25) + f"{step}/{total_steps + start_step}".rjust(25) message += ( "\n" + "normalized time:".ljust(25) + "{0:4.2e} / {1:4.2e}".format(self.time_state["value"][0], Tend).rjust(25) ) message += ( "\n" + "physical time [s]:".ljust(25) + "{0:4.2e} / {1:4.2e}".format( self.time_state["value_sec"][0], Tend * self.model.units.t, ).rjust(25) ) message += "\n" + "wall clock time [s]:".ljust(25) + "{0:8.4f}".format(run_time_now * 60).rjust(25) message += "\n" + "last step duration [s]:".ljust(25) + "{0:8.4f}".format(t1 - t0).rjust(25) logger.info(message) if logger.level <= logging.INFO and self.rank == 0: self.model.print_scalar_quantities() if show_progress_bar: pbar.update(1) pbar.close() # =================================================================== self.Barrier() if self.rank == 0: # save meta-data meta = { "platform": sysconfig.get_platform(), "python version": sysconfig.get_python_version(), "model name": self.model_name, "parameter file": self.params_path, "output folder": self.env.path_out, "MPI processes": self.comm_size, "use MPI.COMM_WORLD": self.comm is not None, "number of domain clones": self.env.num_clones, "restart": self.env.restart, "max wall-clock [min]": self.env.max_runtime, "save interval [steps]": self.env.save_step, "wall-clock time[min]": (end_time - self.start_time) / 60, } dict_to_yaml(meta, os.path.join(self.env.path_out, "meta.yml")) logger.warning("Struphy run finished.") if self.clone_config is not None: self.clone_config.free() ProfileManager.finalize() def pproc( self, step: int = 1, celldivide: int = 1, physical: bool = False, guiding_center: bool = False, classify: bool = False, create_vtk: bool = True, time_trace: bool = False, ): """Run post-processing on saved simulation data. Uses `PostProcessor` to generate plots, process guiding-center or physical field views, and optionally produce VTK outputs. """ # setup post processor and plotting if not hasattr(self, "_post_processor") and self.rank == 0: self._post_processor = PostProcessor(sim=self) if time_trace: self.post_processor.plot_time_traces() self.post_processor.process( step=step, celldivide=celldivide, physical=physical, guiding_center=guiding_center, classify=classify, create_vtk=create_vtk, ) def load_plotting_data(self): """Load plotting datasets produced by post-processing. Creates a `PlottingData` instance on rank 0 (if needed), loads the data and exposes convenient attributes such as `orbits`, `f`, and grid information for downstream plotting or analysis. """ if not hasattr(self, "_plotting_data") and self.rank == 0: self._plotting_data = PlottingData(sim=self) self.plotting_data.load() # expose attributes self.orbits = self.plotting_data.orbits self.f = self.plotting_data.f self.spline_values = self.plotting_data.spline_values self.n_sph = self.plotting_data.n_sph self.grids_log = self.plotting_data.grids_log self.grids_phy = self.plotting_data.grids_phy self.t_grid = self.plotting_data.t_grid # --------------------- # Code specific methods # --------------------- def compute_plasma_params(self): """ Compute and print volume averaged plasma parameters for each species of the model. Global parameters: - plasma volume - transit length - magnetic field Species dependent parameters: - mass - charge - density - pressure - thermal energy kBT - Alfvén speed v_A - thermal speed v_th - thermal frequency Omega_th - cyclotron frequency Omega_c - plasma frequency Omega_p - Alfvèn frequency Omega_A - thermal Larmor radius rho_th - MHD length scale v_a/Omega_c - rho/L - alpha = Omega_p/Omega_c - epsilon = 1/(t*Omega_c) """ # units affices for printing units_affix = {} units_affix["plasma volume"] = " m³" units_affix["transit length"] = " m" units_affix["magnetic field"] = " T" units_affix["mass"] = " kg" units_affix["charge"] = " C" units_affix["density"] = " m⁻³" units_affix["pressure"] = " bar" units_affix["kBT"] = " keV" units_affix["v_A"] = " m/s" units_affix["v_th"] = " m/s" units_affix["vth1"] = " m/s" units_affix["vth2"] = " m/s" units_affix["vth3"] = " m/s" units_affix["Omega_th"] = " Mrad/s" units_affix["Omega_c"] = " Mrad/s" units_affix["Omega_p"] = " Mrad/s" units_affix["Omega_A"] = " Mrad/s" units_affix["rho_th"] = " m" units_affix["v_A/Omega_c"] = " m" units_affix["rho_th/L"] = "" units_affix["alpha"] = "" units_affix["epsilon"] = "" h = 1 / 20 eta1 = xp.linspace(h / 2.0, 1.0 - h / 2.0, 20) eta2 = xp.linspace(h / 2.0, 1.0 - h / 2.0, 20) eta3 = xp.linspace(h / 2.0, 1.0 - h / 2.0, 20) # global parameters # plasma volume (hat x^3) det_tmp = self.domain.jacobian_det(eta1, eta2, eta3) vol1 = xp.mean(xp.abs(det_tmp)) # plasma volume (m⁻³) plasma_volume = vol1 * self.model.units.x**3 # transit length (m) transit_length = plasma_volume ** (1 / 3) # magnetic field (T) if isinstance(self.equil, FluidEquilibriumWithB): B_tmp = self.equil.absB0(eta1, eta2, eta3) else: B_tmp = xp.zeros((eta1.size, eta2.size, eta3.size)) magnetic_field = xp.mean(B_tmp * xp.abs(det_tmp)) / vol1 * self.model.units.B B_max = xp.max(B_tmp) * self.model.units.B B_min = xp.min(B_tmp) * self.model.units.B if magnetic_field < 1e-14: magnetic_field = xp.nan # logger.info("\n+++++++ WARNING +++++++ magnetic field is zero - set to nan !!") logger.info("\nPLASMA PARAMETERS:") logger.info( "Plasma volume:".ljust(25) + "{:4.3e}".format(plasma_volume) + units_affix["plasma volume"], ) logger.info( "Transit length:".ljust(25) + "{:4.3e}".format(transit_length) + units_affix["transit length"], ) logger.info( "Avg. magnetic field:".ljust(25) + "{:4.3e}".format(magnetic_field) + units_affix["magnetic field"], ) logger.info( "Max magnetic field:".ljust(25) + "{:4.3e}".format(B_max) + units_affix["magnetic field"], ) logger.info( "Min magnetic field:".ljust(25) + "{:4.3e}".format(B_min) + units_affix["magnetic field"], ) def spawn_sister( self, model: StruphyModel = None, params_path: str = None, env: EnvironmentOptions = None, time_opts: Time = None, domain: Domain = None, equil: FluidEquilibrium = None, grid: grids.TensorProductGrid = None, derham_opts: DerhamOptions = None, ): """Spawn a sister simulation with parameters that default to the current instance. This can be used to quickly generate multiple similar simulations.""" if model is None: model = self.model if params_path is None: params_path = self.params_path if env is None: env = self.env if time_opts is None: time_opts = self.time_opts if domain is None: domain = self.domain if equil is None: equil = self.equil if grid is None: grid = self.grid if derham_opts is None: derham_opts = self.derham_opts sister = Simulation( model=model, params_path=params_path, env=env, time_opts=time_opts, domain=domain, equil=equil, grid=grid, derham_opts=derham_opts, ) return sister # --------------- # Private methods # --------------- def _setup_folders(self): """ Setup output folders. """ if MPI.COMM_WORLD.Get_rank() == 0: # create output folder if it does not exit if not os.path.exists(self.env.path_out): os.makedirs(self.env.path_out, exist_ok=True) logger.debug("Created folder " + self.env.path_out) # create data folder in output folder if it does not exist if not os.path.exists(os.path.join(self.env.path_out, "data/")): os.mkdir(os.path.join(self.env.path_out, "data/")) logger.debug("Created folder " + os.path.join(self.env.path_out, "data/")) def _remove_existing_output_files(self): """Removes post_processing/, meta.txt and profile_tmp. If not restart, also removes existing hdf5 and png files in output folder.""" if MPI.COMM_WORLD.Get_rank() == 0: # remove post_processing folder folder = os.path.join(self.env.path_out, "post_processing") if os.path.exists(folder): shutil.rmtree(folder) logger.info("Removed existing folder " + folder) # remove meta file file = os.path.join(self.env.path_out, "meta.txt") if os.path.exists(file): os.remove(file) logger.info("Removed existing file " + file) # remove profiling file file = os.path.join(self.env.path_out, "profile_tmp") if os.path.exists(file): os.remove(file) logger.info("Removed existing file " + file) # remove hdf5 and png files (if NOT a restart) if not self.env.restart: files = glob.glob(os.path.join(self.env.path_out, "data", "*.hdf5")) for n, file in enumerate(files): os.remove(file) if n < 10: # print only ten statements in case of many processes logger.info("Removed existing file " + file) files = glob.glob(os.path.join(self.env.path_out, "*.png")) for n, file in enumerate(files): os.remove(file) if n < 10: # print only ten statements in case of many processes logger.info("Removed existing file " + file) def _setup_domain_and_equil(self, domain: Domain, equil: FluidEquilibrium): """If a numerical equilibirum is used, the domain is taken from this equilibirum.""" if equil is not None: if isinstance(equil, NumericalMHDequilibrium): self._domain = equil.domain else: self._domain = domain equil.domain = domain if hasattr(equil, "units"): assert isinstance(equil.units, Units) equil.units.derive_units( velocity_scale=self.model.velocity_scale, A_bulk=self.model.bulk_species.mass_number, Z_bulk=self.model.bulk_species.charge_number, ) else: self._domain = domain self._equil = equil @profile def _allocate_feec(self, grid: grids.TensorProductGrid, derham_opts: DerhamOptions): """Create the discrete Derham sequence, mass/basis operators and projected equilibrium. This sets up the 3D Derham object (unless grid or derham_opts are None), creates weighted mass and basis projection operators, and constructs a projected equilibrium appropriate for the chosen equilibrium type. """ # check for polar singularity if self.domain.pole: assert derham_opts.polar_ck == 1, """Polar singularity detected in domain but derham_opts.polar_ck != 1. You have two options: a) set derham_opts.polar_ck = 1 to use polar splines b) modify your domain definition to cut a hole around the singularity.""" # create discrete derham sequence if self.clone_config is None: derham_comm = MPI.COMM_WORLD else: derham_comm = self.clone_config.sub_comm if grid is None or derham_opts is None: logger.debug(f"\n{grid=}, {derham_opts=}: no Derham object set up.") self._derham = None else: self._derham = Derham( grid, derham_opts, comm=derham_comm, domain=self.domain, ) # create weighted mass and basis operators if self.derham is None: self._mass_ops = None self._basis_ops = None else: self._mass_ops = WeightedMassOperators(self.derham, self.domain, eq_mhd=self.equil) self._basis_ops = BasisProjectionOperators( self.derham, self.domain, eq_mhd=self.equil, ) # create projected equilibrium if self.derham is None: self._projected_equil = None else: if isinstance(self.equil, MHDequilibrium): self._projected_equil = ProjectedMHDequilibrium( self.equil, self.derham, ) elif isinstance(self.equil, FluidEquilibriumWithB): self._projected_equil = ProjectedFluidEquilibriumWithB( self.equil, self.derham, ) elif isinstance(self.equil, FluidEquilibrium): self._projected_equil = ProjectedFluidEquilibrium( self.equil, self.derham, ) else: self._projected_equil = None @profile def _allocate_variables(self): """ Allocate memory for model variables and set initial conditions. """ # allocate memory for FE coeffs of electromagnetic fields/potentials if self.model.field_species: for species, spec in self.model.field_species.items(): assert isinstance(spec, FieldSpecies) for k, v in spec.variables.items(): assert isinstance(v, FEECVariable) v.allocate( derham=self.derham, domain=self.domain, equil=self.equil, ) # allocate memory for FE coeffs of fluid variables if self.model.fluid_species: for species, spec in self.model.fluid_species.items(): assert isinstance(spec, FluidSpecies) for k, v in spec.variables.items(): assert isinstance(v, FEECVariable) v.allocate( derham=self.derham, domain=self.domain, equil=self.equil, ) # allocate memory for marker arrays of kinetic variables if self.model.particle_species: for species, spec in self.model.particle_species.items(): assert isinstance(spec, ParticleSpecies) for k, v in spec.variables.items(): if isinstance(v, PICVariable): v.allocate( clone_config=self.clone_config, derham=self.derham, domain=self.domain, equil=self.equil, projected_equil=self.projected_equil, ) if isinstance(v, SPHVariable): v.allocate( derham=self.derham, domain=self.domain, equil=self.equil, projected_equil=self.projected_equil, ) # allocate memory for FE coeffs of fluid variables if self.model.diagnostic_species: for species, spec in self.model.diagnostic_species.items(): assert isinstance(spec, DiagnosticSpecies) for k, v in spec.variables.items(): assert isinstance(v, FEECVariable) v.allocate( derham=self.derham, domain=self.domain, equil=self.equil, ) # TODO: allocate memory for FE coeffs of diagnostics # if self.params.diagnostic_fields is not None: # for key, val in self.diagnostics.items(): # if "params" in key: # continue # else: # val["obj"] = self.derham.create_spline_function( # key, # val["space"], # bckgr_params=None, # pert_params=None, # ) # self._pointer[key] = val["obj"].vector @profile def _allocate_propagators(self): """Allocate propagators and bind shared FEEC/domain operators. Assigns `derham`, `domain`, `mass_ops`, `basis_ops` and `projected_equil` on the `Propagator` base class so individual propagator instances can access shared resources, then calls each propagator's `allocate` method. """ # set propagators base class attributes (then available to all propagators) Propagator.derham = self.derham Propagator.domain = self.domain if self.derham is not None: Propagator.mass_ops = self.mass_ops Propagator.basis_ops = self.basis_ops Propagator.projected_equil = self.projected_equil assert len(self.model.prop_list) > 0, "No propagators in this model, check the model class." for prop in self.model.prop_list: assert isinstance(prop, Propagator) prop.allocate() logger.debug(f"\nAllocated propagator '{prop.__class__.__name__}'.") @profile def _initialize_hdf5_datasets(self, data: DataContainer, size: int): """ Create datasets in hdf5 files according to model unknowns and diagnostics data. Parameters ---------- data : struphy.io.output_handling.DataContainer The data object that links to the hdf5 files. size : int Number of MPI processes of the model run. Returns ------- save_keys_all : list Keys of datasets which are saved during the simulation. save_keys_end : list Keys of datasets which are saved at the end of a simulation to enable restarts. """ # save scalar quantities in group 'scalar/' for key, scalar in self.model.scalars.dct.items(): val = scalar.value key_scalar = "scalar/" + key data.add_data({key_scalar: val}) with h5py.File(data.file_path, "a") as file: # store grid_info only for runs with 512 ranks or smaller if self.model.scalars.dct and self.derham is not None: if size <= 512: file["scalar"].attrs["grid_info"] = self.derham.domain_array else: file["scalar"].attrs["grid_info"] = self.derham.domain_array[0] else: pass # save feec data in group 'feec/' feec_species = self.model.field_species | self.model.fluid_species | self.model.diagnostic_species for species, val in feec_species.items(): assert isinstance(val, Species) species_path = os.path.join("feec", species) species_path_restart = os.path.join("restart", species) for variable, subval in val.variables.items(): assert isinstance(subval, FEECVariable) spline = subval.spline # in-place extraction of FEM coefficients from field.vector --> field.vector_stencil! spline.extract_coeffs(update_ghost_regions=False) # save numpy array to be updated each time step. if subval.save_data: key_field = os.path.join(species_path, variable) if isinstance(spline.vector_stencil, StencilVector): data.add_data( {key_field: spline.vector_stencil._data}, ) else: for n in range(3): key_component = os.path.join(key_field, str(n + 1)) data.add_data( {key_component: spline.vector_stencil[n]._data}, ) # save field meta data file[key_field].attrs["space_id"] = spline.space_id file[key_field].attrs["starts"] = spline.starts file[key_field].attrs["ends"] = spline.ends file[key_field].attrs["pads"] = spline.pads # save numpy array to be updated only at the end of the simulation for restart. key_field_restart = os.path.join(species_path_restart, variable) if isinstance(spline.vector_stencil, StencilVector): data.add_data( {key_field_restart: spline.vector_stencil._data}, ) else: for n in range(3): key_component_restart = os.path.join(key_field_restart, str(n + 1)) data.add_data( {key_component_restart: spline.vector_stencil[n]._data}, ) # save kinetic data in group 'kinetic/' for name, species in self.model.particle_species.items(): assert isinstance(species, ParticleSpecies) assert len(species.variables) == 1, "More than 1 variable per kinetic species is not allowed." for varname, var in species.variables.items(): assert isinstance(var, PICVariable | SPHVariable) obj = var.particles assert isinstance(obj, Particles) key_spec = os.path.join("kinetic", name) key_spec_restart = os.path.join("restart", name) # restart data data.add_data({key_spec_restart: obj.markers}) # marker data key_mks = os.path.join(key_spec, "markers") data.add_data({key_mks: var.saved_markers}) # binning plot data for bin_plot in species.saving_params.binning_plots: # define slice name with binning quantity slice, output_quantity = bin_plot.slice, bin_plot.output_quantity slice = f"{slice}_{output_quantity}" key_f = os.path.join(key_spec, "f", slice) key_df = os.path.join(key_spec, "df", slice) data.add_data({key_f: bin_plot.f}) data.add_data({key_df: bin_plot.df}) for dim, be in enumerate(bin_plot.bin_edges): file[key_f].attrs["bin_centers" + "_" + str(dim + 1)] = be[:-1] + (be[1] - be[0]) / 2 for i, kd_plot in enumerate(species.saving_params.kernel_density_plots): key_n = os.path.join(key_spec, "n_sph", f"view_{i}") data.add_data({key_n: kd_plot.n_sph}) # save 1d point values, not meshgrids, because attrs size is limited eta1 = kd_plot.plot_pts[0][:, 0, 0] eta2 = kd_plot.plot_pts[1][0, :, 0] eta3 = kd_plot.plot_pts[2][0, 0, :] file[key_n].attrs["eta1"] = eta1 file[key_n].attrs["eta2"] = eta2 file[key_n].attrs["eta3"] = eta3 # TODO: maybe add other data # else: # data.add_data({key_dat: val1}) # keys to be saved at each time step and only at end (restart) save_keys_all = [] save_keys_end = [] for key in data.dset_dict: if "restart" in key: save_keys_end.append(key) else: save_keys_all.append(key) return save_keys_all, save_keys_end def _add_time_state(self, time_state): """Add a pointer to the time variable of the dynamics ('t') to the model and to all propagators of the model. Parameters ---------- time_state : ndarray Of size 1, holds the current physical time 't'. """ assert time_state.size == 1 self._time_state = time_state for _, prop in self.model.propagators.__dict__.items(): if isinstance(prop, Propagator): prop.add_time_state(time_state) def _initialize_from_restart(self, data: DataContainer): """ Set initial conditions for FE coefficients (electromagnetic and fluid) and markers from restart group in hdf5 files. Parameters ---------- data : struphy.io.output_handling.DataContainer The data object that links to the hdf5 files. """ with h5py.File(data.file_path, "a") as file: for species, val in self.model.species.items(): for variable, subval in val.variables.items(): # initialize feec variables if isinstance(subval, FEECVariable): key_restart = os.path.join("restart", species, variable) subval.spline.initialize_coeffs_from_restart_file( file, key=key_restart, ) # initialize pic variables elif isinstance(subval, PICVariable): key_restart = os.path.join("restart", species) subval.particles._markers[:, :] = file[key_restart][-1, :, :] if MPI.COMM_WORLD.Get_size() > 1: subval.particles.mpi_sort_markers(do_test=True) def to_dict(self) -> dict: """Serialize the simulation configuration to a dictionary.""" return { "name": self.name, "description": self.description, "model": self.model.to_dict(), "params_path": self.params_path, "env": self.env.to_dict(), "time_opts": self.time_opts.to_dict(), "domain": self.domain.to_dict(), "equil": self.equil.to_dict() if self.equil is not None else None, "grid": self.grid.to_dict(), "derham_opts": self.derham_opts.to_dict(), } def _collect_particle_metadata(self) -> dict: """Collect per-species marker metadata (Np, ppc, ppb) for the current sim.""" particle_metadata = {} for species_name, species in self.model.particle_species.items(): species_metadata = {} for variable_name, variable in species.variables.items(): if isinstance(variable, PICVariable | SPHVariable) and hasattr(variable, "_particles"): particles = variable.particles species_metadata[variable_name] = { "Np": particles.Np, "ppc": particles.ppc, "ppb": particles.ppb, } if species_metadata: particle_metadata[species_name] = species_metadata return particle_metadata def to_json(self, file_path: str = None) -> str: """Assemble the run's data and metadata by hand and serialize to a JSON string. Parameters ---------- file_path : str, optional If given, also write the JSON string to this file. Returns ------- str The JSON-encoded simulation configuration. """ config = { "name": self.name, "description": self.description, "model_name": self.model_name, "parameter_file": self.params_path, "mpi_ranks": self.comm_size, "use_mpi_comm_world": self.comm is not None, "env": self.env.to_dict(), "time_opts": self.time_opts.to_dict(), "domain": self.domain.to_dict(), "equil": self.equil.to_dict() if self.equil is not None else None, "grid": self.grid.to_dict(), "derham_opts": self.derham_opts.to_dict(), "particle_species": self._collect_particle_metadata(), } json_str = json.dumps(config, indent=4) if file_path is not None: with open(file_path, "w") as f: f.write(json_str) return json_str @classmethod def from_dict(cls, dct) -> "Simulation": """Deserialize a simulation configuration from a dictionary.""" return cls( name=dct["name"], description=dct["description"], model=StruphyModel.from_dict(dct["model"]), params_path=dct["params_path"], env=EnvironmentOptions.from_dict(dct["env"]), time_opts=Time.from_dict(dct["time_opts"]), domain=domains.Cuboid.from_dict(dct["domain"]), equil=FluidEquilibrium.from_dict(dct["equil"]), grid=grids.TensorProductGrid.from_dict(dct["grid"]), derham_opts=DerhamOptions.from_dict(dct["derham_opts"]), ) @classmethod def from_file(cls, file_path: str) -> "SimulationBase": """Deserialize a simulation configuration from a file based on the file extension.""" if file_path.endswith(".yaml") or file_path.endswith(".yml"): with open(file_path, "r") as f: dct = yaml.safe_load(f) elif file_path.endswith(".json"): with open(file_path, "r") as f: dct = json.load(f) else: raise ValueError("Unsupported file format. Use .yaml, .yml or .json.") # YAML and JSON do not have a native tuple type, # so when you load them with PyYAML or json, # sequences are always converted to lists def convert_lists_to_tuples(obj): if isinstance(obj, dict): for k, v in obj.items(): obj[k] = convert_lists_to_tuples(v) return obj elif isinstance(obj, list): return tuple(convert_lists_to_tuples(i) for i in obj) else: return obj # Convert lists to tuples for relevant keys dct = convert_lists_to_tuples(dct) return cls.from_dict(dct) def generate_script( self, include_main_guard: bool = False, include_defaults: bool = False, ) -> str: """Generate a Python script that can be used to reproduce the simulation.""" script = f""" from struphy import ( BaseUnits, DerhamOptions, EnvironmentOptions, FieldsBackground, Simulation, Time, domains, equils, grids, perturbations, ) from struphy.models import {self.model.__class__.__name__} """ sim_setup = "" sim_class_def = "sim = Simulation(" if include_defaults: sim_setup += f"model = {self.model.__repr__()}\n" sim_class_def += "model=model," sim_setup += f"env = {self.env.__repr__()}\n" sim_class_def += "env=env," sim_setup += f"time_opts = {self.time_opts.__repr__()}\n" sim_class_def += "time_opts=time_opts," sim_setup += f"domain = domains.{self.domain.__repr__()}\n" sim_class_def += "domain=domain," sim_setup += f"grid = grids.{self.grid.__repr__()}\n" sim_class_def += "grid=grid," sim_setup += f"derham_opts = {self.derham_opts.__repr__()}\n" sim_class_def += "derham_opts=derham_opts," else: # Only include parameters that are not default to avoid # cluttering the script with unnecessary lines sim_setup += f"model = {self.model.__repr_no_defaults__()}\n" sim_class_def += "model=model," if not self.env.is_default: sim_setup += f"env = {self.env.__repr_no_defaults__()}\n" sim_class_def += "env=env," if not self.time_opts.is_default: sim_setup += f"time_opts = {self.time_opts.__repr_no_defaults__()}\n" sim_class_def += "time_opts=time_opts," if not self.domain.is_default: sim_setup += f"domain = domains.{self.domain.__repr_no_defaults__()}\n" sim_class_def += "domain=domain," if not self.grid.is_default: sim_setup += f"grid = grids.{self.grid.__repr_no_defaults__()}\n" sim_class_def += "grid=grid," if not self.derham_opts.is_default: sim_setup += f"derham_opts = {self.derham_opts.__repr_no_defaults__()}\n" sim_class_def += "derham_opts=derham_opts," # This is a bit of a special case since the default is None, if self.equil is not None: if include_defaults: sim_setup += f"equil = equils.{self.equil.__repr__()}\n" else: sim_setup += f"equil = equils.{self.equil.__repr_no_defaults__()}\n" sim_class_def += "equil=equil," if self.params_path is not None: sim_class_def += f"params_path={repr(self.params_path)},\n" sim_class_def += ")\n" script += sim_setup + "\n" + sim_class_def if include_main_guard: script += """ if __name__ == "__main__": sim.run()""" return ruff_autofix_and_format(script) def save_script( self, file_path: str, include_main_guard: bool = False, ): """Save the generated script to a file.""" script = self.generate_script(include_main_guard=include_main_guard) with open(file_path, "w") as f: f.write(script) def __eq__(self, value: "Simulation") -> bool: assert isinstance(value, Simulation), "Comparison only implemented between Simulation instances." return self.to_dict() == value.to_dict() # ------------------------------------------------------ # Common properties with setters (from input parameters) # ------------------------------------------------------ @property def model(self) -> StruphyModel: """StruphyModel object containing the PDE of the model.""" return self._model @property def name(self) -> str: """Name of the simulation.""" return self._name @property def description(self) -> str: """Description of the simulation.""" return self._description @property def params_path(self): """Path to parameter file used for the run. Can be None if Simulation is instantiated in a notebook environment (no parameter file in this case).""" return self._params_path @property def env(self): """EnvironmentOptions object containing options related to the environment of the run.""" return self._env @property def time_opts(self): """Time object containing time stepping parameters.""" return self._time_opts @property def domain(self): """Domain object, see :ref:`avail_mappings`.""" return self._domain @property def equil(self): """Fluid equilibrium object, see :ref:`fluid_equil`.""" return self._equil @property def grid(self): """Grid object, see :ref:`grids`.""" return self._grid @property def derham_opts(self): """DerhamOptions object containing options for the setup of the 3d Derham sequence.""" return self._derham_opts # ----------------------------------------------------------------- # Common properties (derived from the above properties, no setters) # ----------------------------------------------------------------- @property def derham(self): """3d Derham sequence, see :ref:`derham`.""" return self._derham @property def mass_ops(self): """WeighteMassOperators object, see :ref:`mass_ops`.""" return self._mass_ops @property def basis_ops(self): """Basis projection operators.""" return self._basis_ops @property def projected_equil(self): """Fluid equilibrium projected on 3d Derham sequence with commuting projectors.""" return self._projected_equil @property def post_processor(self): """PostProcessor object for post-processing finished Struphy runs.""" return self._post_processor @property def plotting_data(self): """PlottingData object for loading and storing data generated during post-processing.""" return self._plotting_data @property def clone_config(self): """Config in case domain clones are used.""" return self._clone_config @clone_config.setter def clone_config(self, new): assert isinstance(new, CloneConfig) or new is None self._clone_config = new