class SensorCounting(pg.Sensor): log_folder = prop.StrProperty("sensor_data", required=False) """The file `sensor[<component name>].csv` will be created in here.""" def __init__(self): super().__init__() self._counts = 0 """Number of molecules in this sensor so far in this time step.""" self._csv_writer = None self._csv_file = None def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if 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.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', 'molecule_count'] self._csv_writer = csv.writer(self._csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) self._csv_writer.writerow(fieldnames) def finalize(self, simulation_kernel: 'pg.SimulationKernel'): self._csv_file.close() def process_molecule_moving_after(self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule'): if self.is_inside_sensor_zone(position_global=molecule.position): self._counts = self._counts + 1 def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.LOGGING: return LOG.debug(f"Sensor {self.id}: {self._counts} molecules in zone.") # TODO(jdrees): Is the sim_time off by one time step? # Investigate and refactor if necessary self._csv_writer.writerow( [simulation_kernel.get_simulation_time(), self._counts]) self._counts = 0
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 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 MovementPredictor(pg.Component): component_name = prop.StrProperty('movement_predictor', required=False) integration_method = prop.EnumProperty( default=str(pg.Integration.RUNGE_KUTTA_4.name), name='integration_method', required=True, enum_class=pg.Integration, ) def __init__(self): super().__init__() self._integration_method = pg.Integration[self.integration_method] self._embedded_integrator: Optional[EmbeddedRungeKuttaMethod] = None def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize( simulation_kernel=simulation_kernel, init_stage=init_stage, ) if init_stage == pg.InitStages.CHECK_ARGUMENTS: self._integration_method = pg.Integration[self.integration_method] if self._integration_method in { pg.Integration.RUNGE_KUTTA_FEHLBERG, pg.Integration.RUNGE_KUTTA_FEHLBERG_4, pg.Integration.RUNGE_KUTTA_FEHLBERG_45, }: self._embedded_integrator = RKFehlberg45() def get_integration_method(self) -> 'pg.Integration': return self._integration_method @property def embedded_integrator(self) -> Optional[EmbeddedRungeKuttaMethod]: return self._embedded_integrator def predict( self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule', sim_time: float, delta_time: float, update_molecule: bool = True, ) -> Tuple['pg.Molecule', np.ndarray, float]: object_id = molecule.object_id position_global = molecule.position new_pos_global = molecule.position scene_manager = simulation_kernel.get_scene_manager() # Estimation error; will stay 0 for incompatible integration types: error = 0 # FIXME: Molecule might has no attached object if object_id is not None: if self._integration_method in { pg.Integration.EULER, pg.Integration.RUNGE_KUTTA_4 }: flow = scene_manager.get_flow_by_position( simulation_kernel, position_global, object_id, sim_time) k1 = delta_time * flow if self._integration_method == pg.Integration.EULER: new_pos_global = position_global + k1 elif self._integration_method == pg.Integration.RUNGE_KUTTA_4: # TODO(jdrees): Take the time difference between k1 # and k2-k4 into account. k2 = delta_time * scene_manager.get_flow_by_position( simulation_kernel, position_global + (k1 / 2), object_id, sim_time) k3 = delta_time * scene_manager.get_flow_by_position( simulation_kernel, position_global + (k2 / 2), object_id, sim_time) k4 = delta_time * scene_manager.get_flow_by_position( simulation_kernel, position_global + k3, object_id, sim_time) new_pos_global = (position_global + (1 / 6 * k1) + (1 / 3 * k2) + (1 / 3 * k3) + (1 / 6 * k4)) else: raise AssertionError( "This should really never ever happen.") elif self._integration_method in { pg.Integration.RUNGE_KUTTA_FEHLBERG, pg.Integration.RUNGE_KUTTA_FEHLBERG_4, pg.Integration.RUNGE_KUTTA_FEHLBERG_45, }: (new_pos_global, new_pos_low_global, pos_err) = self._embedded_integrator.compute( func=lambda t, y: scene_manager.get_flow_by_position( simulation_kernel=simulation_kernel, position_global=y, object_id=object_id, sim_time=t, ), t_old=sim_time, y_old=position_global, dt=delta_time) error = np.linalg.norm(pos_err) if self._integration_method \ == pg.Integration.RUNGE_KUTTA_FEHLBERG_4: new_pos_global = new_pos_low_global else: raise NotImplementedError( "Integration type is not implemented yet") # FIXME: Primitive implementation of displacement by velocity # after(!) displacement due to vector field new_pos_global += molecule.velocity * delta_time if update_molecule: molecule.update( scene_manager=scene_manager, new_pos_global=new_pos_global, ) return molecule, new_pos_global, error
class SensorEmpirical(pg.Sensor): """ Sensor based on empirical measurements of susceptibility variations within a susceptometer. For this, we shall assume that molecules pass through the sensor in positive (object-local!) z direction and that the sensor (i.e., the boundary) is 25 mm long! So remember to rotate this sensor via the transformation accordingly if your particles aren't moving from -z to +z. """ DEFAULT_FCT_PARAMS = { KnownSensors.MS2G_BARTINGTON: [ # scale sensor to 25 mm 1.1710899115768234, 0.012806480117364473, 0.0017908368596852163 ], KnownSensors.ERLANGEN_20200310: [ # scale sensor to 35 mm 1.1521874852663174, 0.017531424609419782, 0.003804701283874202 ], } """ Default distribution parameters for known sensors. Values determined using curve fitting for the logistic function. """ log_folder = prop.StrProperty("sensor_data", required=False) """The file `sensor[<component name>].csv` will be created in here.""" use_known_sensor = prop.EnumProperty( str(KnownSensors.MS2G_BARTINGTON.name), name='use_known_sensor', required=False, enum_class=KnownSensors, ) """ Use distribution parameters obtained for known sensors in previous experiments. Default: MS2G_BARTINGTON """ distribution_params = prop.FloatArrayProperty( default=DEFAULT_FCT_PARAMS[KnownSensors.MS2G_BARTINGTON], required=False, ) """ Distribution parameters obtained from curve fitting, for now applied to a logistic function by default. Alternatively, use `use_known_sensor`. """ def __init__(self): super().__init__() self._relative_susceptibility: float = 0 """ Sum of all particle susceptibilities currently within the sensor. Susceptibility is relative to the maximum susceptibility measured in the variability experiment mentioned above. """ self._csv_file = None self._csv_writer = None def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if 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.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', 'rel_susceptibility'] self._csv_writer = csv.writer(self._csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) self._csv_writer.writerow(fieldnames) elif init_stage == pg.InitStages.CHECK_ARGUMENTS: if self.use_known_sensor != KnownSensors.NONE.name: self.distribution_params = self.DEFAULT_FCT_PARAMS[ KnownSensors[self.use_known_sensor]] def finalize(self, simulation_kernel: 'pg.SimulationKernel'): self._csv_file.close() def process_molecule_moving_after(self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule'): pos_local = self._transformation.apply_inverse_to_point( molecule.position) if not self.is_inside_sensor_zone(position_global=molecule.position): return # Local coordinates are in the interval [0, 1] and should be scaled. self._relative_susceptibility += scipy.stats.genlogistic.pdf( ((pos_local[2] + 0.5) # Geometry is centered around origin * self._transformation.scaling[2]), *self.distribution_params) def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.LOGGING: return LOG.info(f"\"{self.component_name}\": " f"{self._relative_susceptibility:.3e} susceptibility") self._csv_writer.writerow([ simulation_kernel.get_simulation_time(), self._relative_susceptibility ]) self._relative_susceptibility = 0
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 Component(ABC): """ Base class for simulation components that can be configured via YAML files. """ component_name = prop.StrProperty( default="Generic component", required=False, ) """ Unique name of this component, unless it is "Generic component". """ def __init__(self): self.id = -1 """Unique integer component ID""" self._arguments_already_set: Set[str] = set() """ Names of arguments already set via `set_arguments()` Mandatory arguments not in this set at the time when `initialize()` is first called will cause an exception. """ self._mandatory_arguments: Set[str] = set() """ Names of arguments that should cause an exception if they are not set by the time `initialize()` is first called. """ self._ignored_arguments: Set[str] = set() """ Names of arguments that should not trigger an exception if they are passed via set_arguments. """ # Convert class properties to instance variables, using their default # values. Any value can be overwritten with `set_arguments()`. # Having properties at the class level lets us read type annotations # and docstrings for the UI in the Blender add-on. properties = dict() for attr_name, class_attr in inspect.getmembers( self.__class__, lambda m: isinstance(m, prop.AbstractProperty)): if not isinstance(class_attr, prop.AbstractProperty): continue current_prop = cast(prop.AbstractProperty, class_attr) properties[attr_name] = current_prop if current_prop.required: self._mandatory_arguments.add(attr_name) self._property_names = set(properties.keys()) self.__dict__.update(properties) def set_arguments(self, **kwargs): """ Read arguments as key value pairs and set this component's member variables accordingly. Validity of the argument values will be checked in :meth:`~pogona.Component.initialize`. """ unrecognized_args = (set(kwargs.keys()) - self._property_names - self._ignored_arguments) if len(unrecognized_args) != 0: # LOG.warning( raise TypeError("Unrecognized arguments for component of type " f"{self.__class__}: " + ", ".join(unrecognized_args) + " -- Valid arguments: " + ", ".join(self._property_names - self._ignored_arguments)) self.__dict__.update(kwargs) for key in kwargs.keys(): self._arguments_already_set.add(key) def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): """ Use :class:`~pogona.InitStages` to initialize this Component instance. """ if init_stage == InitStages.CHECK_ARGUMENTS: missing_args = (self._mandatory_arguments - self._arguments_already_set) if len(missing_args) != 0: raise TypeError("Missing mandatory arguments for component " f"\"{self.component_name}\" with ID {self.id} " f"of class {self.__class__}: " + ", ".join(list(missing_args))) # Iterate over all AbstractProperties of the current class # for additional safety checks. # (Not iterating over items of this instance as they may have # been overwritten with configuration values, replacing # the AbstractProperty instances.) for attr_name, class_attr in inspect.getmembers( self.__class__, lambda m: isinstance(m, prop.AbstractProperty)): instance_attr = getattr(self, attr_name) if isinstance(class_attr, prop.EnumProperty): # Make sure that selected choices are valid. # (E.g., CYLINDER as part of pg.Shape) pg.util.check_enum_key( enum_class=class_attr.property_enum_class, key=instance_attr, param_name=attr_name, ) if isinstance(class_attr, prop.ComponentReferenceProperty): # Ensure that referenced components exist. if (not class_attr.property_can_be_empty(self) and instance_attr == ""): raise ValueError(f"Tried to set {attr_name} on " f"{self.component_name}: " "Value must not be empty!") if (not class_attr.property_can_be_empty(self) and instance_attr != "" and instance_attr not in simulation_kernel.get_components()): raise ValueError( f"Tried to set {attr_name} on " f"{self.component_name}: " "No component with the name " f"{instance_attr} attached to the simulation " "kernel could be found.") if isinstance(class_attr, prop.VectorProperty): # Ensure vectors are valid: pg.util.check_vector( value=instance_attr, component_name=self.component_name, key=attr_name, ) def process_new_time_step( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): pass def finalize(self, simulation_kernel: 'pg.SimulationKernel'): pass
class ObjectYPiece(pg.Object): name = prop.StrProperty("Y-Piece", required=False) """The name of this object.""" radius = prop.FloatProperty(0.00075, required=True) """Radius of the outlet tube in meters.""" angle = prop.FloatProperty(30.0, required=True) """Angle between outlet and particle inlet in degrees.""" outlet_length = prop.FloatProperty(0.04, required=True) """Length of the outlet tube in meters.""" outlet_zone = prop.FloatProperty(0.005, required=False) """ For teleporting: If a molecule enters this zone at the end of the outlet tube, it will be teleported to a connected object. """ background_inlet_length = prop.FloatProperty(0.01, required=False) """Length of the background flow inlet in meters.""" injection_inlet_length = prop.FloatProperty(0.04, required=False) """Length of the particle injection inlet tube in meters.""" flow_rate_injection = prop.FloatProperty(0.0, required=True) """Flow rate in the injection tube in ml/min.""" flow_rate_background = prop.FloatProperty(5.0, required=True) """Background flow rate in ml/min.""" variant = prop.StrProperty('', required=False) """ Additional variant information. If given, look for an OpenFOAM case with '_<variant>' appended to its path name. """ fallback_background_rate_mlpmin = prop.FloatProperty(5.0, required=False) """ Fallback background 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`). """ fallback_injection_rate_mlpmin = prop.FloatProperty(0.0, required=False) """ Fallback injection 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, it's not code relevant self.inlets.append("background") self.inlets.append("injection") self.outlets.append("outlet") self._walls_patch_names.update({'yConnectorPatch'}) 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 (max(self._transformation.scaling) > 1.001 or min(self._transformation.scaling) < 0.999): LOG.warning( "You are trying to scale a y-piece mesh by a factor of " f"{self._transformation.scaling}. Keep in mind that " "this mesh already has an inherent scale from the " "OpenFOAM simulation." ) self._is_active = not np.isclose( self.flow_rate_injection + self.flow_rate_background, 0, atol=1e-20 ) elif init_stage == pg.InitStages.SET_UP_FLOW_SYSTEM: # self.process_changed_inlet_flow_rate( # simulation_kernel=simulation_kernel, # inlet_name="background", # flow_rate=self.flow_rate_background, # ) # Calling this once for just one of the inlets should suffice, # since we only want to load the appropriate mesh, and # the variables for all flow rates should already be set: self.process_changed_inlet_flow_rate( simulation_kernel=simulation_kernel, inlet_name="injection", flow_rate=self.flow_rate_injection, ) @property def _openfoam_cases_subpath(self): return "y_connector" def process_changed_inlet_flow_rate( self, simulation_kernel: 'pg.SimulationKernel', inlet_name: str, flow_rate: float ): if inlet_name == "background": self.flow_rate_background = flow_rate elif inlet_name == "injection": self.flow_rate_injection = flow_rate self._is_active = not np.isclose( self.flow_rate_injection + self.flow_rate_background, 0, atol=1e-20 ) if self._is_active: self.load_current_vector_field(simulation_kernel) # TODO: never tested an inactive ObjectYPiece, # *should* work as in ObjectTube, though simulation_kernel.get_scene_manager().process_changed_outlet_flow_rate( simulation_kernel, self, "outlet", self.flow_rate_injection + self.flow_rate_background ) def get_outlet_area(self, outlet_name: str) -> ( 'pg.Geometry', 'pg.Transformation' ): if outlet_name != "outlet": raise ValueError( f"Outlet \"{outlet_name}\" not found! " "This y-piece only has one outlet named 'outlet'." ) geometry = pg.Geometry(pg.Shapes.CYLINDER) local_transformation = pg.Transformation( translation=np.array([ 0, 0, self.outlet_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) ) def get_ypiece_mesh_index( self, injection_rate_mlpmin: float, background_rate_mlpmin: float, ) -> str: return ( f'y-piece_r{self.radius * 1e3:.2f}mm_' f'bg{round(background_rate_mlpmin, 1):g}mlpmin_' f'in{round(injection_rate_mlpmin, 1):g}mlpmin_' f'o{self.outlet_length * 100:.0f}cm_' f'bg{self.background_inlet_length * 100:.0f}cm_' f'p{self.injection_inlet_length * 100:.0f}cm' + (f'_{self.variant}' if self.variant != '' else '') ) def get_mesh_index(self): return self.get_ypiece_mesh_index( injection_rate_mlpmin=self.flow_rate_injection, background_rate_mlpmin=self.flow_rate_background, ) def get_fallback_mesh_index(self) -> Optional[str]: return self.get_ypiece_mesh_index( injection_rate_mlpmin=self.fallback_injection_rate_mlpmin, background_rate_mlpmin=self.fallback_background_rate_mlpmin, )
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 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 MeshManager(pg.Component): cache_path = prop.StrProperty("", required=False) """ Directory for cached objects. (Not relative to the results folder.) By default, this will be set to `<OpenFOAM cases path>/cache`. """ openfoam_cases_path = prop.StrProperty("", required=True) """ Path to MaMoKo OpenFOAM cases. Usually this is passed to the individual objects directly. In the MeshManager, this path is only used as a default prefix for cache_path if cache_path is not set explicitly. """ def __init__(self): super().__init__() self._parsed_meshes = dict() 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 'cache_path' not in self._arguments_already_set: self.cache_path = os.path.join(self.openfoam_cases_path, 'cache') @staticmethod def get_valid_filename(s): """ Copied from the Django project's django.utils.text: https://github.com/django/django/blob/master/django/utils/text.py Return the given string converted to a string that can be used for a clean filename. Remove leading and trailing spaces; convert other spaces to underscores; and remove anything that is not an alphanumeric, dash, underscore, or dot. >>> MeshManager.get_valid_filename("john's portrait in 2004.jpg") 'johns_portrait_in_2004.jpg' """ s = str(s).strip().replace(' ', '_') return re.sub(r'(?u)[^-\w.]', '', s) @staticmethod def _get_mesh_string_from_path(openfoam_sim_path: str): """ Generate an identifier string for a mesh from the path to its OpenFOAM simulation files. :param openfoam_sim_path: :return: """ path_stripped = openfoam_sim_path.strip('/\\') # Splitting by slashes, might not work on Windows: path_components = path_stripped.split('/') # Using only the 3 lowest directories for the mesh string. # E.g., "/path/to/openfoam/files/tube/42cm_33lps/0.9" # would result in the mesh string "tube__42cm_33lps__0.9". return '__'.join(path_components[-3:]) def save_cached_vector_field(self, mesh_string, vector_field: 'pg.VectorField'): LOG.info(f"Saving mesh file \"{mesh_string}\" to cache.") file_name = self.get_file_name_for_cached_mesh(mesh_string) path = os.path.dirname(file_name) os.makedirs(path, exist_ok=True) pickle.dump(vector_field, open(file_name, "wb")) LOG.info( f"Saved mesh cache file \"{mesh_string}\" in \"{file_name}\".") def load_vector_field( self, openfoam_sim_path: str, mesh_index: str = None, walls_patch_names: Set[str] = None, dummy_boundary_points: str = None, ) -> 'pg.VectorField': """ :param openfoam_sim_path: Path to the OpenFOAM simulation result (the directory for one specific time step) for the vector field to load. :param mesh_index: A unique string for the OpenFOAM simulation case and mesh given by openfoam_sim_path. Will be converted internally using `MeshManager.get_valid_filename`. If None, a mesh index will be generated from openfoam_sim_path. :param walls_patch_names: Which patches to consider as boundaries (typically does not include inlets and outlets). :param dummy_boundary_points: 'face-centers', 'face-points', or 'none'/None (default). TODO: enum? :return: The cached vector field, if it exists. Otherwise the vector field will be loaded from the given path. """ if mesh_index is not None: mesh_string = self.get_valid_filename(mesh_index) else: mesh_string = self._get_mesh_string_from_path(openfoam_sim_path) vector_field = None if mesh_string in self._parsed_meshes: # We already loaded the vector field, use the one in memory vector_field = self._parsed_meshes[mesh_string] if vector_field is None: # Try loading a pickled version file_name = self.get_file_name_for_cached_mesh(mesh_string) LOG.info("Loading cached mesh file " + os.path.realpath(file_name)) try: file = open(file_name, "rb") vector_field = pickle.load(file) except PickleError: LOG.warning("I cannot unpickle the cached mesh file " f"\"{os.path.realpath(file_name)}\". " "Reloading mesh from simulation results…") except IOError or FileNotFoundError: LOG.info(f"Cached file not found for mesh \"{mesh_string}\". " "Reloading mesh from simulation results…") if vector_field is None: # Parse file vector_field = self.parse_mesh( import_folder=openfoam_sim_path, mesh_string=mesh_string, walls_patch_names=walls_patch_names, dummy_boundary_points=dummy_boundary_points, ) # Save vector field to memory for future use self._parsed_meshes[mesh_string] = vector_field # TODO(jdrees): Find out whether copying might be necessary # return copy.deepcopy(vector_field) LOG.info(f"Finished loading mesh file \"{mesh_string}\".") return vector_field def get_file_name_for_cached_mesh(self, mesh_string: str): return os.path.join(self.cache_path, mesh_string + ".pickle") def parse_mesh( self, import_folder: str, mesh_string: str, walls_patch_names: Set[str] = None, dummy_boundary_points: str = None, ) -> 'pg.VectorField': # Check if folder exists if not os.path.isdir(import_folder): LOG.critical( "The openfoam simulation result folder " f"{os.path.realpath(import_folder)} does not exist. " "Are you sure you ran the openfoam simulation and put the " "result in the correct directory?") exit(1) vector_field = pg.VectorFieldParser.parse_folder( folder=import_folder, walls_patch_names=walls_patch_names, dummy_boundary_points=dummy_boundary_points, ) self.save_cached_vector_field(mesh_string, vector_field) return vector_field
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
class SensorManager(pg.Component): default_use_sensor_subscriptions = prop.BoolProperty(True, required=False) """ Notify subscribed sensors based on object mesh cells. This speeds up simulation. Only disable this if you are constrained by memory. Individual objects can override this setting. """ use_range_queries = prop.BoolProperty(True, required=False) """ When determining possible sensor subscriptions, execute range queries on the Object's k-d tree instead of iterating the entire mesh. Use this if all sensors occupy a relatively small volume compared to each mesh. Performance may degrade for large sensors. """ component_name = prop.StrProperty("sensor_manager", required=False) """Since this component is not created by the config, choose a name""" def __init__(self): super().__init__() self._sensors: List[pg.Sensor] = [] self._sensor_subscriptions: List[List[List[int]]] = [] """Indexable by object ID, cell ID; holds a list of sensor IDs""" def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.CREATE_SENSOR_SUBSCRIPTIONS: # Initialize the subscription array for i, obj in enumerate( simulation_kernel.get_scene_manager().get_all_objects()): if obj.object_id != len(self._sensor_subscriptions): raise AssertionError( "Creating the sensor subscription array failed " f"because the ID of object \"{obj.component_name}\" " f"= {obj.object_id}, which has index {i} in the list " "of objects, does not match the length of the " "list of sensor subscriptions " f"= {len(self._sensor_subscriptions)}. " "Maybe it was somehow attached twice?") # Ensure that there is a mesh to add subscriptions to, # even if the object is inactive: fallback_mesh_index = obj.get_fallback_mesh_index() if not obj.is_active and fallback_mesh_index is not None: # Try to temporarily load a fallback mesh, if it exists. vector_field = ( simulation_kernel.get_mesh_manager().load_vector_field( openfoam_sim_path=obj.get_path(fallback=True), mesh_index=fallback_mesh_index, walls_patch_names=obj.walls_patch_names, dummy_boundary_points=obj.dummy_boundary_points, )) vfm = pg.VectorFieldManager( vector_field=vector_field, transformation=obj.get_transformation()) mesh_global = vfm.get_cell_centres_global() else: # Object is likely active, or it doesn't support # subscriptions. # In the latter case, mesh_global will be None. mesh_global = obj.get_current_mesh_global() if (mesh_global is None or not self._uses_sensor_subscriptions(obj)): # The object does not support subscriptions. self._sensor_subscriptions.append([]) else: # Ensure that for every mesh cell, # we have an array of subscriptions ready self._sensor_subscriptions.append( [[] for _ in range(len(mesh_global))]) # Write subscriptions to the array for sensor in self._sensors: if self.use_range_queries: # Use k-d tree range queries to determine possible # cells cell_ids = self._get_mesh_subset_ids(obj, sensor) LOG.debug(f"Range query subset of size " f"{len(cell_ids)}. " f"Original size: {len(mesh_global)} " f"while subscribing sensor " f"\"{sensor.component_name}\" to object " f"\"{obj.component_name}\".") else: # Just ask for all cell ids cell_ids = [[] for _ in range(len(mesh_global))] for cell_id in cell_ids: cell_centre = mesh_global[cell_id] if sensor.is_inside_sensor_zone(cell_centre): self._sensor_subscriptions[ obj.object_id][cell_id].append( sensor.sensor_id) def register_sensor(self, sensor: 'pg.Sensor'): """ :param sensor: :return: nothing """ sensor.sensor_id = len(self._sensors) self._sensors.append(sensor) LOG.debug(f"Registered new sensor \"{sensor.component_name}\"") def _uses_sensor_subscriptions(self, obj: 'pg.Object'): """ :return: True iff obj is supposed to use sensor subscriptions. obj.use_sensor_subscriptions has precedence over self.default_use_sensor_subscriptions. """ return obj.is_active and ((obj.use_sensor_subscriptions == pg.SensorSubscriptionsUsage.USE_DEFAULT and self.default_use_sensor_subscriptions) or (obj.use_sensor_subscriptions == pg.SensorSubscriptionsUsage.ENABLED)) def _get_mesh_subset_ids(self, obj: "pg.Object", sensor: 'pg.Sensor'): # Query radius: All shapes are confined to a box with # side lengths 1 centered around (0, 0, 0). # Use the distance to one of the corners of this box # as the radius: radius = np.sqrt((sensor.transformation.scaling[0] / 2)**2 + (sensor.transformation.scaling[1] / 2)**2 + (sensor.transformation.scaling[2] / 2)**2) return obj.get_vector_field_manager().kd_tree_global.query_ball_point( # Center point of this sensor: x=sensor.transformation.translation, # Query radius: r=radius) def _get_subscribed_sensors( self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule', ): """ :param simulation_kernel: :param molecule: :return: List of sensors subscribed to the current cell of the given molecule if sensor subscriptions are active, otherwise returns the list of all sensors. """ # Just use all sensors if there is no related object_id if molecule.object_id is None: return self._sensors obj = simulation_kernel.get_scene_manager().get_all_objects()[ molecule.object_id] if self._uses_sensor_subscriptions(obj) and len( self._sensor_subscriptions[molecule.object_id]) > 0: # Find out which sensors are subscribed to this particular cell subscribed_sensor_ids = (self._sensor_subscriptions[ molecule.object_id][molecule.cell_id]) return [ self._sensors[sensor_id] for sensor_id in subscribed_sensor_ids ] else: # Just notify all sensors. # Either we do not want to use sensor subscriptions at all, # or the object in which the molecule is does not provide a mesh. return self._sensors def process_molecule_moving_before( self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule', ): """ Called before updating the position of a particle within a time step. Useful for SensorTeleporting, for example, which should consider the position of particles right after they have been spawned. :param simulation_kernel: :param molecule: :return: """ subscribed_sensors = self._get_subscribed_sensors( simulation_kernel=simulation_kernel, molecule=molecule, ) for sensor in subscribed_sensors: sensor.process_molecule_moving_before( simulation_kernel=simulation_kernel, molecule=molecule, ) def process_molecule_moving_after( self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule', ): """ Called after the position of a particle has been updated. Useful for updating regular sensors. :param simulation_kernel: :param molecule: :return: """ subscribed_sensors = self._get_subscribed_sensors( simulation_kernel=simulation_kernel, molecule=molecule, ) for sensor in subscribed_sensors: sensor.process_molecule_moving_after( simulation_kernel=simulation_kernel, molecule=molecule, )
class SensorTeleporting(pg.Sensor): """Teleports molecules from one object to another.""" source_object = prop.ComponentReferenceProperty( '', required=True, can_be_empty=False, ) """Component name of the source object.""" source_outlet_name = prop.StrProperty('', required=True) """Name of the outlet in the source mesh (check your OpenFOAM mesh).""" target_object = prop.ComponentReferenceProperty( '', required=True, can_be_empty=False, ) """Component name of the target object.""" target_inlet_name = prop.StrProperty('', required=True) """Name of the inlet in the target mesh (check your OpenFOAM mesh).""" def __init__(self): super().__init__() self._source_object: Optional['pg.Object'] = None self._target_object: Optional['pg.Object'] = None # Remove mandatory arguments from the Sensor class that will be # set automatically in the BUILD_SCENE initialization stage # (or at least their corresponding results will, i.e., # `transformation` and `geometry`): self._mandatory_arguments = self._mandatory_arguments - { 'translation', 'rotation', 'scale', 'shape' } def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if init_stage == pg.InitStages.BUILD_SCENE: self._source_object = cast( 'pg.Object', simulation_kernel.get_components()[self.source_object]) self._target_object = cast( 'pg.Object', simulation_kernel.get_components()[self.target_object]) if self.source_outlet_name not in self._source_object.outlets: raise ValueError( f"Source Object \"{self.source_object}\" does not have " f"an outlet named \"{self.source_outlet_name}\".") if self.target_inlet_name not in self._target_object.inlets: raise ValueError( f"Target Object \"{self.target_object}\" does not have " f"an inlet named \"{self.target_inlet_name}\".") (self._geometry, self._transformation) = self._source_object.get_outlet_area( self.source_outlet_name) if init_stage == pg.InitStages.CREATE_TELEPORTERS: simulation_kernel.get_scene_manager().add_interconnection( sensor_teleporting=self) def get_source_object(self) -> 'pg.Object': return self._source_object def get_target_object(self) -> 'pg.Object': return self._target_object def process_molecule_moving_before( self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule'): if (molecule.object_id == self._source_object.object_id and self.is_inside_sensor_zone(position_global=molecule.position)): molecule.object_id = self._target_object.object_id molecule.cell_id = (simulation_kernel.get_scene_manager( ).get_closest_cell_centre_id(object_id=molecule.object_id, position_global=molecule.position)) LOG.debug(f"SensorTeleporting: Teleporting {molecule}")
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 SensorEmpiricalRadialTest(pg.Sensor): """ Sensor based on empirical measurements in a magnetic field simulation. The model used in this class doesn't actually fit the data all that well. This class should only be used for testing how much of a difference such a model will make if it also considers susceptibility differences in the radial direction. For this, we shall assume that molecules pass through the sensor in positive (object-local!) z direction and that the sensor (i.e., the boundary) is 25 mm long! So remember to rotate this sensor via the transformation accordingly if your particles aren't moving from -z to +z. """ POPT = [ 5.87697665e-01, 1.53675354e+00, -6.59646889e-01, 8.91311070e-03, 1.63605094e+01, 1.35037185e-03, 7.07910938e+00, 3.87609999e+01, 3.27091249e-01, 2.63243226e+00, 2.18561674e+00, 2.43038985e-03, 6.23107849e-03 ] """Parameters for model_flux determined by curve fitting.""" log_folder = prop.StrProperty("", required=False) """The file `sensor[<component name>].csv` will be created in here.""" def __init__(self): super().__init__() self._relative_susceptibility: float = 0 """ Sum of all particle susceptibilities currently within the sensor. Susceptibility is relative to the maximum susceptibility measured in the variability experiment mentioned above. """ self._csv_file = None self._csv_writer = None def initialize(self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages'): super().initialize(simulation_kernel, init_stage) if 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.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', 'rel_susceptibility'] self._csv_writer = csv.writer(self._csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) self._csv_writer.writerow(fieldnames) def finalize(self, simulation_kernel: 'pg.SimulationKernel'): self._csv_file.close() @staticmethod def model_flux(ax_rad: Tuple[np.ndarray, np.ndarray], a1, a2, a3, a4, a5, b1, b2, b3, b4, b5, b6, c1, c2): """ Polynomial of degree 4 (in radial direction) where every relevant parameter is logistically distributed (in axial direction)… The idea is that in a*x^4, a should be able to change from positive to negative and back to positive with changing axial positions. """ axial, radial = ax_rad return ( # degree 4: (scipy.stats.genlogistic.pdf(axial, 1, loc=0, scale=a1) * a2 + a3) * ((scipy.stats.genlogistic.pdf(axial, 1, loc=0, scale=a4) * a5) * radial)**4 # degree 2: + (scipy.stats.genlogistic.pdf(axial, 1, loc=0, scale=b1) * b2 + b3) * ((scipy.stats.genlogistic.pdf(axial, 1, loc=0, scale=b4) * b5 + b6) * radial)**2 # degree 0: + scipy.stats.genlogistic.pdf(axial, 1, loc=0, scale=c1) * c2) def process_molecule_moving(self, simulation_kernel: 'pg.SimulationKernel', molecule: 'pg.Molecule'): pos_local = self._transformation.apply_inverse_to_point( molecule.position) if not self.is_inside_sensor_zone(position_global=molecule.position): return # Local coordinates are in the interval [0, 1] and should be scaled. axial = pos_local[2] * self._transformation.scaling[2] radial = np.sqrt( np.square(pos_local[0] * self._transformation.scaling[0]) + np.square(pos_local[1] * self._transformation.scaling[1])) LOG.debug(f"{pos_local * self._transformation.scaling}," f" radial pos = {radial:.3f}," f" axial pos = {axial:.3f}") self._relative_susceptibility += self.model_flux( ( # Axial position: axial, # Radial position: radial), # TODO: ax_rad tuple! *SensorEmpiricalRadialTest.POPT) def process_new_time_step_after( self, simulation_kernel: 'pg.SimulationKernel', notification_stage: 'pg.NotificationStages', ): if notification_stage != pg.NotificationStages.LOGGING: return LOG.info( f"Sensor {self.sensor_id}: {self._relative_susceptibility:3e} " "susceptibility") self._csv_writer.writerow([ simulation_kernel.get_simulation_time(), self._relative_susceptibility ]) self._relative_susceptibility = 0
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 SceneManager(pg.Component): component_name = prop.StrProperty("scene_manager", required=False) """Since this component is not created by the config, choose a name""" def __init__(self): super().__init__() self._objects: List['pg.Object'] = [] self._teleporters: Dict[ Tuple[str, str], 'pg.SensorTeleporting' ] = dict() """ Instances of teleporting sensors by a tuple of their source object's component name and the name of the outlet in the source object. """ def initialize( self, simulation_kernel: 'pg.SimulationKernel', init_stage: 'pg.InitStages' ): super().initialize(simulation_kernel, init_stage) def add_object(self, object_to_add: 'pg.Object'): LOG.debug(f"Adding {object_to_add.component_name} with mesh index " f"{object_to_add.get_mesh_index()} as " f"ID {len(self._objects)}") object_to_add.set_arguments( object_id=len(self._objects) ) self._objects.append(object_to_add) def add_interconnection(self, sensor_teleporting: 'pg.SensorTeleporting'): """Called by SensorTeleporting to register itself.""" self._teleporters[( sensor_teleporting.get_source_object().component_name, sensor_teleporting.source_outlet_name )] = sensor_teleporting def get_flow_by_position( self, simulation_kernel: 'pg.SimulationKernel', position_global: np.ndarray, object_id: int, sim_time: float ): flow = self._objects[object_id].get_flow( simulation_kernel, position_global, sim_time ) if True in np.isnan(flow): raise AssertionError( "Flow is NaN for a molecule at position " f"{position_global}." ) return flow def process_changed_outlet_flow_rate( self, simulation_kernel, source_object: 'pg.Object', outlet_name: str, flow_rate: float ): key = source_object.component_name, outlet_name if key not in self._teleporters: # No teleporter exists for the outlet of this source object # to another object. # This is expected behavior when the end of a chain of objects # is reached. return teleporter = self._teleporters[key] teleporter.get_target_object().process_changed_inlet_flow_rate( simulation_kernel=simulation_kernel, inlet_name=teleporter.target_inlet_name, flow_rate=flow_rate ) def get_outlets(self, object_id): return self._objects[object_id].outlets def get_inlets(self, object_id): return self._objects[object_id].inlets def get_all_objects(self): return self._objects def plot_to_csv(self, filename): with open(filename, mode='w') as csv_file: fieldnames = ['name', 'object_id', 'cell_id', 'x', 'y', 'z'] writer = csv.writer( csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL ) writer.writerow(fieldnames) for object_to_write in self._objects: transformed_mesh = object_to_write.get_current_mesh_global() if transformed_mesh is not None: for cell_id, cell_centre in enumerate(transformed_mesh): writer.writerow(( object_to_write.name, object_to_write.object_id, cell_id, cell_centre.x, cell_centre.y, cell_centre.z )) def get_closest_cell_centre_id( self, object_id: int, 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 the Object instance is inactive. See documentation of cKDTree.query(). """ return self._objects[object_id].get_closest_cell_centre_id( position_global=position_global ) @staticmethod def construct_from_config( filename: str, openfoam_cases_path: str, additional_component_classes: Dict[str, Type[Any]] = None, override_config: dict = None, results_dir: str = '.', log_config: bool = False, ) -> ( 'pg.SimulationKernel', Dict[str, Tuple[Optional['pg.Component'], dict]] ): """ Set up the basic scene from a YAML configuration file (possibly created with the MaMoKo Blender Add-on). :param filename: YAML configuration file, not to be confused with the scene *layout* configuration file. Typically `config.yaml`. `scene.yaml` and `config.yaml` are assumed to have matching names in their `objects` entry. Where `scene.yaml` defines the object's shape and transformation, `config.yaml` defines additional configuration parameters for the simulation. :param openfoam_cases_path: Path to OpenFOAM cases (should include a `tube/` subdirectory). `cache/` will be created here. :param additional_component_classes: Dict of additional classes to search in when constructing objects. :param override_config: If given, this dict will be used to update the configuration loaded from `filename`. :param results_dir: :param log_config: Write the final assembled configuration to results_dir. :return: the SimulationKernel, and a dictionary mapping object names to tuples of the corresponding component and its original definition in the configuration. """ if additional_component_classes is None: additional_component_classes = dict() conf = pg.assemble_config_recursively( filename, override_conf=override_config ) # ^ Allows configuration inheritance by specifying # an 'inherit' parameter with one filename or a list of filenames. if log_config: pg.write_config(conf, os.path.join(results_dir, "config.yaml")) simulation_kernel = pg.SimulationKernel() # The kernel gets the top-level configuration arguments: kernel_args = conf.copy() kernel_args.pop('components', None) kernel_args.pop('scene_layout_file', None) if 'mesh_manager' not in kernel_args: kernel_args['mesh_manager'] = dict() # Give the mesh manager the openfoam_cases_path to be used as the # default prefix for its cache path. kernel_args['mesh_manager'][ 'openfoam_cases_path'] = openfoam_cases_path simulation_kernel.set_arguments( results_dir=results_dir, **kernel_args ) components: Dict[str, Tuple[Optional['pg.Component'], dict]] = dict() conf_components = conf.get('components', dict()) # Construct components if possible: available_classes = locals().copy() available_classes.update( dict(inspect.getmembers(pg, inspect.isclass)) ) available_classes.update(additional_component_classes) for name, component_def in conf_components.items(): component_type = component_def.get('type', None) if ( component_type not in available_classes or component_type == 'GENERIC' ): LOG.warning( f"Not constructing object \"{name}\" " f"of type {component_type}.\n" "\tYou can still access its transformation " "and parameters.\n" "\tPlease construct it yourself." ) components[name] = (None, component_def) continue component_class = available_classes[component_type] LOG.info( f"Constructing component \"{name}\" of class " f"\"{component_class}\"." ) component_instance = component_class() kwargs = component_def.copy() # Try to remove all properties that the obj_class constructor is # probably not prepared for: kwargs.pop('type', None) kwargs.pop('visualization_scale', None) component_instance.set_arguments( **kwargs, component_name=name, ) if isinstance(component_instance, pg.Object): component_instance.set_arguments( openfoam_cases_path=openfoam_cases_path ) components[name] = (component_instance, component_def) simulation_kernel.attach_component(component_instance) return ( simulation_kernel, components )