예제 #1
0
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
예제 #2
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))
예제 #3
0
파일: injector.py 프로젝트: tkn-tub/pogona
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)
예제 #4
0
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
예제 #5
0
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
예제 #6
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)
예제 #7
0
파일: component.py 프로젝트: tkn-tub/pogona
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
예제 #8
0
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,
        )
예제 #9
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()
예제 #10
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
        )
예제 #11
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)
        )
예제 #12
0
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
예제 #13
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))
예제 #14
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
예제 #15
0
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,
            )
예제 #16
0
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}")
예제 #17
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
예제 #18
0
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
예제 #19
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)
예제 #20
0
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
        )