diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f080a3a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Build and test +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + merge_group: +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install --upgrade importlib-metadata + pip install pytest + - name: install xDEVS + run: | + python -m pip install . + - name: Test with pytest + run: | + pytest diff --git a/.gitignore b/.gitignore index 8ff9d15..686ef89 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,11 @@ dmypy.json #MacOS stuff .DS_Store + +# Log File +*.log +*.csv + +# visual studio code +.vscode/ + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..913d23f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] + +### Added + +- Add real-time simulation capabilities, including input and output handlers +- CI/CD pipeline for automated testing + +### Changed + +- Transitioned from setup.py to pyproject.toml for package configuration +- Updated dependencies to latest versions +- deltcon now does not accept any input arguments +- All abstract classes are now defined in the `abc` module +- All plugin factories are now defined in the `factory` module +- Minimum Python version is now 3.9 + +### Removed + +- Faulty parallel simulator diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/README.md b/README.md index 46cbf14..2bc4a04 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,145 @@ -# xdevs.py +# `xdevs.py` + Version of the xDEVS simulator for Python projects + +The aim of this repository is to be able to simulate DEVS systems in both virtual and real-time environments using python. +However, additional features are being developed. + +## Sections +1. [Quick Start](#quick-start) +2. [What to Expect](#what-to-expect) +3. [DEVS in a Nutshell](#devs-in-a-nutshell) +4. [Deepening the repository](#deepening-the-repository) + + +## Quick Start + +1. Clone the repository: +````text +git clone https://github.com/iscar-ucm/xdevs.py.git +```` +2. Navigate to the project directory +```text +cd xdevs +``` +3. Install the package +```text +pip install . +``` + +**Now you're ready to start using xDEVS.py for your DEVS simulations!** + +In case additional dependencies (`sql`, `elasticsearch` or `mqtt`) are required, they can be installed. +To add MQTT support with paho-mqtt: +```text +pip install .[mqtt] +``` + + +## What to Expect + +This section summarizes what you may find in the repository folder structure. + +* ### Folder abc: + * The abstract classes folder contains the handler and transducer files. These folders contain the classes that define the general behavior and structure that each I/O handler or Transducer must follow. + +* ### Folder celldevs: + * Contains the implementation of CellDEVS. + +* ### Folder examples + + * Inside this folder, you will find a collection of examples to try and execute in both virtual and wall-clock simulations. Each sub-folder represents an independent case study: + - CellDevs + - Devstone + - Gpt + - Json + - Store + + +* ### Folder plugins + + * This folder encapsulates a collection of folders. Each subfolder stores the implementations of each of the abstract classes. For example, in the `input_handlers` subfolder, you will find several implementations for the Input Handler. + +* ### Folder tests + + * This folder is dedicated to storing the tests for GitHub Actions. + +* ### Factory.py + + * This script is in charge of creating the different types of implementations of an abstract class based on a key that is linked to the desired implementation. + +* ### Models.py + + * It has the basic `DEVS` models such as `Atomic`, `Coupled` `Component`, or `Port`. + +* ### Rt.py + + * It has the adaptation of the `sim.py` components to the real-time simulation methodology developed. + +* ### Sim.py + + * It has the `DEVS` components to carry out a simulation based on the abstract simulator mechanism. + +## DEVS in a Nutshell + +Discrete Event System Specification (DEVS) is a mathematical formalism with modular and hierarchical characteristics. DEVS is based on discrete event simulation where events occur chronologically in discrete instants of time and result in a change of the system. + +DEVS is mainly based on atomic and coupled models. + +### Atomic Model + +An atomic model is the smallest representation of a system. It may remain in a state (S) for a certain time (ta); once the time has passed, it executes the internal transition function (`deltint`). This function will define what to do next for each state. However, if during that time something external occurs, the model reacts with its external transition function (`deltext`) that describes what to do in this case. If the external event occurs at the same time the `ta` has elapsed, the confluent transition function (`deltcon`) defines what to do next. Finally, the output function (`lambdaf`) defines for each state what should be done when transitioning between states. It is only executed after the internal transition function. + +### Coupled Model + +A coupled model defines the connections among atomic models and other coupled models. + +### Simulation Mechanism + +To simulate a system composed of atomic and coupled models, the abstract simulator mechanism is used. This methodology defines the simulators and coordinators. A simulator is attached to an atomic model, while the coordinator is attached to a coupled model. The coordinator will be in charge of carrying out the simulation. + +Refer to the xDEVS user’s manual for further and deeper understanding [here](https://iscar-ucm.github.io/xdevs/). + + +## xDEVS.py Wall-clock Simulation + +A real-time simulation intends to match the virtual time into a wall-clock time. The methodology followed in this repository to achieve the real-time behaviour is based on the arrival of external events. The system will remain waiting for external events between states, when an external event occurs the system will react according to its particular behaviour. + +In this repository, a `RealTimeManager` and a `RealTimeCoordinator` must be combined to achieve a wall-clock simulation. In addition, if the system requires the handling of input and output events, the `input_handler` and `output_handler` will be used. + +### System overview + +The next picture shows the system overview and how the different components interact with each other + +![System Overview](xdevs/images/sysoverview_small.png +) +1. The `input_handler` acts are the interface for any incoming event to the system sending it to the `RealTimeManager`. +2. The `RealTimeCoordinator` send the events collected from the `RealTimeManager` to the `DEVS` model. +3. The `DEVS` model may eject events out of the system, so they are routed to the `RealTimeManager`. +4. Finally, those outgoing events are forwarded to the `output_handler` which act as an interface to send the events. + +In order to execute real-time simulations examples go to `xdevs.examples.gpt.README` + + +## Deepening the repository + +In order to deepen the repository and understand the different functionalities, the following sections should be checked: + + +* What is a Factory? go to `xdevs.abc.README` + +* xDEVS.py simulations? go to `xdevs.examples.gpt.README` + +* JSON to xDEVS.py simulation? go to `xdevs.examples.json.README` + +* TCP examples? go to `xdevs.examples.gps.README` or `xdevs.examples.store.README` + +* MQTT examples? go to `xdevs.examples.store.README` + + + +___ + +Feel free to contribute to the project by opening issues or submitting pull requests. + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ecf1d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "xdevs" +version = "3.0.0" +requires-python = ">=3.9" +authors = [ + {name = "Román Cárdenas"}, + {name = "Óscar Fernández Sebastián"}, + {name = "Kevin Henares"}, + {name = "José L. Risco-Martín"}, +] +maintainers = [ + {name = "Román Cárdenas", email = "r.cardenas@upm.es"}, +] +description = "xDEVS M&S framework" +readme = "README.md" +license = {file = "LICENSE.txt"} +keywords = ["DEVS", "modeling", "simulation"] + +[project.optional-dependencies] +sql = ["sqlalchemy"] +elasticsearch = ["elasticsearch"] +mqtt = ["paho-mqtt"] + +[project.urls] +Homepage = "https://github.com/iscar-ucm/xdevs" +Documentation = "https://github.com/iscar-ucm/xdevs" +Repository = "https://github.com/iscar-ucm/xdevs.py.git" +"Bug Tracker" = "https://github.com/iscar-ucm/xdevs.py/issues" +Changelog = "https://github.com/iscar-ucm/xdevs.py/blob/main/CHANGELOG.md" + +[project.entry-points."xdevs.transducers"] +csv = "xdevs.plugins.transducers.csv:CSVTransducer" +sql = "xdevs.plugins.transducers.sql:SQLTransducer" +elasticsearch = "xdevs.plugins.transducers.elasticsearch:ElasticsearchTransducer" + +[project.entry-points."xdevs.input_handlers"] +function = "xdevs.plugins.input_handlers.function:CallableFunction" +csv = "xdevs.plugins.input_handlers.csv:CSVInputHandler" +tcp = "xdevs.plugins.input_handlers.tcp:TCPInputHandler" +mqtt = "xdevs.plugins.input_handlers.mqtt:MQTTInputHandler" + +[project.entry-points."xdevs.output_handlers"] +csv = "xdevs.plugins.output_handlers.csv:CSVOutputHandler" +tcp = "xdevs.plugins.output_handlers.tcp:TCPOutputHandler" +mqtt = "xdevs.plugins.output_handlers.mqtt:MQTTOutputHandler" + +[project.entry-points."xdevs.components"] +generator = "xdevs.examples.gpt.models:Generator" +transducer = "xdevs.examples.gpt.models:Transducer" +processor = "xdevs.examples.gpt.models:Processor" +gpt = "xdevs.examples.gpt.models:Gpt" +ef = "xdevs.examples.gpt.models:Ef" +efp = "xdevs.examples.gpt.models:Efp" + +[project.entry-points."xdevs.wrappers"] +pypdevs = "xdevs.plugins.wrappers.pypdevs:PyPDEVSWrapper" + +[project.entry-points."xdevs.celldevs_outputs"] +hybrid = "xdevs.plugins.celldevs_outputs.hybrid:HybridDelayedOutput" +inertial = "xdevs.plugins.celldevs_outputs.inertial:InertialDelayedOutput" +transport = "xdevs.plugins.celldevs_outputs.transport:TransportDelayedOutput" + +[tool.setuptools] +include-package-data = false + +[tool.setuptools.packages.find] +include = ["xdevs*"] +exclude = ["xdevs.tests*"] diff --git a/setup.py b/setup.py deleted file mode 100644 index e83e07a..0000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -from setuptools import setup, find_packages - -setup(name='xdevs', - version='2.2.2', - description='xDEVS M&S framework', - url='https://github.com/iscar-ucm/xdevs.py', - author='Román Cárdenas, Kevin Henares', - author_email='r.cardenas@upm.es, khenares@ucm.es', - packages=find_packages(exclude=['xdevs.tests']), - entry_points={ - 'xdevs.plugins.transducers': [ - 'csv = xdevs.plugins.transducers.csv_transducer:CSVTransducer', - 'sql = xdevs.plugins.transducers.sql_transducer:SQLTransducer', - 'elasticsearch = xdevs.plugins.transducers.elasticsearch_transducer:ElasticsearchTransducer', - ], - 'xdevs.plugins.wrappers': [ - 'pypdevs = xdevs.plugins.wrappers.pypdevs:PyPDEVSWrapper' - ]}, - extras_require={ - 'sql': ['sqlalchemy==1.3.22'], - 'elasticsearch': ['elasticsearch==7.10.1'], - }, - zip_safe=False) diff --git a/xdevs/__init__.py b/xdevs/__init__.py index 77136ad..04cb2d7 100644 --- a/xdevs/__init__.py +++ b/xdevs/__init__.py @@ -1,18 +1,24 @@ -import math +from __future__ import annotations +import functools import logging +import math import sys +from typing import TypeVar +import warnings + +T = TypeVar('T') -INFINITY = math.inf -PHASE_PASSIVE = "passive" -PHASE_ACTIVE = "active" +INFINITY: float = math.inf +PHASE_PASSIVE: str = "passive" +PHASE_ACTIVE: str = "active" -DEBUG_LEVEL = None -loggers = dict() +DEBUG_LEVEL: int | str | None = None +LOGGERS: dict[str, logging.Logger] = dict() -def get_logger(name, dl=None): - if name in loggers: - return loggers[name] +def get_logger(name: str, dl: int | str = None): + if name in LOGGERS: + return LOGGERS[name] else: logger = logging.getLogger(name) @@ -24,5 +30,13 @@ def get_logger(name, dl=None): else: logger.disabled = True - loggers[name] = logger + LOGGERS[name] = logger return logger + + +def deprecated(func): + @functools.wraps(func) + def new_func(*args, **kwargs): + warnings.warn(f"Call to deprecated function {func.__name__}.", category=DeprecationWarning, stacklevel=2) + return func(*args, **kwargs) + return new_func diff --git a/xdevs/abc/README.md b/xdevs/abc/README.md new file mode 100644 index 0000000..42cd9cc --- /dev/null +++ b/xdevs/abc/README.md @@ -0,0 +1,24 @@ +# What is a Factory + +A factory is a software methodology for designing and implementing objects that have a common behavior but several possible implementations. This type of methodology is widely used in this repository for creating different components in the simulations such as transducers, handlers, models, etc. + +## Example 1 +_(Focused on handlers, transducer and celldevs)_ + +1. Firstly, a father abstract class is defined, which states the common behavior of the component under developing. (The class `InputHandler` may be found in `xdevs.abc.handler`) +2. A child class that inherits the behavior of its father defines the particular implementation. (The folder `xdevs.plugins.input_handlers.` stores several implementations for this class) +3. An entry point is defined. This name will play the role of a key that links the name to the desired implementation. (In the script `xdevs.pyproject`, the keys for Input Handlers are found after the `[project.entry-points."xdevs.input_handlers"]` statement) +4. Creating an instance of each implementation is as easy as passing to a method of the factory class the desired key and the required parameters (if any) for that specific implementation (Using the class `InputHandlers` in `xdevs.factory` and calling the method `create_input_handler`). + +## Example 2 + +_(Focused on `JSON` to `DEVS` components)_ + +In case of the `JSON` to `DEVS` model conversion, the factory methodology is used to create the components defined in the `JSON` file. The `Factory` class is in charge of creating the different types of implementations of an abstract class based on a key that is linked to the desired implementation. + +1. The `DEVS` model must be defined (i.e. `EFP` in `xdevs.examples.gpt.models`). +2. The entry point must be created in pyproject.toml after `[project.entry-points."xdevs.components"]` + +With this methodology, adding several instances of a component for a range of simulations is easier. +Defining a key that links to the desired implementation allows for a more straightforward way to create the components +and avoids having to create and define each component for each simulation. \ No newline at end of file diff --git a/xdevs/abc/__init__.py b/xdevs/abc/__init__.py new file mode 100644 index 0000000..8882ca6 --- /dev/null +++ b/xdevs/abc/__init__.py @@ -0,0 +1,3 @@ +from .celldevs import DelayedOutput +from .handler import InputHandler, OutputHandler +from .transducer import Transducer diff --git a/xdevs/abc/celldevs.py b/xdevs/abc/celldevs.py new file mode 100644 index 0000000..195b336 --- /dev/null +++ b/xdevs/abc/celldevs.py @@ -0,0 +1,58 @@ +from abc import ABC, abstractmethod +from typing import Generic +from xdevs import INFINITY +from xdevs.models import Port +from xdevs.celldevs import C, S + + +class DelayedOutput(Generic[C, S], ABC): + def __init__(self, cell_id: C, serve: bool = False): + """ + Cell-DEVS delayed output port. This is an abstract base class. + :param cell_id: ID of the cell that owns this delayed output. + :param serve: set to True if the port is going to be accessible via RPC server. Defaults to False. + """ + from xdevs.celldevs.inout import CellMessage + self.cell_id = cell_id + self.port: Port[CellMessage[C, S]] = Port(CellMessage, 'out_celldevs', serve) + + @abstractmethod + def add_to_buffer(self, when: float, state: S): + """ + Schedules a cell state to send events. + :param when: time at which the events must be sent. + :param state: cell state. Events will be obtained by mapping this state. + """ + pass + + @abstractmethod + def next_time(self) -> float: + """:return: next time at which events must be sent.""" + pass + + @abstractmethod + def next_state(self) -> S: + """:return: next cell state used to generate events.""" + pass + + @abstractmethod + def pop_state(self): + """removes schedule state from the delayed output.""" + pass + + def send_events(self, time: float): + """ + If there is an scheduled state, it sends a new event via every Cell-DEVS output port. + :param time: current simulation time. + """ + from xdevs.celldevs.inout import CellMessage + if self.next_time() <= time: + self.port.add(CellMessage(self.cell_id, self.next_state())) + + def clean(self, time: float): + """ + It cleans all the outdated scheduled cell states. + :param time: current simulation time. + """ + while self.next_time() < INFINITY and self.next_time() <= time: + self.pop_state() diff --git a/xdevs/abc/handler.py b/xdevs/abc/handler.py new file mode 100644 index 0000000..d74b67b --- /dev/null +++ b/xdevs/abc/handler.py @@ -0,0 +1,137 @@ +import queue +import sys +from abc import ABC, abstractmethod +from typing import Callable, Any + + +class Connector: + def __init__(self, connections: dict[str, str]): + """ + Function to connect ports correctly (using MQTT protocol) + + :param connections: dict[key: str, value: str]. Where the key is the port I am connecting to (via MQTT) and the value is the port of my coupled. + """ + self.connections: dict[str, str] = connections + + def input_handler(self, port: str): + if self.connections is not None: + value = self.connections.get(port) + if value is not None: + return value + return port + + +class InputHandler(ABC): + def __init__(self, *args, **kwargs): + """ + Handler interface for injecting external events to the system. + + :param queue: used to collect and inject all external events joining the system. + :param Callable[[Any], tuple[str, str]] event_parser: event parser function. It transforms incoming events + into tuples (port, message). Note that both are represented as strings. Messages need further parsing. + :param dict[str, Callable[[str], Any]] msg_parsers: message parsers. Keys are port names, and values are + functions that take a string and returns an object of the corresponding port type. If a parser is not + defined, the input handler assumes that the port type is str and forward the message as is. By default, all + the ports are assumed to accept str objects. + """ + self.queue = kwargs.get('queue') + if self.queue is None: + raise ValueError('queue is mandatory') + self.event_parser: Callable[[Any], tuple[str, str]] | None = kwargs.get('event_parser') + self.msg_parsers: dict[str, Callable[[str], Any]] = kwargs.get('msg_parsers', dict()) + + self.connections: dict[str, str] = kwargs.get('connections', dict()) + self.connector = Connector(connections=self.connections) + + def initialize(self): + """Performs any task before calling the run method. It is implementation-specific. By default, it is empty.""" + pass + + def exit(self): + """Performs any task after the run method. It is implementation-specific. By default, it is empty.""" + pass + + @abstractmethod + def run(self): + """Execution of the input handler. It is implementation-specific""" + pass + + def push_event(self, event: Any): + """Parses event as tuple port-message and pushes it to the queue.""" + try: + port, msg = self.event_parser(event) + # AQUI IRIA EL CONECTOR MQTT; para corregir el puerto en cuestion + port = self.connector.input_handler(port) + except Exception as e: + # if an exception is triggered while parsing the event, we ignore it + print(f'error parsing input event ("{event}"): {e}. Event will be ignored', file=sys.stderr) + return + self.push_msg(port, msg) + + def push_msg(self, port: str, msg: str): + """Parses the message as the proper object and pushes it to the queue.""" + try: + # if parser is not defined, we forward the message as is (i.e., in string format) + msg = self.msg_parsers.get(port, lambda x: x)(msg) + except Exception as e: + # if an exception is triggered while parsing the message, we ignore it + print(f'error parsing input msg ("{msg}") in port {port}: {e}. Message will be ignored', file=sys.stderr) + return + self.queue.put((port, msg)) + + +class OutputHandler(ABC): + def __init__(self, *args, **kwargs): + """ + Handler interface for ejecting internal events from the system. + + :param queue.SimpleQueue() queue: is the queue where all the desired events to be ejected are put. + :param Callable[[str, str], Any] event_parser: event parser function. It transforms incoming tuples + (port, message) into events. Note that both are represented as strings. + :param dict[str, Callable[[Any], str]] msg_parser: message parsers. Keys are port names, and values are + functions that take a string and returns an object of the corresponding port type. If a parser is not + defined, the output handler assumes that the port type is str and forward the message as is. By default, all + the ports are assumed to accept str objects. + + TODO documentation + """ + self.queue = queue.SimpleQueue() + self.event_parser: Callable[[str, str], Any] | None = kwargs.get('event_parser') + self.msg_parsers: dict[str, Callable[[Any], str]] = kwargs.get('msg_parsers', dict()) + + def initialize(self): + """Performs any task before calling the run method. It is implementation-specific. By default, it is empty.""" + pass + + def exit(self): + """Performs any task before calling the run method. It is implementation-specific. By default, it is empty.""" + pass + + @abstractmethod + def run(self): + """Execution of the output handler. It is implementation-specific""" + pass + + def pop_event(self) -> Any: + """Waits until it receives an outgoing event and parses it with the desired format.""" + while True: + port, msg = self.pop_msg() + # print(f'POP_EVENT: recibo port = {port} y msg = {msg}') + try: + event = self.event_parser(port, msg) + except Exception as e: + print(f'error parsing output event ("{port}","{msg}"): {e}. Event will be ignored', file=sys.stderr) + continue + return event + + def pop_msg(self) -> tuple[str, str]: + """Waits until it receives an outgoing message and returns the port and message in string format.""" + while True: + port, msg = self.queue.get() + # print(f'POP_MSG: recibo port = {port} y msg = {msg}') + try: + msg = self.msg_parsers.get(port, lambda x: str(x))(msg) + except Exception as e: + print(f'error parsing output msg ("{msg}"): {e}. Message will be ignored', file=sys.stderr) + continue + return port, msg diff --git a/xdevs/transducers.py b/xdevs/abc/transducer.py similarity index 80% rename from xdevs/transducers.py rename to xdevs/abc/transducer.py index de3f8a1..7154a72 100644 --- a/xdevs/transducers.py +++ b/xdevs/abc/transducer.py @@ -1,22 +1,18 @@ -from __future__ import annotations import inspect import itertools import logging -import pkg_resources import re from abc import ABC, abstractmethod from math import isinf, isnan -from typing import Any, Callable, ClassVar, Type, TypeVar, Iterable +from typing import Any, Callable, Type, Iterable +from xdevs import T from xdevs.models import Atomic, Component, Coupled, Port -T = TypeVar('T') - - class Transducible(ABC): - @staticmethod + @classmethod @abstractmethod - def transducer_map() -> dict[str, tuple[Type[T], Callable[[Any], T]]]: + def transducer_map(cls) -> dict[str, tuple[Type[T], Callable[[Any], T]]]: pass @@ -25,10 +21,10 @@ class Transducer(ABC): state_mapper: dict[str, tuple[Type[T], Callable[[Atomic], T]]] event_mapper: dict[str, tuple[Type[T], Callable[[Any], T]]] - def __init__(self, **kwargs): + def __init__(self, *args, **kwargs): """ Transducer for the xDEVS M&S tool. - :param str transducer_id: ID of the transducer. + :param str transducer_id: ID of the transducer. This parameter is mandatory. :param str sim_time_id: ID of the data field containing the simulation time. By default, it is "sim_time". :param bool include_names: when True, the logs include DEVS port and component names. By default, it is True. :param str model_name_id: ID of the data field containing the DEVS model name. By default, it is "model_name". @@ -36,17 +32,14 @@ def __init__(self, **kwargs): :param bool exhaustive: determines if the output contains the state of the target components for each iteration (True) or only includes the change states (False). """ - self.active: bool = True - self.transducer_id: str = kwargs.get('transducer_id', None) - if self.transducer_id is None: - raise AttributeError("You must specify a transducer ID.") - + self.transducer_id: str = kwargs['transducer_id'] self.sim_time_id: str = kwargs.get('sim_time_id', 'sim_time') self.include_names: bool = kwargs.get('include_names', True) self.model_name_id: str = kwargs.get('model_name_id', 'model_name') self.port_name_id: str = kwargs.get('port_name_id', 'port_name') - self.exhaustive: bool = kwargs.get('exhaustive', False) + + self.active: bool = True self.target_components: set[Atomic] = set() self.target_ports: set[Port] = set() self.imminent_components: list[Atomic] | None = None if self.exhaustive else [] @@ -63,30 +56,32 @@ def __init__(self, **kwargs): self._remove_special_numbers: bool = False def activate_remove_special_numbers(self): - logging.warning('Transducer {} does not support special number values (e.g., infinity). ' - 'It will automatically substitute these values by None'.format(self.transducer_id)) + logging.warning(f'Transducer {self.transducer_id} does not support special number values (e.g., infinity). ' + 'It will automatically substitute these values with None') self._remove_special_numbers = True - def add_target_component(self, component: Atomic or Coupled, *filters): + def add_target_component(self, component: Component, *filters): components = self._iterate_components(component) self.target_components |= self._apply_filters(filters, components) - def _iterate_components(self, root_comp, include_coupled=False): + def _iterate_components(self, root_comp: Component, include_coupled: bool = False): if isinstance(root_comp, Atomic): yield root_comp - else: # Coupled + elif isinstance(root_comp, Coupled): # Coupled if include_coupled: yield root_comp for child_comp in root_comp.components: yield from self._iterate_components(child_comp, include_coupled=include_coupled) + else: + raise ValueError(f'Component {root_comp.name} is not an Atomic nor a Coupled') def add_target_port(self, port: Port): parent: Component | None = port.parent if parent is None: - raise ValueError('Port {} does not have a parent component'.format(port.name)) + raise ValueError(f'Port {port.name} does not have a parent component') self.target_ports.add(port) - def add_target_ports_by_component(self, component, component_filters=None, port_filters=None): + def add_target_ports_by_component(self, component: Component, component_filters=None, port_filters=None): components = self._iterate_components(component, include_coupled=True) filtered_components = Transducer._apply_filters(component_filters, components) for comp in filtered_components: @@ -94,11 +89,11 @@ def add_target_ports_by_component(self, component, component_filters=None, port_ filtered_comp_ports = Transducer._apply_filters(port_filters, comp_ports) self.target_ports |= filtered_comp_ports - def add_imminent_model(self, component): + def add_imminent_model(self, component: Atomic): if not self.exhaustive and self.active: self.imminent_components.append(component) - def add_imminent_port(self, port): + def add_imminent_port(self, port: Port): if not self.exhaustive and self.active: self.imminent_ports.append(port) @@ -114,9 +109,9 @@ def add_event_field(self, field_name: str, field_type: Type[T], field_getter: Ca :raise KeyError: if field name is already in event mapper. """ if field_name == self.sim_time_id: - raise KeyError('Field name {} is reserved for the simulation time field'.format(field_name)) + raise KeyError(f'Field name {field_name} is reserved for the simulation time field') elif self.include_names and field_name in (self.model_name_id, self.port_name_id): - raise KeyError('Field name {} is reserved for DEVS element name field'.format(field_name)) + raise KeyError(f'Field name {field_name} is reserved for DEVS element name field') self._add_field(self.event_mapper, field_name, field_type, field_getter) def add_state_field(self, field_name: str, field_type: Type[T], field_getter: Callable[[Atomic], T]): @@ -128,16 +123,16 @@ def add_state_field(self, field_name: str, field_type: Type[T], field_getter: Ca :raise KeyError: if field name is already in state mapper. """ if field_name == self.sim_time_id: - raise KeyError('Field name {} is reserved for the simulation time field'.format(field_name)) + raise KeyError(f'Field name {field_name} is reserved for the simulation time field') elif self.include_names and field_name == self.model_name_id: - raise KeyError('Field name {} is reserved for DEVS component name field'.format(field_name)) + raise KeyError(f'Field name {field_name} is reserved for DEVS component name field') self._add_field(self.state_mapper, field_name, field_type, field_getter) @staticmethod def _add_field(field_mapper: dict[str, tuple[Type[T], Callable[[Any], T]]], field_name: str, field_type: Type[T], field_getter: Callable[[Any], T]): if field_name in field_mapper: - raise KeyError('Field name {} is already included in field mapper'.format(field_name)) + raise KeyError(f'Field name {field_name} is already included in field mapper') field_mapper[field_name] = (field_type, field_getter) def drop_event_field(self, field_name: str): @@ -255,24 +250,5 @@ def map_extra_fields(self, target: Any, field_mapper: dict) -> dict[str, Any]: return extra_fields def _log_unknown_data(self, data_type: type, field_name: str): - logging.warning('Transducer {} does not support data type {} of field {}. ' - 'It will cast it to string'.format(self.transducer_id, data_type, field_name)) - - -class Transducers: - - _plugins: ClassVar[dict[str, Type[Transducer]]] = { - ep.name: ep.load() for ep in pkg_resources.iter_entry_points('xdevs.plugins.transducers') - } - - @staticmethod - def add_plugin(name: str, plugin: Type[Transducer]): - if name in Transducers._plugins: - raise ValueError('xDEVS transducer plugin with name "{}" already exists'.format(name)) - Transducers._plugins[name] = plugin - - @staticmethod - def create_transducer(name: str, **kwargs) -> Transducer: - if name not in Transducers._plugins: - raise ValueError('xDEVS transducer plugin with name "{}" not found'.format(name)) - return Transducers._plugins[name](**kwargs) + logging.warning(f'Transducer {self.transducer_id} does not support data type {data_type} of field {field_name}. ' + 'It will cast it to string') diff --git a/xdevs/celldevs/__init__.py b/xdevs/celldevs/__init__.py new file mode 100644 index 0000000..6d7d5bd --- /dev/null +++ b/xdevs/celldevs/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations +from typing import TypeVar + +C = TypeVar('C') # Variable type used for cell IDs +S = TypeVar('S') # Variable type used for cell states +V = TypeVar('V') # Variable type used for cell vicinities diff --git a/xdevs/celldevs/cell.py b/xdevs/celldevs/cell.py new file mode 100644 index 0000000..dcf69f1 --- /dev/null +++ b/xdevs/celldevs/cell.py @@ -0,0 +1,192 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from copy import deepcopy +from typing import Any, Generic +from xdevs.celldevs import C, S, V +from xdevs.celldevs.inout import CellMessage, InPort +from xdevs.models import Atomic +from xdevs.factory import DelayedOutputs, DelayedOutput + + +class CellConfig(Generic[C, S, V]): + def __init__(self, config_id: str, c_type: type[C], s_type: type[S], v_type: type[V], **kwargs): + """ + Cell-DEVS configuration structure. + :param config_id: identifier of the configuration. + :param c_type: type used to identify cells. + :param s_type: type used to represent cell states. + :param v_type: type used to represent vicinity between cells. + :param cell_type: identifier of the cell type. + :param delay: identifier of the delay buffer implemented by the cell. By default, it is set to inertial. + :param config: any additional configuration parameters. + :param state: parameters required to create the initial state of the cell. + :param neighborhood: representation of the cell neighborhood. By default, it is empty. + :param cell_map: list of cells that implement this configuration. By default, it is empty. + :param eic: list of external input couplings. By default, it is empty. + :param eoc: list of external output couplings. By default, it is empty. + """ + self.config_id: str = config_id + self.c_type: type[C] = c_type + self.s_type: type[S] = s_type + self.v_type: type[V] = v_type + CellMessage.state_t = s_type + + self.cell_type: str = kwargs['cell_type'] + self.delay_type: str = kwargs.get('delay', 'inertial') + self.cell_config = kwargs.get('config') + self.state = kwargs.get('state') + self.raw_neighborhood: list[dict] = kwargs.get('neighborhood', list()) + self.cell_map: list[C] | None = None if self.default else self._load_map(*kwargs.get('cell_map', list())) + self.eic: list[tuple[str, str]] = self._parse_couplings(kwargs.get('eic', list())) + self.ic: list[tuple[str, str]] = [('out_celldevs', 'in_celldevs')] + self.eoc: list[tuple[str, str]] = self._parse_couplings(kwargs.get('eoc', list())) + + @property + def default(self) -> bool: + """:return: true if this configuration profile is the default one.""" + return self.config_id == 'default' + + def apply_patch(self, config_id: str, **kwargs): + """ + Applies a configuration patch. This method is used for non-default configurations. + :param config_id: configuration ID. + :param cell_type: identifier of the cell type. + :param delay: identifier of the delay buffer implemented by the cell. By default, it is set to inertial. + :param config: any additional configuration parameters. + :param state: parameters required to create the initial state of the cell. + :param neighborhood: representation of the cell neighborhood. By default, it is empty. + :param cell_map: list of cells that implement this configuration. By default, it is empty. + :param eic: list of external input couplings. By default, it is empty. + :param ic: list of internal couplings. By default, it is empty. # TODO remove this? + :param eoc: list of external output couplings. By default, it is empty. + """ + self.config_id = config_id + self.cell_type = kwargs.get('cell_type', self.cell_type) + self.delay_type = kwargs.get('delay', self.delay_type) + if 'config' in kwargs: + self.cell_config = self._patch_dict(self.cell_config, kwargs['config']) \ + if isinstance(self.cell_config, dict) else kwargs['config'] + if 'state' in kwargs: + self.state = self._patch_dict(self.state, kwargs['state']) \ + if isinstance(self.state, dict) else kwargs['state'] + self.raw_neighborhood = kwargs.get('neighborhood', self.raw_neighborhood) + if 'cell_map' in kwargs: + self.cell_map = self._load_map(*kwargs['cell_map']) + if 'eic' in kwargs: + self.eic = self._parse_couplings(kwargs['eic']) + if 'ic' in kwargs: + self.ic = self._parse_couplings(kwargs['ic']) + if 'eoc' in kwargs: + self.eoc = self._parse_couplings(kwargs['eoc']) + + def load_state(self) -> S: + """:return: a new initial state structure.""" + return self._load_value(self.s_type, self.state) + + def load_neighborhood(self) -> dict[C, V]: + """:return: a new neighborhood.""" + neighbors: dict[C, V] = dict() + for neighborhood in self.raw_neighborhood: + for neighbor, vicinity in neighborhood.items(): + neighbors[self.c_type(neighbor)] = self._load_vicinity(vicinity) + return neighbors + + def _load_map(self, *args) -> list[C]: + return [self.c_type(self.config_id)] + + def _load_vicinity(self, vicinity: Any): + return self._load_value(self.v_type, vicinity) + + @classmethod + def _patch_dict(cls, d: dict, patch: dict) -> dict: + for k, v in patch.items(): + d[k] = cls._patch_dict(d[k], v) if isinstance(v, dict) and k in d and isinstance(d[k], dict) else v + return d + + @staticmethod + def _parse_couplings(couplings: list[list[str]]) -> list[tuple[str, str]]: + return [(coupling[0], coupling[1]) for coupling in couplings] + + @staticmethod + def _load_value(t_type, params: Any): + params = deepcopy(params) + if isinstance(params, dict): + return t_type(**params) + elif isinstance(params, list): + return t_type(*params) + elif params is not None: + return t_type(params) + return t_type() + + +class Cell(Atomic, ABC, Generic[C, S, V]): + def __init__(self, cell_id: C, config: CellConfig[C, S, V]): + """ + Abstract Base Class for a Cell-DEVS cell. + :param cell_id: cell identifier. + :param config: cell configuration structure. + """ + super().__init__(str(cell_id)) + self._clock: float = 0 + self._config: CellConfig = config + self.ics = config.eic + self.cell_id: C = cell_id + self.cell_state: S = config.load_state() + self.neighborhood: dict[C, V] = self._load_neighborhood() + + self.in_celldevs: InPort[C, S] = InPort(self.cell_id) + self.out_celldevs: DelayedOutput[C, S] = DelayedOutputs.create_delayed_output(config.delay_type, self.cell_id) + self.add_in_port(self.in_celldevs.port) + self.add_out_port(self.out_celldevs.port) + + @property + def neighbors_state(self) -> dict[C, S]: + return self.in_celldevs.history + + @abstractmethod + def local_computation(self, cell_state: S) -> S: + """ + Computes new cell state depending on its previous state. + :param cell_state: current cell state. + :return: new cell state. + """ + pass + + @abstractmethod + def output_delay(self, cell_state: S) -> float: + """ + Returns delay to be applied to output messages related to new cell state. + :param cell_state: new cell state. + :return: delay to be applied. + """ + pass + + def deltint(self): + self._clock += self.sigma + self.out_celldevs.clean(self._clock) + self.sigma = self.out_celldevs.next_time() - self._clock + + def deltext(self, e: float): + self._clock += e + self.sigma -= e + self.in_celldevs.read_new_events() + + new_state = self.local_computation(deepcopy(self.cell_state)) + if new_state != self.cell_state: + state = deepcopy(new_state) + self.out_celldevs.add_to_buffer(self._clock + self.output_delay(state), state) + self.sigma = self.out_celldevs.next_time() - self._clock + self.cell_state = new_state + + def lambdaf(self): + self.out_celldevs.send_events(self._clock + self.sigma) + + def initialize(self): + self.out_celldevs.add_to_buffer(0, self.cell_state) + self.activate() + + def exit(self): + pass + + def _load_neighborhood(self) -> dict[C, V]: + return self._config.load_neighborhood() diff --git a/xdevs/celldevs/coupled.py b/xdevs/celldevs/coupled.py new file mode 100644 index 0000000..fa25e00 --- /dev/null +++ b/xdevs/celldevs/coupled.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import json +from abc import abstractmethod, ABC +from copy import deepcopy +from typing import Dict, Generic, Optional, Tuple, Type +from xdevs.celldevs import C, S, V +from xdevs.celldevs.cell import Cell, CellConfig +from xdevs.celldevs.grid import GridCell, GridCellConfig, GridScenario +from xdevs.models import Coupled + + +class CoupledCellDEVS(Coupled, ABC, Generic[C, S, V]): + def __init__(self, c_type: Type[C], s_type: Type[S], v_type: Type[V], config_file: str, name: Optional[str] = None): + super().__init__(name) + self.c_type: Type[C] = c_type + self.s_type: Type[S] = s_type + self.v_type: Type[V] = v_type + with open(config_file) as file: + self.raw_config = json.load(file) + self._configs: Dict[str, CellConfig[C, S, V]] = dict() + self._cells: Dict[C, Tuple[Cell[C, S, V], CellConfig]] = dict() + + def load_config(self): + raw_configs = self.raw_config['cells'] + default_config: CellConfig[C, S, V] = self._load_default_config(raw_configs['default']) + self._configs = {'default': default_config} + for config_id, raw_config in raw_configs.items(): + if config_id != 'default': + config = deepcopy(default_config) + config.apply_patch(config_id, **raw_config) + self._configs[config_id] = config + + def load_cells(self): + for cell_config in self._configs.values(): + if not cell_config.default: + for cell_id in cell_config.cell_map: + if cell_id in self._cells: + raise ValueError('cell with the same ID already exists') + cell: Cell[C, S, V] = self.create_cell(cell_config.cell_type, cell_id, cell_config) + self._cells[cell_id] = (cell, cell_config) + self.add_component(cell) + + def load_couplings(self): + for cell_to, cell_config in self._cells.values(): + for port_from, port_to in cell_config.eic: + self.add_coupling(self.get_in_port(port_from), cell_to.get_in_port(port_to)) + for neighbor in cell_to.neighborhood: + cell_from = self._cells[neighbor][0] + for port_from, port_to in cell_config.ic: + self.add_coupling(cell_from.get_out_port(port_from), cell_to.get_in_port(port_to)) + for port_from, port_to in cell_config.eoc: + self.add_coupling(cell_to.get_out_port(port_from), self.get_out_port(port_to)) + + def _load_default_config(self, raw_config: Dict) -> CellConfig[C, S, V]: + return CellConfig('default', self.c_type, self.s_type, self.v_type, **raw_config) + + @abstractmethod + def create_cell(self, cell_type: str, cell_id: C, cell_config: CellConfig[C, S, V]) -> Cell[C, S, V]: + pass + + +class CoupledGridCellDEVS(CoupledCellDEVS[Tuple[int, ...], S, V], ABC, Generic[S, V]): + + _configs: Dict[str, GridCellConfig] + + def __init__(self, s_type: Type[S], v_type: Type[V], config_file: str): + super().__init__(tuple, s_type, v_type, config_file) + + scenario_config = self.raw_config['scenario'] + shape = tuple(scenario_config['shape']) + origin = tuple(scenario_config['origin']) if 'origin' in scenario_config else None + wrapped = scenario_config.get('wrapped', False) + self.scenario: GridScenario = GridScenario(shape, origin, wrapped) + + def load_cells(self): + super().load_cells() + default_config = self._configs['default'] + for cell_id in self.scenario.iter_cells(): + if cell_id not in self._cells: + cell: GridCell[S, V] = self.create_cell(default_config.cell_type, cell_id, default_config) + self._cells[cell_id] = (cell, default_config) + self.add_component(cell) + + def _load_default_config(self, raw_config: Dict) -> GridCellConfig[S, V]: + return GridCellConfig(self.scenario, 'default', self.s_type, self.v_type, **raw_config) + + @abstractmethod + def create_cell(self, cell_type: str, cell_id: Tuple[int, ...], + cell_config: GridCellConfig[S, V]) -> GridCell[S, V]: + pass diff --git a/xdevs/celldevs/grid.py b/xdevs/celldevs/grid.py new file mode 100644 index 0000000..27f28fa --- /dev/null +++ b/xdevs/celldevs/grid.py @@ -0,0 +1,288 @@ +from __future__ import annotations +import math +from abc import ABC +from math import copysign, isinf +from typing import Dict, Generic, Iterator, List, Optional, Tuple, Type, Union +from xdevs.celldevs import S, V +from xdevs.celldevs.cell import Cell, CellConfig + +C = Tuple[int, ...] # Cell IDs in grids are tuples of integers + + +class GridScenario: + def __init__(self, shape: C, origin: Optional[C] = None, wrapped: bool = False): + """ + Grid scenario configuration. + :param shape: tuple describing the dimension of the scenario. + :param origin: tuple describing the origin of the scenario. By default, it is set to (0, 0, ...). + :param wrapped: if true, the scenario wraps the edges. It defaults to False. + """ + if len(shape) < 1: + raise ValueError('scenario dimension is invalid') + for dim in shape: + if dim <= 0: + raise ValueError('scenario shape is invalid') + self.shape = tuple(shape) + if origin is None: + origin = tuple(0 for _ in range(self.dimension)) + if len(origin) != len(shape): + raise ValueError('scenario shape and origin must have the same dimension') + self.origin = tuple(origin) + self.wrapped = wrapped + + @property + def dimension(self) -> int: + """:return: number of dimensions of the scenario.""" + return len(self.shape) + + def cell_in_scenario(self, cell: C) -> bool: + """ + Checks if a cell is inside the scenario. + :param cell: coordinates of the cell under study. + :return: True if the coordinates of the cell are inside the scenario. + """ + return self._cell_in_scenario(cell, self.shape, self.origin) + + def distance_vector(self, cell_from: C, cell_to: C) -> C: + """ + Computes the distance vector between two cells. + :param cell_from: origin cell. + :param cell_to: destination cell. + :return: relative distance vector. + """ + return self._distance_vector(cell_from, cell_to, self.shape, self.origin, self.wrapped) + + def cell_to(self, cell_from: C, distance_vector: C): + """ + Deduces destination cell according to an origin cell and a distance vector. + :param cell_from: origin cell. + :param distance_vector: distance vector. + :return: destination cell. + """ + if not self.cell_in_scenario(cell_from): + raise ValueError('cell_from is not part of the scenario') + elif len(distance_vector) != self.dimension: + raise ValueError('scenario shape and distance_vector must have the same dimension') + cell_to: C = tuple(cell_from[i] + distance_vector[i] for i in range(self.dimension)) + if self.wrapped: + cell_to = tuple((cell_to[i] + self.shape[i]) % self.shape[i] for i in range(self.dimension)) + if not self.cell_in_scenario(cell_to): + raise OverflowError('cell_to is not part of the scenario') + return cell_to + + def minkowski_distance(self, p: int, cell_from: C, cell_to: C) -> Union[int, float]: + """ + Computes Minkowski distance between two cells. + :param p: Minkowski distance order. + :param cell_from: origin cell. + :param cell_to: destination cell. + :return: Minkowski distance between cells. + """ + return self._minkowski_distance(p, cell_from, cell_to, self.shape, self.origin, self.wrapped) + + def moore_neighborhood(self, r: int = 1) -> List[C]: + """ + Creates a Moore neighborhood of the desired range. + :param r: neighborhood range. + :return: List with relative distance vectors of neighbors. + """ + return self._moore_neighborhood(self.dimension, r) + + def von_neumann_neighborhood(self, r: int = 1) -> List[C]: + """ + Creates a von Neumann neighborhood of the desired range. + :param r: neighborhood range. + :return: List with relative distance vectors of neighbors. + """ + return self._von_neumann_neighborhood(self.dimension, r) + + def iter_cells(self) -> Iterator[C]: + """:return: iterator that goes through all the cells in the scenario.""" + return self._iter_cells(self.shape, self.origin) + + @staticmethod + def _cell_in_scenario(cell: C, shape: C, origin: C) -> bool: + if len(cell) != len(shape): + raise ValueError('scenario shape and cell location must have the same dimension') + return all(0 <= cell[i] - origin[i] < shape[i] for i in range(len(shape))) + + @classmethod + def _distance_vector(cls, cell_from: C, cell_to: C, shape: C, origin: C, wrapped: bool) -> C: + if not cls._cell_in_scenario(cell_from, shape, origin) or not cls._cell_in_scenario(cell_to, shape, origin): + raise ValueError('cell_from and/or cell_to are not part of the scenario') + dimension = len(shape) + distance: C = tuple(cell_to[i] - cell_from[i] for i in range(dimension)) + if wrapped: + distance = tuple(distance[i] - copysign(shape[i], distance[i]) if abs(distance[i]) > shape[i] / 2 + else distance[i] for i in range(dimension)) + return distance + + @classmethod + def _minkowski_distance(cls, p: int, cell_from: C, cell_to: C, shape: C, + origin: C, wrapped: bool) -> Union[int, float]: + if p <= 0: + raise ValueError('Minkowski distance is only valid for p greater than 0') + d_vector: C = cls._distance_vector(cell_from, cell_to, shape, origin, wrapped) + if p == 1: + return sum(abs(d) for d in d_vector) + elif isinf(p): + return max(abs(d) for d in d_vector) + else: + return sum(abs(d) ** p for d in d_vector) ** (1 / p) + + @classmethod + def _moore_neighborhood(cls, dim: int, r: int) -> List[C]: + if dim < 0: + raise ValueError('invalid number of dimensions') + if r < 0: + raise ValueError('neighborhood range must be greater than or equal to 0') + n_shape: C = tuple(2 * r + 1 for _ in range(dim)) + n_origin: C = tuple(-r for _ in range(dim)) + return list(cls._iter_cells(n_shape, n_origin)) + + @classmethod + def _von_neumann_neighborhood(cls, dim: int, r: int) -> List[C]: + moore: List[C] = cls._moore_neighborhood(dim, r) + n_shape: C = tuple(2 * r + 1 for _ in range(dim)) + n_origin: C = tuple(-r for _ in range(dim)) + center: C = tuple(0 for _ in range(dim)) + neighborhood: List[C] = list() + for neighbor in moore: + if cls._minkowski_distance(1, center, neighbor, n_shape, n_origin, False) <= r: + neighborhood.append(neighbor) + return neighborhood + + @classmethod + def _next_cell(cls, prev_cell: C, shape: C, origin: C, d: int) -> Optional[C]: + if cls._cell_in_scenario(prev_cell, shape, origin): + if prev_cell[d] - origin[d] < shape[d] - 1: + return tuple(prev_cell[i] if i != d else prev_cell[i] + 1 for i in range(len(shape))) + elif d < len(shape) - 1: + prev_cell = tuple(prev_cell[i] if i != d else origin[i] for i in range(len(shape))) + return cls._next_cell(prev_cell, shape, origin, d + 1) + + @classmethod + def _iter_cells(cls, shape: C, origin: C) -> Iterator[C]: + cell: C = origin + while cell is not None: + yield cell + cell = cls._next_cell(cell, shape, origin, 0) + + +class GridCellConfig(CellConfig[C, S, V], Generic[S, V]): + def __init__(self, scenario: GridScenario, config_id: str, s_type: Type[S], v_type: Type[V], **kwargs): + """ + Grid cell configuration structure. + :param scenario: grid scenario structure. + :param config_id: identifier of the configuration. + :param s_type: type used to represent cell states. + :param v_type: type used to represent vicinity between cells. + :param kwargs: any additional configuration parameters required for creating a cell configuration structure. + """ + self.scenario: GridScenario = scenario + super().__init__(config_id, tuple, s_type, v_type, **kwargs) + + def _load_map(self, *args) -> List[C]: + return [tuple(cell_id) for cell_id in args] + + def load_cell_neighborhood(self, cell: C) -> Dict[C, V]: + """ + Creates the neighborhood corresponding to a given cell. + :param cell: target cell tu create the neighborhood. + :return: dictionary {neighbor cell: vicinity} + """ + neighbors: Dict[C, V] = dict() + for neighborhood in self.raw_neighborhood: + vicinity = neighborhood.get('vicinity') + n_type: str = neighborhood.get('type', 'absolute') + if n_type == 'absolute': + for neighbor in neighborhood.get('neighbors', list()): + neighbor = tuple(neighbor) + if not self.scenario.cell_in_scenario(neighbor): + raise OverflowError('absolute neighbor is not part of the scenario') + neighbors[neighbor] = self._load_vicinity(vicinity) + else: + if n_type == 'relative': + relative: List[C] = [tuple(neighbor) for neighbor in neighborhood.get('neighbors', list())] + elif n_type == 'moore': + relative: List[C] = self.scenario.moore_neighborhood(neighborhood.get('range', 1)) + elif n_type == 'von_neumann': + relative: List[C] = self.scenario.von_neumann_neighborhood(neighborhood.get('range', 1)) + else: + raise ValueError('unknown neighborhood type') + for neighbor in relative: + try: + neighbors[self.scenario.cell_to(cell, tuple(neighbor))] = self._load_vicinity(vicinity) + except OverflowError: + continue + return neighbors + + +class GridCell(Cell[C, S, V], ABC, Generic[S, V]): + + _config: GridCellConfig[S, V] + + def __init__(self, cell_id: C, config: GridCellConfig): + """ + Grid Cell class for Cell-DEVS scenarios. + :param config: configuration structure for grid cells. + """ + super().__init__(cell_id, config) + self.scenario = config.scenario + + @property + def location(self) -> C: + """:return: location of the cell.""" + return self.cell_id + + def minkowski_distance(self, p: int, other: C) -> float: + """ + Computes Minkowski distance from cell to another cell. + :param p: Minkowski distance order. + :param other: destination cell. + :return: Minkowski distance. + """ + return self.scenario.minkowski_distance(p, self.location, other) + + def manhattan_distance(self, other: C) -> int: + """ + Computes Manhattan distance from cell to another cell + :param other: destination cell. + :return: Manhattan distance. + """ + return self.scenario.minkowski_distance(1, self.location, other) + + def euclidean_distance(self, other: C) -> float: + """ + Computes Euclidean distance from cell to another cell. + :param other: destination cell. + :return: Euclidean distance. + """ + return self.scenario.minkowski_distance(2, self.location, other) + + def chebyshev_distance(self, other: C) -> int: + """ + Computes Chebyshev distance from cell to another cell. + :param other: destination cell. + :return: Chebyshev distance. + """ + return self.scenario.minkowski_distance(math.inf, self.location, other) + + def neighbor(self, relative: C) -> C: + """ + Computes the coordinates of a neighboring cell from a relative distance vector. + :param relative: relative distance vector. + :return: coordinates of neighboring cell. + """ + return self.scenario.cell_to(self.location, relative) + + def relative(self, neighbor: C) -> C: + """ + Computes the relative distance vector from the coordinates of a neighboring cell. + :param neighbor: coordinates of a neighboring cell. + :return: relative distance vector. + """ + return self.scenario.distance_vector(self.location, neighbor) + + def _load_neighborhood(self) -> Dict[C, V]: + return self._config.load_cell_neighborhood(self.cell_id) diff --git a/xdevs/celldevs/inout.py b/xdevs/celldevs/inout.py new file mode 100644 index 0000000..ec71bbb --- /dev/null +++ b/xdevs/celldevs/inout.py @@ -0,0 +1,50 @@ +from __future__ import annotations +from typing import ClassVar, Dict, Generic, Optional, Type, Callable, Any +from xdevs.models import Port +from xdevs.abc.transducer import Transducible, T +from xdevs.celldevs import C, S + + +class CellMessage(Transducible, Generic[C, S]): + + state_t: ClassVar[Type[S]] = None + + def __init__(self, cell_id: C, cell_state: S): + self.cell_id = cell_id + self.cell_state = cell_state + + @classmethod + def transducer_map(cls) -> dict[str, tuple[Type[T], Callable[[Any], T]]]: + if issubclass(cls.state_t, Transducible): + res = {'cell_id': (str, lambda x: x.cell_id)} + for field, (t, l) in cls.state_t.transducer_map().items(): + # f is a fake lambda input parameter to capture the current value of l + # We need this to avoid the late binding problem in lambda functions + res[field] = (t, lambda x, f=l: f(x.cell_state)) + return res + return { + 'cell_id': (str, lambda x: x.cell_id), + 'cell_state': (str, lambda x: x.cell_state), + } + +class InPort(Generic[C, S]): + def __init__(self, serve: bool = False): + """ + Cell-DEVS in port. + :param serve: set to True if the port is going to be accessible via RPC server. Defaults to False. + """ + self.port: Port[CellMessage[C, S]] = Port(CellMessage, 'in_celldevs', serve) + self.history: Dict[C, S] = dict() + + def read_new_events(self): + """It stores the latest incoming events into self.history""" + for cell_message in self.port.values: + self.history[cell_message.cell_id] = cell_message.cell_state + + def get(self, cell_id: C) -> Optional[S]: + """ + Returns latest received event. + :param cell_id: ID of the cell that sent the event. + :return: latest received event. If no event has been received, it returns None. + """ + return self.history.get(cell_id) diff --git a/xdevs/examples/basic/basic.py b/xdevs/examples/basic/basic.py deleted file mode 100644 index 62fa003..0000000 --- a/xdevs/examples/basic/basic.py +++ /dev/null @@ -1,189 +0,0 @@ -import logging - -from xdevs import PHASE_ACTIVE, PHASE_PASSIVE, get_logger -from xdevs.models import Atomic, Coupled, Port -from xdevs.sim import Coordinator - -logger = get_logger(__name__, logging.DEBUG) - -PHASE_DONE = "done" - - -class Job: - def __init__(self, name): - self.name = name - self.time = 0 - - -class Generator(Atomic): - - def __init__(self, name, period): - super().__init__(name) - self.i_start = Port(Job, "i_start") - self.i_stop = Port(Job, "i_stop") - self.o_out = Port(Job, "o_out") - - self.add_in_port(self.i_start) - self.add_in_port(self.i_stop) - self.add_out_port(self.o_out) - - self.period = period - self.job_counter = 1 - - def initialize(self): - self.hold_in(PHASE_ACTIVE, self.period) - - def exit(self): - pass - - def deltint(self): - self.job_counter += 1 - self.hold_in(PHASE_ACTIVE, self.period) - - def deltext(self, e): - self.passivate() - - def lambdaf(self): - self.o_out.add(Job(str(self.job_counter))) - - -class Processor(Atomic): - def __init__(self, name, proc_time): - super().__init__(name) - - self.i_in = Port(Job, "i_in") - self.o_out = Port(Job, "o_out") - - self.add_in_port(self.i_in) - self.add_out_port(self.o_out) - - self.current_job = None - self.proc_time = proc_time - - def initialize(self): - self.passivate() - - def exit(self): - pass - - def deltint(self): - self.passivate() - - def deltext(self, e): - if self.phase == PHASE_PASSIVE: - self.current_job = self.i_in.get() - self.hold_in(PHASE_ACTIVE, self.proc_time) - self.continuef(e) - - def lambdaf(self): - self.o_out.add(self.current_job) - - -class Transducer(Atomic): - - def __init__(self, name, obs_time): - super().__init__(name) - - self.i_arrived = Port(Job, "i_arrived") - self.i_solved = Port(Job, "i_solved") - self.o_out = Port(Job, "o_out") - - self.add_in_port(self.i_arrived) - self.add_in_port(self.i_solved) - self.add_out_port(self.o_out) - - self.jobs_arrived = [] - self.jobs_solved = [] - - self.total_ta = 0 - self.clock = 0 - self.obs_time = obs_time - - def initialize(self): - self.hold_in(PHASE_ACTIVE, self.obs_time) - - def exit(self): - pass - - def deltint(self): - self.clock += self.sigma - - if self.phase == PHASE_ACTIVE: - if self.jobs_solved: - avg_ta = self.total_ta / len(self.jobs_solved) - throughput = len(self.jobs_solved) / self.clock if self.clock > 0 else 0 - else: - avg_ta = 0 - throughput = 0 - - logger.info("End time: %f" % self.clock) - logger.info("Jobs arrived: %d" % len(self.jobs_arrived)) - logger.info("Jobs solved: %d" % len(self.jobs_solved)) - logger.info("Average TA: %f" % avg_ta) - logger.info("Throughput: %f\n" % throughput) - - self.hold_in(PHASE_DONE, 0) - else: - self.passivate() - - def deltext(self, e): - self.clock += e - - if self.phase == PHASE_ACTIVE: - if self.i_arrived: - job = self.i_arrived.get() - logger.info("Starting job %s @ t = %d" % (job.name, self.clock)) - job.time = self.clock - self.jobs_arrived.append(job) - - if self.i_solved: - job = self.i_solved.get() - logger.info("Job %s finished @ t = %d" % (job.name, self.clock)) - self.total_ta += self.clock - job.time - self.jobs_solved.append(job) - - self.continuef(e) - - def lambdaf(self): - if self.phase == PHASE_DONE: - self.o_out.add(Job("null")) - - -class Gpt(Coupled): - def __init__(self, name, period, obs_time): - super().__init__(name) - - if period < 1: - raise ValueError("period has to be greater than 0") - - if obs_time < 0: - raise ValueError("obs_time has to be greater or equal than 0") - - gen = Generator("generator", period) - proc = Processor("processor", 3*period) - trans = Transducer("transducer", obs_time) - - self.add_component(gen) - self.add_component(proc) - self.add_component(trans) - - self.add_coupling(gen.o_out, proc.i_in) - self.add_coupling(gen.o_out, trans.i_arrived) - self.add_coupling(proc.o_out, trans.i_solved) - self.add_coupling(trans.o_out, gen.i_stop) - - -class Wrap(Coupled): - def __init__(self, name, period, obs_time): - super().__init__("wrap") - - gpt = Gpt(name, period, obs_time) - - self.add_component(gpt) - - -if __name__ == '__main__': - gpt = Gpt("gpt", 1, 100) - coord = Coordinator(gpt) - coord.initialize() - coord.simulate() diff --git a/xdevs/examples/basic/basic_inter.py b/xdevs/examples/basic/basic_inter.py deleted file mode 100644 index cc40eaa..0000000 --- a/xdevs/examples/basic/basic_inter.py +++ /dev/null @@ -1,211 +0,0 @@ -from pypdevs.DEVS import AtomicDEVS - -from xdevs import PHASE_ACTIVE, PHASE_PASSIVE, get_logger -from xdevs.models import Atomic, Coupled, Port -from xdevs.plugins.wrappers.pypdevs import PyPDEVSWrapper -from xdevs.sim import Coordinator, ParallelCoordinator -from pypdevs.infinity import INFINITY -import logging - -logger = get_logger(__name__, logging.DEBUG) - -PHASE_DONE = "done" - - -class Job: - def __init__(self, name): - self.name = name - self.time = 0 - - -class Generator(Atomic): - - def __init__(self, name, period): - super().__init__(name) - self.i_start = Port(Job, "i_start") - self.i_stop = Port(Job, "i_stop") - self.o_out = Port(Job, "o_out") - - self.add_in_port(self.i_start) - self.add_in_port(self.i_stop) - self.add_out_port(self.o_out) - - self.period = period - self.job_counter = 1 - - def initialize(self): - self.hold_in(PHASE_ACTIVE, self.period) - - def exit(self): - pass - - def deltint(self): - self.job_counter += 1 - self.hold_in(PHASE_ACTIVE, self.period) - - def deltext(self, e): - self.passivate() - - def lambdaf(self): - self.o_out.add(Job(str(self.job_counter))) - - -# -# class Processor(Atomic): -# def __init__(self, name, proc_time): -# super().__init__(name) -# -# self.i_in = Port(Job, "i_in") -# self.o_out = Port(Job, "o_out") -# -# self.add_in_port(self.i_in) -# self.add_out_port(self.o_out) -# -# self.current_job = None -# self.proc_time = proc_time -# -# def initialize(self): -# self.passivate() -# -# def exit(self): -# pass -# -# def deltint(self): -# self.passivate() -# -# def deltext(self, e): -# if self.phase == PHASE_PASSIVE: -# self.current_job = self.i_in.get() -# self.hold_in(PHASE_ACTIVE, self.proc_time) -# -# def lambdaf(self): -# self.o_out.add(self.current_job) - -class Processor(AtomicDEVS): - def __init__(self, name, proc_time): - super().__init__(name) - - self.i_in = self.addInPort("i_in") - self.o_out = self.addOutPort("o_out") - - self.current_job = None - self.proc_time = proc_time - self.state = "passive" - - def intTransition(self): - return "passive" - - def extTransition(self, inputs): - if self.state == "passive": - self.current_job = inputs[self.i_in][0] - return "active" - - def timeAdvance(self): - if self.state == "active": - return self.proc_time - else: - return INFINITY - - def outputFnc(self): - return {self.o_out: [self.current_job]} - - -class Transducer(Atomic): - - def __init__(self, name, obs_time): - super().__init__(name) - - self.i_arrived = Port(Job, "i_arrived") - self.i_solved = Port(Job, "i_solved") - self.o_out = Port(Job, "o_out") - - self.add_in_port(self.i_arrived) - self.add_in_port(self.i_solved) - self.add_out_port(self.o_out) - - self.jobs_arrived = [] - self.jobs_solved = [] - - self.total_ta = 0 - self.clock = 0 - self.obs_time = obs_time - - def initialize(self): - self.hold_in(PHASE_ACTIVE, self.obs_time) - - def exit(self): - pass - - def deltint(self): - self.clock += self.sigma - - if self.phase == PHASE_ACTIVE: - if self.jobs_solved: - avg_ta = self.total_ta / len(self.jobs_solved) - throughput = len(self.jobs_solved) / self.clock if self.clock > 0 else 0 - else: - avg_ta = 0 - throughput = 0 - - logger.info("End time: %f" % self.clock) - logger.info("Jobs arrived: %d" % len(self.jobs_arrived)) - logger.info("Jobs solved: %d" % len(self.jobs_solved)) - logger.info("Average TA: %f" % avg_ta) - logger.info("Throughput: %f\n" % throughput) - - self.hold_in(PHASE_DONE, 0) - else: - self.passivate() - - def deltext(self, e): - self.clock += e - - if self.phase == PHASE_ACTIVE: - if self.i_arrived: - job = self.i_arrived.get() - logger.info("Starting job %s @ t = %d" % (job.name, self.clock)) - job.time = self.clock - self.jobs_arrived.append(job) - - if self.i_solved: - job = self.i_solved.get() - logger.info("Job %s finished @ t = %d" % (job.name, self.clock)) - self.total_ta += self.clock - job.time - self.jobs_solved.append(job) - - self.continuef(e) - - def lambdaf(self): - if self.phase == PHASE_DONE: - self.o_out.add(Job("null")) - - -class Gpt(Coupled): - def __init__(self, name, period, obs_time): - super().__init__(name) - - if period < 1: - raise ValueError("period has to be greater than 0") - - if obs_time < 0: - raise ValueError("obs_time has to be greater or equal than 0") - - gen = Generator("generator", period) - proc = PyPDEVSWrapper(Processor("processor", 3 * period)) - trans = Transducer("transducer", obs_time) - - self.add_component(gen) - self.add_component(proc) - self.add_component(trans) - - self.add_coupling(gen.o_out, proc.i_in) - self.add_coupling(gen.o_out, trans.i_arrived) - self.add_coupling(proc.o_out, trans.i_solved) - self.add_coupling(trans.o_out, gen.i_stop) - - -if __name__ == '__main__': - gpt = Gpt("gpt", 3, 1000) - coord = Coordinator(gpt, flatten=False, chain=False) - coord.initialize() - coord.simulate() diff --git a/xdevs/examples/store_cashier/__init__.py b/xdevs/examples/celldevs_sir/__init__.py similarity index 100% rename from xdevs/examples/store_cashier/__init__.py rename to xdevs/examples/celldevs_sir/__init__.py diff --git a/xdevs/examples/celldevs_sir/main.py b/xdevs/examples/celldevs_sir/main.py new file mode 100644 index 0000000..52ee898 --- /dev/null +++ b/xdevs/examples/celldevs_sir/main.py @@ -0,0 +1,37 @@ +import math +from xdevs.celldevs.inout import CellMessage +from xdevs.models import Coupled +from xdevs.sim import Coordinator +from xdevs.factory import Transducer, Transducers +from sir_coupled import SIRGridCoupled +from sir_sink import SIRSink, State + + +class SIRModel(Coupled): + def __init__(self, config_path: str): + super().__init__() + self.celldevs = SIRGridCoupled(config_path) + self.sink = SIRSink('sink') + + self.add_component(self.celldevs) + self.add_component(self.sink) + self.add_coupling(self.celldevs.sink_port, self.sink.in_sink) + + +if __name__ == '__main__': + model = SIRModel('scenario.json') + + celldevs_transducer: Transducer = Transducers.create_transducer('csv', transducer_id='celldevs', + event_type=CellMessage, include_names=False, + exhaustive=True) + celldevs_transducer.add_target_port(model.celldevs.sink_port) + + sink_transducer: Transducer = Transducers.create_transducer('csv', transducer_id='sink', + event_type=State, include_names=False) + sink_transducer.add_target_port(model.sink.out_sink) + + coordinator = Coordinator(model) + coordinator.add_transducer(celldevs_transducer) + coordinator.add_transducer(sink_transducer) + coordinator.initialize() + coordinator.simulate_time(math.inf) diff --git a/xdevs/examples/celldevs_sir/scenario.json b/xdevs/examples/celldevs_sir/scenario.json new file mode 100644 index 0000000..2354737 --- /dev/null +++ b/xdevs/examples/celldevs_sir/scenario.json @@ -0,0 +1,49 @@ +{ + "scenario":{ + "shape": [25, 25], + "origin": [-12, -12], + "wrapped": false + }, + "cells": { + "default": { + "delay": "inertial", + "cell_type": "hoya", + "neighborhood": [ + { + "type": "von_neumann", + "range": 1, + "vicinity": { + "connectivity": 1, + "mobility": 0.5 + } + }, + { + "type": "relative", + "neighbors": [[0, 0]], + "vicinity": { + "connectivity": 1, + "mobility": 1 + } + } + ], + "state": { + "population": 100, + "susceptible": 1, + "infected": 0, + "recovered": 0 + }, + "config": { + "virulence": 0.6, + "recovery": 0.4 + }, + "eoc": [["out_celldevs", "out_sink"]] + }, + "epicenter": { + "state": { + "susceptible": 0.7, + "infected": 0.3 + }, + "cell_map": [[0, 0]] + } + } +} diff --git a/xdevs/examples/celldevs_sir/sir_cell.py b/xdevs/examples/celldevs_sir/sir_cell.py new file mode 100644 index 0000000..46f1a30 --- /dev/null +++ b/xdevs/examples/celldevs_sir/sir_cell.py @@ -0,0 +1,68 @@ +from __future__ import annotations +from typing import Callable, Any +from xdevs.celldevs.cell import S +from xdevs.celldevs.grid import C, GridCell, GridCellConfig +from xdevs.abc.transducer import Transducible, T + + +class State(Transducible): + def __init__(self, population: int, susceptible: float, infected: float, recovered: float): + self.population: int = population + self.susceptible: float = susceptible + self.infected: float = infected + self.recovered: float = recovered + + def __eq__(self, other: State): + return self.population == other.population and self.susceptible == other.susceptible \ + and self.infected == other.infected and self.recovered == other.recovered + + @classmethod + def transducer_map(cls) -> dict[str, tuple[type[T], Callable[[Any], T]]]: + return { + 'population': (int, lambda x: x.population), + 'susceptible': (float, lambda x: x.susceptible), + 'infected': (float, lambda x: x.infected), + 'recovered': (float, lambda x: x.recovered) + } + + +class Vicinity: + def __init__(self, connectivity: float, mobility: float): + self.connectivity: float = connectivity + self.mobility: float = mobility + + @property + def correlation(self) -> float: + return self.connectivity * self.mobility + + +class Config: + def __init__(self, virulence: float, recovery: float): + self.virulence = virulence + self.recovery = recovery + + +class SIRGridCell(GridCell[State, Vicinity]): + def __init__(self, cell_id: C, config: GridCellConfig): + super().__init__(cell_id, config) + self.config: Config = Config(**config.cell_config) + + def local_computation(self, cell_state: S) -> S: + new_infections = self.new_infections(cell_state) + new_recoveries = self.new_recoveries(cell_state) + cell_state.recovered = round((cell_state.recovered + new_recoveries) * 100) / 100 + cell_state.infected = round((cell_state.infected + new_infections - new_recoveries) * 100) / 100 + cell_state.susceptible = 1 - cell_state.infected - cell_state.recovered + return cell_state + + def new_infections(self, state: State) -> float: + neighbor_effect = sum(state.infected * state.population * self.neighborhood[neighbor].correlation + for neighbor, state in self.neighbors_state.items()) + new_infections = state.susceptible * self.config.virulence * neighbor_effect / state.population + return min(state.susceptible, new_infections) + + def new_recoveries(self, state: State) -> float: + return state.infected * self.config.recovery + + def output_delay(self, cell_state: S) -> float: + return 1 diff --git a/xdevs/examples/celldevs_sir/sir_coupled.py b/xdevs/examples/celldevs_sir/sir_coupled.py new file mode 100644 index 0000000..dbac3d1 --- /dev/null +++ b/xdevs/examples/celldevs_sir/sir_coupled.py @@ -0,0 +1,36 @@ +import math + +from xdevs.celldevs.coupled import CoupledGridCellDEVS +from xdevs.celldevs.grid import C, GridCellConfig, GridCell +from xdevs.celldevs.inout import CellMessage +from xdevs.models import Port +from xdevs.sim import Coordinator +from xdevs.factory import Transducer, Transducers +from sir_cell import State, Vicinity, SIRGridCell + + +class SIRGridCoupled(CoupledGridCellDEVS[State, Vicinity]): + def __init__(self, config_file: str): + super().__init__(State, Vicinity, config_file) + self.sink_port = Port(CellMessage, 'out_sink') + self.add_out_port(self.sink_port) + + self.load_config() + self.load_cells() + self.load_couplings() + + def create_cell(self, cell_type: str, cell_id: C, cell_config: GridCellConfig[State, Vicinity]) -> GridCell[State, Vicinity]: + return SIRGridCell(cell_id, cell_config) + + +if __name__ == '__main__': + model = SIRGridCoupled('scenario.json') + + transducer: Transducer = Transducers.create_transducer('csv', transducer_id='transducer', event_type=CellMessage, + include_names=False, exhaustive=True) + transducer.add_target_port(model.sink_port) + + coordinator = Coordinator(model) + coordinator.add_transducer(transducer) + coordinator.initialize() + coordinator.simulate_time(math.inf) diff --git a/xdevs/examples/celldevs_sir/sir_sink.py b/xdevs/examples/celldevs_sir/sir_sink.py new file mode 100644 index 0000000..105cae4 --- /dev/null +++ b/xdevs/examples/celldevs_sir/sir_sink.py @@ -0,0 +1,53 @@ +from typing import Dict, NoReturn, Optional, Tuple +from sir_cell import State +from xdevs.celldevs.inout import CellMessage +from xdevs.models import Atomic, Port + + +class SIRSink(Atomic): + def __init__(self, name: str = None): + super().__init__(name) + self.started: bool = False + self.cell_reports: Dict[Tuple[int, ...], State] = dict() + self.scenario_report: Optional[State] = None + + self.in_sink: Port[CellMessage[Tuple[int, ...], State]] = Port(CellMessage, 'in_sink') + self.out_sink: Port[State] = Port(State, 'out_sink') + self.add_in_port(self.in_sink) + self.add_out_port(self.out_sink) + + def deltint(self) -> NoReturn: + self.passivate() + self.started = True + + def deltext(self, e: float) -> NoReturn: + self.activate() + for msg in self.in_sink.values: + if self.started: + prev_report = self.cell_reports[msg.cell_id] + delta_s = msg.cell_state.susceptible - prev_report.susceptible + delta_i = msg.cell_state.infected - prev_report.infected + delta_r = msg.cell_state.recovered - prev_report.recovered + self.scenario_report.susceptible += delta_s * prev_report.population / self.scenario_report.population + self.scenario_report.infected += delta_i * prev_report.population / self.scenario_report.population + self.scenario_report.recovered += delta_r * prev_report.population / self.scenario_report.population + self.cell_reports[msg.cell_id] = msg.cell_state + if not self.started: + self.scenario_report: State = State(0, 0, 0, 0) + for cell_state in self.cell_reports.values(): + self.scenario_report.population += cell_state.population + self.scenario_report.susceptible += cell_state.population * cell_state.susceptible + self.scenario_report.infected += cell_state.population * cell_state.infected + self.scenario_report.recovered += cell_state.population * cell_state.recovered + self.scenario_report.susceptible /= self.scenario_report.population + self.scenario_report.infected /= self.scenario_report.population + self.scenario_report.recovered /= self.scenario_report.population + + def lambdaf(self) -> NoReturn: + self.out_sink.add(self.scenario_report) + + def initialize(self) -> NoReturn: + self.passivate() + + def exit(self) -> NoReturn: + pass diff --git a/xdevs/examples/devstone/devstone.py b/xdevs/examples/devstone/devstone.py index 8d187a1..769787d 100644 --- a/xdevs/examples/devstone/devstone.py +++ b/xdevs/examples/devstone/devstone.py @@ -1,11 +1,11 @@ from __future__ import annotations + from abc import ABC -import sys -import time from typing import Any from xdevs.models import Atomic, Coupled, Port from xdevs.sim import Coordinator -from xdevs.examples.devstone.pystone import pystones +from .pystone import pystones + class DelayedAtomic(Atomic): def __init__(self, name: str, int_delay: float, ext_delay: float, test: bool = False): @@ -49,6 +49,8 @@ def exit(self): class AbstractDEVStone(Coupled, ABC): + components: list[AbstractDEVStone | DelayedAtomic] + def __init__(self, name: str, width: int, depth: int, int_delay: float, ext_delay: float, test: bool = False): super().__init__(name) if width < 1: @@ -284,34 +286,10 @@ def n_events(self) -> int: if __name__ == '__main__': + import sys sys.setrecursionlimit(10000) - - try: - model_type = sys.argv[1] - except IndexError: - raise ValueError("first argument must select the model type") - try: - width = int(sys.argv[2]) - except ValueError: - raise ValueError("width could not be parsed") - except IndexError: - raise ValueError("second argument must select the width") - try: - depth = int(sys.argv[3]) - except ValueError: - raise ValueError("depth could not be parsed") - except IndexError: - raise ValueError("third argument must select the depth") - # TODO add internal and external delays - - start = time.time() - root = DEVStone("devstone", model_type, width, depth, 0, 0) - middle = time.time() + root = HO("HO_root", 50, 50, 0, 0) coord = Coordinator(root) coord.initialize() - middle2 = time.time() - coord.simulate() - end = time.time() - print(f"Model creation time: {middle - start} seconds") - print(f"Engine setup time: {middle2 - middle} seconds") - print(f"Simulation time: {end - middle2} seconds") + coord.inject(root.i_in, 0) + coord.simulate_iters() diff --git a/xdevs/examples/devstone/generator.py b/xdevs/examples/devstone/generator.py new file mode 100644 index 0000000..aaddbeb --- /dev/null +++ b/xdevs/examples/devstone/generator.py @@ -0,0 +1,34 @@ +from xdevs.models import Atomic, Port + + +class Generator(Atomic): + def __init__(self, name: str, num_outputs: int = 1, period: float = float('inf'), num_ports: int = 1): + super(Generator, self).__init__(name=name) + if num_outputs < 1: + raise ValueError('Number of outputs must greater than zero') + if num_ports < 1: + raise ValueError('Number of ports must be greater than zero') + if period <= 0: + raise ValueError('Period must be greater than zero') + self.num_outputs: int = num_outputs + self.period: float = period + + self.o_out: list[Port[int]] = [Port(int, f'o_out_{i}') for i in range(num_ports)] + for port in self.o_out: + self.add_out_port(port) + + def deltint(self): + self.sigma = self.period + + def deltext(self, e: float): + pass + + def lambdaf(self): + for port in self.o_out: + port.extend(range(self.num_outputs)) + + def initialize(self): + self.activate() + + def exit(self): + pass diff --git a/xdevs/examples/devstone/main.py b/xdevs/examples/devstone/main.py new file mode 100644 index 0000000..2220214 --- /dev/null +++ b/xdevs/examples/devstone/main.py @@ -0,0 +1,74 @@ +import argparse +import sys +import time + +from xdevs.sim import Coordinator + +from xdevs.examples.devstone.devstone import LI, HO, HI, HOmod +from xdevs.examples.devstone.generator import Generator +from xdevs.models import Coupled + + +sys.setrecursionlimit(10000) + +MODEL_TYPES = ("LI", "HI", "HO", "HOmod") + + +class DEVStoneEnvironment(Coupled): + def __init__(self, name, devstone_model, num_gen_outputs=1): + super(DEVStoneEnvironment, self).__init__(name=name) + generator = Generator("generator", num_gen_outputs) + self.add_component(generator) + self.add_component(devstone_model) + + self.add_coupling(generator.o_out[0], devstone_model.i_in) + if isinstance(devstone_model, HO) or isinstance(devstone_model, HOmod): + self.add_coupling(generator.o_out[0], devstone_model.i_in2) + + +def parse_args(): + parser = argparse.ArgumentParser(description='Script to compare DEVStone implementations with different engines') + + parser.add_argument('-m', '--model-type', required=True, help='DEVStone model type (LI, HI, HO, HOmod)') + parser.add_argument('-d', '--depth', type=int, required=True, help='Number of recursive levels of the model.') + parser.add_argument('-w', '--width', type=int, required=True, help='Width of each coupled model.') + parser.add_argument('-i', '--int-cycles', type=int, default=0, help='Dhrystone cycles executed in internal transitions') + parser.add_argument('-e', '--ext-cycles', type=int, default=0, help='Dhrystone cycles executed in external transitions') + parser.add_argument('-f', '--flatten', action="store_true", help='Activate flattening on model') + + args = parser.parse_args() + + if args.model_type not in MODEL_TYPES: + raise RuntimeError("Unrecognized model type.") + + return args + + +if __name__ == '__main__': + args = parse_args() + + if args.model_type == "LI": + devstone_model = LI("LI_root", args.depth, args.width, args.int_cycles, args.ext_cycles) + elif args.model_type == "HI": + devstone_model = HI("HI_root", args.depth, args.width, args.int_cycles, args.ext_cycles) + elif args.model_type == "HO": + devstone_model = HO("HO_root", args.depth, args.width, args.int_cycles, args.ext_cycles) + elif args.model_type == "HOmod": + devstone_model = HOmod("HOmod_root", args.depth, args.width, args.int_cycles, args.ext_cycles) + else: + raise RuntimeError("Unrecognized model type.") + + start_time = time.time() + env = DEVStoneEnvironment("DEVStoneEnvironment", devstone_model) + model_created_time = time.time() + + coord = Coordinator(env, flatten=args.flatten) + coord.initialize() + engine_setup_time = time.time() + + coord.simulate_iters() + sim_time = time.time() + + print(f"Model creation time: {model_created_time - start_time}") + print(f"Engine setup time: {engine_setup_time - model_created_time}") + print(f"Simulation time: {sim_time - engine_setup_time}") diff --git a/xdevs/examples/gpt/README.md b/xdevs/examples/gpt/README.md new file mode 100644 index 0000000..8bcac30 --- /dev/null +++ b/xdevs/examples/gpt/README.md @@ -0,0 +1,94 @@ +# GPT examples + +This folder stores several examples in order to illustrate some of the possibilities offered by the repository. + + +## `xDEVS.py` Virtual Simulation + +A virtual simulation is carried out only taking into account the virtual environment. +In the context of this repository, a DEVS virtual simulation will be achieved by a Coordinator. Two possible options are provided based on two methods: `simulate` and `simulate_time`. The first one is based on the number of iterations and the second one on the desired virtual time to simulate. + +### Example: + + +```bash +$ cd xdevs/examples/gpt +$ python3 gpt_v_sim.py +``` + +## `xDEVS` Real-Time Simulation + +This section aims to show a collection of examples based on the methodology followed in this repository for achieving the wall-clock behaviour. + +### Examples: + +1. #### No handlers real-time simulation + +```bash +$ cd xdevs/examples/gpt +$ python3 gpt_rt_sim.py +``` +2. #### (TCP) Input handler real-time simulation + +```bash +$ cd xdevs/examples/gpt +$ python3 gpt_rt_ih_sim.py +``` +However, in order to be able to completely understand the system you must execute a TCP client to send the input events to the model. +The following code, show a basic example of a TCP client that sends an input event to the model. +It is important to keep in mind that the expect message of the TCP Input handler is `Port_name,message` as it is specified in `xdevs.plugins.input_handlers.tcp` +```bash +import socket + +HOST = 'LocalHost' +PORT = 4321 + +c = socket.socket(socket.AF_INET,socket.SOCK_STREAM) # AF_INET: IPv4, SOCK_STREAM: TCP + +c.connect((HOST,PORT)) + +c.sendall('ih_in,TCP'.encode()) # The data is 'port_name,msg' +``` + +3. #### (TCP) Output handler real-time simulation + +```bash +$ cd xdevs/examples/gpt +$ python3 gpt_rt_oh_sim.py +``` +In order to capture the outgoing events of the model, you must execute a TCP server to receive the output events. +A basic TCP server is shown below: +```bash +import socket + +HOST = 'LocalHost' +PORT = 4321 + +s = socket.socket(socket.AF_INET,socket.SOCK_STREAM) # AF_INET: IPv4, SOCK_STREAM: TCP + +s.bind((HOST,PORT)) +s.listen() + +s_con, add = s.accept() +print(f'Connected to: {add}') +while True: + try: + data = s_con.recv(1024) # 1024 is the buffer size + data = data.decode() + if not data: + print(f'Client disconnected: {add}') + break + print(f'data received is: {data}') + except ConnectionResetError: + print(f'Client closed unexpectedly: {add}') + break +```` + +4. #### (TCP) Input and Output handlers real-time simulation + +````bash +$ cd xdevs/examples/gpt +$ python3 gpt_rt_ih_oh_sim.py +```` +If you want to test the system properly, you will have to execute the server TCP first, then run the example and later +execute the client TCP to send the input events to the model. \ No newline at end of file diff --git a/xdevs/examples/gpt/__init__.py b/xdevs/examples/gpt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/examples/gpt/efp.py b/xdevs/examples/gpt/efp.py new file mode 100644 index 0000000..67dbda2 --- /dev/null +++ b/xdevs/examples/gpt/efp.py @@ -0,0 +1,9 @@ +from xdevs.sim import Coordinator +from xdevs.examples.gpt.models import Efp + +if __name__ == '__main__': + + efp = Efp('efp', 3, 5, 100) + coord = Coordinator(efp) + coord.initialize() + coord.simulate() diff --git a/xdevs/examples/gpt/gpt_rt_ih_oh_sim.py b/xdevs/examples/gpt/gpt_rt_ih_oh_sim.py new file mode 100644 index 0000000..1e626df --- /dev/null +++ b/xdevs/examples/gpt/gpt_rt_ih_oh_sim.py @@ -0,0 +1,18 @@ +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.examples.gpt.models import GptIHOH, Job + +if __name__ == '__main__': + gpt = GptIHOH("gpt", 20, 1, 100) + manager = RealTimeManager(max_jitter=0.02,time_scale=1,event_window=.05) + + # The InputHandler under study will be a TCP one + # msg_parser: How must the arrived messages adapt to the system, in this case they are converted into Jobs named + # after the receiving message + msg_parser = {"ih_in" : lambda x : Job(str(x))} + # We pass the identifier and the required arguments for the TCP handler + manager.add_input_handler("tcp", port=4321, msg_parsers=msg_parser) + + manager.add_output_handler('tcp', port=1234) + + coord = RealTimeCoordinator(gpt, manager) + coord.simulate_rt() \ No newline at end of file diff --git a/xdevs/examples/gpt/gpt_rt_ih_sim.py b/xdevs/examples/gpt/gpt_rt_ih_sim.py new file mode 100644 index 0000000..2a957ab --- /dev/null +++ b/xdevs/examples/gpt/gpt_rt_ih_sim.py @@ -0,0 +1,16 @@ +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.examples.gpt.models import GptIHOH, Job + +if __name__ == '__main__': + gpt = GptIHOH("gpt", 20, 1, 100) + manager = RealTimeManager(max_jitter=0.02,time_scale=1,event_window=.05) + + # The InputHandler under study will be a TCP one + # msg_parser: How must the arrived messages adapt to the system, in this case they are converted into Jobs named + # after the receiving message + msg_parser = {"ih_in" : lambda x : Job(str(x))} + # We pass the identifier and the required arguments for the TCP handler + manager.add_input_handler("tcp", port=4321, msg_parsers=msg_parser) + + coord = RealTimeCoordinator(gpt, manager) + coord.simulate_rt() diff --git a/xdevs/examples/gpt/gpt_rt_oh_sim.py b/xdevs/examples/gpt/gpt_rt_oh_sim.py new file mode 100644 index 0000000..87a9d64 --- /dev/null +++ b/xdevs/examples/gpt/gpt_rt_oh_sim.py @@ -0,0 +1,12 @@ +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.examples.gpt.models import GptIHOH, Job + +if __name__ == '__main__': + gpt = GptIHOH("gpt", 5, 3, 100) + manager = RealTimeManager(max_jitter=0.02,time_scale=1,event_window=.05) + + manager.add_output_handler('tcp',port=4321) + + coord = RealTimeCoordinator(gpt, manager) + coord.simulate_rt() + diff --git a/xdevs/examples/gpt/gpt_rt_sim.py b/xdevs/examples/gpt/gpt_rt_sim.py new file mode 100644 index 0000000..2c98c77 --- /dev/null +++ b/xdevs/examples/gpt/gpt_rt_sim.py @@ -0,0 +1,8 @@ +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.examples.gpt.models import Gpt + +if __name__ == '__main__': + gpt = Gpt("gpt", 2, 7, 100) + manager = RealTimeManager(0.02,1,0.05) + coordinator = RealTimeCoordinator(gpt, manager) + coordinator.simulate_rt(100) diff --git a/xdevs/examples/gpt/gpt_v_sim.py b/xdevs/examples/gpt/gpt_v_sim.py new file mode 100644 index 0000000..27801de --- /dev/null +++ b/xdevs/examples/gpt/gpt_v_sim.py @@ -0,0 +1,10 @@ +from xdevs.sim import Coordinator +from xdevs.examples.gpt.models import Gpt + +if __name__ == '__main__': + + gpt = Gpt("gpt", 3, 5, 100) + coord = Coordinator(gpt) + coord.initialize() + coord.simulate() + diff --git a/xdevs/examples/gpt/models.py b/xdevs/examples/gpt/models.py new file mode 100644 index 0000000..3433493 --- /dev/null +++ b/xdevs/examples/gpt/models.py @@ -0,0 +1,264 @@ +import logging +import time +from xdevs import PHASE_ACTIVE, PHASE_PASSIVE, get_logger +from xdevs.models import Atomic, Coupled, Port + +logger = get_logger(__name__, logging.DEBUG) + +PHASE_DONE = "done" + + +class Job: + def __init__(self, name: str): + """ + Job event class. It represents a job sent by the generator and processed by the processor. + :param name: job name + """ + self.name: str = str(name) + self.time: float = 0 + def __str__(self): + return self.name + + +class Generator(Atomic): + def __init__(self, name: str, gen_t: float): + """ + Generator model. It generates jobs at a given period. + :param name: model name + :param gen_t: period between job generations + """ + super().__init__(name) + + if gen_t < 1: + raise ValueError('gen_t must be greater than 0') + + self.i_stop: Port[bool] = Port(bool, "i_stop") + self.o_job: Port[Job] = Port(Job, "o_out") + + self.add_in_port(self.i_stop) + self.add_out_port(self.o_job) + + self.gen_t: float = gen_t + self.job_counter: int = 1 + + def initialize(self): + self.hold_in(PHASE_ACTIVE, self.gen_t) + + def exit(self): + pass + + def deltint(self): + self.job_counter += 1 + self.hold_in(PHASE_ACTIVE, self.gen_t) + + def deltext(self, e): + self.continuef(e) + if self.i_stop.get(): + self.passivate() + elif self.sigma == float('inf'): + self.hold_in(PHASE_ACTIVE, self.gen_t) + + def lambdaf(self): + self.o_job.add(Job(str(self.job_counter))) + + +class Processor(Atomic): + def __init__(self, name: str, proc_t: float): + """ + Processor model. It processes jobs with a given processing time. + :param name: model name + :param proc_t: processing time + """ + super().__init__(name) + + if proc_t < 1: + raise ValueError('proc_t must be greater than 0') + + self.i_in: Port[Job] = Port(Job, "i_in") + self.o_out: Port[Job] = Port(Job, "o_out") + + self.add_in_port(self.i_in) + self.add_out_port(self.o_out) + + self.current_job: Job | None = None + self.proc_t: float = proc_t + + def initialize(self): + self.passivate() + + def exit(self): + pass + + def deltint(self): + self.passivate() + + def deltext(self, e): + if self.phase == PHASE_PASSIVE: + self.current_job = self.i_in.get() + self.hold_in(PHASE_ACTIVE, self.proc_t) + else: + self.continuef(e) + + def lambdaf(self): + self.o_out.add(self.current_job) + + +class Transducer(Atomic): + def __init__(self, name: str, obs_t: float): + super().__init__(name) + + if obs_t < 0: + raise ValueError('obs_t must be greater or equal than 0') + + self.i_arrived: Port[Job] = Port(Job, "i_arrived") + self.i_solved: Port[Job] = Port(Job, "i_solved") + self.o_out: Port[bool] = Port(bool, "o_out") + + self.add_in_port(self.i_arrived) + self.add_in_port(self.i_solved) + self.add_out_port(self.o_out) + + self.jobs_arrived: list[Job] = [] + self.jobs_solved: list[Job] = [] + + self.total_ta: float = 0 + self.clock: float = 0 + self.obs_t: float = obs_t + + def initialize(self): + self.hold_in(PHASE_ACTIVE, self.obs_t) + + def exit(self): + pass + + def deltint(self): + self.clock += self.sigma + + if self.phase == PHASE_ACTIVE: + avg_ta = 0 + throughput = 0 + if self.jobs_solved: + avg_ta = self.total_ta / len(self.jobs_solved) + throughput = len(self.jobs_solved) / self.clock if self.clock > 0 else 0 + + logger.info(f'End time: {self.clock}') + logger.info(f'Jobs arrived: {len(self.jobs_arrived)}') + logger.info(f'Jobs solved: {len(self.jobs_solved)}') + logger.info(f'Average TA: {avg_ta}') + logger.info(f'Throughput: {throughput}') + + self.hold_in(PHASE_DONE, 0) + else: + self.passivate() + + def deltext(self, e): + self.clock += e + + if self.phase == PHASE_ACTIVE: + if self.i_arrived: + job = self.i_arrived.get() + logger.info(f'Starting job {job.name} @ t = {self.clock} @ t = {time.time_ns()}') + job.time = self.clock + self.jobs_arrived.append(job) + + if self.i_solved: + job = self.i_solved.get() + logger.info(f'Job {job.name} finished @ t = {self.clock} @ t = {time.time()}') + self.total_ta += self.clock - job.time + self.jobs_solved.append(job) + + self.continuef(e) + + def lambdaf(self): + if self.phase == PHASE_DONE: + self.o_out.add(True) + +class Ef(Coupled): + def __init__(self, name: str, gen_t: float, obs_t: float): + super().__init__(name) + + gen = Generator('generator', gen_t) + trans = Transducer('transducer', obs_t) + + self.add_component(gen) + self.add_component(trans) + + self.p_in_ef = Port(Job, name='p_in_ef') + self.p_out_ef = Port(Job, name='p_out_ef') + + self.add_in_port(self.p_in_ef) + self.add_out_port(self.p_out_ef) + + self.add_coupling(gen.o_job, trans.i_arrived) + self.add_coupling(gen.o_job, self.p_out_ef) + self.add_coupling(trans.o_out, gen.i_stop) + self.add_coupling(self.p_in_ef, trans.i_solved) + + +class Efp(Coupled): + def __init__(self, name: str, gen_t: float, proc_t: float, obs_t: float): + super().__init__(name) + + ef = Ef('ef', gen_t, obs_t) + proc = Processor('processor', proc_t) + + self.add_component(ef) + self.add_component(proc) + + self.add_coupling(ef.p_out_ef, proc.i_in) + self.add_coupling(proc.o_out, ef.p_in_ef) +class Gpt(Coupled): + def __init__(self, name: str, gen_t: float, proc_t: float, obs_t: float): + super().__init__(name) + + gen = Generator('generator', gen_t) + proc = Processor('processor', proc_t) + trans = Transducer('transducer', obs_t) + + self.add_component(gen) + self.add_component(proc) + self.add_component(trans) + + self.add_coupling(gen.o_job, proc.i_in) + self.add_coupling(gen.o_job, trans.i_arrived) + self.add_coupling(proc.o_out, trans.i_solved) + self.add_coupling(trans.o_out, gen.i_stop) + +class GptIHOH(Coupled): + + # Adaptation of the GPT DEVS model for injecting events via a new input port and for ejection of events via a new + # output port. + + def __init__(self, name: str, gen_t: float, proc_t: float, obs_t: float): + super().__init__(name) + + gen = Generator('generator', gen_t) + proc = Processor('processor', proc_t) + trans = Transducer('transducer', obs_t) + + # New input handler port + self.ih_in = Port(Job, name='ih_in') + self.add_in_port(self.ih_in) + + # New output handler port + self.oh_out = Port(Job, name='oh_out') + self.add_out_port(self.oh_out) + + self.add_component(gen) + self.add_component(proc) + self.add_component(trans) + + # New coupling for the input handler + self.add_coupling(self.ih_in, proc.i_in) + + # New coupling for the output handler + self.add_coupling(proc.o_out, self.oh_out) + + self.add_coupling(gen.o_job, proc.i_in) + self.add_coupling(gen.o_job, trans.i_arrived) + self.add_coupling(proc.o_out, trans.i_solved) + self.add_coupling(trans.o_out, gen.i_stop) + + + + diff --git a/xdevs/examples/json/README.md b/xdevs/examples/json/README.md new file mode 100644 index 0000000..fc9fe1e --- /dev/null +++ b/xdevs/examples/json/README.md @@ -0,0 +1,91 @@ +# JSON to `xDEVS` Simulations + +The `json` folder contains two examples of how to generate a `DEVS` model using a `JSON` file, these examples are the `efp.json` and `gpt.json`. + +Next, a concise guide on using the `from_json` method from `xdevs.factory` to parse a `JSON` file into a `DEVS` model is presented. +For detailed information, please refer to the method's documentation. + +## Overview + +The `from_json` method allows you to parse a `JSON` file into a `DEVS` model. The `JSON` file must follow specific rules to correctly define components and their couplings. + +**ATTENTION PLEASE** ❗❗ + + Take into account that the `component_id` inside the `JSON` file must be identified as an `entry-point` of `Components` in `xdevs.factory`. (See the `Factory` section in `xdevs.abc.README` for more information). + + +## JSON Structure + +### Master Component + +The top-level JSON object represents the master component. + +```json +{ + "MasterComponentName": { + "components": { + "Nested components" : {} + }, + "couplings": [ + "List of couplings" + ] + } +} +``` + +### Components + +Components can be either already registered in the `entry-points` or couple: + +* Coupled: Contains `components` and `couplings` keys. +* Component: Identified by the `component_id` key. It must be already defined in the `entry-points`. + + +```json +"components": { + "CoupledModel1": { + "components": { + Nested components + }, + "couplings": [ + List of connection dictionaries + ] + }, + "Component2": { + "component_id": "ID_from_factory", + "args": [ positional arguments ], + "kwargs": { + "a_parameter": "value", + Other keyword arguments + } + } + Additional components +} + +``` + +### Couplings +Couplings define connections between components: + +* IC (Internal Coupling): Both componentFrom and componentTo are specified. +* EIC (External Input Coupling): componentFrom is missing. (A new input port is created) +* EOC (External Output Coupling): componentTo is missing. (A new output port is created) + +```json +"couplings": [ + { + "componentFrom": "Model1", + "portFrom": "PortA", Port name defined in Model1 + "componentTo": "Model2", + "portTo": "PortB" + } + Additional couplings +] +``` + +## Example + +```bash +$ cd xdevs/examples/json +$ python3 json2xdevs_example.py +``` diff --git a/xdevs/examples/json/__init__.py b/xdevs/examples/json/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/examples/json/efp.json b/xdevs/examples/json/efp.json new file mode 100644 index 0000000..a172b88 --- /dev/null +++ b/xdevs/examples/json/efp.json @@ -0,0 +1,33 @@ +{ + "efp": { + "components": { + "ef": { + "component_id": "ef", + "kwargs": { + "gen_t": 3.0, + "obs_t": 100.0 + } + }, + "processor": { + "component_id": "processor", + "kwargs": { + "proc_t": 5.0 + } + } + }, + "couplings": [ + { + "componentFrom": "ef", + "portFrom": "p_out_ef", + "componentTo": "processor", + "portTo": "i_in" + }, + { + "componentFrom": "processor", + "portFrom": "o_out", + "componentTo": "ef", + "portTo": "p_in_ef" + } + ] + } +} diff --git a/xdevs/examples/json/gpt.json b/xdevs/examples/json/gpt.json new file mode 100644 index 0000000..7480c41 --- /dev/null +++ b/xdevs/examples/json/gpt.json @@ -0,0 +1,50 @@ +{ + "gpt": { + "components": { + "generator": { + "component_id": "generator", + "kwargs": { + "gen_t": 3.0 + } + }, + "processor": { + "component_id": "processor", + "kwargs": { + "proc_t": 5.0 + } + }, + "transducer": { + "component_id": "transducer", + "kwargs": { + "obs_t": 100.0 + } + } + }, + "couplings": [ + { + "componentFrom": "processor", + "portFrom": "o_out", + "componentTo": "transducer", + "portTo": "i_solved" + }, + { + "componentFrom": "generator", + "portFrom": "o_out", + "componentTo": "processor", + "portTo": "i_in" + }, + { + "componentFrom": "generator", + "portFrom": "o_out", + "componentTo": "transducer", + "portTo": "i_arrived" + }, + { + "componentFrom": "transducer", + "portFrom": "o_out", + "componentTo": "generator", + "portTo": "i_stop" + } + ] + } +} diff --git a/xdevs/examples/json/json2xdevs_example.py b/xdevs/examples/json/json2xdevs_example.py new file mode 100644 index 0000000..a637e40 --- /dev/null +++ b/xdevs/examples/json/json2xdevs_example.py @@ -0,0 +1,21 @@ +import sys + +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.factory import Components + + +if __name__ == '__main__': + file_path = sys.argv[1] if len(sys.argv) > 1 else 'gpt.json' + + model = Components.from_json(file_path) + """ + # Virtual simulation + coord_v_sim = Coordinator(model) + coord_v_sim.initialize() + coord_v_sim.simulate() + """ + # Wall-clock simulation + m_rt = RealTimeManager(0.2,1) + coord_rt_sim = RealTimeCoordinator(model, m_rt) + coord_rt_sim.simulate_rt() + diff --git a/xdevs/examples/store/1_vt_simulation.py b/xdevs/examples/store/1_vt_simulation.py new file mode 100644 index 0000000..6a71e0a --- /dev/null +++ b/xdevs/examples/store/1_vt_simulation.py @@ -0,0 +1,58 @@ +import sys +import time +from xdevs.sim import Coordinator +from xdevs.examples.store.models.store import Store + + +def get_sec(time_str): + h, m, s = time_str.split(':') + return int(h) * 3600 + int(m) * 60 + int(s) + + +if __name__ == '__main__': + sim_time: float = 13 + n_employees = 3 + mean_employees = 5 + mean_generator = 3 + stddev_employees = 0 + stddev_clients = 0 + + if len(sys.argv) > 8: + print("Program used with more arguments than accepted. Last arguments will be ignored.") + elif len(sys.argv) < 8: + print("Program used with less arguments than accepted. Missing parameters will be set to their default value.") + if len(sys.argv) != 8: + print("Correct usage:") + print("\t" "python3 " + sys.argv[ + 0] + " ") + try: + sim_time = get_sec(sys.argv[1]) + n_employees = int(sys.argv[2]) + mean_employees = float(sys.argv[3]) + mean_generator = float(sys.argv[4]) + stddev_employees = float(sys.argv[5]) + stddev_clients = float(sys.argv[6]) + force_chain = bool(int(sys.argv[7])) + except IndexError: + pass + + print("CONFIGURATION OF THE SCENARIO:") + print("\tSimulation time: {} seconds".format(sim_time)) + print("\tNumber of Employees: {}".format(n_employees)) + print("\tMean time required by employee to dispatch clients: {} seconds (standard deviation of {})".format( + mean_employees, stddev_employees)) + print("\tMean time between new clients: {} seconds (standard deviation of {})".format(mean_generator, + stddev_employees)) + + # PURE SIMULATION + start = time.time() + store = Store(n_employees, mean_employees, mean_generator, stddev_employees, stddev_clients) + middle = time.time() + print("Model Created. Elapsed time: {} sec".format(middle - start)) + coord = Coordinator(store) + coord.initialize() + middle = time.time() + print("Coordinator Created. Elapsed time: {} sec".format(middle - start)) + coord.simulate(sim_time) + end = time.time() + print("Simulation took: {} sec".format(end - start)) diff --git a/xdevs/examples/store/2_rt_simulation_csv_output_handler.py b/xdevs/examples/store/2_rt_simulation_csv_output_handler.py new file mode 100644 index 0000000..88b436f --- /dev/null +++ b/xdevs/examples/store/2_rt_simulation_csv_output_handler.py @@ -0,0 +1,60 @@ +import sys +from xdevs.rt import RealTimeCoordinator, RealTimeManager +import time + +from xdevs.examples.store.models.store import Store + + +def get_sec(time_str): + h, m, s = time_str.split(':') + return int(h) * 3600 + int(m) * 60 + int(s) + + +if __name__ == '__main__': + sim_time: float = 52 + n_employees = 3 + mean_employees = 5 + mean_generator = 3 + stddev_employees = 0.8 + stddev_clients = 0.5 + + if len(sys.argv) > 8: + print("Program used with more arguments than accepted. Last arguments will be ignored.") + elif len(sys.argv) < 8: + print("Program used with less arguments than accepted. Missing parameters will be set to their default value.") + if len(sys.argv) != 8: + print("Correct usage:") + print("\t" "python3 " + sys.argv[ + 0] + " " + " ") + try: + sim_time = get_sec(sys.argv[1]) + n_employees = int(sys.argv[2]) + mean_employees = float(sys.argv[3]) + mean_generator = float(sys.argv[4]) + stddev_employees = float(sys.argv[5]) + stddev_clients = float(sys.argv[6]) + force_chain = bool(int(sys.argv[7])) + except IndexError: + pass + + print("CONFIGURATION OF THE SCENARIO:") + print(f'\tSimulation time: {sim_time} seconds') + print(f'\tNumber of Employees: {n_employees}') + print(f'\tMean time required to dispatch clients: {mean_employees} seconds (stddev of {stddev_employees})') + print(f'\tMean time between new clients: {mean_generator} seconds (standard deviation of {stddev_employees})') + + start = time.time() + store = Store(n_employees, mean_employees, mean_generator, stddev_employees, stddev_clients) + middle = time.time() + print(f'Model Created. Elapsed time: {middle - start} sec') + rt_manager = RealTimeManager(max_jitter=0.2, event_window=0.5) + rt_manager.add_output_handler('csv', file='rt_simulation_csv_output_handler.csv') + c = RealTimeCoordinator(store, rt_manager) + middle = time.time() + print(f'Coordinator, Manager and Handlers Created. Elapsed time: {middle - start} sec') + c.simulate_rt(time_interv=sim_time) + end = time.time() + print(f'Simulation time (s) = {sim_time}') + print(f'Simulation took: {end - start} sec') + print(f'Error (%) = {((time.time() - start - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store/3_rt_simulation_tcp_input_handler.py b/xdevs/examples/store/3_rt_simulation_tcp_input_handler.py new file mode 100644 index 0000000..21ac052 --- /dev/null +++ b/xdevs/examples/store/3_rt_simulation_tcp_input_handler.py @@ -0,0 +1,73 @@ +import sys +import time +from xdevs.examples.store.models.msg import NewClient +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.examples.store.models.store import Store + + +def get_sec(time_str): + h, m, s = time_str.split(':') + return int(h) * 3600 + int(m) * 60 + int(s) + + +def new_client_parser(msg: str): + client_id, t_entered = msg.split('?') + + c = NewClient(client_id=client_id, t_entered=time.time()) + return c + + +if __name__ == '__main__': + sim_time: float = 52 + n_employees = 3 + mean_employees = 5 + mean_generator = 3 + stddev_employees = 0.8 + stddev_clients = 0.5 + + if len(sys.argv) > 8: + print("Program used with more arguments than accepted. Last arguments will be ignored.") + elif len(sys.argv) < 8: + print("Program used with less arguments than accepted. Missing parameters will be set to their default value.") + if len(sys.argv) != 8: + print("Correct usage:") + print("\t" "python3 " + sys.argv[0] + + " " + " ") + try: + sim_time = get_sec(sys.argv[1]) + n_employees = int(sys.argv[2]) + mean_employees = float(sys.argv[3]) + mean_generator = float(sys.argv[4]) + stddev_employees = float(sys.argv[5]) + stddev_clients = float(sys.argv[6]) + force_chain = bool(int(sys.argv[7])) + except IndexError: + pass + + print("CONFIGURATION OF THE SCENARIO:") + print(f'\tSimulation time: {sim_time} seconds') + print(f'\tNumber of Employees: {n_employees}') + print(f'\tMean time required to dispatch clients: {mean_employees} seconds (stddev of {stddev_employees})') + print(f'\tMean time between new clients: {mean_generator} seconds (standard deviation of {stddev_clients})') + + msg_parser = { + 'IP_NewClient': new_client_parser, + } + + # Real Time simulation: + start = time.time() + store = Store(n_employees, mean_employees, mean_generator, stddev_employees, stddev_clients) + middle = time.time() + print(f'Model Created. Elapsed time: {middle - start} sec') + rt_manager = RealTimeManager(max_jitter=0.2, event_window=3) + rt_manager.add_input_handler('tcp', port=4321, max_clients=5, msg_parsers=msg_parser) + + c = RealTimeCoordinator(store, rt_manager) + middle = time.time() + print(f'Coordinator, Manager and Handlers Created. Elapsed time: {middle - start} sec') + c.simulate_rt(time_interv=sim_time) + end = time.time() + print(f'Simulation time (s) = {sim_time}') + print(f'Simulation took: {end - start} sec') + print(f'Error (%) = {((time.time() - start - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store/4_1_rt_simulation_mqtt_input_handler.py b/xdevs/examples/store/4_1_rt_simulation_mqtt_input_handler.py new file mode 100644 index 0000000..a842c28 --- /dev/null +++ b/xdevs/examples/store/4_1_rt_simulation_mqtt_input_handler.py @@ -0,0 +1,70 @@ +import sys +import time +from xdevs.examples.store.models.msg import NewClient +from xdevs.rt import RealTimeCoordinator, RealTimeManager +from xdevs.examples.store.models.store import StoreWithoutGen + + +def get_sec(time_str): + h, m, s = time_str.split(':') + return int(h) * 3600 + int(m) * 60 + int(s) + + +def mqtt_parser(msg: str): + c_id, t = msg.split(';') + return NewClient(c_id, t) + + +if __name__ == '__main__': + sim_time: float = 52 + n_employees = 3 + mean_employees = 5 + stddev_employees = 0.8 + + if len(sys.argv) > 8: + print("Program used with more arguments than accepted. Last arguments will be ignored.") + elif len(sys.argv) < 8: + print( + "Program used with less arguments than accepted. Missing parameters will be set to their default value.") + if len(sys.argv) != 8: + print("Correct usage:") + print("\tpython3 " + sys.argv[0] + " ") + try: + sim_time = get_sec(sys.argv[1]) + n_employees = int(sys.argv[2]) + mean_employees = float(sys.argv[3]) + stddev_employees = float(sys.argv[4]) + except IndexError: + pass + + print("CONFIGURATION OF THE SCENARIO:") + print(f"\tSimulation time: {sim_time} seconds") + print(f"\tNumber of Employees: {n_employees}") + print(f"\tMean time required to dispatch clients: {mean_employees} seconds (stddev of {stddev_employees})") + + # Map of the port of I am subscribing to and the port of the model + connections = { + 'Gen_ClientOut': 'i_ExternalGen' + } + # Topics I am subscribing to + topics = {'RTsys/output/Gen_ClientOut': 0} + # Parser of the port of my model to the desired Port Type + msg_parser = { + 'i_ExternalGen': mqtt_parser, + } + + start = time.time() + storeNOGEN = StoreWithoutGen(n_employees, mean_employees, stddev_employees) + middle = time.time() + print(f"Model Created. Elapsed time: {middle - start} sec") + rt_manager = RealTimeManager(max_jitter=0.2, event_window=0.5) + rt_manager.add_input_handler('mqtt', subscriptions=topics, connections=connections, msg_parsers=msg_parser) + c = RealTimeCoordinator(storeNOGEN, rt_manager) + middle = time.time() + print(f"Coordinator and Manager Created. Elapsed time: {middle - start} sec") + t_ini = time.time() + c.simulate_rt(time_interv=sim_time) + end = time.time() + print(f' Simulation time (s) = {sim_time}') + print(f"Simulation took: {end - start} sec") + print(f' Error (%) = {((time.time() - start - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store/4_2_rt_simulation_mqtt_output_handler.py b/xdevs/examples/store/4_2_rt_simulation_mqtt_output_handler.py new file mode 100644 index 0000000..64322f3 --- /dev/null +++ b/xdevs/examples/store/4_2_rt_simulation_mqtt_output_handler.py @@ -0,0 +1,77 @@ +import sys + +from xdevs.examples.store.models.msg import NewClient +from xdevs.models import Coupled, Port +from xdevs.rt import RealTimeCoordinator, RealTimeManager +import time + +from xdevs.examples.store.models.clients import ClientGenerator + + +class GenSys(Coupled): + def __init__(self, mean_clients: float = 1, stddev_clients: float = 0, name=None): + super().__init__(name) + generator = ClientGenerator(mean_clients, stddev_clients) + + self.out_gen_port = Port(NewClient, 'Gen_ClientOut') + self.add_out_port(self.out_gen_port) + + self.add_component(generator) + + self.add_coupling(generator.output_new_client, self.out_gen_port) + + +def get_sec(time_str): + h, m, s = time_str.split(':') + return int(h) * 3600 + int(m) * 60 + int(s) + + +if __name__ == '__main__': + sim_time: float = 52 + n_employees = 3 + mean_employees = 5 + mean_generator = 3 + stddev_employees = 0.8 + stddev_clients = 0.5 + + if len(sys.argv) > 8: + print("Program used with more arguments than accepted. Last arguments will be ignored.") + elif len(sys.argv) < 8: + print("Program used with less arguments than accepted. Missing parameters will be set to their default value.") + if len(sys.argv) != 8: + print("Correct usage:") + print("\t" "python3 " + sys.argv[ + 0] + " ") + try: + sim_time = get_sec(sys.argv[1]) + n_employees = int(sys.argv[2]) + mean_employees = float(sys.argv[3]) + mean_generator = float(sys.argv[4]) + stddev_employees = float(sys.argv[5]) + stddev_clients = float(sys.argv[6]) + force_chain = bool(int(sys.argv[7])) + except IndexError: + pass + + print("CONFIGURATION OF THE SCENARIO:") + print("\tSimulation time: {} seconds".format(sim_time)) + print("\tNumber of Employees: {}".format(n_employees)) + print("\tMean time required by employee to dispatch clients: {} seconds (standard deviation of {})".format( + mean_employees, stddev_employees)) + print("\tMean time between new clients: {} seconds (standard deviation of {})".format(mean_generator, + stddev_employees)) + + start = time.time() + gens = GenSys(mean_generator, stddev_clients) + middle = time.time() + print("Model Created. Elapsed time: {} sec".format(middle - start)) + rt_manager = RealTimeManager(max_jitter=0.2, event_window=0.5) + rt_manager.add_output_handler('mqtt') + c = RealTimeCoordinator(gens, rt_manager) + middle = time.time() + print("Coordinator and Manager Created. Elapsed time: {} sec".format(middle - start)) + c.simulate_rt(time_interv=sim_time) + end = time.time() + print(f' Simulation time (s) = {sim_time}') + print("Simulation took: {} sec".format(end - start)) + print(f' Error (%) = {((time.time() - start - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store/README.md b/xdevs/examples/store/README.md new file mode 100644 index 0000000..4321a98 --- /dev/null +++ b/xdevs/examples/store/README.md @@ -0,0 +1,63 @@ +# Instructions for running the store examples + +## Virtual time (regular simulation) + +To run the store examples with virtual time, you can use the following command: + +```bash +$ cd xdevs/examples/store +$ python3 1_vt_simulation.py +``` + +## Real time with CSV output log + +This example runs the store model in real time and uses an output handler to store output events. +The CSV file is saved in the `output` directory. +You can run the example with the following command: + +```bash +$ cd xdevs/examples/store +$ python3 2_rt_simulation_csv_output_handler.py +``` + +## Real time with TCP input clients + +This example runs the store model in real time and uses a TCP input handler to inject extra clients. +You can run the example with the following command: + +```bash +$ cd xdevs/examples/store +$ python3 3_rt_simulation_tcp_input_handler.py +``` + +This executable will start a TCP server that listens for incoming connections on `localhost:4321`. +You can connect to the server using a TCP client, such as `netcat`: + +```bash +$ nc localhost 4321 +``` + +The client can send messages to the server in the following format: + +``` +,? +``` + +The model only has one input port, called `IP_NewClient`. + +## MQTT Example + +An MQTT example is provided, in which the connection between two `DEVS` models is created. +The execution of both models should be carried out in parallel. + +_First model, MQTT subscriber_ +```bash +$ cd xdevs/examples/store +$ python3 4_1_rt_simulation_mqtt_input_handler.py +``` + +_Second model, MQTT publisher_ +```bash +$ cd xdevs/examples/store +$ python3 4_2_rt_simulation_mqtt_output_handler.py +``` \ No newline at end of file diff --git a/xdevs/examples/store/__init__.py b/xdevs/examples/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/examples/store/models/__init__.py b/xdevs/examples/store/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/examples/store_cashier/client_generator.py b/xdevs/examples/store/models/clients.py similarity index 58% rename from xdevs/examples/store_cashier/client_generator.py rename to xdevs/examples/store/models/clients.py index ba86491..645f448 100644 --- a/xdevs/examples/store_cashier/client_generator.py +++ b/xdevs/examples/store/models/clients.py @@ -1,36 +1,39 @@ -import logging from random import gauss from xdevs.models import Atomic, Port - -from msg import NewClient +import time +from .msg import NewClient class ClientGeneratorStatus: def __init__(self): - self.next_client_id = 0 - self.time_to_next = 0 + self.next_client_id: int = 0 + self.time_to_next: float = 0 def __str__(self): - return ''.format(self.next_client_id, self.time_to_next) + return f'' class ClientGenerator(Atomic): - def __init__(self, mean: float = 10, stddev: float = 0, name: str = None): super().__init__(name) - self.mean = mean - self.stddev = stddev - self.clock = 0 + self.mean: float = mean + self.stddev: float = stddev + self.clock: float = 0 self.state: ClientGeneratorStatus = ClientGeneratorStatus() - self.output_new_client = Port(NewClient) + self.output_new_client: Port[NewClient] = Port(NewClient) self.add_out_port(self.output_new_client) + self.time_started: float = time.time() + def deltint(self): self.clock += self.sigma self.state.next_client_id += 1 self.state.time_to_next = max(gauss(self.mean, self.stddev), 0) - logging.debug('({}) [{}]-> {}'.format(self.clock, self.name, str(self.state))) + # Para simulacion + # print('({}) [{}]-> {}'.format(self.clock, self.name, str(self.state))) + # Para RT + print('({:.4f}) [{}]-> {}'.format(time.time()-self.time_started, self.name, str(self.state))) self.hold_in(self.phase, self.state.time_to_next) def deltext(self, e): diff --git a/xdevs/examples/store_cashier/employee.py b/xdevs/examples/store/models/employee.py similarity index 70% rename from xdevs/examples/store_cashier/employee.py rename to xdevs/examples/store/models/employee.py index 9750f68..45d1851 100644 --- a/xdevs/examples/store_cashier/employee.py +++ b/xdevs/examples/store/models/employee.py @@ -1,8 +1,8 @@ -import logging +import time from random import gauss from xdevs.models import Atomic, Port, INFINITY -from msg import ClientToEmployee, LeavingClient +from .msg import ClientToEmployee, LeavingClient class EmployeeState: @@ -12,11 +12,10 @@ def __init__(self): self.time_remaining = 0 def __str__(self): - return ''.format(self.clients_so_far, bool(self.client), self.time_remaining) + return f'' class Employee(Atomic): - def __init__(self, employee_id: int, mean: float = 30, stddev: float = 0, name: str = None): super().__init__(name) self.name += str(employee_id) @@ -26,14 +25,16 @@ def __init__(self, employee_id: int, mean: float = 30, stddev: float = 0, name: self.clock = 0 self.state = EmployeeState() - self.input_client = Port(ClientToEmployee) + self.input_client = Port(ClientToEmployee, 'in_client') self.add_in_port(self.input_client) - self.output_ready = Port(int) - self.output_client = Port(LeavingClient) + self.output_ready = Port(int, 'out_ready') + self.output_client = Port(LeavingClient, 'out_client') self.add_out_port(self.output_ready) self.add_out_port(self.output_client) + self.time_started = time.time() + def deltint(self): self.clock += self.sigma self.state.client = None @@ -50,7 +51,11 @@ def deltext(self, e): self.state.clients_so_far += 1 self.state.client = pairing.client self.state.time_remaining = max(gauss(self.mean, self.stddev), 0) - logging.debug('({}) [{}]-> {}'.format(self.clock, self.name, str(self.state))) + # Para simulacion + # print('({}) [{}]-> {}'.format(self.clock, self.name, str(self.state))) + # Para RT + print('({:.4f}) [{}]-> {}'.format(time.time() - self.time_started, self.name, str(self.state))) + #print(f'EMPAREJADO C-E EN:{datetime.datetime.now()}') self.hold_in(self.phase, self.state.time_remaining) def lambdaf(self): @@ -60,7 +65,7 @@ def lambdaf(self): self.output_ready.add(self.employee_id) def initialize(self): - self.activate(0) + self.activate() def exit(self): pass diff --git a/xdevs/examples/store_cashier/msg.py b/xdevs/examples/store/models/msg.py similarity index 59% rename from xdevs/examples/store_cashier/msg.py rename to xdevs/examples/store/models/msg.py index 0c2aa84..67d0067 100644 --- a/xdevs/examples/store_cashier/msg.py +++ b/xdevs/examples/store/models/msg.py @@ -3,15 +3,24 @@ def __init__(self, client_id, t_entered): self.client_id = client_id self.t_entered = t_entered + def __str__(self): + return f'id::{self.client_id}; t_entered::{self.t_entered}' + class ClientToEmployee: def __init__(self, new_client, employee_id): self.client = new_client self.employee_id = employee_id + def __str__(self): + return f'Client::{self.client} to Employee::{self.employee_id}' + class LeavingClient: def __init__(self, client_id, t_entered, t_exited): self.client_id = client_id self.t_entered = t_entered self.t_exited = t_exited + + def __str__(self): + return f'Client::{self.client_id}; t_entered::{self.t_entered}; t_exited::{self.t_exited}' diff --git a/xdevs/examples/store_cashier/store_queue.py b/xdevs/examples/store/models/queue.py similarity index 69% rename from xdevs/examples/store_cashier/store_queue.py rename to xdevs/examples/store/models/queue.py index af260f1..c93344a 100644 --- a/xdevs/examples/store_cashier/store_queue.py +++ b/xdevs/examples/store/models/queue.py @@ -1,8 +1,7 @@ from _collections import deque from xdevs.models import Atomic, Port, INFINITY -import logging - -from msg import NewClient, ClientToEmployee +import time +from .msg import NewClient, ClientToEmployee class QueueStatus: @@ -12,26 +11,27 @@ def __init__(self): self.pairings = deque() def __str__(self): - return ''.format(len(self.clients), len(self.employees)) + return f'' class StoreQueue(Atomic): - def __init__(self, name: str = None): super().__init__(name) self.clock = 0 self.state = QueueStatus() - self.input_new_client = Port(NewClient) - self.input_available_employee = Port(int) + self.input_new_client = Port(NewClient, 'in_new_client') + self.input_available_employee = Port(int, 'in_available_employee') self.add_in_port(self.input_new_client) self.add_in_port(self.input_available_employee) - self.output_client_to_employee = Port(ClientToEmployee) + self.output_client_to_employee = Port(ClientToEmployee, 'OUTqueue') self.add_out_port(self.output_client_to_employee) + self.time_started = time.time() + def deltint(self): - self.clock += self.ta + self.clock += self.ta() self.state.pairings.clear() self.passivate() @@ -47,7 +47,10 @@ def deltext(self, e): self.state.pairings.appendleft(ClientToEmployee(new_client, self.state.employees.pop())) except IndexError: self.state.clients.appendleft(new_client) - logging.debug('({}) [{}]-> {}'.format(self.clock, self.name, str(self.state))) + # Para simulacion normal + # print('({}) [{}]-> {}'.format(self.clock, self.name, str(self.state))) + # Para RT + print('({:.4f}) [{}]-> {}'.format(time.time()-self.time_started, self.name, str(self.state))) timeout = 0 if self.state.pairings else INFINITY self.hold_in(self.phase, timeout) diff --git a/xdevs/examples/store/models/store.py b/xdevs/examples/store/models/store.py new file mode 100644 index 0000000..ad3c2f0 --- /dev/null +++ b/xdevs/examples/store/models/store.py @@ -0,0 +1,79 @@ +from xdevs.models import Coupled, Port +from xdevs.examples.store.models.msg import NewClient, ClientToEmployee, LeavingClient +from xdevs.examples.store.models.clients import ClientGenerator +from xdevs.examples.store.models.queue import StoreQueue +from xdevs.examples.store.models.employee import Employee + + +class Store(Coupled): + def __init__(self, n_employees: int = 10000, mean_employees: float = 30, mean_clients: float = 1, + stddev_employees: float = 0, stddev_clients: float = 0, name=None): + super().__init__(name) + + generator = ClientGenerator(mean_clients, stddev_clients) + queue = StoreQueue() + + self.input_new_client = Port(NewClient, 'IP_NewClient') + self.add_in_port(self.input_new_client) + + self.output_port_queue = Port(ClientToEmployee, 'OP_LeavingQueue') + self.output_port_gen = Port(NewClient, 'OP_LeavingGenerator') + self.output_port_employee = Port(LeavingClient, 'OP_LeavingEmployee') + + self.add_out_port(self.output_port_queue) + self.add_out_port(self.output_port_gen) + self.add_out_port(self.output_port_employee) + + self.add_component(generator) + self.add_component(queue) + + self.add_coupling(self.input_new_client, queue.input_new_client) + self.add_coupling(generator.output_new_client, queue.input_new_client) + self.add_coupling(queue.output_client_to_employee, self.output_port_queue) + self.add_coupling(generator.output_new_client, self.output_port_gen) + + for i in range(n_employees): + employee = Employee(i, mean_employees, stddev_employees) + self.add_component(employee) + self.add_coupling(queue.output_client_to_employee, employee.input_client) + self.add_coupling(employee.output_ready, queue.input_available_employee) + self.add_coupling(employee.output_client, self.output_port_employee) + + +class GenSys(Coupled): + def __init__(self, mean_clients: float = 1, stddev_clients: float = 0, name=None): + super().__init__(name) + generator = ClientGenerator(mean_clients, stddev_clients) + + self.out_gen_port = Port(NewClient) + self.add_out_port(self.out_gen_port) + + self.add_component(generator) + + self.add_coupling(generator.output_new_client, self.out_gen_port) + + +class StoreWithoutGen(Coupled): + def __init__(self, n_employees: int = 10000, mean_employees: float = 30, stddev_employees: float = 0, + name=None): + super().__init__(name) + + queue = StoreQueue() + + self.o_p_queue = Port(ClientToEmployee) + self.add_out_port(self.o_p_queue) + + self.i_ExternalGen = Port(NewClient, 'i_ExternalGen') + self.add_in_port(self.i_ExternalGen) + + self.add_component(queue) + + self.add_coupling(self.i_ExternalGen, queue.input_new_client) + + self.add_coupling(queue.output_client_to_employee, self.o_p_queue) + + for i in range(n_employees): + employee = Employee(i, mean_employees, stddev_employees) + self.add_component(employee) + self.add_coupling(queue.output_client_to_employee, employee.input_client) + self.add_coupling(employee.output_ready, queue.input_available_employee) diff --git a/xdevs/examples/store/system_clients.py b/xdevs/examples/store/system_clients.py new file mode 100644 index 0000000..c62bb32 --- /dev/null +++ b/xdevs/examples/store/system_clients.py @@ -0,0 +1,40 @@ +from xdevs.examples.store.models.msg import NewClient +from xdevs.models import Coupled, Port +from xdevs.rt import RealTimeManager, RealTimeCoordinator +import time + +from xdevs.examples.store.models.clients import ClientGenerator + + +class GenSys(Coupled): + def __init__(self, mean_clients: float = 1, stddev_clients: float =0, name=None): + super().__init__(name) + generator = ClientGenerator(mean_clients, stddev_clients) + + self.out_gen_port = Port(NewClient) + self.add_out_port(self.out_gen_port) + + self.add_component(generator) + + self.add_coupling(generator.output_new_client, self.out_gen_port) + + +if __name__ == '__main__': + sim_time = 30 + mean_clients = 3 + stddev_clients = 0 + + gen = GenSys(mean_clients=mean_clients, stddev_clients=stddev_clients) + + gen_manager = RealTimeManager(max_jitter=0.2, event_window=0.5) + gen_manager.add_output_handler('tcp', PORT=5055) + + gen_coord = RealTimeCoordinator(gen, gen_manager) + + t_ini = time.time() + print(f' >>> COMENZAMOS : {t_ini}') + gen_coord.simulate_rt(time_interv=sim_time) + print(f' >>> FIN : {time.time()}') + print(f' Tiempo a ejecutar (s) = {sim_time }') + print(f' Tiempo ejecutado (s) = {(time.time() - t_ini)}') + print(f' Error (%) = {((time.time() - t_ini - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store/system_employees.py b/xdevs/examples/store/system_employees.py new file mode 100644 index 0000000..95eaad3 --- /dev/null +++ b/xdevs/examples/store/system_employees.py @@ -0,0 +1,80 @@ +import datetime +import time + +from xdevs.examples.store.models.employee import Employee +from xdevs.examples.store.models.msg import LeavingClient, ClientToEmployee, NewClient +from xdevs.models import Coupled, Port +from xdevs.rt import RealTimeManager, RealTimeCoordinator + + +class EmployeesSys(Coupled): + def __init__(self, n_employees: int = 3, mean_employees: float = 10, + stddev_employees: float = 0.8, name=None): + super().__init__(name) + + # A single Employee has: + # self.input_client = Port(ClientToEmployee) + # self.output_ready = Port(int) + # self.output_client = Port(LeavingClient) + + self.input_client = Port(ClientToEmployee, 'InputClient') + self.output_ready = Port(int, 'OutputReady') + self.output_client = Port(LeavingClient, 'LeavingClient') + + self.add_in_port(self.input_client) + self.add_out_port(self.output_client) + self.add_out_port(self.output_ready) + + for i in range(n_employees): + employee = Employee(i, mean_employees, stddev_employees) + self.add_component(employee) + self.add_coupling(self.input_client, employee.input_client) + self.add_coupling(employee.output_ready, self.output_ready) + self.add_coupling(employee.output_client, self.output_client) + + +def input_client_parser(msg: str): + # ("Client::id::3; t_entered::time.time to Employee::3") Formato de entrada + client = msg.split("::id::")[1].split(";")[0] + # t = time.time() - float(msg.split("t_entered::")[1].split(" t")[0]) + t = time.time() - t_ini + e_id = msg.split("Employee::")[1] + return ClientToEmployee(NewClient(client, t), int(e_id)) + + +if __name__ == '__main__': + sim_time = 50 + + E = EmployeesSys() + + + msg_parser = { + 'InputClient': input_client_parser, + } + + sub_input = { + 'RTsys/Output/Client2Employee': 0, + } + + sub_output = { # QUITAR + 'RTsys/AvailableEmployee': 0 + } + + connections = { + 'Client2Employee': 'InputClient' + } + + e_manager = RealTimeManager(max_jitter=0.2, event_window=0.5) + e_manager.add_input_handler('mqtt', subscriptions=sub_input, msg_parsers=msg_parser, connections=connections) + e_manager.add_output_handler('mqtt', subscriptions=sub_output) + e_manager.add_output_handler('csv', file='employees.csv') + + e_coord = RealTimeCoordinator(E, e_manager) + + t_ini = time.time() + print(f' >>> COMENZAMOS : {t_ini} : {datetime.datetime.now()}') + e_coord.simulate_rt(time_interv=sim_time) + print(f' >>> FIN : {time.time()}') + print(f' Tiempo a ejecutar (s) = {sim_time}') + print(f' Tiempo ejecutado (s) = {(time.time() - t_ini)}') + print(f' Error (%) = {((time.time() - t_ini - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store/system_queue.py b/xdevs/examples/store/system_queue.py new file mode 100644 index 0000000..4827900 --- /dev/null +++ b/xdevs/examples/store/system_queue.py @@ -0,0 +1,72 @@ +import time +from xdevs.examples.store.models.msg import ClientToEmployee, NewClient +from xdevs.examples.store.models.queue import StoreQueue +from xdevs.models import Coupled, Port +from xdevs.rt import RealTimeManager, RealTimeCoordinator + + +class QueueSys(Coupled): + def __init__(self, name=None): + super().__init__(name) + + q_atomic = StoreQueue() + self.add_component(q_atomic) + # Available ports of queue component: + # self.input_new_client = port(newclient) + # self.input_available_employee = port(int) + # self.output_client_to_employee = port(clienttoemployee) + + self.input_new_client = Port(NewClient, 'NewClient') + self.input_available_employee = Port(int, 'AvailableEmployee') + self.output_client_to_employee = Port(ClientToEmployee, 'Client2Employee') + + self.add_in_port(self.input_available_employee) + self.add_in_port(self.input_new_client) + self.add_out_port(self.output_client_to_employee) + + self.add_coupling(self.input_new_client, q_atomic.input_new_client) + self.add_coupling(self.input_available_employee, q_atomic.input_available_employee) + self.add_coupling(q_atomic.output_client_to_employee, self.output_client_to_employee) + + +def parser_new_client(msg: str): + client_id, t_entered = msg.split('?') + c = NewClient(client_id=client_id, t_entered=t_entered) + return c + + +if __name__ == '__main__': + sim_time = 60 + q = QueueSys() + + q_manager = RealTimeManager(max_jitter=0.2, event_window=0.5) + + msg_parser = { + 'NewClient': parser_new_client, + 'AvailableEmployee': lambda x: int(x) + } + + subs_input = { + 'RTsys/Output/OutputReady': 0, + } + + subs_output = { # QUITAR + 'RTsys/InputClient': 0, + } + connections = { + 'OutputReady': 'AvailableEmployee' + } + + q_manager.add_input_handler('tcp', port=4321, max_clients=5, msg_parsers=msg_parser, connections=connections) + q_manager.add_input_handler('mqtt', subscriptions=subs_input, connections=connections, msg_parsers=msg_parser) + q_manager.add_output_handler('mqtt', subscriptions=subs_output) + + q_coord = RealTimeCoordinator(q, q_manager) + + t_ini = time.time() + print(f' >>> COMENZAMOS : {t_ini}') + q_coord.simulate_rt(time_interv=sim_time) + print(f' >>> FIN : {time.time()}') + print(f' Tiempo a ejecutar (s) = {sim_time}') + print(f' Tiempo ejecutado (s) = {(time.time() - t_ini)}') + print(f' Error (%) = {((time.time() - t_ini - sim_time) / sim_time) * 100}') diff --git a/xdevs/examples/store_cashier/STORE_CASHIER b/xdevs/examples/store_cashier/STORE_CASHIER deleted file mode 100644 index 60e2bba..0000000 Binary files a/xdevs/examples/store_cashier/STORE_CASHIER and /dev/null differ diff --git a/xdevs/examples/store_cashier/execution_loop.py b/xdevs/examples/store_cashier/execution_loop.py deleted file mode 100644 index d2b3798..0000000 --- a/xdevs/examples/store_cashier/execution_loop.py +++ /dev/null @@ -1,35 +0,0 @@ -import subprocess -import re -import csv - -CMD_XDEVS = "python3 store_cashier.py 00:30:00 {n_employees} 10 {t_new_clients} 0 0 {activate_chain}" -CMD_CADMIUM = "./STORE_CASHIER 00:30:00 {n_employees} 10 {t_new_clients} 0 0" - -n_employees_range = range(10, 1001, 10) -t_new_clients_range = range(10, 0, -1) - -with open("out.csv", "w") as csv_file: - csv_writer = csv.writer(csv_file, delimiter=';') - csv_writer.writerow(("engine", "n_employees", "t_new_clients", "chained", "model_time", "runner_time", "sim_time")) - - for engine_cmd in (CMD_CADMIUM, CMD_XDEVS): - for chain_activated in (0, 1): - if engine_cmd == CMD_CADMIUM and chain_activated == 1: - continue - - for n_employees in range(10, 10011, 100): - for t_new_clients in range(10, 0, -1): - exec_cmd = engine_cmd.format(n_employees=n_employees, t_new_clients=t_new_clients, activate_chain=chain_activated) - print("\n" + exec_cmd) - result = subprocess.run(exec_cmd.split(), stdout=subprocess.PIPE) - #print(result.stdout) - - found = re.search("Elapsed time: ([0-9.e-]+) ?sec.*Elapsed time: ([0-9.e-]+) ?sec.*Simulation took: ([0-9.e-]+) ?sec", str(result.stdout)) - if found: - print("Times: " + str(found.groups())) - else: - raise RuntimeError("Simulation times not found") - - engine = "xdevs" if engine_cmd == CMD_XDEVS else "cadmium" - csv_writer.writerow((engine, n_employees, t_new_clients, chain_activated) + found.groups()) - csv_file.flush() diff --git a/xdevs/examples/store_cashier/store_cashier.py b/xdevs/examples/store_cashier/store_cashier.py deleted file mode 100644 index 4eeff9b..0000000 --- a/xdevs/examples/store_cashier/store_cashier.py +++ /dev/null @@ -1,77 +0,0 @@ -import sys -from xdevs.models import Coupled -from xdevs.sim import Coordinator -import time - -from client_generator import ClientGenerator -from store_queue import StoreQueue -from employee import Employee - - -class StoreCashier(Coupled): - def __init__(self, n_employees:int = 10000, mean_employees: float = 30, mean_clients: float = 1, - stddev_employees: float =0, stddev_clients:float =0, - name=None): - super().__init__(name) - - generator = ClientGenerator(mean_clients, stddev_clients) - queue = StoreQueue() - - self.add_component(generator) - self.add_component(queue) - self.add_coupling(generator.output_new_client, queue.input_new_client) - for i in range(n_employees): - employee = Employee(i, mean_employees, stddev_employees) - self.add_component(employee) - self.add_coupling(queue.output_client_to_employee, employee.input_client) - self.add_coupling(employee.output_ready, queue.input_available_employee) - - -def get_sec(time_str): - h, m, s = time_str.split(':') - return int(h) * 3600 + int(m) * 60 + int(s) - - -if __name__ == '__main__': - sim_time = 30 * 60 - n_employees = 3 - mean_employees = 30 - mean_generator = 10 - stddev_employees = 0 - stddev_clients = 0 - - if len(sys.argv) > 8: - print("Program used with more arguments than accepted. Last arguments will be ignored.") - elif len(sys.argv) < 8: - print("Program used with less arguments than accepted. Missing parameters will be set to their default value.") - if len(sys.argv) != 8: - print("Correct usage:") - print("\t" "python3 " + sys.argv[0] + " ") - try: - sim_time = get_sec(sys.argv[1]) - n_employees = int(sys.argv[2]) - mean_employees = float(sys.argv[3]) - mean_generator = float(sys.argv[4]) - stddev_employees = float(sys.argv[5]) - stddev_clients = float(sys.argv[6]) - force_chain = bool(int(sys.argv[7])) - except IndexError: - pass - - print("CONFIGURATION OF THE SCENARIO:") - print("\tSimulation time: {} seconds".format(sim_time)) - print("\tNumber of Employees: {}".format(n_employees)) - print("\tMean time required by employee to dispatch clients: {} seconds (standard deviation of {})".format(mean_employees, stddev_employees)) - print("\tMean time between new clients: {} seconds (standard deviation of {})".format(mean_generator, stddev_employees)) - - start = time.time() - store = StoreCashier(n_employees, mean_employees, mean_generator, stddev_employees, stddev_clients) - middle = time.time() - print("Model Created. Elapsed time: {} sec".format(middle - start)) - coord = Coordinator(store, flatten=True) - coord.initialize() - middle = time.time() - print("Coordinator Created. Elapsed time: {} sec".format(middle - start)) - coord.simulate_time(sim_time) - end = time.time() - print("Simulation took: {} sec".format(end - start)) diff --git a/xdevs/factory.py b/xdevs/factory.py new file mode 100644 index 0000000..3ec298b --- /dev/null +++ b/xdevs/factory.py @@ -0,0 +1,266 @@ +from __future__ import annotations +import json +import sys +from importlib.metadata import entry_points, EntryPoint +from typing import ClassVar +from xdevs.abc import InputHandler, OutputHandler, Transducer, DelayedOutput +from xdevs.celldevs import C +from xdevs.models import Atomic, Component, Port, Coupled + + +def load_entry_points(group: str) -> list[EntryPoint]: + if sys.version_info < (3, 10): + return entry_points().get(group, []) + else: + return entry_points(group=group) + + +class InputHandlers: + _plugins: ClassVar[dict[str, type[InputHandler]]] = { + ep.name: ep.load() for ep in load_entry_points('xdevs.input_handlers') + } + + @staticmethod + def add_plugin(name: str, plugin: type[InputHandler]): + """ + Registers a custom input handler to the plugin system. + + :param name: name used to identify the custom input handler. It must be unique. + :param plugin: custom input handler type. Note that it must not be an object, just the class. + """ + if name in InputHandlers._plugins: + raise ValueError(f'xDEVS input_handler plugin with name "{name}" already exists') + InputHandlers._plugins[name] = plugin + + @staticmethod + def create_input_handler(name: str, *args, **kwargs) -> InputHandler: + """ + Creates a new input handler. Note that this is done by the real-time manager. + Users do not directly create input handlers using this method. + + :param name: unique ID of the input handler to be created. + :param kwargs: any additional configuration parameter needed for creating the input handler. + :return: an instance of the InputHandler class. + """ + if name not in InputHandlers._plugins: + raise ValueError(f'xDEVS input_handler plugin with name "{name}" not found') + return InputHandlers._plugins[name](*args, **kwargs) + + +class OutputHandlers: + _plugins: ClassVar[dict[str, type[OutputHandler]]] = { + ep.name: ep.load() for ep in load_entry_points('xdevs.output_handlers') + } + + @staticmethod + def add_plugin(name: str, plugin: type[OutputHandler]): + """ + Registers a custom output handler to the plugin system. + + :param name: name used to identify the custom input handler. It must be unique. + :param plugin: custom input handler type. Note that it must not be an object, just the class. + """ + if name in OutputHandlers._plugins: + raise ValueError(f'xDEVS output_handler plugin with name "{name}" already exists') + OutputHandlers._plugins[name] = plugin + + @staticmethod + def create_output_handler(name: str, *args, **kwargs) -> OutputHandler: + """ + + Creates a new output handler. Note that this is done by the real-time manager. + Users do not directly create output handlers using this method. + + :param name: unique ID of the output handler to be created. + :param kwargs: any additional configuration parameter needed for creating the output handler. + :return: an instance of the OutputHandler class. + """ + if name not in OutputHandlers._plugins: + raise ValueError(f'xDEVS output_handler plugin with name "{name}" not found') + return OutputHandlers._plugins[name](*args, **kwargs) + + +class Wrappers: + _plugins: ClassVar[dict[str, type[Atomic]]] = { + ep.name: ep.load() for ep in load_entry_points('xdevs.wrappers') + } + + @staticmethod + def add_plugin(name: str, plugin: type[Atomic]): + if name in Wrappers._plugins: + raise ValueError(f'xDEVS wrapper plugin with name "{name}" already exists') + Wrappers._plugins[name] = plugin + + @staticmethod + def create_wrapper(name: str, *args, **kwargs) -> Atomic: + if name not in Wrappers._plugins: + raise ValueError(f'xDEVS wrapper plugin with name "{name}" not found') + return Wrappers._plugins[name](*args, **kwargs) + + +class Transducers: + _plugins: ClassVar[dict[str, type[Transducer]]] = { + ep.name: ep.load() for ep in load_entry_points('xdevs.transducers') + } + + @staticmethod + def add_plugin(name: str, plugin: type[Transducer]): + if name in Transducers._plugins: + raise ValueError(f'xDEVS transducer plugin with name "{name}" already exists') + Transducers._plugins[name] = plugin + + @staticmethod + def create_transducer(name: str, *args, **kwargs) -> Transducer: + if name not in Transducers._plugins: + raise ValueError(f'xDEVS transducer plugin with name "{name}" not found') + return Transducers._plugins[name](*args, **kwargs) + + +class Components: + """This class creates components from unique identifiers called "component_id".""" + _plugins: ClassVar[dict[str, type[Component]]] = { + ep.name: ep.load() for ep in load_entry_points('xdevs.components') + } + + @staticmethod + def add_plugin(component_id: str, plugin: type[Component]): + if component_id in Components._plugins: + raise ValueError(f'xDEVS component plugin with name "{component_id}" already exists') + Components._plugins[component_id] = plugin + + @staticmethod + def create_component(component_id: str, *args, **kwargs) -> Component: + if component_id not in Components._plugins: + raise ValueError(f'xDEVS component plugin with name "{component_id}" not found') + return Components._plugins[component_id](*args, **kwargs) + + @staticmethod + def _nested_component(name: str, config: dict) -> Component: + if 'component_id' in config: + # Predefined component, use factory + component_id: str = config['component_id'] + args = config.get('args', []) + kwargs = config.get('kwargs', {}) + kwargs['name'] = name + return Components.create_component(component_id, *args, **kwargs) + elif 'components' in config: + # It is a coupled model + component = Coupled(name) + children: dict[str, Component] = dict() + # Create children components + for component_name, component_config in config['components'].items(): + child = Components._nested_component(component_name, component_config) + children[component_name] = child + component.add_component(child) + # Create connections + for coupling in config.get('couplings', []): + child_from = coupling.get('componentFrom') + child_to = coupling.get('componentTo') + if child_from is not None: + child_from = children[child_from] + port_from = child_from.get_out_port(coupling['portFrom']) + if port_from is None: + raise Exception(f'Invalid coupling in: {coupling}. Reason: portFrom not found') + if child_to is not None: + # this is an IC + child_to = children[child_to] + port_to = child_to.get_in_port(coupling['portTo']) + if port_to is None: + raise Exception(f'Invalid coupling in: {coupling}. Reason: portTo not found') + else: + # this is an EOC + port_to = child_to.get_in_port(coupling['portTo']) + if port_to is None: + port_to = Port(p_type=port_from.p_type, name=coupling['portTo']) + component.add_out_port(port_to) + elif child_to is not None: + # this is an EIC + child_to = children[child_to] + port_to = child_to.get_in_port(coupling['portTo']) + if port_to is None: + raise Exception(f'Invalid coupling in: {coupling}. Reason: portTo not found') + port_from = component.get_in_port(coupling['portFrom']) + if port_from is None: + port_from = Port(p_type=port_to.p_type, name=coupling['portFrom']) + component.add_in_port(port_from) + else: + raise Exception( + f'Invalid coupling in: {coupling}. Reason: componentFrom and componentTo are None') + + component.add_coupling(port_from, port_to) + else: + raise Exception('No component found') + return component + + @staticmethod + def from_json(file_path: str): + """ + A function to parser a JSON file into a DEVS model. The JSON file structure should follow the next rules: + + When adding a component, if it contains the key "component_id", the component will be created using it and the + args and kwargs associated with it. The "component_id" value refers to the key to identify each component in + the class Components. + + When the component does not have the key "component_id", it is assumed to be a coupled model. + Therefore, it must have the keys "components" and "couplings". + This component will be implementing several components and their couplings inside itself. + + The couplings are created using four keys: + - If both componentFrom/To keys are added, the connection will be of the type IC. + - If componentFrom key is missing, the connection will be of the type EIC. + - If componentTo key is missing, the connection will be of the type EOC. + - If any portFrom/To value is missing the connections is not valid. + + Structure: + + - 'MasterComponentName' (dict): The master component. + - 'components' (dict): A dictionary containing multiple components. + - 'ComponentName1' (dict): Iterative component. + - 'components' (dict): Nested components if any. + - 'couplings' (list): List of connection dictionaries. + - 'componentFrom' (str): Name of the component where the connection starts. + - 'portFrom' (str): Port name from 'componentFrom'. + - 'componentTo' (str): Name of the component where the connection ends. + - 'portTo' (str): Port name from 'componentTo'. + - 'ComponentName2' (dict): Single component. + - 'component_id' (str): ID read from the factory for this component. + - 'args' (list): Positional arguments for the component. + - 'kwargs' (dict): Keyword arguments for the component. + - 'a_parameter' (any): A parameter for the component. + - ... : Other keyword arguments if any. + - ... : Additional components if any. + - 'couplings' (list): List of couplings. + - 'componentFrom' (str): Name of the component where the connection starts. + - 'portFrom' (str): Port name from 'componentFrom'. + - 'componentTo' (str): Name of the component where the connection ends. + - 'portTo' (str): Port name from 'componentTo'. + + :param file_path: Path to the JSON file + :return: a DEVS model according to the JSON file + """ + with open(file_path) as f: + data = json.load(f) + + name = list(data.keys())[0] # Gets the actual component name + config = data[name] # Gets the actual component config + + return Components._nested_component(name, config) + + +class DelayedOutputs: + + _plugins: ClassVar[dict[str, type[DelayedOutput]]] = { + ep.name: ep.load() for ep in load_entry_points('xdevs.celldevs_outputs') + } + + @staticmethod + def add_plugin(delay_id: str, plugin: type[DelayedOutput]): + if delay_id in DelayedOutputs._plugins: + raise ValueError('xDEVS Cell-DEVS delayed output plugin with name "{}" already exists'.format(delay_id)) + DelayedOutputs._plugins[delay_id] = plugin + + @staticmethod + def create_delayed_output(delay_id: str, cell_id: C, serve: bool = False) -> DelayedOutput: + if delay_id not in DelayedOutputs._plugins: + raise ValueError('xDEVS Cell-DEVS delayed output plugin with name "{}" not found'.format(delay_id)) + return DelayedOutputs._plugins[delay_id](cell_id, serve) diff --git a/xdevs/images/sysoverview_small.png b/xdevs/images/sysoverview_small.png new file mode 100644 index 0000000..ab32e11 Binary files /dev/null and b/xdevs/images/sysoverview_small.png differ diff --git a/xdevs/models.py b/xdevs/models.py index f1f66c0..b859e56 100644 --- a/xdevs/models.py +++ b/xdevs/models.py @@ -3,26 +3,24 @@ import pickle from abc import ABC, abstractmethod from collections import deque, defaultdict -from typing import Generator, Generic, Iterator, Type, TypeVar -from xdevs import PHASE_ACTIVE, PHASE_PASSIVE, INFINITY - -T = TypeVar('T') +from typing import Generator, Generic, Iterator +from xdevs import PHASE_ACTIVE, PHASE_PASSIVE, INFINITY, T class Port(Generic[T]): - def __init__(self, p_type: Type[T] = None, name: str = None, serve: bool = False): + def __init__(self, p_type: type[T] | None = None, name: str = None, serve: bool = False): """ xDEVS implementation of DEVS Port. :param p_type: data type of events to be sent/received via the new port instance. :param name: name of the new port instance. Defaults to the name of the port's class. :param serve: set to True if the port is going to be accessible via RPC server. Defaults to False. """ - self.name: str = name if name else self.__class__.__name__ - self.p_type: Type[T] = p_type - self.serve: bool = serve - self.parent: Component | None = None # xDEVS Component that owns the port - self._values: deque[T] = deque() # Bag containing events directly written to the port - self._bag: list[Port[T]] = list() # Bag containing coupled ports containing events + self.name: str = name if name else self.__class__.__name__ # Name of the port + self.p_type: type[T] | None = p_type # Port type. If None, it can contain any type of event. + self.serve: bool = serve # True if port is going to be accessible via RPC server + self.parent: Component | None = None # xDEVS Component that owns the port + self._values: deque[T] = deque() # Bag containing events directly written to the port + self._bag: list[Port[T]] = list() # Bag containing coupled ports containing events def __bool__(self) -> bool: return not self.empty() @@ -31,7 +29,8 @@ def __len__(self) -> int: return sum((len(port) for port in self._bag), len(self._values)) def __str__(self) -> str: - return '%s.%s(%s)' % (self.parent.name, self.name, self.p_type or 'None') + p_type = self.p_type.__name__ if self.p_type is not None else 'None' + return f'{self.name}<{p_type}>' def __repr__(self) -> str: return str(self) @@ -66,7 +65,7 @@ def add(self, val: T): :raises TypeError: If event is not instance of port type. """ if self.p_type is not None and not isinstance(val, self.p_type): - raise TypeError('Value type is %s (%s expected)' % (type(val).__name__, self.p_type.__name__)) + raise TypeError(f'Value type is {type(val).__name__} ({self.p_type.__name__} expected)') self._values.append(val) def extend(self, vals: Iterator[T]): @@ -94,14 +93,17 @@ def __init__(self, name: str = None): :param name: name of the xDEVS model. Defaults to the name of the component's class. """ self.name: str = name if name else self.__class__.__name__ - self.parent: Coupled | None = None # Parent component of this component - self.in_ports: list[Port] = list() # List containing all the component's input ports - self.out_ports: list[Port] = list() # List containing all the component's output ports + self.parent: Coupled | None = None # Parent component of this component + self.input: dict[str, Port] = dict() # Dictionary containing all the component's input ports by name + self.output: dict[str, Port] = dict() # Dictionary containing all the component's output ports by name + # TODO make these lists private + self.in_ports: list[Port] = list() # List containing all the component's input ports (serialized for performance) + self.out_ports: list[Port] = list() # List containing all the component's output ports (serialized for performance) def __str__(self) -> str: in_str = " ".join([p.name for p in self.in_ports]) out_str = " ".join([p.name for p in self.out_ports]) - return '%s: InPorts[%s] OutPorts[%s]' % (self.name, in_str, out_str) + return f'{self.name}: InPorts[{in_str}] OutPorts[{out_str}]' def __repr__(self): return self.name @@ -136,29 +138,33 @@ def add_in_port(self, port: Port): """ Adds an input port to the xDEVS model. :param port: port to be added to the model. + :panics NameError: if port name already exists. """ + if port.name in self.input: + raise NameError("Input port name already exists") port.parent = self + self.input[port.name] = port self.in_ports.append(port) def add_out_port(self, port: Port): """ Adds an output port to the xDEVS model :param port: port to be added to the model. + :panics NameError: if port name already exists. """ + if port.name in self.output: + raise ValueError("Output port name already exists") port.parent = self + self.output[port.name] = port self.out_ports.append(port) def get_in_port(self, name) -> Port | None: - for port in self.in_ports: - if port.name == name: - return port - return None + """:return: Input port with the given name. If port is not found, returns None.""" + return self.input.get(name) def get_out_port(self, name) -> Port | None: - for port in self.out_ports: - if port.name == name: - return port - return None + """:return: Output port with the given name. If port is not found, returns None.""" + return self.output.get(name) class Coupling(Generic[T]): @@ -168,16 +174,10 @@ def __init__(self, port_from: Port[T], port_to: Port[T], host=None): :param port_from: DEVS transmitter port. :param port_to: DEVS receiver port. :param host: TODO documentation for this - :raises ValueError: if coupling direction is incompatible with the target ports. + :raises ValueError: port types are incompatible. """ # Check that couplings are valid - comp_from: Component = port_from.parent - comp_to: Component = port_to.parent - if isinstance(comp_from, Atomic) and port_from in comp_from.in_ports: - raise ValueError("Input ports whose parent is an Atomic model can not be coupled to any other port") - if isinstance(comp_to, Atomic) and port_to in comp_from.out_ports: - raise ValueError("Output ports whose parent is an Atomic model can not be recipient of any other port") - if port_from.p_type is not None and port_to in inspect.getmro(port_from.p_type): + if port_from.p_type is not None and port_to.p_type is not None and port_to in inspect.getmro(port_from.p_type): raise ValueError("Ports don't share the same port type") self.port_from: Port = port_from @@ -185,7 +185,7 @@ def __init__(self, port_from: Port[T], port_to: Port[T], host=None): self.host = host # TODO identify host's variable type def __str__(self) -> str: - return "(%s -> %s)" % (self.port_from, self.port_to) + return f"({self.port_from} -> {self.port_to})" def __repr__(self) -> str: return str(self) @@ -211,13 +211,12 @@ def __init__(self, name: str = None): self.phase: str = PHASE_PASSIVE self.sigma: float = INFINITY - @property def ta(self) -> float: """:return: remaining time for the atomic model's internal transition.""" return self.sigma def __str__(self) -> str: - return "%s(%s, %s)" % (self.name, str(self.phase), self.sigma) + return f'{self.name}({self.phase}, {self.sigma})' @abstractmethod def deltint(self): @@ -237,11 +236,8 @@ def lambdaf(self): """Describes the output function of the atomic model.""" pass - def deltcon(self, e: float): - """ - Describes the confluent transitions of the atomic model. By default, the internal transition is triggered first. - :param e: elapsed time between last transition and the confluent transition. - """ + def deltcon(self): + """Confluent transitions of the atomic model. By default, internal transition is triggered first.""" self.deltint() self.deltext(0) @@ -289,6 +285,7 @@ def __init__(self, name: str = None): self.ic: dict[Port, dict[Port, Coupling]] = dict() self.eic: dict[Port, dict[Port, Coupling]] = dict() self.eoc: dict[Port, dict[Port, Coupling]] = dict() + # TODO serialized versions of ic, eic and eoc for performance def initialize(self): pass diff --git a/xdevs/plugins/celldevs_outputs/__init__.py b/xdevs/plugins/celldevs_outputs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/plugins/celldevs_outputs/hybrid.py b/xdevs/plugins/celldevs_outputs/hybrid.py new file mode 100644 index 0000000..85c8d57 --- /dev/null +++ b/xdevs/plugins/celldevs_outputs/hybrid.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from collections import deque +from typing import Deque, Generic +from xdevs.abc.celldevs import C, S, DelayedOutput, INFINITY + + +class HybridDelayedOutput(DelayedOutput[C, S], Generic[C, S]): + def __init__(self, cell_id: C, serve: bool = False): + super().__init__(cell_id, serve) + self.last_state: S | None = None + self.next_states: Deque[tuple[float, S]] = deque() + + def add_to_buffer(self, when: float, state: S): + while self.next_states and self.next_states[-1][0] >= when: + self.next_states.pop() + self.next_states.append((when, state)) + + def next_time(self) -> float: + return INFINITY if not self.next_states else self.next_states[0][0] + + def next_state(self) -> S: + return self.last_state if not self.next_states else self.next_states[0][1] + + def pop_state(self): + if self.next_states: + self.last_state = self.next_states.popleft()[1] diff --git a/xdevs/plugins/celldevs_outputs/inertial.py b/xdevs/plugins/celldevs_outputs/inertial.py new file mode 100644 index 0000000..34f7808 --- /dev/null +++ b/xdevs/plugins/celldevs_outputs/inertial.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from typing import Generic +from xdevs.abc.celldevs import C, S, DelayedOutput, INFINITY + + +class InertialDelayedOutput(DelayedOutput[C, S], Generic[C, S]): + def __init__(self, cell_id: C, serve: bool = False): + super().__init__(cell_id, serve) + self.last_state: S | None = None + self.next_t: float = INFINITY + + def add_to_buffer(self, when: float, state: S): + self.next_t, self.last_state = when, state + + def next_time(self) -> float: + return self.next_t + + def next_state(self) -> S: + return self.last_state + + def pop_state(self): + self.next_t = INFINITY diff --git a/xdevs/plugins/celldevs_outputs/transport.py b/xdevs/plugins/celldevs_outputs/transport.py new file mode 100644 index 0000000..acdc285 --- /dev/null +++ b/xdevs/plugins/celldevs_outputs/transport.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from typing import Generic +from queue import PriorityQueue +from xdevs.abc.celldevs import C, S, DelayedOutput, INFINITY + + +class TransportDelayedOutput(DelayedOutput[C, S], Generic[C, S]): + def __init__(self, cell_id: C, serve: bool = False): + super().__init__(cell_id, serve) + self.last_state: S | None = None + self.schedule: PriorityQueue = PriorityQueue() + self.next_states: dict[float, S] = dict() + + def add_to_buffer(self, when: float, state: S): + if when not in self.next_states: + self.schedule.put(when) + self.next_states[when] = state + + def next_time(self) -> float: + return self.schedule.queue[0] if self.next_states else INFINITY + + def next_state(self) -> S: + return self.next_states[self.schedule.queue[0]] if self.next_states else self.last_state + + def pop_state(self): + if not self.schedule.empty(): + self.last_state = self.next_states.pop(self.schedule.get()) diff --git a/xdevs/plugins/input_handlers/__init__.py b/xdevs/plugins/input_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/plugins/input_handlers/bad_dependencies.py b/xdevs/plugins/input_handlers/bad_dependencies.py new file mode 100644 index 0000000..8c3c8f2 --- /dev/null +++ b/xdevs/plugins/input_handlers/bad_dependencies.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from abc import ABC +from xdevs.abc.handler import InputHandler + + +class BadDependenciesHandler(InputHandler, ABC): + def __init__(self, **kwargs): + """ + Template input handler for using when dependencies are not imported. + :param str handler_type: transducer type. + """ + super().__init__(**kwargs) + handler_type = kwargs['handler_type'] + raise ImportError(f'{handler_type} input handler specific dependencies are not imported') + + def run(self): + pass diff --git a/xdevs/plugins/input_handlers/csv.py b/xdevs/plugins/input_handlers/csv.py new file mode 100644 index 0000000..b69e24e --- /dev/null +++ b/xdevs/plugins/input_handlers/csv.py @@ -0,0 +1,53 @@ +from __future__ import annotations +import csv +import sys +import time +from xdevs.abc.handler import InputHandler + + +class CSVInputHandler(InputHandler): + def __init__(self, **kwargs): + """ + CSVInputHandler reads a file and insert the messages in the corresponding port of the system. + + File must contain 3 columns: + + 1st -> t, is for the time between the messages are inserted in the system. t = 0 or '' , no time is waited. + + 2nd -> port, is for specifying the port name. Port = '' ,the row will be omitted. + + 3rd -> msg, is for inserting the message which will be transmitted. + + :param str file: CSV file path. + :param str delimiter: column delimiter in CSV file. By default, it is set to ','. + """ + super().__init__(**kwargs) + self.file: str = kwargs.get('file') + if self.file is None: + raise ValueError('file is mandatory') + self.delimiter: str = kwargs.get('delimiter', ',') + + def run(self): + with open(self.file, newline='') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=self.delimiter) + for i, row in enumerate(csv_reader): + # 1. unwrap row + try: + t, port, msg, *_others = row + except ValueError: + print(f'LINE {i + 1}: invalid row ({row}). Rows must have 3 columns:' + ' t, port, and msg. Row will be ignored', file=sys.stderr) + continue + # 2. sleep + try: + time.sleep(float(t)) + except ValueError: + if i != 0: # To avoid logging an error while parsing the header + print(f'LINE {i + 1}: error parsing t ("{t}"). Row will be ignored', file=sys.stderr) + continue + # 3. make sure that port is not empty + if not port: + print(f'LINE {i + 1}: port ID is empty. Row will be ignored', file=sys.stderr) + continue + # 4. inject event to queue + self.push_msg(port, msg) diff --git a/xdevs/plugins/input_handlers/function.py b/xdevs/plugins/input_handlers/function.py new file mode 100644 index 0000000..ac09420 --- /dev/null +++ b/xdevs/plugins/input_handlers/function.py @@ -0,0 +1,13 @@ +from __future__ import annotations +from xdevs.abc.handler import InputHandler + + +class CallableFunction(InputHandler): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.function = kwargs['function'] + self.args = kwargs.get('f_args', list()) + self.kwargs = kwargs.get('f_kwargs', dict()) + + def run(self): + self.function(self.queue, *self.args, **self.kwargs) diff --git a/xdevs/plugins/input_handlers/mqtt.py b/xdevs/plugins/input_handlers/mqtt.py new file mode 100644 index 0000000..15f7c12 --- /dev/null +++ b/xdevs/plugins/input_handlers/mqtt.py @@ -0,0 +1,83 @@ +from __future__ import annotations +import queue +import threading + +try: + from paho.mqtt.client import Client + from xdevs.abc.handler import InputHandler + + + + def on_connect(client, userdata, flags, rc): + print(f'MQTT client connected with mqtt: {rc}') # rc value for success or failure + return rc + + def on_message(client, userdata, msg): + # print(f'New msg arrived in {msg.topic} : {msg.payload.decode()} ') + client.event_queue.put(msg) + + + class MQTTClient(Client): + def __init__(self, event_queue: queue = None, **kwargs): + super().__init__(**kwargs) + + self.on_message = kwargs.get('on_message', on_message) + self.on_connect = kwargs.get('on_connect', on_connect) + + self.event_queue = event_queue + + def mqtt_parser(mqtt_msg): + topic = [item for item in mqtt_msg.topic.split('/')] + port = topic[-1] + + msg = mqtt_msg.payload.decode() + return port, msg + + class MQTTInputHandler(InputHandler): + def __init__(self, subscriptions: dict[str, int] = None, **kwargs): + """ + This input handler is the implementation of the MQTT protocol. + It subscribes to the desired topics and pushes the messages received to the system + + :param dict[str, int] subscriptions: dict of topics and their QoS. Default is None + :param str host: desired MQTT broker. Default is 'test.mosquitto.org' + :param int port: port of the MQTT broker to be used. Default is 1883 + :param int keepalive: keepalive time for the MQTT connection. Default is 60 + :param Callable[[mqtt.Message], str, str] event_parser: from the received message obtain the topic and the message payload. Default is mqtt_parser + """ + + kwargs['event_parser'] = kwargs.get('event_parser', mqtt_parser) + + super().__init__(**kwargs) + + self.subscriptions = subscriptions + self.host: str = kwargs.get('host', 'test.mosquitto.org') + self.port: int = kwargs.get('port', 1883) + self.keepalive: int = kwargs.get('keepalive', 60) + + self.event_queue: queue.SimpleQueue = queue.SimpleQueue() + self.client = MQTTClient(event_queue=self.event_queue) + + self.client_thread: threading.Thread = threading.Thread(target=self.client.loop_forever, daemon=True) + + def initialize(self): + self.client.connect(self.host, self.port, self.keepalive) + for topic, qos in self.subscriptions.items(): + self.client.subscribe(topic, qos) + + self.client_thread.start() + + def run(self): + while True: + event = self.event_queue.get() + print(f'MQTT: Event pushed') # {event} t = {datetime.datetime.now()}') + self.push_event(event) + + +except ImportError: + from .bad_dependencies import BadDependenciesHandler + + + class MQTTInputHandler(BadDependenciesHandler): + def __init__(self, **kwargs): + super().__init__(handler_type='mqtt', **kwargs) diff --git a/xdevs/plugins/input_handlers/tcp.py b/xdevs/plugins/input_handlers/tcp.py new file mode 100644 index 0000000..2b1a2b5 --- /dev/null +++ b/xdevs/plugins/input_handlers/tcp.py @@ -0,0 +1,66 @@ +from __future__ import annotations +import queue +import threading +from typing import Any +from xdevs.plugins.util.socket_server import SocketServer +from xdevs.abc.handler import InputHandler +import socket + + +class TCPInputHandler(InputHandler): # TODO cambiar a SocketServerInputHandler (más generico que TCP, abre la puerta a SocketClientInputHandler) + def __init__(self, **kwargs): + """ + TCPInputHandler is a socket server. The server receives the clients messages and inject them to the system as + ingoing events. + + Default format for client messages must be: Port,msg. If a different format is chosen a new function to parser + them must be given (event_parser). + + Be aware that all the clients must use the same format. It is recommended that to implement multiple clients + with different message formats, create as many TCPInputHandlers as formats. + + :param str host: is the IP of the network interface on which the server is listening for incoming connections. + Interesting values are '127.0.0.1' for the loopback interface (LocalHost) or '0.0.0.0' for listening to all + interfaces. Default is 'LocalHost' + :param int port: is the port in which the server is listening + :param Callable[any, [str,str]] event_parser: A function that converts the messages of each client (any) to the + correct ingoing event format required by the system (str, str). First str must be the port name for the + ingoing event and the second one what is going to be injected in that port. + """ + + kwargs['event_parser'] = kwargs.get('event_parser', lambda x: x.decode().split(',')) + super().__init__(**kwargs) + + # process socket server configuration + self.server_address: tuple[Any, ...] = kwargs.get('address') + if self.server_address is None: # Este default es solo para ipv4. + host: str = kwargs.get('host', 'LocalHost') + port: int = kwargs.get('port') + if port is None: + raise ValueError('TCP port is mandatory') + self.server_address = (host, port) + self.server_socket = kwargs.get('socket') + self.max_clients: int | None = kwargs.get('max_clients', 5) # Si no le paso nada da error en socket_server + + # create socket server to handle the communications + self.server = SocketServer(self.server_address, self.server_socket, self.max_clients) + self.server_thread: threading.Thread = threading.Thread(target=self.server.start_ih, daemon=True) + + def initialize(self): + self.server_thread.start() + + def run(self): + """It just forwards messages from the server queue to the RT manager's queue.""" + while True: + event = self.server.input_queue.get() + print(f'TCP: Event pushed') #: [{event.decode()}]') # Porque no .decode() + self.push_event(event) + + +if __name__ == '__main__': + input_queue = queue.SimpleQueue() + server_socket = socket.socket() + + TCP = TCPInputHandler(port=4321, queue=input_queue, max_clients=10) + TCP.initialize() + TCP.run() diff --git a/xdevs/plugins/output_handlers/__init__.py b/xdevs/plugins/output_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/plugins/output_handlers/bad_dependencies.py b/xdevs/plugins/output_handlers/bad_dependencies.py new file mode 100644 index 0000000..201313d --- /dev/null +++ b/xdevs/plugins/output_handlers/bad_dependencies.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from abc import ABC +from xdevs.abc.handler import OutputHandler + + +class BadDependenciesHandler(OutputHandler, ABC): + def __init__(self, **kwargs): + """ + Template input handler for using when dependencies are not imported. + :param str handler_type: transducer type. + """ + super().__init__(**kwargs) + handler_type = kwargs['handler_type'] + raise ImportError(f'{handler_type} input handler specific dependencies are not imported') + + def run(self): + pass diff --git a/xdevs/plugins/output_handlers/csv.py b/xdevs/plugins/output_handlers/csv.py new file mode 100644 index 0000000..b77d365 --- /dev/null +++ b/xdevs/plugins/output_handlers/csv.py @@ -0,0 +1,29 @@ +from __future__ import annotations +import csv +import time +from xdevs.abc.handler import OutputHandler + + +class CSVOutputHandler(OutputHandler): + def __init__(self, **kwargs): + """ + CSVOutputHandler stores in a file the outgoing events in the form : time, port, msg. + If not file is given, a default file is created by the name output.csv. + + :param file: path or name of the output file. By default, it is set to 'output.csv' + :param str delimiter: column delimiter in CSV file. By default, it is set to ','. + """ + super().__init__() + self.file = kwargs.get('file', 'output.csv') + self.delimiter: str = kwargs.get('delimiter', ',') + + def run(self): + initial_time = time.time() + # in general, it is not a good idea to append to an existing file when logging a simulation + with open(self.file, 'w', newline='') as file: + writer = csv.writer(file, delimiter=self.delimiter) + writer.writerow(("t", "port", "msg")) + while True: + port, msg = self.queue.get() # blocks indefinitely until it receives a message + writer.writerow((time.time() - initial_time, port, msg)) + file.flush() diff --git a/xdevs/plugins/output_handlers/mqtt.py b/xdevs/plugins/output_handlers/mqtt.py new file mode 100644 index 0000000..fa6d088 --- /dev/null +++ b/xdevs/plugins/output_handlers/mqtt.py @@ -0,0 +1,51 @@ +from __future__ import annotations +from typing import Callable, Any + +try: + from xdevs.abc.handler import OutputHandler + from ..input_handlers.mqtt import MQTTClient + + + class MQTTOutputHandler(OutputHandler): + """ + This output handler is the implementation of the MQTT protocol. + It publishes events to the desired topics. + + :param str host: desired MQTT broker. Default is 'test.mosquitto.org' + :param int port: port of the MQTT broker to be used. Default is 1883 + :param int keepalive: keepalive time for the MQTT connection. Default is 60 + :param str topic: desired topic to publish the events. Default is 'RTsys' and generate the topic as 'RTsys/output/' + :param Callable[[str, str], Tuple[str,str]] event_parser: function to parser the port and the message to be + ejected into a MQTT topic and the payload of the MQTT message. + """ + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.host = kwargs.get('host', 'test.mosquitto.org') + self.port = kwargs.get('port', 1883) + self.keepalive = kwargs.get('keepalive', 60) + + self.client = MQTTClient() + + self.topic: str = kwargs.get('topic', 'RTsys') + + self.event_parser: Callable[[str, Any], str] = kwargs.get('event_parser', + lambda port, msg: (f'{self.topic}/output/{port}', msg)) + + def initialize(self): + self.client.connect(self.host, self.port, self.keepalive) + + def run(self): + while True: + topic, payload = self.pop_event() + print(f'Publishing {payload} to {topic}') + self.client.publish(topic, payload) + + +except ImportError: + from .bad_dependencies import BadDependenciesHandler + + + class MQTTOutputHandler(BadDependenciesHandler): + def __init__(self, **kwargs): + super().__init__(handler_type='mqtt', **kwargs) diff --git a/xdevs/plugins/output_handlers/tcp.py b/xdevs/plugins/output_handlers/tcp.py new file mode 100644 index 0000000..a26c019 --- /dev/null +++ b/xdevs/plugins/output_handlers/tcp.py @@ -0,0 +1,83 @@ +from __future__ import annotations +import time +from typing import Any, Callable + +from xdevs.plugins.util.socket_server import SocketServer +from xdevs.abc.handler import OutputHandler + + +class TCPOutputHandler(OutputHandler): # TODO cambiar a SocketClientOutputHandler (más generico que TCP, abre la puerta a SocketServerOutputHandler) + def __init__(self, **kwargs): + """ + TCPOutHandler is a socket client that sends to a server (described as host, port) the outgoing events of the + system. By default, the events are in the form: port, msg. + + :param str host: is the IP of the device where the server is hosted. Default is 'LocalHost'. + :param int port: is the port in which the host is listening. + :param float t_wait: is the time (in s) for trying to reconnect to the server if a ConnectionRefusedError + exception occurs. Default is 10 s. + :param Callable[[str, Any], str] event_parser: A function that determines the format of outgoing events. By + default, the format is 'port,msg', where 'port' is the name of the port in which an event occurred, and + 'msg' is the message given by the port. + + """ + super().__init__(**kwargs) + + self.client_address: tuple[Any, ...] = kwargs.get('address') + if self.client_address is None: + host: str = kwargs.get('host', 'LocalHost') + port: int = kwargs.get('port') + if port is None: + raise ValueError('TCP port is mandatory') + self.client_address = (host, port) + + self.server_socket = kwargs.get('server_socket') + + self.client = SocketServer(server_address=self.client_address, server_socket=self.server_socket) + + self.t_wait: float = kwargs.get('t_wait', 10) + + self.event_parser: Callable[[str, Any], str] = kwargs.get('event_parser', lambda port, msg: f'{port},{msg}') + + # self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.is_connected: bool = False + +# def exit(self): +# print(f'Closing client to server {host} in port {self.port}...') +# self.client_socket.close() +# self.is_connected = False + + def run(self): + timeout = 0 # Probar a poner aqui time.time() y ver que pasa + while True: + # Wait for an outgoing event + event = self.pop_event() + try: + if self.is_connected: + # self.client_socket.sendall(event.encode()) + self.client.output_queue.put(event) + elif time.time() > timeout: + try: + + # self.client_socket.connect((self.host, self.port)) + # print('Connected to server...') + + self.client.start_oh() + + self.is_connected = True + # self.client_socket.sendall(event.encode()) + + self.client.output_queue.put(event) + + except ConnectionRefusedError: + # If the connection is refused, wait for a time t_wait and try again. + # This exception can be raised when: the port is blocked or closed by a firewall, host is not + # available or close, among others. + print(f'Connection refused, trying again in {self.t_wait} s.') + # Si un outgoing event tardase mas de self.t_wait, se conectaría cuando llegase dicho event. + timeout = time.time() + self.t_wait + + except OSError as e: + # If a system error occurred when connecting, we assume that the server has been shut down. + print(f'Error while connecting to server: {e}') + break diff --git a/xdevs/plugins/transducers/bad_dependencies_transducer.py b/xdevs/plugins/transducers/bad_dependencies.py similarity index 67% rename from xdevs/plugins/transducers/bad_dependencies_transducer.py rename to xdevs/plugins/transducers/bad_dependencies.py index 6981433..432c757 100644 --- a/xdevs/plugins/transducers/bad_dependencies_transducer.py +++ b/xdevs/plugins/transducers/bad_dependencies.py @@ -1,5 +1,6 @@ +from __future__ import annotations from abc import ABC -from xdevs.transducers import Transducer +from xdevs.abc.transducer import Transducer class BadDependenciesTransducer(Transducer, ABC): @@ -9,7 +10,8 @@ def __init__(self, **kwargs): :param str transducer_type: transducer type. """ super().__init__(**kwargs) - raise ImportError('{} transducer specific dependencies are not imported'.format(kwargs.get('transducer_type'))) + transducer_type = kwargs['transducer_type'] + raise ImportError(f'{transducer_type} transducer specific dependencies are not imported') def create_known_data_types_map(self): pass diff --git a/xdevs/plugins/transducers/csv_transducer.py b/xdevs/plugins/transducers/csv.py similarity index 84% rename from xdevs/plugins/transducers/csv_transducer.py rename to xdevs/plugins/transducers/csv.py index 80d44ac..1a569bf 100644 --- a/xdevs/plugins/transducers/csv_transducer.py +++ b/xdevs/plugins/transducers/csv.py @@ -2,7 +2,7 @@ import csv import os from typing import Any, Iterable, Type -from ...transducers import Transducer +from xdevs.abc.transducer import Transducer class CSVTransducer(Transducer): @@ -50,21 +50,16 @@ def bulk_data(self, sim_time: float): for state_insert in self._iterate_state_inserts(sim_time): self.state_csv_writer.writerow([state_insert[field] for field in self.state_header]) + for event_insert in self._iterate_event_inserts(sim_time): self.event_csv_writer.writerow([event_insert[field] for field in self.event_header]) def _create_csv_file(self, filename: str, header: list[str]): - # 1. If output file already exist, we ask the use if he/she wants to overwrite it. - # if os.path.exists(filename): - # print('Transducer output file {} already exists.'.format(filename)) - # if input('Do you want to overwrite it? [Y/n] >').lower() in ['n', 'no']: - # raise FileExistsError('File already exists and user does not want to overwrite it') - # 2. If directory that will contain the file does not exist, we create it. os.makedirs(os.path.dirname(filename), exist_ok=True) # 3. Create output CSV file and write the header row. - csv_file = open(filename, 'w') + csv_file = open(filename,newline='', mode='w') writer = csv.writer(csv_file, delimiter=self.delimiter) writer.writerow(header) diff --git a/xdevs/plugins/transducers/elasticsearch_transducer.py b/xdevs/plugins/transducers/elasticsearch.py similarity index 97% rename from xdevs/plugins/transducers/elasticsearch_transducer.py rename to xdevs/plugins/transducers/elasticsearch.py index 02629bb..b0cb412 100644 --- a/xdevs/plugins/transducers/elasticsearch_transducer.py +++ b/xdevs/plugins/transducers/elasticsearch.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from ...transducers import Transducer -from .bad_dependencies_transducer import BadDependenciesTransducer +from xdevs.abc.transducer import Transducer try: from elasticsearch import Elasticsearch @@ -82,6 +81,9 @@ def create_index(self, index_name: str, field_properties: dict[str, dict[str, st except ModuleNotFoundError: + from .bad_dependencies import BadDependenciesTransducer + + class ElasticsearchTransducer(BadDependenciesTransducer): def __init__(self, **kwargs): super().__init__(transducer_type='elasticsearch', **kwargs) diff --git a/xdevs/plugins/transducers/sql_transducer.py b/xdevs/plugins/transducers/sql.py similarity index 97% rename from xdevs/plugins/transducers/sql_transducer.py rename to xdevs/plugins/transducers/sql.py index 3977d65..e8cd62c 100644 --- a/xdevs/plugins/transducers/sql_transducer.py +++ b/xdevs/plugins/transducers/sql.py @@ -1,6 +1,5 @@ from __future__ import annotations -from xdevs.transducers import Transducer -from .bad_dependencies_transducer import BadDependenciesTransducer +from xdevs.abc.transducer import Transducer try: from sqlalchemy import create_engine, text, Column, Float, Integer, MetaData, String, Table @@ -86,6 +85,9 @@ def create_table(self, table_name: str, columns: list[Column], except ModuleNotFoundError: + from .bad_dependencies import BadDependenciesTransducer + + class SQLTransducer(BadDependenciesTransducer): def __init__(self, **kwargs): super().__init__(transducer_type='sql', **kwargs) diff --git a/xdevs/plugins/util/__init__.py b/xdevs/plugins/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevs/plugins/util/socket_server.py b/xdevs/plugins/util/socket_server.py new file mode 100644 index 0000000..502200d --- /dev/null +++ b/xdevs/plugins/util/socket_server.py @@ -0,0 +1,94 @@ +from __future__ import annotations +import queue +import socket +import threading +from typing import Any + + +def input_client_handler(client_socket: socket.socket, address: tuple[Any, ...], + input_queue: queue.SimpleQueue, max_size: int = 1024): + """ + Function to handle a socket client that inputs external events to the simulation events. + + :param client_socket: socket assigned to the running client. + :param address: socket address of the event source endpoint. + :param input_queue: queue from the outside to the simulation. Messages are injected as raw byte arrays. + :param max_size: maximum size of incoming messages (in bytes). + """ + # TODO probablemente esto no tenga mucho sentido aquí y la lógica es mejor que la hagan los handlers + print(f'socket input client connected to {address}') + try: + while True: + data = client_socket.recv(max_size) + if not data: # connection closed + break + input_queue.put(data) + finally: + print(f'socket input client disconnected from {address}') + client_socket.close() + + +def output_client_handler(client_socket: socket.socket, address: tuple[Any, ...], output_queue: queue.SimpleQueue): + """ + Function to handle a TCP socket client that outputs simulation events to the outside. + + :param client_socket: socket assigned to the running client. + :param address: socket address of the event destination endpoint. + :param output_queue: queue from simulation to outside. Messages are already parsed as strings + """ + # TODO Podemos tener sockets tanto servidor como clientes para inputs y outputs + # TODO probablemente esto no tenga mucho sentido aquí y la lógica es mejor que la hagan los handlers + print(f'socket output client connected to {address}') + try: + while True: + event = output_queue.get() + client_socket.sendall(event.encode()) + except OSError as e: + # If a system error occurred when connecting, we assume that the server has been shut down. + print(f'Error while connecting to server: {e}') + finally: + print(f'socket output client disconnected from {address}') + client_socket.close() + + +class SocketServer: + def __init__(self, server_address: tuple[Any, ...], server_socket: socket.socket = None, max_clients: int = None): + """ + TCP server that manages the connectivity with TCP clients for inputting events. + + :param server_address: server address used when binding the server socket. + Usually, it is a tuple (IP address, socket number). However, this depends on the socket type used. + :param server_socket: server socket. By default, it uses the IPv4 family and socket stream type. + :param max_clients: maximum number of clients allowed concurrently. By default, it is None (i.e., no limit). + """ + # TODO idea loca: el servidor añade a una cola de clientes los nuevos clientes y se olvida. + # TODO es la responsabilidad del handler de turno hacer lo que sea que tiene que hacer con los sockets + self.server_address: tuple[Any, ...] = server_address + if server_socket is None: + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_socket: socket.socket = server_socket + self.max_clients: int | None = max_clients + self.clients: list[threading.Thread] = list() # TODO yo creo que nos lo podemos ahorrar + + self.input_queue: queue.SimpleQueue = queue.SimpleQueue() # Para InputHandler + self.output_queue: queue.SimpleQueue = queue.SimpleQueue() # Para OutputHandler + + def start_ih(self): + self.server_socket.bind(self.server_address) + self.server_socket.listen(self.max_clients) + print(f'socket server with address {self.server_address} is listening...') + while True: + client_socket, address = self.server_socket.accept() + # TODO en vez de esto, añadimos el resultado de accept a la cola + # TODO la hebra etc. la abre el handler de turno + self.clients.append(threading.Thread(target=input_client_handler, daemon=True, + args=(client_socket, address, self.input_queue))) + #print('TCP MSG ARRIVED') + self.clients[-1].start() + + def start_oh(self): + self.server_socket.connect(self.server_address) + print('Connected to server...') + c_thread = threading.Thread(target=output_client_handler, daemon=True, + args=(self.server_socket, self.server_address, self.output_queue)) + c_thread.start() diff --git a/xdevs/plugins/wrappers/bad_dependencies.py b/xdevs/plugins/wrappers/bad_dependencies.py new file mode 100644 index 0000000..116ad49 --- /dev/null +++ b/xdevs/plugins/wrappers/bad_dependencies.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from abc import ABC +from xdevs.models import Atomic + + +class BadDependenciesWrapper(Atomic, ABC): + def __init__(self, **kwargs): + """ + Template wrapper for using when dependencies are not installed. + :param str wrapper_type: wrapper type. + """ + super().__init__(**kwargs) + wrapper_type = kwargs['wrapper_type'] + raise ImportError(f'{wrapper_type} wrapper specific dependencies are not installed') + + def deltint(self): + pass + + def deltext(self, e: float): + pass + + def lambdaf(self): + pass + + def initialize(self): + pass + + def exit(self): + pass diff --git a/xdevs/plugins/wrappers/pypdevs.py b/xdevs/plugins/wrappers/pypdevs.py index 224f1f8..789577f 100644 --- a/xdevs/plugins/wrappers/pypdevs.py +++ b/xdevs/plugins/wrappers/pypdevs.py @@ -1,71 +1,78 @@ -import logging -from xdevs.models import Atomic, Port +from __future__ import annotations try: from pypdevs.DEVS import AtomicDEVS from pypdevs.minimal import AtomicDEVS as AtomicDEVSMin -except ImportError: - logging.warning("pypdevs module not installed.") + from xdevs.models import Atomic, Port + + + def update_sigma_on_state_change(delt_func): + def inner(self, *args, **kwargs): + prev_state = self.phase + delt_func(self, *args, **kwargs) + if prev_state != self.phase: + self.sigma = self.atomic.timeAdvance() + return inner -def update_sigma_on_state_change(delt_func): - def inner(self, *args, **kwargs): - prev_state = self.phase - delt_func(self, *args, **kwargs) - if prev_state != self.phase: - self.sigma = self.atomic.timeAdvance() - return inner + class PyPDEVSWrapper(Atomic): + def __init__(self, atomic: AtomicDEVS or AtomicDEVSMin): + super().__init__() + self.atomic: AtomicDEVS or AtomicDEVSMin = atomic + self.pypdevs_in_ports = {} # IO ports dictionaries to efficiently manage pypdevs ports + self.xdevs_out_ports = {} -class PyPDEVSWrapper(Atomic): + for pypdevs_in_port in self.atomic.IPorts: + xdevs_in_port = Port(None, pypdevs_in_port.name) + self.add_in_port(xdevs_in_port) + self.pypdevs_in_ports[pypdevs_in_port.name] = pypdevs_in_port + setattr(self, pypdevs_in_port.name, xdevs_in_port) - def __init__(self, atomic: AtomicDEVS or AtomicDEVSMin): - super().__init__() - self.atomic: AtomicDEVS or AtomicDEVSMin = atomic - self.pypdevs_in_ports = {} # IO ports dictionaries to efficiently manage pypdevs ports - self.xdevs_out_ports = {} + for pypdevs_out_port in self.atomic.OPorts: + xdevs_out_port = Port(None, pypdevs_out_port.name) + self.add_out_port(xdevs_out_port) + self.xdevs_out_ports[pypdevs_out_port.name] = xdevs_out_port + setattr(self, pypdevs_out_port.name, xdevs_out_port) - for pypdevs_in_port in self.atomic.IPorts: - xdevs_in_port = Port(None, pypdevs_in_port.name) - self.add_in_port(xdevs_in_port) - self.pypdevs_in_ports[pypdevs_in_port.name] = pypdevs_in_port - setattr(self, pypdevs_in_port.name, xdevs_in_port) + @update_sigma_on_state_change + def deltint(self): + self.phase = self.atomic.state = self.atomic.intTransition() - for pypdevs_out_port in self.atomic.OPorts: - xdevs_out_port = Port(None, pypdevs_out_port.name) - self.add_out_port(xdevs_out_port) - self.xdevs_out_ports[pypdevs_out_port.name] = xdevs_out_port - setattr(self, pypdevs_out_port.name, xdevs_out_port) + @update_sigma_on_state_change + def deltext(self, e: float): + self.phase = self.atomic.state = self.atomic.extTransition(self._inputs_to_dict()) + self.continuef(e) - @update_sigma_on_state_change - def deltint(self): - self.phase = self.atomic.state = self.atomic.intTransition() + def lambdaf(self) -> None: + outputs = self.atomic.outputFnc() - @update_sigma_on_state_change - def deltext(self, e: float): - self.phase = self.atomic.state = self.atomic.extTransition(self._inputs_to_dict()) - self.continuef(e) + for pypdevs_out_port, values in outputs.items(): + if len(values) > 0: + xdevs_out_port = self.xdevs_out_ports[pypdevs_out_port.name] + xdevs_out_port.extend(values) - def lambdaf(self) -> None: - outputs = self.atomic.outputFnc() + def initialize(self) -> None: + pass - for pypdevs_out_port, values in outputs.items(): - if len(values) > 0: - xdevs_out_port = self.xdevs_out_ports[pypdevs_out_port.name] - xdevs_out_port.extend(values) + def exit(self) -> None: + pass - def initialize(self) -> None: - pass + def _inputs_to_dict(self): + in_values = {} - def exit(self) -> None: - pass + for in_port in self.in_ports: + in_port_values = list(in_port.values) + in_values[self.pypdevs_in_ports[in_port.name]] = in_port_values - def _inputs_to_dict(self): - in_values = {} + return in_values + + +except ImportError: + from .bad_dependencies import BadDependenciesWrapper - for in_port in self.in_ports: - in_port_values = list(in_port.values) - in_values[self.pypdevs_in_ports[in_port.name]] = in_port_values - return in_values + class PyPDEVSWrapper(BadDependenciesWrapper): + def __init__(self, **kwargs): + super().__init__(wrapper_type='pypdevs') diff --git a/xdevs/rt.py b/xdevs/rt.py new file mode 100644 index 0000000..ae3998c --- /dev/null +++ b/xdevs/rt.py @@ -0,0 +1,181 @@ +from __future__ import annotations +import queue +import sys +import threading +import time +from typing import Any +from xdevs.factory import InputHandler, InputHandlers, OutputHandler, OutputHandlers +from xdevs.models import Coupled, Port +from xdevs.sim import Coordinator + + +def run_handler(handler: InputHandler | OutputHandler): + handler.initialize() + try: + handler.run() + except Exception: # TODO try to narrow this catch + handler.exit() + sys.exit() + + +class RealTimeManager: + def __init__(self, max_jitter: float = None, time_scale: float = 1, event_window: float = 0): + """ + The RealTimeManager is responsible for collecting external events and implement real time in the simulation. + + :param max_jitter: Maximum delay time the system can absorb. Default is None (i.e., no jitter check) + :param time_scale: Scale for increasing or decreasing the simulated time. Default is 1 s (i.e., no scale) + :param event_window: Additional time is added to check for others events. Default is 0 (i.e., no window) + """ + if max_jitter is not None and max_jitter < 0: + raise ValueError('negative max_jitter is not valid.') + self.max_jitter: float | None = max_jitter + if time_scale <= 0: + raise ValueError('negative or zero time_scale is not valid.') + self.time_scale: float = time_scale + if event_window < 0: + raise ValueError('negative event_window is not valid.') + self.event_window: float = event_window + + self.initial_r_time: float = 0 + self.last_r_time: float = 0 + self.last_v_time: float = 0 + + self.threads = list() + # Queue for processing the external events that are being injecting in the system + self.input_queue: queue.SimpleQueue = queue.SimpleQueue() + # Lists for storing any handler + self.input_handlers: list[InputHandler] = list() + self.output_handlers: list[OutputHandler] = list() + + def add_input_handler(self, handler_id: str, *args, **kwargs): + """ + Add a new InputHandler to the system. + + :param handler_id: unique ID of the input handler to be created. + :param kwargs: any additional configuration parameter needed for creating the input handler. + """ + i_handler = InputHandlers.create_input_handler(handler_id, *args, **kwargs, queue=self.input_queue) + self.input_handlers.append(i_handler) + + def add_output_handler(self, handler_id: str, *args, **kwargs): + """ + Add a new OutputHandler to the system. + + :param handler_id: unique ID of the output handler to be created. + :param kwargs: any additional configuration parameter needed for creating the output handler. + """ + o_handler = OutputHandlers.create_output_handler(handler_id, *args, **kwargs) + self.output_handlers.append(o_handler) + + def initialize(self, initial_t: float): + """ + Initialize function of the real time manager. + It is responsible for creating and starting any handler in the handler's list. + + :param initial_t: initial time of the simulation. + """ + for handlers in self.input_handlers, self.output_handlers: + for handler in handlers: + thread = threading.Thread(daemon=True, target=run_handler, args=[handler]) + thread.start() + self.threads.append(thread) + self.last_v_time = initial_t + self.initial_r_time = time.time() + self.last_r_time = self.initial_r_time + + def exit(self, final_t: float): + self.last_v_time = final_t + + def wait_until(self, next_v_time: float) -> tuple[float, list[tuple[str, Any]]]: + """ + Function that implements the real time specification by waiting for ingoing events to the system. + + :param next_v_time: simulation time of the next internal event in the simulation. + :return: a tuple of: actual simulation time when function returned and list of input events. + """ + next_r_time = self.last_r_time + (next_v_time - self.last_v_time) * self.time_scale + events: list[tuple[str, Any]] = list() + try: + # First, we wait for a single message + events.append(self.input_queue.get(timeout=max(next_r_time - time.time(), 0))) + # Only if we receive one message will we wait for an additional event time window + t_window = min(time.time() + self.event_window, next_r_time) + while True: + try: + events.append(self.input_queue.get(timeout=max(t_window - time.time(), 0))) + except queue.Empty: + break # event window timeout, we are done with messages + # Finally, we compute the current time. Must be between last_r_time and next_r_time + self.last_r_time = min(next_r_time, time.time()) + self.last_v_time = min(next_v_time, (self.last_r_time - self.initial_r_time) / self.time_scale) + except queue.Empty: + # we did not receive any message, just update the time + self.last_r_time = next_r_time + self.last_v_time = next_v_time + # If needed, we check that the jitter is not too big + if self.max_jitter is not None and abs(time.time() - self.last_r_time) > self.max_jitter: + raise RuntimeError('maximum jitter exceeded.') + return self.last_v_time, events + + def propagate_output(self, port: Port): + """ + An outgoing event is inserted in the queues of all OutputHandlers. + + :param port: output port of the topmost DEVS model under simulation. + """ + for o_handler in self.output_handlers: + for msg in port.values: + o_handler.queue.put((port.name, msg)) + + +class RealTimeCoordinator(Coordinator): + """ + The RealTimeCoordinator is the adaptation of the already existing class Coordinator to the real-time simulations. + + :param Coupled model: A DEVS model to simulate in real-time. + :param RealTimeManager manager: A RealTimeManager to handle the external events. + """ + def __init__(self, model: Coupled, manager: RealTimeManager): + super().__init__(model) + self.manager: RealTimeManager = manager + + def initialize(self): + super().initialize() + self.manager.initialize(self.clock.time) + + def exit(self): + self.manager.exit(self.clock.time) + super().exit() + + def simulate_rt(self, time_interv: float = float("inf")): + self.initialize() + while self.clock.time < time_interv: + if self.time_next == float("inf") and not self.manager.input_handlers: + break + # WAIT UNTIL NEXT STATE TRANSITION + t, events = self.manager.wait_until(min(time_interv, self.time_next)) + # INJECT EXTERNAL EVENTS (if any) + for port_id, msg in events: + port = self.model.get_in_port(port_id) + if port is not None: + try: + port.add(msg) + except TypeError as e: + print(f'invalid message type: {e}', file=sys.stderr) + else: + print(f'input port "{port_id}" does not exit', file=sys.stderr) + # UPDATE SIMULATION CLOCK + self.clock.time = t + # EXECUTE NEXT CYCLE (if applies) + if self.clock.time == self.time_next: + self.lambdaf() + self.deltfcn() + # EXECUTE TRANSDUCERS (if any) + self._execute_transducers() + # EJECT NEW OUTPUT EVENTS + for port in self.model.out_ports: + self.manager.propagate_output(port) + # CLEAR THE PORTS OF THE MODEL + self.clear() + self.exit() diff --git a/xdevs/sim.py b/xdevs/sim.py index ea5b909..164d1f5 100644 --- a/xdevs/sim.py +++ b/xdevs/sim.py @@ -3,16 +3,16 @@ import _thread import itertools import pickle +import logging from abc import ABC, abstractmethod from collections import defaultdict -from concurrent import futures -from typing import Generator +from typing import Generator, Optional from xmlrpc.server import SimpleXMLRPCServer -from xdevs import INFINITY -from xdevs.models import Atomic, Coupled, Component, Port, T -from xdevs.transducers import Transducer +from xdevs import INFINITY, T +from xdevs.models import Atomic, Coupled, Component, Port +from xdevs.abc import Transducer class SimulationClock: @@ -22,13 +22,13 @@ def __init__(self, time: float = 0): class AbstractSimulator(ABC): def __init__(self, model: Component, clock: SimulationClock, - event_transducers_mapping: dict[Port, list[Transducer]] = None): + event_transducers_mapping: Optional[dict[Port, list[Transducer]]] = None): self.model: Component = model self.clock: SimulationClock = clock self.time_last: float = 0 self.time_next: float = 0 - self.event_transducers: dict[Port, list[Transducer]] | None = None + self.event_transducers: Optional[dict[Port, list[Transducer]]] = None if event_transducers_mapping: port_transducers: dict[Port, list[Transducer]] = dict() for port in itertools.chain(self.model.in_ports, self.model.out_ports): @@ -75,12 +75,11 @@ def clear(self): class Simulator(AbstractSimulator): - model: Atomic def __init__(self, model: Atomic, clock: SimulationClock, - event_transducers_mapping: dict[Port, list[Transducer]] = None, - state_transducers_mapping: dict[Atomic, list[Transducer]] = None): + event_transducers_mapping: Optional[dict[Port, list[Transducer]]] = None, + state_transducers_mapping: Optional[dict[Atomic, list[Transducer]]] = None): super().__init__(model, clock, event_transducers_mapping) self.state_transducers: list[Transducer] | None = None if state_transducers_mapping: @@ -88,22 +87,22 @@ def __init__(self, model: Atomic, clock: SimulationClock, @property def ta(self) -> float: - return self.model.ta + return self.model.ta() def initialize(self): self.model.initialize() self.time_last = self.clock.time - self.time_next = self.time_last + self.model.ta + self.time_next = self.time_last + self.model.ta() def exit(self): self.model.exit() def deltfcn(self) -> Simulator | None: # TODO if not self.model.in_empty(): - e = self.clock.time - self.time_last if self.clock.time == self.time_next: - self.model.deltcon(e) + self.model.deltcon() else: + e = self.clock.time - self.time_last self.model.deltext(e) elif self.clock.time == self.time_next: self.model.deltint() @@ -117,7 +116,7 @@ def deltfcn(self) -> Simulator | None: # TODO self.trigger_event_transducers() self.time_last = self.clock.time - self.time_next = self.time_last + self.model.ta + self.time_next = self.time_last + self.model.ta() return self def lambdaf(self): @@ -130,20 +129,20 @@ def clear(self): class Coordinator(AbstractSimulator): - model: Coupled - def __init__(self, model: Coupled, clock: SimulationClock = None, flatten: bool = False, - event_transducers_mapping: dict[Port, list[Transducer]] = None, - state_transducers_mapping: dict[Atomic, list[Transducer]] = None): + def __init__(self, model: Coupled, clock: Optional[SimulationClock] = None, flatten: bool = False, + event_transducers_mapping: Optional[dict[Port, list[Transducer]]] = None, + state_transducers_mapping: Optional[dict[Atomic, list[Transducer]]] = None): super().__init__(model, clock or SimulationClock(), event_transducers_mapping) self.coordinators: list[Coordinator] = list() self.simulators: list[Simulator] = list() - self._transducers: list[Transducer] = [] if self.root_coordinator else None + self._transducers: Optional[list[Transducer]] = [] if self.root_coordinator else None if flatten: self.model.flatten() + # TODO we must fix transducers here! self.ports_to_serve = dict() self.__event_transducers_mapping: dict[Port, list[Transducer]] | None = None @@ -196,7 +195,7 @@ def initialize(self): self.time_last = self.clock.time self.time_next = self.time_last + self.ta() - if self.root_coordinator: + if self._transducers is not None: for transducer in self._transducers: transducer.initialize() @@ -229,7 +228,7 @@ def _build_hierarchy(self): self.ports_to_serve[port_name] = pts def add_transducer(self, transducer: Transducer): - if not self.root_coordinator: + if self._transducers is None: raise RuntimeError('Only the root coordinator can contain transducers') self._transducers.append(transducer) @@ -242,7 +241,7 @@ def exit(self): for processor in self.processors: processor.exit() - if self.root_coordinator: + if self._transducers is not None: for transducer in self._transducers: transducer.exit() @@ -310,7 +309,6 @@ def inject(self, port: str | Port[T], values: T | list[T], e: float = 0) -> bool def simulate(self, num_iters: int = 10000): self.clock.time = self.time_next cont = 0 - while cont < num_iters and self.clock.time < INFINITY: self.lambdaf() self.deltfcn() @@ -319,7 +317,7 @@ def simulate(self, num_iters: int = 10000): self.clock.time = self.time_next cont += 1 - def simulate_time(self, time_interv: float = 10000): + def simulate_time(self, time_interv: float = INFINITY): self.clock.time = self.time_next tf = self.clock.time + time_interv while self.clock.time < tf: @@ -329,166 +327,6 @@ def simulate_time(self, time_interv: float = 10000): self.clear() self.clock.time = self.time_next - def simulate_inf(self): - while True: - self.lambdaf() - self.deltfcn() - self._execute_transducers() - self.clear() - self.clock.time = self.time_next - def _execute_transducers(self): for transducer in self._transducers: transducer.trigger(self.clock.time) - - -# TODO we should review the parallel implementation -class ParallelCoordinator(Coordinator): - def __init__(self, model: Coupled, clock: SimulationClock = None, flatten: bool = False, - event_transducers_mapping: dict[Port, list[Transducer]] = None, - state_transducers_mapping: dict[Atomic, list[Transducer]] = None, executor=None): - super().__init__(model, clock, flatten, event_transducers_mapping=event_transducers_mapping, - state_transducers_mapping=state_transducers_mapping) - - self.executor = executor or futures.ThreadPoolExecutor(max_workers=8) # TODO calc max workers - - def _add_coordinator(self, coupled: Coupled): - coord = ParallelCoordinator(coupled, self.clock, executor=False) - self.coordinators.append(coord) - self.ports_to_serve.update(coord.ports_to_serve) - - def _lambdaf(self): - for coord in self.coordinators: - coord.lambdaf() - ex_futures = [] - for sim in self.simulators: - self.add_task_to_pool(sim.lambdaf) - - for future in futures.as_completed(ex_futures): - future.result() - - self.propagate_output() - - def deltfcn(self): - self.propagate_input() - - for coord in self.coordinators: - coord.deltfcn() - ex_futures = [] - for sim in self.simulators: - self.add_task_to_pool(sim.deltfcn) - - for future in futures.as_completed(ex_futures): - future.result() - - self.time_last = self.clock.time - self.time_next = self.time_last + self.ta() - - def add_task_to_pool(self, task): - self.executor.submit(task) - - -class ParallelProcessCoordinator(Coordinator): - - coordinators: list[ParallelProcessCoordinator] - - def __init__(self, model: Coupled, clock: SimulationClock = None, - event_transducers_mapping: dict[Port, list[Transducer]] = None, - state_transducers_mapping: dict[Atomic, list[Transducer]] = None, - master=True, executor=None, executor_futures=None): - super().__init__(model, clock, event_transducers_mapping=event_transducers_mapping, - state_transducers_mapping=state_transducers_mapping) - self.master = master - - if master: - self.executor = futures.ProcessPoolExecutor() - self.executor_futures = dict() - else: - self.executor = executor - self.executor_futures = executor_futures - - def _add_coordinator(self, coupled: Coupled): - coord = ParallelProcessCoordinator(coupled, self.clock, master=False, executor=self.executor, - executor_futures=self.executor_futures) - self.coordinators.append(coord) - self.ports_to_serve.update(coord.ports_to_serve) - - def lambdaf(self): - - for coord in self.coordinators: - if coord.clock.time == coord.time_next: - coord.lambdaf() - - for sim in self.simulators: - if sim.clock.time == sim.time_next: - self.executor_futures[self.executor.submit(sim.lambdaf)] = (self, sim) - - if self.master: - for i, future in enumerate(self.executor_futures): - # logger.debug("D: Waiting... (%d/%d)" % (i+1, len(self.executor_futures))) - futures.wait((future,)) - - res = future.result() - if isinstance(res, Simulator): - coord, sim = self.executor_futures[future] - for model_port, new_model_port in zip(sim.model.out_ports, future.result().model.out_ports): - model_port.extend(list(new_model_port.values)) - - self.executor_futures.clear() - self.propagate_output() - - def deltfcn(self): - if self.master: - self.propagate_input() - - for coord in self.coordinators: - coord.deltfcn() - - for sim in self.simulators: - self.executor_futures[self.executor.submit(sim.deltfcn)] = (self, sim) - - if self.master: - for i, future in enumerate(self.executor_futures): - # logger.debug("D: Waiting... (%d/%d)" % (i+1, len(self.executor_futures))) - futures.wait((future,)) - - res = future.result() - if isinstance(res, Simulator): - coord, sim = self.executor_futures[future] - model = sim.model - new_sim = future.result() - new_model = new_sim.model - - # Copy new state - if hasattr(new_model, "__state__"): - for state_att in new_model.__state__: - setattr(model, state_att, getattr(new_model, state_att)) - - # Update simulator info - sim.model = model - sim.time_last = new_sim.time_last - sim.time_next = new_sim.time_next - - self.executor_futures.clear() - self.update_times() - - def propagate_output(self): - for coord in self.coordinators: - coord.propagate_output() - - super().propagate_output() - - def propagate_input(self): - super().propagate_input() - - for coord in self.coordinators: - coord.propagate_input() - - def update_times(self): - for coord in self.coordinators: - coord.update_times() - - self.time_last = self.clock.time - self.time_next = self.time_last + self.ta() - # logger.debug({proc.model.name:proc.time_next for proc in self.processors}) - # logger.debug("Deltfcn %s: TL: %s, TN: %s" % (self.model.name, self.time_last, self.time_next)) diff --git a/xdevs/tests/test_csv_transducer.py b/xdevs/tests/test_csv_transducer.py index 2093045..293fbb2 100644 --- a/xdevs/tests/test_csv_transducer.py +++ b/xdevs/tests/test_csv_transducer.py @@ -6,15 +6,15 @@ from xdevs.sim import Coordinator from xdevs.examples.devstone.devstone import LI, DelayedAtomic, HI -from xdevs.transducers import Transducers -from xdevs.examples.basic.basic import Job, Processor, Gpt +from xdevs.factory import Transducers +from xdevs.examples.gpt.models import Job, Processor, Gpt class TestCsvTransducer(TestCase): def test_component_filtering_by_type(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) # csv.add_target_port() # csv.add_target_port_by_components(gpt, component_filter=[Coupled, "coupled_.*"], port_filter=OutPort) @@ -29,7 +29,7 @@ def test_component_filtering_by_type(self): def test_component_filtering_by_regex(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) csv.filter_components(".*or") self.assertEqual(len(csv.target_components), 2) @@ -42,45 +42,45 @@ def test_component_filtering_by_regex(self): def test_component_filtering_by_callable(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) - csv.filter_components(lambda comp: hasattr(comp, "proc_time")) + csv.filter_components(lambda comp: hasattr(comp, "proc_t")) self.assertEqual(1, len(csv.target_components)) csv = Transducers.create_transducer('csv', transducer_id='tt') li = LI("LI_root", depth=10, width=10, int_delay=0, ext_delay=0) csv.add_target_component(li) - csv.filter_components(lambda comp: isinstance(comp, DelayedAtomic) and comp.name[-1] == "0") + csv.filter_components(lambda comp: isinstance(comp, DelayedAtomic) and comp.name[-3] == "0") self.assertEqual(10, len(csv.target_components)) def test_ports_filtering_by_type(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) csv.add_target_ports_by_component(gpt, port_filters=Port) - self.assertEqual(8, len(csv.target_ports)) + self.assertEqual(7, len(csv.target_ports)) def test_ports_filtering_by_regex(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) csv.add_target_ports_by_component(gpt, port_filters="i_.*") - self.assertEqual(5, len(csv.target_ports)) + self.assertEqual(4, len(csv.target_ports)) csv.target_ports.clear() csv.add_target_ports_by_component(gpt, port_filters="[io]_.{3,5}ed") self.assertEqual(2, len(csv.target_ports)) csv.target_ports.clear() - csv.add_target_ports_by_component(gpt, port_filters=".*start.*") + csv.add_target_ports_by_component(gpt, port_filters=".*stop.*") self.assertEqual(1, len(csv.target_ports)) csv.target_ports.clear() def test_ports_filtering_by_callable(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) filter_func = lambda port: port.name.startswith("i_") and port.name.endswith("ed") @@ -89,11 +89,11 @@ def test_ports_filtering_by_callable(self): def test_ports_filtering_mixed(self): csv = Transducers.create_transducer('csv', transducer_id='tt') - gpt = Gpt("gpt", 3, 100) + gpt = Gpt("gpt", 3, 9, 100) csv.add_target_component(gpt) csv.add_target_ports_by_component(gpt) - self.assertEqual(8, len(csv.target_ports)) + self.assertEqual(7, len(csv.target_ports)) csv.target_ports.clear() input_ports_filter = lambda port: port.name.startswith("i_") @@ -106,7 +106,7 @@ def test_ports_filtering_mixed2(self): csv = Transducers.create_transducer('csv', transducer_id='tt') hi = HI("HI_root", depth=10, width=10, int_delay=0, ext_delay=0) - comp_filters = (lambda comp: isinstance(comp, DelayedAtomic), ".*[0-2]$") + comp_filters = (lambda comp: isinstance(comp, DelayedAtomic), ".*[0-2]_.*$") csv.add_target_component(hi, *comp_filters) print(csv.target_components) @@ -173,13 +173,13 @@ def test_behavior(self): trans_id = "%s_test_behavior" % self.__class__.__name__ csv_transducer = Transducers.create_transducer('csv', transducer_id=trans_id, exhaustive=True) - gpt = Gpt("gpt", 3, 1000) + gpt = Gpt("gpt", 3, 9, 1000) csv_transducer.add_target_component(gpt) coord = Coordinator(gpt, flatten=False) coord.add_transducer(csv_transducer) coord.initialize() - coord.simulate_time() + coord.simulate() coord.exit() self.assertTrue(os.path.exists(csv_transducer.state_filename)) @@ -187,3 +187,8 @@ def test_behavior(self): # TODO: continue when state changes are register appropiately # TODO: def test_pause_resume(self): + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/xdevs/tests/test_devstone.py b/xdevs/tests/test_devstone.py index 6b2d189..026168b 100644 --- a/xdevs/tests/test_devstone.py +++ b/xdevs/tests/test_devstone.py @@ -1,10 +1,11 @@ -from unittest import TestCase +import unittest +from xdevs import INFINITY from xdevs.sim import Coordinator from xdevs.examples.devstone.devstone import DEVStone, LI, HI, HO, HOmod import random -class DevstoneUtilsTestCase(TestCase): +class DevstoneUtilsTestCase(unittest.TestCase): def __init__(self, name, num_valid_params_sets: int = 10): super().__init__(name) @@ -66,7 +67,7 @@ def test_behavior(self): coord = Coordinator(root) coord.initialize() # coord.inject(li_root.i_in, 0) - coord.simulate() + coord.simulate_time(INFINITY) self.assertEqual(root.n_internals, (params["width"] - 1) * (params["depth"] - 1) + 1) self.assertEqual(root.n_externals, (params["width"] - 1) * (params["depth"] - 1) + 1) @@ -116,7 +117,7 @@ def test_behavior(self): root = DEVStone("HI_root", **params) coord = Coordinator(root) coord.initialize() - coord.simulate() + coord.simulate_time(INFINITY) self.assertEqual(root.n_internals, (((params["width"] - 1) * params["width"]) / 2) * (params["depth"] - 1) + 1) self.assertEqual(root.n_externals, (((params["width"] - 1) * params["width"]) / 2) * (params["depth"] - 1) + 1) @@ -165,7 +166,7 @@ def test_behavior(self): coord = Coordinator(root) coord.initialize() # TODO aqui n_externals debería ser igual a n_atomics (pero no lo es...) - coord.simulate() + coord.simulate_time(INFINITY) self.assertEqual(root.n_internals, (params["width"] - 1) * params["width"] / 2 * (params["depth"] - 1) + 1) self.assertEqual(root.n_externals, (params["width"] - 1) * params["width"] / 2 * (params["depth"] - 1) + 1) @@ -221,7 +222,7 @@ def test_behavior(self): root = DEVStone("HOmod_root", **params) coord = Coordinator(root) coord.initialize() - coord.simulate() + coord.simulate_time(INFINITY) calc_in = lambda x, w: 1 + (x - 1)*(w - 1) exp_trans = 1 @@ -245,3 +246,8 @@ def test_behavior(self): def test_invalid_inputs(self): super().check_invalid_inputs(HOmod) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/xdevs/tests/test_elasticsearch_transducer.py b/xdevs/tests/test_elasticsearch_transducer.py deleted file mode 100644 index 66a4973..0000000 --- a/xdevs/tests/test_elasticsearch_transducer.py +++ /dev/null @@ -1,34 +0,0 @@ -from xdevs.transducers import Transducers -from xdevs.examples.basic.basic import Job, Processor - - -if __name__ == '__main__': - es = Transducers.create_transducer('elasticsearch', - transducer_id='transducer_test', - exhaustive=True, - url='http://localhost:9200') - - model = Processor('processor', 100) - es.add_target_component(model) - es.add_target_port(model.o_out) - - # Try to comment and uncomment the mapper lines to see the effect on the output file - # csv.state_mapper = {'current_job': (str, lambda x: str(x.current_job))} - es.state_mapper['current_job'] = (str, lambda x: str(x.current_job)) - es.event_mapper = {'name': (int, lambda x: x.name), 'time': (int, lambda x: x.time)} - - es.initialize() - clock = 0 - es.bulk_data(clock) - - model.i_in.add(Job(0)) - model.deltext(1) - clock += 1 - model.i_in.clear() - es.bulk_data(1) - clock += model.sigma - model.lambdaf() - model.deltint() - es.bulk_data(clock) - - print('done') diff --git a/xdevs/tests/test_models.py b/xdevs/tests/test_models.py new file mode 100644 index 0000000..75e5693 --- /dev/null +++ b/xdevs/tests/test_models.py @@ -0,0 +1,32 @@ +import unittest +from xdevs.models import * + + +class TestModels(unittest.TestCase): + + def test_port(self): + p = Port(int, "test") + self.assertEqual(p.p_type, int) + self.assertEqual(p.name, "test") + self.assertEqual(str(p), "test") + self.assertIsNone(p.parent) + self.assertFalse(p) # __bool__ is !empty() + + p.add(1) + self.assertTrue(p) + self.assertEqual(p.get(), 1) + self.assertEqual(len(p), 1) + + p.add(2) + self.assertTrue(p) + self.assertEqual(p.get(), 1) + self.assertEqual(len(p), 2) + + p.clear() + self.assertFalse(p) + + self.assertRaises(TypeError, p.add, "test") + + +if __name__ == '__main__': + unittest.main() diff --git a/xdevs/tests/test_sql_transducer.py b/xdevs/tests/test_sql_transducer.py deleted file mode 100644 index e12d087..0000000 --- a/xdevs/tests/test_sql_transducer.py +++ /dev/null @@ -1,36 +0,0 @@ -from xdevs.transducers import Transducers -from xdevs.examples.basic.basic import Job, Processor - - -if __name__ == '__main__': - sql = Transducers.create_transducer('sql', - transducer_id='transducer_test', - sim_time_id='time', - include_names=True, - exhaustive=True, - url='mysql+pymysql://root@localhost/test') - - model = Processor('processor', 100) - sql.add_target_component(model) - sql.add_target_port(model.o_out) - - # Try to comment and uncomment the mapper lines to see the effect on the output file - # csv.state_mapper = {'current_job': (str, lambda x: str(x.current_job))} - sql.state_mapper['current_job'] = (Job, lambda x: x.current_job) - sql.event_mapper = {'name': (int, lambda x: x.name), 'time': (int, lambda x: x.time)} - - sql.initialize() - clock = 0 - sql.bulk_data(clock) - - model.i_in.add(Job(0)) - model.deltext(1) - clock += 1 - model.i_in.clear() - sql.bulk_data(1) - clock += model.sigma - model.lambdaf() - model.deltint() - sql.bulk_data(clock) - - print('done') diff --git a/xdevs/tests/test_transducible.py b/xdevs/tests/test_transducible.py index cc2f905..0a94a30 100644 --- a/xdevs/tests/test_transducible.py +++ b/xdevs/tests/test_transducible.py @@ -1,7 +1,9 @@ from __future__ import annotations import unittest from typing import Dict, Tuple, Type, Callable -from xdevs.transducers import Transducer, Transducers, Transducible, T +from xdevs import T +from xdevs.abc.transducer import Transducer, Transducible +from xdevs.factory import Transducers class NonTransducibleClass: diff --git a/xdevs/utils.py b/xdevs/utils.py deleted file mode 100644 index fd48800..0000000 --- a/xdevs/utils.py +++ /dev/null @@ -1,26 +0,0 @@ -from xdevs.models import Atomic, Port - - -class Generator(Atomic): - def __init__(self, name: str, num_outputs: int = 1, period: float = None): - super(Generator, self).__init__(name=name) - self.num_outputs: int = num_outputs - self.period: float = period - - self.o_out: Port[int] = Port(int, 'o_out') - self.add_out_port(self.o_out) - - def deltint(self): - self.hold_in('active', self.period) if self.period else self.passivate() - - def deltext(self, e: float): - pass - - def lambdaf(self): - self.o_out.extend(range(self.num_outputs)) - - def initialize(self): - self.activate() - - def exit(self): - pass diff --git a/xdevs/wrappers.py b/xdevs/wrappers.py deleted file mode 100644 index 8eb8858..0000000 --- a/xdevs/wrappers.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations -import pkg_resources -from typing import ClassVar, Type -from xdevs.models import Atomic - - -class Wrappers: - _plugins: ClassVar[dict[str, Type[Atomic]]] = { - ep.name: ep.load() for ep in pkg_resources.iter_entry_points('xdevs.plugins.wrappers') - } - - @staticmethod - def add_plugin(name: str, plugin: Type[Atomic]): - if name in Wrappers._plugins: - raise ValueError('xDEVS wrapper plugin with name "{}" already exists'.format(name)) - Wrappers._plugins[name] = plugin - - @staticmethod - def get_wrapper(name: str) -> Type[Atomic]: - if name not in Wrappers._plugins: - raise ValueError('xDEVS wrapper plugin with name "{}" not found'.format(name)) - return Wrappers._plugins[name]