def register_handlers(subclass, handlers): """ Register a `SimulationObject`'s event handler methods. The simulation engine vectors execution of a simulation message to the message's registered event handler method. The priority of message execution in an event containing multiple messages is determined by the sequence of tuples in `handlers`. These relationships are stored in an `ApplicationSimulationObject`'s `metadata.event_handlers_dict`. Each call to `register_handlers` re-initializes all event handler methods. Args: subclass (:obj:`SimulationObject`): a subclass of `SimulationObject` that is registering the relationships between the simulation messages it receives and the methods that handle them handlers (:obj:`list` of (`SimulationMessage`, `function`)): a list of tuples, indicating which method should handle which type of `SimulationMessage` in `subclass`; ordered in decreasing priority for handling simulation message types Raises: :obj:`SimulatorError`: if a `SimulationMessage` appears repeatedly in `handlers`, or if a method in `handlers` is not callable """ for message_type, handler in handlers: if message_type in subclass.metadata.event_handlers_dict: raise SimulatorError( "message type '{}' appears repeatedly".format( most_qual_cls_name(message_type))) if not callable(handler): raise SimulatorError( "handler '{}' must be callable".format(handler)) subclass.metadata.event_handlers_dict[message_type] = handler for index, (message_type, _) in enumerate(handlers): subclass.metadata.event_handler_priorities[message_type] = index
def send_event(self, delay, receiving_object, message, copy=False): """ Send a simulation event message, specifing the event time as a delay. Args: delay (:obj:`float`): the simulation delay at which `receiving_object` should execute the event receiving_object (:obj:`SimulationObject`): the simulation object that will receive and execute the event message (:obj:`SimulationMessage`): the simulation message which will be carried by the event copy (:obj:`bool`, optional): if `True`, copy the message before adding it to the event; set `False` by default to optimize performance; set `True` as a safety measure to avoid unexpected changes to shared objects Raises: :obj:`SimulatorError`: if `delay` < 0 or `delay` is NaN, or if the sending object type is not registered to send messages with the type of `message`, or if the receiving simulation object type is not registered to receive messages with the type of `message` """ if math.isnan(delay): raise SimulatorError("delay is 'NaN'") if delay < 0: raise SimulatorError("delay < 0 in send_event(): {}".format( str(delay))) self.send_event_absolute(delay + self.time, receiving_object, message, copy=copy)
def _get_sim_config(time_max=None, sim_config=None, config_dict=None): """ External simulate interface Legal combinations of the three parameters: 1. Just `time_max` 2. Just `sim_config`, which will contain an entry for `time_max` 3. Just `config_dict`, which must contain an entry for `time_max` Other combinations are illegal. Args: time_max (:obj:`float`, optional): the time of the end of the simulation sim_config (:obj:`SimulationConfig`, optional): the simulation run's configuration config_dict (:obj:`dict`, optional): a dictionary with keys chosen from the field names in :obj:`SimulationConfig`; note that `config_dict` is not a `kwargs` argument Returns: :obj:`SimulationConfig`: a validated simulation configuration Raises: :obj:`SimulatorError`: if no arguments are provided, or multiple arguments are provided, or `time_max` is missing from `config_dict` """ num_args = 0 if time_max is not None: num_args += 1 if sim_config is not None: num_args += 1 if config_dict: num_args += 1 if num_args == 0: raise SimulatorError('time_max, sim_config, or config_dict must be provided') if 1 < num_args: raise SimulatorError('at most 1 of time_max, sim_config, or config_dict may be provided') # catch common error generated when sim_config= is not used by SimulationEngine.simulate(sim_config) if isinstance(time_max, SimulationConfig): raise SimulatorError(f"sim_config is not provided, sim_config= is probably needed") # initialize sim_config if it is not provided if sim_config is None: if time_max is not None: sim_config = SimulationConfig(time_max) else: # config_dict must be initialized if 'time_max' not in config_dict: raise SimulatorError('time_max must be provided in config_dict') sim_config = SimulationConfig(**config_dict) sim_config.validate() return sim_config
def schedule_event(self, send_time, receive_time, sending_object, receiving_object, message): """ Create an event and insert in this event queue, scheduled to execute at `receive_time` Simulation object `X` can sends an event to simulation object `Y` by invoking `X.send_event(receive_delay, Y, message)`. Args: send_time (:obj:`float`): the simulation time at which the event was generated (sent) receive_time (:obj:`float`): the simulation time at which the `receiving_object` will execute the event sending_object (:obj:`SimulationObject`): the object sending the event receiving_object (:obj:`SimulationObject`): the object that will receive the event; when the simulation is parallelized `sending_object` and `receiving_object` will need to be global identifiers. message (:obj:`SimulationMessage`): a `SimulationMessage` carried by the event; its type provides the simulation application's type for an `Event`; it may also carry a payload for the `Event` in its attributes. Raises: :obj:`SimulatorError`: if `receive_time` < `send_time`, or `receive_time` or `send_time` is NaN """ if math.isnan(send_time) or math.isnan(receive_time): raise SimulatorError( "send_time ({}) and/or receive_time ({}) is NaN".format( receive_time, send_time)) # Ensure that send_time <= receive_time. # Events with send_time == receive_time can cause loops, but the application programmer # is responsible for avoiding them. if receive_time < send_time: raise SimulatorError( "receive_time < send_time in schedule_event(): {} < {}".format( receive_time, send_time)) if not isinstance(message, SimulationMessage): raise SimulatorError( "message should be an instance of {} but is a '{}'".format( SimulationMessage.__name__, type(message).__name__)) event = Event(send_time, receive_time, sending_object, receiving_object, message) # As per David Jefferson's thinking, the event queue is ordered by data provided by the # simulation application, in particular the tuple (event time, receiving object name). # See the comparison operators for Event. This achieves deterministic and reproducible # simulations. heapq.heappush(self.event_heap, event)
def _SimulationEngine__handle_event_list(self, event_list): """ Handle a list of simulation events, which may contain multiple concurrent events This method's special name ensures that it cannot be overridden, and can only be called from `SimulationEngine`. Attributes: event_list (:obj:`list` of :obj:`Event`): the `Event` message(s) in the simulation event Raises: :obj:`SimulatorError`: if a message in `event_list` has an invalid type """ self.num_events += 1 if self.LOG_EVENTS: # write events to a plot log # plot logging is controlled by configuration files pointed to by config_constants and by env vars for event in event_list: self.fast_plot_file_logger.fast_log(str(event), sim_time=self.time) # iterate through event_list, branching to handler for event in event_list: try: handler = self.__class__.metadata.event_handlers_dict[ event.message.__class__] handler(self, event) except KeyError: # pragma: no cover # unreachable because of check that receiving sim # obj type is registered to receive the message type raise SimulatorError( "No handler registered for Simulation message type: '{}'". format(event.message.__class__.__name__))
def __init__(self, name, period, start_time=0.): if period <= 0: raise SimulatorError( "period must be positive, but is {}".format(period)) self.period = period self.start_time = start_time self.num_periods = 0 super().__init__(name, start_time=start_time)
def get_file_name(self, time): """ Get file name for checkpoint at time `time` Args: time (:obj:`float`): time Returns: :obj:`str`: file name for checkpoint at time `time` """ filename_time = f'{time:.{MAX_TIME_PRECISION}f}' if not math.isclose(float(filename_time), time): raise SimulatorError(f"filename time {filename_time} is not close to time {time}") return os.path.join(self.dir_path, f'{filename_time}.pickle')
def validate(self): """ Validate a `SimulationConfig` instance Validation tests that involve multiple fields must be made in this method. Call it after the `SimulationConfig` instance is in a consistent state. Returns: :obj:`None`: if no error is found Raises: :obj:`SimulatorError`: if `self` fails validation """ self.validate_individual_fields() # other validation if self.time_max <= self.time_init: raise SimulatorError(f'time_max ({self.time_max}) must be greater than time_init ({self.time_init})') if self.profile and 0 < self.object_memory_change_interval: raise SimulatorError('profile and object_memory_change_interval cannot both be active, ' 'as the combination slows DE Sim dramatically')
def get_object(self, simulation_object_name): """ Get a simulation object instance Args: simulation_object_name (:obj:`str`): get a simulation object instance that is part of this simulation Raises: :obj:`SimulatorError`: if the simulation object is not part of this simulation """ if simulation_object_name not in self.simulation_objects: raise SimulatorError("cannot get simulation object '{}'".format(simulation_object_name)) return self.simulation_objects[simulation_object_name]
def initialize(self): """ Initialize a simulation Call `send_initial_events()` in each simulation object that has been loaded. Raises: :obj:`SimulatorError`: if the simulation has already been initialized """ if self.__initialized: raise SimulatorError('Simulation has already been initialized') for sim_obj in self.simulation_objects.values(): sim_obj.send_initial_events() self.event_counts.clear() self.__initialized = True
def validate_individual_fields(self): """ Validate constraints other than types in individual fields in a `SimulationConfig` instance Returns: :obj:`None`: if no error is found Raises: :obj:`SimulatorError`: if an attribute of `self` fails validation """ # make sure stop_condition is callable if self.stop_condition is not None and not callable(self.stop_condition): raise SimulatorError(f"stop_condition ('{self.stop_condition}') must be a function") # validate output_dir and convert to absolute path if self.output_dir is not None: absolute_output_dir = os.path.abspath(os.path.expanduser(self.output_dir)) if os.path.exists(absolute_output_dir): # raise error if absolute_output_dir exists and is not a dir if not os.path.isdir(absolute_output_dir): raise SimulatorError(f"output_dir '{absolute_output_dir}' must be a directory") # raise error if absolute_output_dir is not empty if os.listdir(absolute_output_dir): raise SimulatorError(f"output_dir '{absolute_output_dir}' is not empty") # if absolute_output_dir does not exist, make it if not os.path.exists(absolute_output_dir): os.makedirs(absolute_output_dir) self.output_dir = absolute_output_dir # make sure object_memory_change_interval is non-negative if self.object_memory_change_interval < 0: raise SimulatorError(f"object_memory_change_interval ('{self.object_memory_change_interval}') " "must be non-negative")
def add_object(self, simulation_object): """ Add a simulation object instance to this simulation Args: simulation_object (:obj:`SimulationObject`): a simulation object instance that will be used by this simulation Raises: :obj:`SimulatorError`: if the simulation object's name is already in use """ name = simulation_object.name if name in self.simulation_objects: raise SimulatorError("cannot add simulation object '{}', name already in use".format(name)) simulation_object.add(self) self.simulation_objects[name] = simulation_object
def assign_decreasing_priority(cls, aso_classes): """ Assign decreasing simultaneous execution priorities for a list of simulation object classes Args: aso_classes (:obj:`iterator` of :obj:`ApplicationSimulationObject`): an iterator over simulation object classes Raises: :obj:`SimulatorError`: if too many :obj:`ApplicationSimulationObject`\ s are given """ if cls.LOW < len(aso_classes): raise SimulatorError( f"Too many ApplicationSimulationObjects: {len(aso_classes)}") for index, aso_class in enumerate(aso_classes): aso_class.set_class_priority(SimObjClassPriority(index + 1))
def add(self, simulator): """ Add this object to a simulation. Args: simulator (:obj:`SimulationEngine`): the simulator that will use this `SimulationObject` Raises: :obj:`SimulatorError`: if this `SimulationObject` is already registered with a simulator """ if self.simulator is None: # TODO(Arthur): reference to the simulator is problematic because it means simulator can't be GC'ed self.simulator = simulator return raise SimulatorError( "SimulationObject '{}' is already part of a simulator".format( self.name))
def delete_object(self, simulation_object): """ Delete a simulation object instance from this simulation Args: simulation_object (:obj:`SimulationObject`): a simulation object instance that is part of this simulation Raises: :obj:`SimulatorError`: if the simulation object is not part of this simulation """ # TODO(Arthur): is this an operation that makes sense to support? if not, remove it; if yes, # remove all of this object's state from simulator, and test it properly name = simulation_object.name if name not in self.simulation_objects: raise SimulatorError("cannot delete simulation object '{}', has not been added".format(name)) simulation_object.delete() del self.simulation_objects[name]
def list_checkpoints(self, error_if_empty=True): """ Get sorted list of times of saved checkpoints in checkpoint directory `self.dir_path` To enhance performance the list of times is cached in attribute `all_checkpoints` and reloaded if the directory is updated. Args: error_if_empty (:obj:`bool`, optional): if set, report an error if no checkpoints found Returns: :obj:`list`: sorted list of times of saved checkpoints Raises: :obj:`SimulatorError`: if `dirname` doesn't contain any checkpoints """ # reload all_checkpoints if they have not been obtained # or self.dir_path has been modified since all_checkpoints was last obtained if self.all_checkpoints is None or self.last_dir_mod < os.stat(self.dir_path).st_mtime_ns: self.last_dir_mod = os.stat(self.dir_path).st_mtime_ns # find checkpoint times checkpoint_times = [] pattern = r'^(\d+\.\d{' + f'{MAX_TIME_PRECISION},{MAX_TIME_PRECISION}' + r'})\.pickle$' for file_name in os.listdir(self.dir_path): match = re.match(pattern, file_name) if os.path.isfile(os.path.join(self.dir_path, file_name)) and match: checkpoint_times.append(float(match.group(1))) # sort by time checkpoint_times.sort() self.all_checkpoints = checkpoint_times # error if no checkpoints found if error_if_empty and not self.all_checkpoints: raise SimulatorError("no checkpoints found in '{}'".format(self.dir_path)) # return list of checkpoint times return self.all_checkpoints
def make_error(self): raise SimulatorError(self.test_msg)
def __setattr__(self, name, value): """ Validate an attribute when it is changed """ try: super().__setattr__(name, value) except TypeError as e: raise SimulatorError(e)
def __new__(cls, clsname, superclasses, namespace): """ Args: cls (:obj:`class`): this class clsname (:obj:`str`): name of the :class:`SimulationObject` subclass being created superclasses (:obj:`tuple`): tuple of superclasses namespace (:obj:`dict`): namespace of subclass of `ApplicationSimulationObject` being created Returns: :obj:`SimulationObject`: a new instance of a subclass of `SimulationObject` Raises: :obj:`SimulatorError`: if class priority is not an `int`, or if the :obj:`ApplicationSimulationObject` doesn't define `messages_sent` or `event_handlers`, or if handlers in `event_handlers` don't refer to methods in the :obj:`ApplicationSimulationObject`, or if `event_handlers` isn't an iterator over pairs, or if a message type sent isn't a subclass of SimulationMessage, or if `messages_sent` isn't an iterator over pairs. """ # Short circuit when ApplicationSimulationObject is defined if clsname == 'ApplicationSimulationObject': return super().__new__(cls, clsname, superclasses, namespace) EVENT_HANDLERS = cls.EVENT_HANDLERS MESSAGES_SENT = cls.MESSAGES_SENT CLASS_PRIORITY = cls.CLASS_PRIORITY new_application_simulation_obj_subclass = super().__new__( cls, clsname, superclasses, namespace) new_application_simulation_obj_subclass.metadata = ApplicationSimulationObjectMetadata( ) # use 'abstract' to indicate that an ApplicationSimulationObject should not be instantiated if 'abstract' in namespace and namespace['abstract'] is True: return new_application_simulation_obj_subclass # approach: # look for EVENT_HANDLERS & MESSAGES_SENT attributes: # use declaration in namespace, if found # use first definition in metadata of a superclass, if found # if not found, issue warning and return or raise exception # # found: # if EVENT_HANDLERS found, check types, and use register_handlers() to set # if MESSAGES_SENT found, check types, and use register_sent_messages() to set event_handlers = None if EVENT_HANDLERS in namespace: event_handlers = namespace[EVENT_HANDLERS] messages_sent = None if MESSAGES_SENT in namespace: messages_sent = namespace[MESSAGES_SENT] class_priority = None if CLASS_PRIORITY in namespace: class_priority = namespace[CLASS_PRIORITY] if not isinstance(class_priority, int): raise SimulatorError( f"ApplicationSimulationObject '{clsname}' {CLASS_PRIORITY} must be " f"an int, but '{class_priority}' is a {type(class_priority).__name__}" ) for superclass in superclasses: if event_handlers is None: if hasattr(superclass, 'metadata') and hasattr( superclass.metadata, 'event_handlers_dict'): # convert dict in superclass to list of tuple pairs event_handlers = [(k, v) for k, v in getattr( superclass.metadata, 'event_handlers_dict').items()] for superclass in superclasses: if messages_sent is None: if hasattr(superclass, 'metadata') and hasattr( superclass.metadata, 'message_types_sent'): messages_sent = getattr(superclass.metadata, 'message_types_sent') for superclass in superclasses: if class_priority is None: if hasattr(superclass, 'metadata') and hasattr( superclass.metadata, CLASS_PRIORITY): class_priority = getattr(superclass.metadata, CLASS_PRIORITY) if class_priority is not None: setattr(new_application_simulation_obj_subclass.metadata, CLASS_PRIORITY, class_priority) # either messages_sent or event_handlers must contain values if (not event_handlers and not messages_sent): raise SimulatorError( "ApplicationSimulationObject '{}' definition must inherit or provide a " "non-empty '{}' or '{}'.".format(clsname, EVENT_HANDLERS, MESSAGES_SENT)) elif not event_handlers: warnings.warn( "ApplicationSimulationObject '{}' definition does not inherit or provide a " "non-empty '{}'.".format(clsname, EVENT_HANDLERS)) elif not messages_sent: warnings.warn( "ApplicationSimulationObject '{}' definition does not inherit or provide a " "non-empty '{}'.".format(clsname, MESSAGES_SENT)) if event_handlers: try: resolved_handers = [] errors = [] for msg_type, handler in event_handlers: # handler may be the string name of a method if isinstance(handler, str): try: handler = namespace[handler] except Exception: errors.append( "ApplicationSimulationObject '{}' definition must define " "'{}'.".format(clsname, handler)) if not isinstance(handler, str) and not callable(handler): errors.append( "handler '{}' must be callable".format(handler)) if not issubclass(msg_type, SimulationMessage): errors.append( "'{}' must be a subclass of SimulationMessage". format(msg_type.__name__)) resolved_handers.append((msg_type, handler)) if errors: raise SimulatorError("\n".join(errors)) new_application_simulation_obj_subclass.register_handlers( new_application_simulation_obj_subclass, resolved_handers) except (TypeError, ValueError): raise SimulatorError( "ApplicationSimulationObject '{}': '{}' must iterate over pairs" .format(clsname, EVENT_HANDLERS)) if messages_sent: try: errors = [] for msg_type in messages_sent: if not issubclass(msg_type, SimulationMessage): errors.append( "'{}' in '{}' must be a subclass of SimulationMessage" .format(msg_type.__name__, MESSAGES_SENT)) if errors: raise SimulatorError("\n".join(errors)) new_application_simulation_obj_subclass.register_sent_messages( new_application_simulation_obj_subclass, messages_sent) except (TypeError, ValueError): raise SimulatorError( "ApplicationSimulationObject '{}': '{}' must iterate over " "SimulationMessages".format(clsname, MESSAGES_SENT)) # return the class to instantiate it return new_application_simulation_obj_subclass
def _simulate(self): """ Run the simulation Returns: :obj:`int`: the number of times a simulation object executes `_handle_event()`. This may be smaller than the number of events sent, because simultaneous events at one simulation object are handled together. Raises: :obj:`SimulatorError`: if the simulation has not been initialized, or has no objects, or has no initial events, or attempts to start before the start time in `time_init`, or attempts to execute an event that violates non-decreasing time order """ if not self.__initialized: raise SimulatorError("Simulation has not been initialized") if not len(self.get_objects()): raise SimulatorError("Simulation has no objects") if self.event_queue.empty(): raise SimulatorError("Simulation has no initial events") _object_mem_tracking = False if 0 < self.sim_config.object_memory_change_interval: _object_mem_tracking = True # don't import tracker unless it's being used from pympler import tracker self.mem_tracker = tracker.SummaryTracker() # set simulation time to `time_init` self.time = self.sim_config.time_init # error if first event occurs before time_init next_time = self.event_queue.next_event_time() if next_time < self.sim_config.time_init: raise SimulatorError(f"Time of first event ({next_time}) is earlier than the start time " f"({self.sim_config.time_init})") # set up progress bar self.progress = SimulationProgressBar(self.sim_config.progress) # write header to a plot log # plot logging is controlled by configuration files pointed to by config_constants and by env vars self.fast_plotting_logger.fast_log('# {:%Y-%m-%d %H:%M:%S}'.format(datetime.now()), sim_time=0) self.num_events_handled = 0 self.log_with_time(f"Simulation to {self.sim_config.time_max} starting") try: self.progress.start(self.sim_config.time_max) self.init_metadata_collection(self.sim_config) while True: # use the stop condition if self.sim_config.stop_condition is not None and self.sim_config.stop_condition(self.time): self.log_with_time(self.TERMINATE_WITH_STOP_CONDITION_SATISFIED) self.progress.end() break # if tracking object use, record object and memory use changes if _object_mem_tracking: self.track_obj_mem() # get the earliest next event in the simulation # get parameters of next event from self.event_queue next_time = self.event_queue.next_event_time() next_sim_obj = self.event_queue.next_event_obj() if float('inf') == next_time: self.log_with_time(self.NO_EVENTS_REMAIN) self.progress.end() break if self.sim_config.time_max < next_time: self.log_with_time(self.END_TIME_EXCEEDED) self.progress.end() break self.time = next_time # error will only be raised if an object decreases its time if next_time < next_sim_obj.time: raise SimulatorError("Dispatching '{}', but event time ({}) " "< object time ({})".format(next_sim_obj.name, next_time, next_sim_obj.time)) # dispatch object that's ready to execute next event next_sim_obj.time = next_time self.log_with_time(" Running '{}' at {}".format(next_sim_obj.name, next_sim_obj.time)) next_events = self.event_queue.next_events() for e in next_events: e_name = ' - '.join([next_sim_obj.__class__.__name__, next_sim_obj.name, e.message.__class__.__name__]) self.event_counts[e_name] += 1 next_sim_obj.__handle_event_list(next_events) self.num_events_handled += 1 self.progress.progress(next_time) except SimulatorError as e: raise SimulatorError('Simulation ended with error:\n' + str(e)) self.finish_metadata_collection() return self.num_events_handled
def send_event_absolute(self, event_time, receiving_object, message, copy=False): """ Send a simulation event message with an absolute event time. Args: event_time (:obj:`float`): the absolute simulation time at which `receiving_object` will execute the event receiving_object (:obj:`SimulationObject`): the simulation object that will receive and execute the event message (:obj:`SimulationMessage`): the simulation message which will be carried by the event copy (:obj:`bool`, optional): if `True`, copy the message before adding it to the event; set `False` by default to optimize performance; set `True` as a safety measure to avoid unexpected changes to shared objects Raises: :obj:`SimulatorError`: if `event_time` < 0, or if the sending object type is not registered to send messages with the type of `message`, or if the receiving simulation object type is not registered to receive messages with the type of `message` """ if math.isnan(event_time): raise SimulatorError("event_time is 'NaN'") if event_time < self.time: raise SimulatorError( "event_time ({}) < current time ({}) in send_event_absolute()". format(round_direct(event_time, precision=3), round_direct(self.time, precision=3))) # Do not put a class reference in a message, as the message might not be received in the # same address space. # To eliminate the risk of name collisions use the fully qualified classname. # TODO(Arthur): wait until after MVP # event_type_name = most_qual_cls_name(message) event_type_name = message.__class__.__name__ # check that the sending object type is registered to send the message type if not isinstance(message, SimulationMessage): raise SimulatorError( "simulation messages must be instances of type 'SimulationMessage'; " "'{}' is not".format(event_type_name)) if message.__class__ not in self.__class__.metadata.message_types_sent: raise SimulatorError( "'{}' simulation objects not registered to send '{}' messages". format(most_qual_cls_name(self), event_type_name)) # check that the receiving simulation object type is registered to receive the message type receiver_priorities = receiving_object.get_receiving_priorities_dict() if message.__class__ not in receiver_priorities: raise SimulatorError( "'{}' simulation objects not registered to receive '{}' messages" .format(most_qual_cls_name(receiving_object), event_type_name)) if copy: message = deepcopy(message) self.simulator.event_queue.schedule_event(self.time, event_time, self, receiving_object, message) self.log_with_time("Send: ({}, {:6.2f}) -> ({}, {:6.2f}): {}".format( self.name, self.time, receiving_object.name, event_time, message.__class__.__name__))