class PlotterCSV(pg.Component): """Writes molecule positions to CSVs.""" write_interval = prop.IntProperty(1, required=False) """ Allows skipping time steps if other than 1. For example, `writer_interval=3` will produce a CSV file in time step 0, then do nothing in time steps 1 and 2, then write again in time step 3. """ folder = prop.StrProperty("", required=False) """ Output folder for molecule positions relative to results_dir of the kernel. A series of CSV files will be created here, one for each time step. """ def __init__(self): super().__init__() def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CREATE_FOLDERS: path = os.path.join(simulation_kernel.results_dir, self.folder) os.makedirs(path, exist_ok=True) def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.LOGGING: return sk = simulation_kernel if sk.get_elapsed_base_time_steps() % self.write_interval != 0: return with open(os.path.join( simulation_kernel.results_dir, self.folder, "positions.csv." + str(sk.get_elapsed_base_time_steps())), mode='w') as csv_file: fieldnames = ['id', 'x', 'y', 'z', 'cell_id', 'object_id'] writer = csv.writer(csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) writer.writerow(fieldnames) for molecule in \ sk.get_molecule_manager().get_all_molecules().values(): writer.writerow((molecule.id, molecule.position[0], molecule.position[1], molecule.position[2], molecule.cell_id, molecule.object_id))
class SensorFlowRate(pg.Component): """ At the beginning of the simulation, the position of N sample points will be randomly set inside this sensor's Geometry. In each time step, the flow speed inside `attached_object` will be sampled and logged for each of these points. This class is intentionally not a subclass of the Sensor class, since child classes of Sensor have so far been reserved for sensors monitoring the positions of particles. (In its current implementation, the SensorManager considers all Sensor instances for sensor subscriptions, which we don't need for sampling the flow rate.) """ translation = prop.VectorProperty([0, 0, 0], required=True) rotation = prop.VectorProperty([0, 0, 0], required=True) scale = prop.VectorProperty([1, 1, 1], required=True) shape = prop.EnumProperty( str(pg.Shapes.NONE.name), name='shape', required=True, enum_class=pg.Shapes, ) """ Shape of this sensor. Valid shapes are defined in the `pg.Shapes` enum. """ log_folder = prop.StrProperty("sensor_data", required=False) """The file `sensor[<component name>].csv` will be created in here.""" log_mesh_index = prop.BoolProperty(False, required=False) """ If True, log the attached object's unique mesh index in each time step. """ num_sample_points = prop.IntProperty(1, required=False) """ The number of sample points N that will be added in addition to any sample points already defined in `sample_points`. """ custom_sample_points_global = prop.ListOfVectorsProperty( [], required=False, ) """ Points to sample the flow rate at in every time step. Coordinates are in global coordinates. If `num_sample_points` is not 0, this list will be extended with randomly positioned points. """ seed = prop.StrProperty('', required=False) """ Seed for the pseudo-random number generator. If '', the random number generator of the simulation kernel will be used. If 'random', a random seed will be used for initialization. """ attached_object = prop.ComponentReferenceProperty( '', required=True, can_be_empty=False, ) """ Component name of the object in which to sample the flow rate. """ column_prefix = prop.StrProperty("flow_mps_", required=False) """ Column header prefix for each sample point. By default, if `num_sample_points` is 2, for example, the output CSV may have the following columns: `[sim_time, flow_mps_[0 0 0]_x, flow_mps_[0 0 0]_y, flow_mps_[0 0 0]_z, flow_mps_[1 0 0]_x, flow_mps_[1 0 0]_y, flow_mps_[1 0 0]_z]` """ def __init__(self): super().__init__() self._transformation = pg.Transformation() """Transformation of this sensor in the scene.""" self._geometry = pg.Geometry(pg.Shapes.NONE) """Geometry of this sensor, set from shape.""" self._attached_object: Optional['pg.Object'] = None self._csv_file: Optional[io.TextIOWrapper] = None self._csv_writer: Optional[csv.writer] = None self._rng = np.random.RandomState(seed=None) self._sample_points_global: Optional[np.ndarray] = None def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): if init_stage == pg.InitStages.CHECK_ARGUMENTS: self._geometry = pg.Geometry(shape=pg.Shapes[self.shape]) self._transformation = pg.Transformation( translation=np.array(self.translation), rotation=np.array(self.rotation), scaling=np.array(self.scale)) if self.attached_object not in simulation_kernel.get_components(): raise ValueError( "No object component with the name " f"{self.attached_object} attached to the simulation " "kernel could be found.") if self.seed == 'random': self._rng = np.random.RandomState(seed=None) elif self.seed != '': self._rng = np.random.RandomState(seed=int(self.seed)) else: self._rng = simulation_kernel.get_random_number_generator() self.initialize_sample_points() elif init_stage == pg.InitStages.CREATE_FOLDERS: os.makedirs(os.path.join(simulation_kernel.results_dir, self.log_folder), exist_ok=True) elif init_stage == pg.InitStages.BUILD_SCENE: self._attached_object = cast( 'pg.Object', simulation_kernel.get_components()[self.attached_object]) elif init_stage == pg.InitStages.CREATE_FILES: name = (self.id if self.component_name == "Generic component" else self.component_name) self._csv_file = open(os.path.join(simulation_kernel.results_dir, self.log_folder, f'sensor[{name}].csv'), mode='w') fieldnames = ['sim_time'] if self.log_mesh_index: fieldnames.append('mesh_index') # Assumes self.initialize_sample_points() to have been called: fieldnames.extend([ # e.g., "my_prefix_[0.123, 42.0, 1.21]_x": f'{col}_{axis}' for col, axis in zip([ self.column_prefix + str(point_global) for point_global in self._sample_points_global ], ['x', 'y', 'z']) ]) self._csv_writer = csv.writer(self._csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) self._csv_writer.writerow(fieldnames) def initialize_sample_points(self): new_points_local = [] if self._geometry.shape == pg.Shapes.POINT: new_points_local = np.zeros(self.num_sample_points, dtype=float) elif self._geometry.shape == pg.Shapes.CUBE: new_points_local = pg.util.get_random_points_in_cube_local( n=self.num_sample_points, rng=self._rng, ) elif self._geometry.shape != pg.Shapes.NONE: new_points_local = pg.util.get_random_points_in_geometry_local( n=self.num_sample_points, geometry=self._geometry, rng=self._rng, ) self._sample_points_global = self._transformation.apply_to_points( self.custom_sample_points_global + new_points_local) def finalize(self, simulation_kernel: 'pg.SimulationKernel'): self._csv_file.close() def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.LOGGING: return csv_row = [simulation_kernel.sim_time] if self.log_mesh_index: csv_row.append(self._attached_object.get_mesh_index()) vfm = self._attached_object.get_vector_field_manager() for sample_point_global in self._sample_points_global: csv_row.extend( vfm.get_flow_by_position( simulation_kernel=simulation_kernel, position_global=sample_point_global, interpolation_type=None, # inherit from kernel )) self._csv_writer.writerow(csv_row)
class SimulationKernel(pg.Component): """ The simulation kernel that is responsible for starting and coordinating how a simulation is run. For configuration, kernel parameters are configured on the root level, along with the following 'kernel components': :molecule_manager: Configuration of the :class:`~pogona.MoleculeManager` :movement_predictor: Configuration of the :class:`~pogona.MovementPredictor` :scene_manager: Configuration of the :class:`~pogona.SceneManager` :sensor_manager: Configuration of the :class:`~pogona.SensorManager` :mesh_manager: Configuration of the :class:`~pogona.MeshManager` All other :class:`~pogona.Component` instances must be configured under the YAML key ``components``. """ sim_time = prop.FloatProperty(0.0, required=False) sim_time_limit = prop.FloatProperty(0.0, required=True) seed = prop.IntProperty(1, required=True) results_dir = prop.StrProperty('', required=False) """Base directory for any result files (e.g., sensor logs).""" # Time step and step size control parameters # Fig. A.1 is a helpful flow chart: # https://web.archive.org/web/20191025090930/https://www.uni-muenster.de/imperia/md/content/physik_tp/lectures/ss2016/num_methods_ii/rkm.pdf base_delta_time = prop.FloatProperty(1.0, required=True) """ Base time step for adaptive time stepping. Delta times will never be larger than this value. All logging and sensor evaluations happen at this interval. """ adaptive_time_max_error_threshold = prop.FloatProperty( np.inf, required=False, ) """ If, when using adaptive time stepping, the error exceeds this threshold, the time step will be multiplied with time_step_reduction_factor and the molecule positions will be recalculated for the current step. """ adaptive_time_safety_factor = prop.FloatProperty(0.85, required=False) """ Safety factor for estimating the optimal next step size to keep the error below `adaptive_time_max_error_threshold`. We want to avoid picking a next step size that would cause us to just barely overshoot the error threshold in the next (sub-)step. """ use_adaptive_time_stepping = prop.BoolProperty(False, required=False) """ If True, use adaptive time stepping. Requires an appropriate integration method in the MovementPredictor. """ adaptive_time_corrections_limit = prop.IntProperty(100, required=False) """ Maximum number of corrections within one sub time step. Since we estimate the optimal sub step size, the number of corrections should usually be very low (< 10?). """ interpolation_method = prop.EnumProperty( str(pg.Interpolation.MODIFIED_SHEPARD.name), name='interpolation_method', required=False, enum_class=pg.Interpolation, ) def __init__(self): """ """ super().__init__() self._molecule_manager = pg.MoleculeManager() self._movement_predictor = pg.MovementPredictor() self._scene_manager = pg.SceneManager() self._sensor_manager = pg.SensorManager() self._mesh_manager = pg.MeshManager() self._kernel_components = dict( molecule_manager=self._molecule_manager, movement_predictor=self._movement_predictor, scene_manager=self._scene_manager, sensor_manager=self._sensor_manager, mesh_manager=self._mesh_manager, ) for i, kernel_component in enumerate(self._kernel_components.values()): kernel_component.id = i # Ordinary components' IDs will be offset by the length # of the list of kernel components; see attach_component. self._components: Dict[str, 'pg.Component'] = dict() """ A dictionary of all attached components by their respective component name. Being able to find components by a unique name is useful for components like the ModulationOOK, which has to find other components that are attached to it. """ self._elapsed_base_time_steps = 0 """Number of elapsed time steps (at *base_delta_time*!)""" self._elapsed_sub_time_steps = 0 """Number of elapsed time steps, including all sub steps.""" self._interpolation_method = pg.Interpolation.NEAREST_NEIGHBOR self._rng = np.random.RandomState(self.seed) def set_arguments(self, **kwargs): for kernel_component_name, kernel_component \ in self._kernel_components.items(): # (Pop kernel component sub-dictionaries from kwargs # such that the parent class method won't complain about # unrecognized arguments.) kernel_component.set_arguments( **kwargs.pop(kernel_component_name, dict())) super().set_arguments(**kwargs) def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel=self, init_stage=init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: self._rng = np.random.RandomState(self.seed) self._interpolation_method = pg.Interpolation[ self.interpolation_method] if (self.use_adaptive_time_stepping and self.adaptive_time_max_error_threshold == np.inf): LOG.warning("`use_adaptive_time_stepping` is `True`, but " "`adaptive_time_max_error_threshold` is not set.") def attach_component(self, component: 'pg.Component'): if component.component_name in self._components: raise ValueError( f"A component named {component.component_name} has already " f"been attached to the simulation kernel!") if component.component_name in self._kernel_components: raise ValueError( f"\"{component.component_name}\" of the class " f"{component.__class__} is a kernel component and should " "not be added to the list of ordinary components.") component.id = len(self._components) + len(self._kernel_components) self._components[component.component_name] = component def notify_components_new_time_step(self): """ Allow sensors and other components to process the new positions of molecules. If using adaptive time stepping, this is only called in base time steps! """ for notification_stage in pg.NotificationStages: for component in self._components.values(): component.process_new_time_step( simulation_kernel=self, notification_stage=notification_stage, ) def initialize_components(self): """ Run through all initialization stages for all attached components, starting with this simulation kernel itself, then the kernel components, and then the remaining components. Will be called automatically by the `start()` method. """ for init_stage in pg.InitStages: LOG.info(f"running initialization stage {init_stage}") components = itertools.chain([self], self._kernel_components.values(), self._components.values()) for component in components: component.initialize(self, init_stage) def start(self, skip_initialization=False): """ Start the simulation. :param skip_initialization: Should only ever be True if you previously called initialize_components() yourself. """ if not skip_initialization: LOG.info("Initializing components...") self.initialize_components() LOG.info("Starting simulation loop...") if not self.use_adaptive_time_stepping: self.simulation_loop_legacy() else: self.simulation_loop_adaptive_rkf() LOG.info("Finalizing...") for component in self._components.values(): component.finalize(self) self.finalize(self) def simulation_loop_legacy(self): if self._molecule_manager.update_molecule_collection_immediately: raise ValueError( "`update_molecule_collection_immediately` should be set to " "False if using the default simulation loop (i.e., no " "adaptive time stepping).") # Give all observers the chance to see the initial system at t=0 LOG.debug("Initial simulation time " + str(self.sim_time)) self.notify_components_new_time_step() while self.sim_time < self.sim_time_limit: # Use list() to copy the molecule dict keys because # _sensor_manager.process_molecule_moving may cause # molecules to be inserted/deleted. molecules = self._molecule_manager.get_all_molecules() for mid in self._molecule_manager.get_all_molecules().keys(): molecule = molecules[mid] self._sensor_manager.process_molecule_moving_before( self, molecule) updated_molecule, _, _ = self._movement_predictor.predict( self, molecule, self.sim_time, self.base_delta_time, ) self._molecule_manager.update_molecule(updated_molecule) self._sensor_manager.process_molecule_moving_after( self, updated_molecule) self._molecule_manager.apply_changes() self._elapsed_base_time_steps += 1 self.sim_time = (self._elapsed_base_time_steps * self.base_delta_time) # self.sim_time = self.sim_time + self.sim_time_step_duration LOG.info(f"New simulation time {round(self.sim_time, 10)}") self.notify_components_new_time_step() def simulation_loop_adaptive_rkf(self): """ Simulation loop with adaptive time stepping. Uses a constant base time step `base_delta_time`. Within each base time step, sub-step sizes are chosen on a per-particle basis to ensure that the error of the selected Runge-Kutta-Fehlberg method (RKF) will stay below the threshold `adaptive_time_max_error_threshold`. Sensors are only notified in base time steps, for now. """ if self._molecule_manager.update_molecule_collection_immediately: raise ValueError( "`update_molecule_collection_immediately` should be set to " "False in the MoleculeManager if using this variant of " "adaptive time stepping.") # TODO: double check; also: don't make users have to config this rkf_order = self._movement_predictor.embedded_integrator.order def _update_molecule( _molecule: 'pg.Molecule', _sub_sim_time: float, ) -> Tuple['pg.Molecule', float, int]: """ Update a single molecule. First try with the latest recorded optimal time step. If the error threshold is exceeded, repeat with a smaller step size. :returns: The updated molecule, the most recently used step size, and the number of corrections. """ _error = np.inf _dbg_errors = [] _new_pos_global = _molecule.position # will be overridden _delta_time = np.inf # will be overridden _num_corrections = -1 while (_error > self.adaptive_time_max_error_threshold and _num_corrections <= self.adaptive_time_corrections_limit): _num_corrections += 1 _delta_time = min( # Take estimated optimal step size from prev. (sub-)step: _molecule.delta_time_opt, # may be np.inf self.base_delta_time, # Don't overshoot the base time step: abs(self.base_delta_time - (_sub_sim_time - self.sim_time))) _, _new_pos_global, _error = self._movement_predictor.predict( self, _molecule, _sub_sim_time, delta_time=_delta_time, update_molecule=False, # we'll call update() manually ) _dbg_errors.append(_error) # Determine an 'optimal' step size for the given threshold: if _error >= self.adaptive_time_max_error_threshold: _exponent = 1 / (rkf_order + 1) else: _exponent = 1 / rkf_order if _error == 0: _molecule.delta_time_opt = np.inf else: _molecule.delta_time_opt = ( self.adaptive_time_safety_factor * _delta_time * (self.adaptive_time_max_error_threshold / _error)** _exponent) if _num_corrections > self.adaptive_time_corrections_limit: LOG.warning( f"Maximum number of corrections limit exceeded for " f"molecule {_molecule.id} at sub step time " f"{_sub_sim_time} s, dt={_delta_time} s, {_dbg_errors=}") _molecule.update( new_pos_global=_new_pos_global, scene_manager=self._scene_manager, ) return molecule, _delta_time, _num_corrections def _advance_molecule_to_next_base_step(_molecule: 'pg.Molecule', ): _sim_time_next = self.sim_time + self.base_delta_time _sub_sim_time = self.sim_time _num_steps = 0 _num_corrections_m_total = 0 while (_sub_sim_time < _sim_time_next and not np.isclose( # ensure 'strictly less than' _sub_sim_time, _sim_time_next, rtol=1e-10, atol=1e-15, )): _num_steps += 1 _, _delta_time, _num_corrections_u = _update_molecule( _molecule=_molecule, # will be updated in-place _sub_sim_time=_sub_sim_time, ) # ^ Takes care to not overshoot base steps; # _delta_time will be made just small enough. _num_corrections_m_total += _num_corrections_u _sub_sim_time += _delta_time return _molecule, _num_steps, _num_corrections_m_total # Give all observers the chance to see the initial system at t=0 LOG.debug("Initial simulation time " + str(self.sim_time)) self.notify_components_new_time_step() while self.sim_time < self.sim_time_limit: molecules = self._molecule_manager.get_all_molecules() num_steps = [] num_corrections = [] for molecule in molecules.values(): self._sensor_manager.process_molecule_moving_before( simulation_kernel=self, molecule=molecule, ) # mainly for updating SensorTeleporting _, num_steps_m, num_corrections_m = ( _advance_molecule_to_next_base_step(_molecule=molecule, )) num_steps.append(num_steps_m) num_corrections.append(num_corrections_m) self._sensor_manager.process_molecule_moving_after( simulation_kernel=self, molecule=molecule) # update all remaining sensors self._molecule_manager.apply_changes() # LOG.debug( # f"t={self.sim_time}: " # f"{len(molecules)} molecules, " # f"mean_max_error={np.mean(errors)}, " # f"min_max_error={np.min(errors)}, " # f"max_max_error={np.max(errors)}, " # f"mean_steps={np.mean(num_steps)}, " # f"min_steps={np.min(num_steps)}, " # f"max_steps={np.max(num_steps)}" # ) self._elapsed_base_time_steps += 1 self.sim_time = (self._elapsed_base_time_steps * self.base_delta_time) LOG.info( f"New (base-step) simulation time {round(self.sim_time, 10)}") self.notify_components_new_time_step() def destroy_molecule(self, molecule): self._molecule_manager.destroy_molecule(molecule) def get_components(self) -> Dict[str, 'pg.Component']: return self._components def get_molecule_manager(self) -> 'pg.MoleculeManager': return self._molecule_manager def get_movement_predictor(self) -> 'pg.MovementPredictor': return self._movement_predictor def get_scene_manager(self) -> 'pg.SceneManager': return self._scene_manager def get_sensor_manager(self) -> 'pg.SensorManager': return self._sensor_manager def get_mesh_manager(self) -> 'pg.MeshManager': return self._mesh_manager def get_simulation_time(self) -> float: return self.sim_time def get_elapsed_base_time_steps(self) -> int: return self._elapsed_base_time_steps def get_elapsed_sub_time_steps(self) -> int: return self._elapsed_sub_time_steps def get_base_delta_time(self) -> float: return self.base_delta_time def get_random_number_generator(self) -> np.random.RandomState: return self._rng def get_seed(self) -> Optional[int]: return self.seed def get_interpolation_method(self) -> 'pg.Interpolation': return self._interpolation_method def get_integration_method(self) -> 'pg.Integration': return self._movement_predictor.get_integration_method()
class SprayNozzle(pg.Component): translation = prop.VectorProperty([0, 0, 0], required=True) rotation = prop.VectorProperty([0, 0, 0], required=True) injection_amount = prop.IntProperty(100, required=True) velocity = prop.FloatProperty(1000, required=True) velocity_sigma = prop.FloatProperty(100, required=True) distribution_sigma = prop.FloatProperty(2.5, required=True) seed = prop.StrProperty('', required=False) """ Seed for the pseudo-random number generator. If empty, the random number generator of the simulation kernel will be used. If 'random', a random seed will be used for initialization. Will be converted to int otherwise. """ """ A spray nozzle is an injector that "sprays" particles at a given position into a given direction. Usually an injector is controlled by an instance of ModulationOOK or a similar component, which determines when to switch the injector on or off and which also controls the flow speed in connected objects. """ def __init__(self): super().__init__() self._turned_on = False self._burst_on = False self._rng = np.random.RandomState(seed=None) self._transformation = pg.Transformation() """Transformation of this sensor in the scene.""" def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: if self.seed == 'random': self._rng = np.random.RandomState(seed=None) elif self.seed != '': self._rng = np.random.RandomState(seed=int(self.seed)) else: self._rng = simulation_kernel.get_random_number_generator() self._transformation = pg.Transformation( translation=np.array(self.translation), rotation=np.array(self.rotation), scaling=np.array([1, 1, 1])) elif init_stage == pg.InitStages.BUILD_SCENE: pass def turn_on(self): """ Turn on the injector and start spawning particles. Usually called in the NotificationStages.MODULATION stage, so before SPAWNING in the current time step. :return: """ # Start injecting molecules every time step LOG.debug("Injector turned on") self._turned_on = True def turn_off(self): """Stop injecting molecules""" LOG.debug("Injector turned off") self._turned_on = False def inject_burst(self): """Inject molecules in the next time step only""" LOG.debug("Injector bursts") self._burst_on = True def set_transformation(self, transformation: pg.Transformation): self._transformation = transformation def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.SPAWNING: return if self._turned_on or self._burst_on: # Only inject bursts once self._burst_on = False LOG.debug(f"Injecting {self.injection_amount} new molecules") base_delta_time = simulation_kernel.base_delta_time step_delta_time = base_delta_time / self.injection_amount delta_time = 0 while delta_time < base_delta_time: # Spray one particle in positive y direction # with some randomness. used_velocity = (self.velocity_sigma * self._rng.randn() + self.velocity) # Uniformly distributed angle between 0 and 2*pi by which # the new velocity vector will be roated around the y-axis: used_3d_angle = self._rng.random() * 2 * np.pi # Normally distributed angle by which the new velocity # vector will be rotated around the x- and z-axis: used_distribution = (self.distribution_sigma * self._rng.randn() * (np.pi / 180)) velocity_x = (used_velocity * np.sin(used_distribution) * np.sin(used_3d_angle)) velocity_y = used_velocity * np.cos(used_distribution) velocity_z = (used_velocity * np.sin(used_distribution) * np.cos(used_3d_angle)) velocity = self._transformation.apply_to_direction( np.array((velocity_x, velocity_y, velocity_z))) position = self._transformation.apply_to_point( np.array((0, 0, 0))) injected_molecule = pg.Molecule( position + (velocity * delta_time), velocity, None, ) simulation_kernel.get_molecule_manager().add_molecule( injected_molecule) delta_time += step_delta_time
class Injector(pg.Component): """ An injector spawns particles within its defined volume in every time step in which it is turned on. Usually an injector is controlled by an instance of ModulationOOK or a similar component, which determines when to switch the injector on or off and which also controls the flow speed in connected objects. """ shape = prop.EnumProperty( default='NONE', name='shape', required=True, enum_class=pg.Shapes, ) """ Shape of this injector. Valid shapes are defined in the `pg.Shapes` enum. """ translation = prop.VectorProperty([0, 0, 0], required=True) rotation = prop.VectorProperty([0, 0, 0], required=True) scale = prop.VectorProperty([1, 1, 1], required=True) attached_object = prop.ComponentReferenceProperty( "", required=False, can_be_empty=True, ) """ Component name of the attached object. This has to be set if you want newly spawned particles to be influenced by a vector field! """ injection_amount = prop.IntProperty(0, required=False) """ *Number* of molecules spawned in every time step while the injector is active. """ seed = prop.StrProperty('', required=False) """ Seed for the pseudo-random number generator. If empty, the random number generator of the simulation kernel will be used. If 'random', a random seed will be used for initialization. Will be converted to int otherwise. """ def __init__(self): super().__init__() self._attached_object: Optional['pg.Object'] = None self._turned_on = False self._burst_on = False self._rng = np.random.RandomState(seed=None) self._geometry: Optional['pg.Geometry'] = None """Geometry of this injector.""" self._transformation: Optional['pg.Transformation'] = None """Transformation of this injector in the scene.""" def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: if self.seed == 'random': self._rng = np.random.RandomState(seed=None) elif self.seed != '': self._rng = np.random.RandomState(seed=int(self.seed)) else: self._rng = simulation_kernel.get_random_number_generator() self._geometry = pg.Geometry(shape=pg.Shapes[self.shape]) self._transformation = pg.Transformation( translation=np.array(self.translation), rotation=np.array(self.rotation), scaling=np.array(self.scale)) if (self.attached_object != '' and self.attached_object not in simulation_kernel.get_components()): raise ValueError( "No object component with the name " f"{self.attached_object} attached to the simulation " "kernel could be found.") elif init_stage == pg.InitStages.BUILD_SCENE: self._attached_object = cast( 'pg.Object', simulation_kernel.get_components()[self.attached_object]) def turn_on(self): """ Turn on the injector and start spawning particles. Usually called in the NotificationStages.MODULATION stage, so before SPAWNING in the current time step. :return: """ # Start injecting molecules every time step self._turned_on = True def turn_off(self): # Stop injecting molecules self._turned_on = False def inject_burst(self): # Inject molecules in the next time step only self._burst_on = True def set_geometry(self, geometry: pg.Geometry): self._geometry = geometry def set_transformation(self, transformation: pg.Transformation): self._transformation = transformation def generate_points_local(self) -> List[np.ndarray]: return [np.array((0, 0, 0)) for _ in range(self.injection_amount)] def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.SPAWNING: return if self._turned_on or self._burst_on: # Only inject bursts once self._burst_on = False LOG.debug(f"Injecting {self.injection_amount} new molecules") if self._geometry.shape == pg.Shapes.POINT: points_local = self.generate_points_local() elif self._geometry.shape == pg.Shapes.CUBE: points_local = pg.util.get_random_points_in_cube_local( n=self.injection_amount, rng=self._rng, ) else: points_local = pg.util.get_random_points_in_geometry_local( n=self.injection_amount, geometry=self._geometry, rng=self._rng, ) new_points_global = self._transformation.apply_to_points( points_local) # TODO: transformation should probably be applied earlier! # see issue #160 for new_point_global in new_points_global: injected_molecule = pg.Molecule( position=new_point_global, velocity=np.zeros(3), object_id=self._attached_object.object_id, ) simulation_kernel.get_molecule_manager().add_molecule( injected_molecule)
class ModulationPPM(pg.ModulationOOK): """ Separates the symbol duration into N chips for N-PPM. """ chips_per_symbol = prop.IntProperty(2, required=False) """Must be a power of 2 and greater than 1.""" def __init__(self): super().__init__() def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: if (self.chips_per_symbol < 2 or not pg.util.is_power_of_two(self.chips_per_symbol)): raise ValueError( f"{self.chips_per_symbol=}, but must be a power of " "2 and greater than 1.") @property def chip_duration(self) -> float: try: return self.injection_duration + self.pause_duration except TypeError: # TODO: this failed once b/c pause_duration couldn't be cast to # float -> improve property checking! raise TypeError(f"Sum didn't work, {self.injection_duration=}, " f"{self.pause_duration=}, type inj dur " f"{type(self.injection_duration)}, " f"type pause dur {type(self.pause_duration)}") @property def symbol_duration(self) -> float: # The actual symbol duration is chip_duration * chips_per_symbol, # but this property is used in Modulation.transmit_bitstream, # among other places, expecting the duration of one injection plus # pause. return self.chip_duration def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): super().process_new_time_step(simulation_kernel, notification_stage) @staticmethod def bitstream_to_ppm(bitstream: str, chips_per_symbol: int) -> str: """ Convert bitstream into PPM bitstream, e.g., 101101 for 2-PPM: 01|10|01|01|10|01 or 101101 for 4-PPM: 0010|0001|0100 or 101101 for 8-PPM: 000001000|00000100 """ assert pg.util.is_power_of_two(chips_per_symbol) bits_per_symbol = chips_per_symbol.bit_length() - 1 # == log2(chips) result = "" for bits_tuple in pg.util.grouper(bitstream, n=bits_per_symbol, fillvalue='0'): symbol_int = int(''.join(bits_tuple), base=2) result += (symbol_int * '0' + '1' + (chips_per_symbol - symbol_int - 1) * '0') return result def transmit_bitstream(self, bitstream: str, simulation_kernel: 'pg.SimulationKernel', finish_callback: Optional[Callable]): super().transmit_bitstream( bitstream=bitstream, simulation_kernel=simulation_kernel, finish_callback=finish_callback, ) self._bitstream = self.bitstream_to_ppm( bitstream=bitstream, chips_per_symbol=self.chips_per_symbol, ) self._bitstream_duration = self.chip_duration * len(self._bitstream) LOG.warning( f"Transmitting bitstream\n{bitstream}\nconverted to\n" f"{self._bitstream}\n" f"{self._bitstream_duration=} for {self.chips_per_symbol=} and " f"{self.pause_duration=} and {self.chip_duration=}")
class BitstreamGenerator(pg.Component): start_time = prop.FloatProperty(0, required=False) """ At what simulated time in seconds to start the sequence transmission. """ repetitions = prop.IntProperty(1, required=False) """ Number of times the sequence is to be repeated. """ bit_sequence = prop.StrProperty("1", required=False) """ Which bit sequence to transmit. A valid input would be "101010", for example. Synchronization bits will not be inserted automatically! Will be overridden if `ascii_sequence` is set. """ ascii_sequence = prop.StrProperty('', required=False) """ If given, overrides bit_sequence with a bit sequence generated from the ASCII characters. A valid input would be b"HELLO_WORLD", for example. Overrides `self.bit_sequence.` """ attached_modulation = prop.StrProperty("", required=False) def __init__(self): super().__init__() self._attached_modulation: Optional['pg.Modulation'] = None self._current_repetition = 0 """ Which iteration of the resending (self.repetitions) we are in """ def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: if (self.attached_modulation not in simulation_kernel.get_components()): raise ValueError( "No modulation component with the name " f"{self.attached_modulation} attached to the simulation " "kernel could be found.") elif init_stage == pg.InitStages.BUILD_SCENE: self._attached_modulation = simulation_kernel.get_components()[ self.attached_modulation] def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.BITSTREAMING: # TODO: Find a more appropriate name for this stage… # Should still happen before MODULATION though, for consistency. return if simulation_kernel.get_simulation_time() > self.start_time \ and self._current_repetition == 0: self._start_stream_transmission(simulation_kernel) def set_arguments(self, **kwargs): super().set_arguments(**kwargs) # Convert the ascii sequence to a bit sequence. # Doing this here rather than in initialize (stage CHECK_ARGUMENTS) # so that the resulting bit sequence can be checked from outside # before the kernel starts. if self.ascii_sequence is not None and self.ascii_sequence != '': for char in self.ascii_sequence.encode(): if char < 64 or char > 95: raise ValueError( "ascii_sequence does not support ASCII characters " "below the decimal value of 64 or above 95, " "because with the current decoding scheme " "(as hinted to in unterweger2018experimental, " "DOI 10.1109/SPAWC.2018.8446011, section II D), " "we rely on the '010' prefix for synchronization. " f"The character '{chr(char)}'={char} " f"is therefore invalid.") self.bit_sequence = ''.join([ # Convert to binary, zero-pad to full bytes: f"{char:08b}" for char in self.ascii_sequence.encode() ]) LOG.info(f"Converted ASCII sequence '{self.ascii_sequence}' to " f"bit sequence '{self.bit_sequence}'.") def _start_stream_transmission(self, simulation_kernel): # Initialize a transmission by handing it to the modulation self._current_repetition += 1 self._attached_modulation.transmit_bitstream( self.bit_sequence, simulation_kernel, finish_callback=self.finished_stream_transmission, ) def finished_stream_transmission(self, simulation_kernel: 'pg.SimulationKernel'): # Callback from the modulation that the last stream was transmitted if self._current_repetition < self.repetitions: # We still need to transmit the sequence another time self._start_stream_transmission( simulation_kernel=simulation_kernel)
class ObjectPumpPeristaltic(ObjectPumpVolume): r""" Model for a peristaltic pump that tries to avoid overdosing by ramping down the flow speed towards the end of an injection. We want the injection volume to remain constant. For a static flow pump (ObjectPumpVolume), the flow rate :math:`r` is constant over the entire injection duration :math:`T` and equal to the maximum flow rate :math:`R`. At the end of what would be a constant injection, we stop at time :math:`T-t` and decrease the flow speed linearly in :math:`n` steps. In order to maintain the original injection volume, the following must hold for intermediate flow rates :math:`h_i`: .. math:: \sum_{i=0}^{n-1} h_i \cdot s &= t \cdot R \\ \Rightarrow h_i &= \frac{n-i}{n+1} \cdot R The step size :math:`s` must thus be .. math:: s &= t \cdot R \cdot \left(\sum_{i=0}^{n-1} h_i\right)^{-1} \\ &= tR\left(\frac{Rn}{2}\right)^{-1} = \frac{2t}{n}. If we don't want to define the time :math:`t` we want to cut off from the original injection duration, but instead the ramp-down time :math:`t_s`, we can convert between it and :math:`t` like so: .. math:: t_s = n \cdot s = 2t """ # TODO: brand and model number of the pump name = prop.StrProperty("Peristaltic pump", required=False) ramp_down_steps = prop.IntProperty(1, required=False) """ Number of in-between steps between the highest flow rate and the pump being turned off. """ ramp_down_time = prop.FloatProperty(0, required=False) """ A constant time in which to linearly ramp down the flow speed from its maximum to 0. Should be lower than twice of what the injection duration would be if the flow rate were constant. If equal to twice this hypothetical injection duration, the injection should start with the first intermediate step immediately. In the limited data we have so far (for lack of a proper high-speed camera), it looks as though this time is constant for a given flow rate and varying injection volumes. """ def __init__(self): super().__init__() self._static_flow_injection_duration = 0 self._most_recent_ramp_index = -1 self._target_injection_flow_mlpmin = 0 def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: pass def start_injection( self, simulation_kernel: 'pg.SimulationKernel', ): """ Start an injection. Warning: ObjectPumpPeristaltic only supports injection_volume, not injection_duration. """ self._target_injection_flow_mlpmin = self.injection_flow_mlpmin self._static_flow_injection_duration = ( self.injection_volume_l / pg.util.mlpmin_to_lps(self.injection_flow_mlpmin)) self._t_end_injection = (simulation_kernel.get_simulation_time() + self._static_flow_injection_duration + self.ramp_down_time / 2) self._set_current_pump_flow( simulation_kernel=simulation_kernel, flow_rate=self.injection_flow_mlpmin, ) self._is_active = True def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): # Ensure this method is always called after a Modulation instance # has made its changes in this time step: if notification_stage != pg.NotificationStages.PUMPING: return if not self._is_active: return t_sim = simulation_kernel.get_simulation_time() t_start_ramping = self._t_end_injection - self.ramp_down_time if t_sim < t_start_ramping: return if t_sim >= self._t_end_injection: self._is_active = False self._most_recent_ramp_index = -1 self._set_current_pump_flow( simulation_kernel=simulation_kernel, flow_rate=0, ) return ramp_down_step_index = int( (t_sim - t_start_ramping) / self.ramp_down_time * float(self.ramp_down_steps)) if ramp_down_step_index <= self._most_recent_ramp_index: return # should never be '<' though # Proceed with the next ramp-down step: self._most_recent_ramp_index = ramp_down_step_index self._set_current_pump_flow( simulation_kernel=simulation_kernel, flow_rate=(self._target_injection_flow_mlpmin * (self.ramp_down_steps - ramp_down_step_index) / (self.ramp_down_steps + 1)) # TODO: make sure vector fields can be found for all # intermediate steps )
class ObjectTube(pg.Object): name = prop.StrProperty("Tube", required=False) radius = prop.FloatProperty(0.00075, required=True) """Radius of the tube in m.""" length = prop.FloatProperty(0.05, required=True) """Length of the tube in m.""" inlet_zone = prop.FloatProperty(0.05, required=False) """ Length of the inlet zone in m. This segment at the start of the tube mesh will be cut off, since the OpenFOAM simulation showed that the flow profile before this threshold does not yet match the analytically determined profile of a tube to a sufficient degree. 'Cut off' here means that this ObjectTube will be shifted along its axis such that it will appear as though you are dealing with a tube that is simply `inlet_zone` metres shorter. """ outlet_zone = prop.FloatProperty(0.005, required=False) """ For teleporting: If a molecule enters this zone at the end of the tube, it will be teleported to a connected object. """ flow_rate = prop.FloatProperty(5, required=True) """Flow rate in ml/min.""" mesh_resolution = prop.IntProperty(11, required=False) """ Mesh resolution, defined as number of 'radius cells' in the OpenFOAM blockMeshDict. """ variant = prop.StrProperty('', required=False) """ Additional variant information. If given, look for an OpenFOAM case with '_<variant>' appended to its path name. """ fallback_flow_rate = prop.FloatProperty(5, required=False) """ Fallback flow rate for making sensor subscriptions possible in the case that this Object starts with an overall flow rate of 0 (i.e., `is_active == False`). """ def __init__(self): super().__init__() # Do this for documentation purposes, its not code relevant self.inlets.append("inlet") self.outlets.append("outlet") # The 5cm tube is useless because the flow does not fully develop # TODO: determine this length dynamically depending on available # OpenFOAM cases: self._mesh_length_cm = 15 """ The actual length of the underlying mesh. Should account for both self.length and self.inlet_zone. """ self._walls_patch_names.update({'tubePatch'}) def initialize( self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages' ): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: shift = pg.Transformation( translation=np.array([0, 0, -self.inlet_zone]) ) self._transformation = ( self._transformation.apply_to_transformation(shift) ) if (max(self._transformation.scaling) > 1.001 or min(self._transformation.scaling) < 0.999): LOG.warning( "You are trying to scale a tube mesh by a factor of " f"{self._transformation.scaling}. Keep in mind that " "this mesh already has an inherent scale from the " "OpenFOAM simulation." ) if self.length + self.inlet_zone > 0.15: # TODO: determine this dynamically based on available cases raise ValueError( "No path to OpenFOAM simulation files is known for a tube " "that is longer than 0.15 m. " f"Selected length: {self.length} m. " f"Keep in mind that the inlet zone of {self.inlet_zone} m " "is not usable." ) self._is_active = not np.isclose(self.flow_rate, 0, atol=1e-20) elif init_stage == pg.InitStages.SET_UP_FLOW_SYSTEM: self.process_changed_inlet_flow_rate(simulation_kernel, "inlet", self.flow_rate) def get_tube_mesh_index(self, flow_rate_mlpmin: float) -> str: return ( f'tube_r{self.radius * 1e3:.2f}mm_' f'l{round(self._mesh_length_cm, 1):g}cm_' f'{round(flow_rate_mlpmin, 1):g}mlpmin_' f'{self.mesh_resolution}cells' + (f'_{self.variant}' if self.variant != '' else '') ) @property def _openfoam_cases_subpath(self): return "tube" def get_mesh_index(self): return self.get_tube_mesh_index(flow_rate_mlpmin=self.flow_rate) def get_fallback_mesh_index(self) -> Optional[str]: return self.get_tube_mesh_index( flow_rate_mlpmin=self.fallback_flow_rate ) def process_changed_inlet_flow_rate( self, simulation_kernel: 'pg.SimulationKernel', inlet_name: str, flow_rate: float ): self._is_active = not np.isclose(flow_rate, 0, atol=1e-20) self.flow_rate = flow_rate if self._is_active: self.load_current_vector_field(simulation_kernel=simulation_kernel) simulation_kernel.get_scene_manager().process_changed_outlet_flow_rate( simulation_kernel, self, "outlet", flow_rate ) def get_outlet_area(self, outlet_name: str) -> ( 'pg.Geometry', 'pg.Transformation' ): if outlet_name != 'outlet': raise ValueError( "Outlet not found! " "This pipe only has one outlet named 'outlet'." ) geometry = pg.Geometry(pg.Shapes.CYLINDER) # TODO(jdrees): calculate the proper transformation for the # outlet based on the tube length and radius local_transformation = pg.Transformation( translation=np.array(( 0, 0, self.inlet_zone + self.length - self.outlet_zone / 2 )), scaling=np.array(( self.radius * 2, self.radius * 2, self.outlet_zone )) ) return ( geometry, self._transformation.apply_to_transformation(local_transformation) )
class Object(pg.Component, metaclass=ABCMeta): object_id = prop.IntProperty(-1, required=False) """Object index, set by the scene manager.""" # TODO: hide from user translation = prop.VectorProperty([0, 0, 0], required=True) rotation = prop.VectorProperty([0, 0, 0], required=True) scale = prop.VectorProperty([1, 1, 1], required=True) openfoam_cases_path = prop.StrProperty("", required=True) """ Path to all OpenFOAM simulation cases. Subclasses may use this as the base path to find their respective meshes via `get_path()`. """ use_sensor_subscriptions = prop.EnumProperty( str(pg.SensorSubscriptionsUsage.USE_DEFAULT.name), name='use_sensor_subscriptions', required=False, enum_class=pg.SensorSubscriptionsUsage, ) """ Notify subscribed sensors based on object mesh cells. This speeds up simulation. Only disable this if you are constrained by memory or if the object does not have a mesh in the first place. If None, use the SensorManager's default_use_sensor_subscriptions. """ dummy_boundary_points = prop.EnumProperty( str(pg.DummyBoundaryPointsVariant.NONE.name), name='dummy_boundary_points', required=False, enum_class=pg.DummyBoundaryPointsVariant, ) """ Whether to insert dummy points at the boundary of the mesh, and where exactly. This may help with interpolation. """ def __init__(self): super().__init__() self._vector_field_manager: Optional['pg.VectorFieldManager'] = None """This object's VectorFieldManager, set by child classes.""" self._walls_patch_names: Set[str] = { 'walls', 'yConnectorPatch', 'tubePatch' } """ A superset of names of wall patches. Required by the VectorFieldParser: "Which patches to consider as boundaries (typically does not include inlets and outlets)." """ self._transformation: Optional['pg.Transformation'] = None """ Transformation of this object in the scene. """ self.inlets: List[str] = [] """Names of the inlets in the OpenFOAM mesh.""" self.outlets: List[str] = [] """Names of the outlets in the OpenFOAM mesh.""" self.name: str = "Generic Object" """ The name of this type of object. Not a unique identifier like Component.component_name! """ self._is_active = True """ If True, indicates that fluid inside this Object is moving. """ self._default_time_str: str = "latest" """ Default sub-folder of the OpenFOAM simulation results to use. If "latest", will search for the sub-folder which name is a floating point number and has the greatest value. """ self._ignored_arguments.update({ # These arguments are passed to all components present in the # scene. A (mesh) object won't need them, but we also don't # want to see warnings if these arguments are passed anyway: 'geometry', 'shape', 'visualization_scale', }) def initialize( self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages' ): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: self._transformation = pg.Transformation( translation=np.array(self.translation), rotation=np.array(self.rotation), scaling=np.array(self.scale) ) if init_stage == pg.InitStages.CREATE_DATA_STRUCTURES: self.load_current_vector_field(simulation_kernel) if init_stage == pg.InitStages.BUILD_SCENE: simulation_kernel.get_scene_manager().add_object(self) @property @abstractmethod def _openfoam_cases_subpath(self): """ In which folder of `openfoam_cases_path` to find the cases for this Object class. `get_path()` will be relative to `<openfoam_cases_path>/<openfoam_cases_subpath>`. """ pass @property def is_active(self): return self._is_active @property def walls_patch_names(self): return self._walls_patch_names def get_transformation(self) -> 'pg.Transformation': return self._transformation def get_vector_field_manager(self) -> Optional['pg.VectorFieldManager']: if not self._is_active: return None return self._vector_field_manager def get_current_mesh_local(self) -> Optional[np.ndarray]: if not self._is_active: return None return self._vector_field_manager.get_cell_centres_local() def get_current_mesh_global(self) -> Optional[np.ndarray]: if not self._is_active: return None return self._vector_field_manager.get_cell_centres_global() def get_closest_cell_centre_id( self, position_global: np.ndarray ) -> Optional[np.ndarray]: """ :returns: Array of closest cell centers to `position`. If there's only one position, output is squeezed. None if this Object is inactive. See documentation of cKDTree.query(). """ if not self.is_active: return None return self._vector_field_manager.get_closest_cell_centre_id( position_global=position_global ) def get_flow( self, simulation_kernel: 'pg.SimulationKernel', position_global: np.ndarray, sim_time: float ): if not self._is_active: return np.array([0, 0, 0], dtype=np.float) return self._vector_field_manager.get_flow_by_position( simulation_kernel=simulation_kernel, position_global=position_global ) def load_current_vector_field( self, simulation_kernel: 'pg.SimulationKernel' ): if not self._is_active: # This saves us the extra step of having to load a mesh # with all 0s. return old_mesh_size = ( len(self._vector_field_manager.get_mesh()) if self._vector_field_manager is not None else None ) vector_field = simulation_kernel.get_mesh_manager().load_vector_field( openfoam_sim_path=self.get_path(), mesh_index=self.get_mesh_index(), walls_patch_names=self._walls_patch_names, dummy_boundary_points=pg.DummyBoundaryPointsVariant[ self.dummy_boundary_points], ) new_mesh_size = ( len(self._vector_field_manager.get_mesh()) if self._vector_field_manager is not None else None ) if (old_mesh_size is not None and new_mesh_size is not None and old_mesh_size != new_mesh_size): # Comparison will only apply to states of this Object in which # self._is_active was True. raise AssertionError( f"Object {self.name} loaded a new vector field, " f"but the new mesh ({new_mesh_size} cells) doesn't match " f"the old one ({old_mesh_size} cells)." ) self._vector_field_manager = pg.VectorFieldManager( vector_field=vector_field, transformation=self._transformation ) @abstractmethod def get_mesh_index(self) -> str: """ :returns: A unique string for the current configuration of this object. Used for caching. """ pass @abstractmethod def get_fallback_mesh_index(self) -> Optional[str]: """ If an Object supports sensor subscriptions, some OpenFOAM case with a non-zero flow rate should exist (i.e., a vector field for which this Object has `self.is_active == True`). If this object is initially inactive, however, we still need to know where to find this mesh for the initialization of the SensorManager. :returns: A valid mesh index independent of this Object's `is_active` status. None if this Object does not support sensor subscriptions. """ pass def get_path(self, use_latest_time_step=False, fallback=False) -> str: """ :param use_latest_time_step: Find the latest time step in path. If False, use this Object's `_default_time_str`, which may still be set to 'latest', which has the same effect. :param fallback: Use `get_fallback_mesh_index` instead of `get_mesh_index`. :return: Path to the OpenFOAM files for this object. """ """ :param use_latest_time_step: Find the latest time step in path :return: Path to the OpenFOAM files for this object. """ if not self._is_active: return '' mesh_index = ( self.get_mesh_index() if not fallback else self.get_fallback_mesh_index() ) if fallback and mesh_index is None: raise ValueError( "Tried to call `get_path` with the fallback flag enabled, " "but this Object does not have a fallback mesh index." ) path = os.path.join( self.openfoam_cases_path, self._openfoam_cases_subpath, mesh_index ) if use_latest_time_step or self._default_time_str == 'latest': return os.path.join(path, self.find_latest_time_step(path)) return os.path.join(path, self._default_time_str) @abstractmethod def process_changed_inlet_flow_rate( self, simulation_kernel: 'pg.SimulationKernel', inlet_name: str, flow_rate: float ): """ Update the flow rate inside this Object based on the changed flow rate of the inlet with name `inlet_name`, then propagate this change to any Object connected to this Object's outlets. Should also update `is_active` accordingly. """ pass @abstractmethod def get_outlet_area(self, outlet_name: str) -> ( 'pg.Geometry', 'pg.Transformation' ): pass @staticmethod def find_latest_time_step(path: str) -> str: """ :return: The highest-valued subdirectory of path that is named only after a floating point value. E.g., if there are '{path}/0/' and '{path}/0.1/', this will return '0.1'. """ # Float pattern from # https://docs.python.org/3/library/re.html#simulating-scanf: p = re.compile(r'^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$') all_files_and_dirs = os.listdir(path) matching_files_and_dirs = [f for f in all_files_and_dirs if p.match(f)] if len(matching_files_and_dirs) == 0: raise ValueError( f"Could not find a subfolder for the latest time step " f"in \"{path}\"." ) return max(matching_files_and_dirs, key=lambda x: float(x))
class ObjectPumpTimed(pg.Object): name = prop.StrProperty("Timed pump", required=False) flow_rate = prop.IntProperty(0, required=False) """Flow rate when pump is inactive.""" injection_flow_mlpmin = prop.FloatProperty(10, required=False) """ Flow rate when pump is active. Not strictly mandatory as this pump is also useful for ModulationOOK for cases in which no vector field is used at all. """ pump_duration_s = prop.FloatProperty(0, required=True) """How long to keep the pump turned on in seconds.""" def __init__(self): super().__init__() self.outlets.append("outlet") self._t_end_injection: float = 0 """Simulation time when to end an injection.""" self._mandatory_arguments -= { 'scale', 'rotation', 'translation', 'openfoam_cases_path', } def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CHECK_ARGUMENTS: self._is_active = False def start_injection( self, simulation_kernel: 'pg.SimulationKernel', ): """ Start an injection with the flow speed (`injection_flow`) and total volume `injection_volume` configured in this object. """ self._t_end_injection = (simulation_kernel.get_simulation_time() + self.pump_duration_s) self._set_current_pump_flow( simulation_kernel=simulation_kernel, flow_rate=self.injection_flow_mlpmin, ) self._is_active = True def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): """ Update the pump activity based on the current time (since an injection started). """ # Ensure this method is always called after a Modulation instance # has made its changes in this time step: if notification_stage != pg.NotificationStages.PUMPING: return if not self._is_active: return if simulation_kernel.get_simulation_time() < self._t_end_injection: return self._is_active = False self._set_current_pump_flow( simulation_kernel=simulation_kernel, flow_rate=0, ) def get_mesh_index(self) -> str: pass def process_changed_inlet_flow_rate( self, simulation_kernel: 'pg.SimulationKernel', inlet_name: str, flow_rate: float): raise RuntimeError("A pump only has an outlet, " "so nothing should be connected to its inlet") def get_flow(self, simulation_kernel: 'pg.SimulationKernel', position_global: np.ndarray, sim_time: float): raise RuntimeError( "A pump does not have an actual associated geometry, " "molecules should not be inside it.") def get_outlet_area( self, outlet_name: str) -> ('pg.Geometry', 'pg.Transformation'): if outlet_name != "outlet": raise ValueError("Outlet not found! " "This pump only has one outlet named 'outlet'.") # The pump doesn't really define any outlet area, # so let's use a nonexistent one. geometry = pg.Geometry(pg.Shapes.NONE) transformation = pg.Transformation() # identity transformation return geometry, transformation def set_interpolation_method(self, interpolation_method: 'pg.Interpolation'): pass def get_current_mesh_local(self): return None def get_current_mesh_global(self): return None @property def _openfoam_cases_subpath(self): return None def get_fallback_mesh_index(self) -> Optional[str]: return None def get_closest_cell_centre_id(self, position: np.ndarray): raise RuntimeError( "A pump does not have an actual associated geometry, " "molecules should not be inside it.") def load_current_vector_field(self, simulation_kernel: 'pg.SimulationKernel'): # No need to do anything pass def _set_current_pump_flow(self, simulation_kernel, flow_rate: float): self.flow_rate = flow_rate simulation_kernel.get_scene_manager().process_changed_outlet_flow_rate( simulation_kernel=simulation_kernel, source_object=self, outlet_name='outlet', flow_rate=flow_rate, ) def get_path(self, use_latest_time_step=False, fallback=False) -> str: pass # Pumps don't need a path