Exemplo n.º 1
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))
Exemplo n.º 2
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)
Exemplo n.º 3
0
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()
Exemplo n.º 4
0
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
Exemplo n.º 5
0
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)
Exemplo n.º 6
0
class ModulationPPM(pg.ModulationOOK):
    """
    Separates the symbol duration into N chips for N-PPM.

    """

    chips_per_symbol = prop.IntProperty(2, required=False)
    """Must be a power of 2 and greater than 1."""
    def __init__(self):
        super().__init__()

    def initialize(self, simulation_kernel: 'pg.SimulationKernel',
                   init_stage: 'pg.InitStages'):
        super().initialize(simulation_kernel, init_stage)
        if init_stage == pg.InitStages.CHECK_ARGUMENTS:
            if (self.chips_per_symbol < 2
                    or not pg.util.is_power_of_two(self.chips_per_symbol)):
                raise ValueError(
                    f"{self.chips_per_symbol=}, but must be a power of "
                    "2 and greater than 1.")

    @property
    def chip_duration(self) -> float:
        try:
            return self.injection_duration + self.pause_duration
        except TypeError:
            # TODO: this failed once b/c pause_duration couldn't be cast to
            #  float -> improve property checking!
            raise TypeError(f"Sum didn't work, {self.injection_duration=}, "
                            f"{self.pause_duration=}, type inj dur "
                            f"{type(self.injection_duration)}, "
                            f"type pause dur {type(self.pause_duration)}")

    @property
    def symbol_duration(self) -> float:
        # The actual symbol duration is chip_duration * chips_per_symbol,
        # but this property is used in Modulation.transmit_bitstream,
        # among other places, expecting the duration of one injection plus
        # pause.
        return self.chip_duration

    def process_new_time_step(
        self,
        simulation_kernel: 'pg.SimulationKernel',
        notification_stage: 'pg.NotificationStages',
    ):
        super().process_new_time_step(simulation_kernel, notification_stage)

    @staticmethod
    def bitstream_to_ppm(bitstream: str, chips_per_symbol: int) -> str:
        """
        Convert bitstream into PPM bitstream,
        e.g., 101101 for 2-PPM: 01|10|01|01|10|01
        or 101101 for 4-PPM: 0010|0001|0100
        or 101101 for 8-PPM: 000001000|00000100
        """
        assert pg.util.is_power_of_two(chips_per_symbol)
        bits_per_symbol = chips_per_symbol.bit_length() - 1  # == log2(chips)
        result = ""
        for bits_tuple in pg.util.grouper(bitstream,
                                          n=bits_per_symbol,
                                          fillvalue='0'):
            symbol_int = int(''.join(bits_tuple), base=2)
            result += (symbol_int * '0' + '1' +
                       (chips_per_symbol - symbol_int - 1) * '0')
        return result

    def transmit_bitstream(self, bitstream: str,
                           simulation_kernel: 'pg.SimulationKernel',
                           finish_callback: Optional[Callable]):
        super().transmit_bitstream(
            bitstream=bitstream,
            simulation_kernel=simulation_kernel,
            finish_callback=finish_callback,
        )
        self._bitstream = self.bitstream_to_ppm(
            bitstream=bitstream,
            chips_per_symbol=self.chips_per_symbol,
        )
        self._bitstream_duration = self.chip_duration * len(self._bitstream)
        LOG.warning(
            f"Transmitting bitstream\n{bitstream}\nconverted to\n"
            f"{self._bitstream}\n"
            f"{self._bitstream_duration=} for {self.chips_per_symbol=} and "
            f"{self.pause_duration=} and {self.chip_duration=}")
Exemplo n.º 7
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)
Exemplo n.º 8
0
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
        )
Exemplo n.º 9
0
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)
        )
Exemplo n.º 10
0
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))
Exemplo n.º 11
0
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