2023-09-04 10:20:06 +01:00
|
|
|
# flake8: noqa
|
2023-07-28 14:49:21 +01:00
|
|
|
"""Core of the PrimAITE Simulator."""
|
2023-08-03 13:09:04 +01:00
|
|
|
from abc import ABC, abstractmethod
|
2023-08-25 17:56:05 +01:00
|
|
|
from typing import Callable, Dict, List, Optional, Union
|
2023-08-03 16:26:33 +01:00
|
|
|
from uuid import uuid4
|
2023-07-28 14:49:21 +01:00
|
|
|
|
2023-09-04 16:44:29 +01:00
|
|
|
from pydantic import BaseModel, ConfigDict
|
2023-08-03 13:09:04 +01:00
|
|
|
|
|
|
|
|
from primaite import getLogger
|
|
|
|
|
|
|
|
|
|
_LOGGER = getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
class ActionPermissionValidator(BaseModel):
|
2023-08-03 13:09:04 +01:00
|
|
|
"""
|
|
|
|
|
Base class for action validators.
|
|
|
|
|
|
|
|
|
|
The permissions manager is designed to be generic. So, although in the first instance the permissions
|
|
|
|
|
are evaluated purely on membership to AccountGroup, this class can support validating permissions based on any
|
|
|
|
|
arbitrary criteria.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
def __call__(self, request: List[str], context: Dict) -> bool:
|
2023-08-07 17:24:14 +01:00
|
|
|
"""Use the request and context paramters to decide whether the action should be permitted."""
|
2023-08-03 13:09:04 +01:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AllowAllValidator(ActionPermissionValidator):
|
|
|
|
|
"""Always allows the action."""
|
|
|
|
|
|
|
|
|
|
def __call__(self, request: List[str], context: Dict) -> bool:
|
|
|
|
|
"""Always allow the action."""
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
class Action(BaseModel):
|
2023-08-03 13:09:04 +01:00
|
|
|
"""
|
|
|
|
|
This object stores data related to a single action.
|
|
|
|
|
|
|
|
|
|
This includes the callable that can execute the action request, and the validator that will decide whether
|
|
|
|
|
the action can be performed or not.
|
|
|
|
|
"""
|
|
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
func: Callable[[List[str], Dict], None]
|
|
|
|
|
"""
|
|
|
|
|
``func`` is a function that accepts a request and a context dict. Typically this would be a lambda function
|
|
|
|
|
that invokes a class method of your SimComponent. For example if the component is a node and the action is for
|
|
|
|
|
turning it off, then the SimComponent should have a turn_off(self) method that does not need to accept any args.
|
|
|
|
|
Then, this Action will be given something like ``func = lambda request, context: self.turn_off()``.
|
|
|
|
|
"""
|
|
|
|
|
validator: ActionPermissionValidator = AllowAllValidator()
|
|
|
|
|
"""
|
|
|
|
|
``validator`` is an instance of `ActionPermissionValidator`. This is essentially a callable that
|
|
|
|
|
accepts `request` and `context` and returns a boolean to represent whether the permission is granted to perform
|
|
|
|
|
the action. The default validator will allow
|
|
|
|
|
"""
|
2023-08-03 13:09:04 +01:00
|
|
|
|
|
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
class ActionManager(BaseModel):
|
2023-08-07 17:24:14 +01:00
|
|
|
"""
|
|
|
|
|
ActionManager is used by `SimComponent` instances to keep track of actions.
|
|
|
|
|
|
|
|
|
|
Its main purpose is to be a lookup from action name to action function and corresponding validation function. This
|
|
|
|
|
class is responsible for providing a consistent API for processing actions as well as helpful error messages.
|
|
|
|
|
"""
|
2023-08-03 13:09:04 +01:00
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
actions: Dict[str, Action] = {}
|
|
|
|
|
"""maps action verb to an action object."""
|
2023-08-03 13:09:04 +01:00
|
|
|
|
|
|
|
|
def process_request(self, request: List[str], context: Dict) -> None:
|
2023-08-07 17:24:14 +01:00
|
|
|
"""Process an action request.
|
|
|
|
|
|
|
|
|
|
:param request: A list of strings which specify what action to take. The first string must be one of the allowed
|
|
|
|
|
actions, i.e. it must be a key of self.actions. The subsequent strings in the list are passed as parameters
|
|
|
|
|
to the action function.
|
|
|
|
|
:type request: List[str]
|
|
|
|
|
:param context: Dictionary of additional information necessary to process or validate the request.
|
|
|
|
|
:type context: Dict
|
|
|
|
|
:raises RuntimeError: If the request parameter does not have a valid action identifier as the first item.
|
|
|
|
|
"""
|
2023-08-03 13:09:04 +01:00
|
|
|
action_key = request[0]
|
|
|
|
|
|
|
|
|
|
if action_key not in self.actions:
|
|
|
|
|
msg = (
|
|
|
|
|
f"Action request {request} could not be processed because {action_key} is not a valid action",
|
|
|
|
|
"within this ActionManager",
|
|
|
|
|
)
|
|
|
|
|
_LOGGER.error(msg)
|
|
|
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
|
|
|
|
action = self.actions[action_key]
|
|
|
|
|
action_options = request[1:]
|
|
|
|
|
|
|
|
|
|
if not action.validator(action_options, context):
|
|
|
|
|
_LOGGER.debug(f"Action request {request} was denied due to insufficient permissions")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
action.func(action_options, context)
|
2023-07-28 14:49:21 +01:00
|
|
|
|
2023-08-03 16:26:33 +01:00
|
|
|
def add_action(self, name: str, action: Action) -> None:
|
2023-08-07 17:24:14 +01:00
|
|
|
"""Add an action to this action manager.
|
|
|
|
|
|
|
|
|
|
:param name: The string associated to this action.
|
|
|
|
|
:type name: str
|
|
|
|
|
:param action: Action object.
|
|
|
|
|
:type action: Action
|
|
|
|
|
"""
|
|
|
|
|
if name in self.actions:
|
|
|
|
|
msg = f"Attempted to register an action but the action name {name} is already taken."
|
|
|
|
|
_LOGGER.error(msg)
|
|
|
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
2023-08-03 16:26:33 +01:00
|
|
|
self.actions[name] = action
|
|
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
def list_actions(self) -> List[List[str]]:
|
|
|
|
|
actions = []
|
|
|
|
|
for act_name, act in self.actions.items():
|
|
|
|
|
pass # TODO:
|
|
|
|
|
|
2023-07-28 14:49:21 +01:00
|
|
|
|
|
|
|
|
class SimComponent(BaseModel):
|
2023-08-01 16:18:49 +01:00
|
|
|
"""Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator."""
|
2023-07-28 14:49:21 +01:00
|
|
|
|
2023-09-04 16:44:29 +01:00
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
|
2023-08-09 15:55:28 +01:00
|
|
|
"""Configure pydantic to allow arbitrary types and to let the instance have attributes not present in model."""
|
2023-08-09 12:36:09 +01:00
|
|
|
|
2023-08-03 12:14:11 +01:00
|
|
|
uuid: str
|
2023-08-07 16:20:55 +01:00
|
|
|
"""The component UUID."""
|
2023-08-03 12:14:11 +01:00
|
|
|
|
|
|
|
|
def __init__(self, **kwargs):
|
|
|
|
|
if not kwargs.get("uuid"):
|
|
|
|
|
kwargs["uuid"] = str(uuid4())
|
|
|
|
|
super().__init__(**kwargs)
|
2023-08-28 22:34:20 +01:00
|
|
|
self._action_manager: ActionManager = self._init_action_manager()
|
2023-08-23 14:41:30 +01:00
|
|
|
self._parent: Optional["SimComponent"] = None
|
2023-08-03 13:09:04 +01:00
|
|
|
|
2023-08-28 22:34:20 +01:00
|
|
|
def _init_action_manager(self) -> ActionManager:
|
|
|
|
|
"""
|
|
|
|
|
Initialise the action manager for this component.
|
|
|
|
|
|
|
|
|
|
When using a hierarchy of components, the child classes should call the parent class's _init_action_manager and
|
|
|
|
|
add additional actions on top of the existing generic ones.
|
|
|
|
|
|
|
|
|
|
Example usage for inherited classes:
|
|
|
|
|
|
|
|
|
|
..code::python
|
|
|
|
|
|
|
|
|
|
class WebBrowser(Application):
|
|
|
|
|
def _init_action_manager(self) -> ActionManager:
|
|
|
|
|
am = super()._init_action_manager() # all actions generic to any Application get initialised
|
|
|
|
|
am.add_action(...) # initialise any actions specific to the web browser
|
|
|
|
|
return am
|
|
|
|
|
|
|
|
|
|
:return: Actiona manager object belonging to this sim component.
|
|
|
|
|
:rtype: ActionManager
|
|
|
|
|
"""
|
|
|
|
|
return ActionManager()
|
|
|
|
|
|
2023-08-29 14:33:28 +01:00
|
|
|
@abstractmethod
|
|
|
|
|
def describe_state(self) -> Dict:
|
|
|
|
|
"""
|
|
|
|
|
Return a dictionary describing the state of this object and any objects managed by it.
|
|
|
|
|
|
|
|
|
|
This is similar to pydantic ``model_dump()``, but it only outputs information about the objects owned by this
|
|
|
|
|
object. If there are objects referenced by this object that are owned by something else, it is not included in
|
|
|
|
|
this output.
|
|
|
|
|
"""
|
|
|
|
|
state = {
|
|
|
|
|
"uuid": self.uuid,
|
|
|
|
|
}
|
|
|
|
|
return state
|
|
|
|
|
|
2023-09-04 10:20:06 +01:00
|
|
|
def possible_actions(self) -> List[List[str]]:
|
|
|
|
|
"""Enumerate all actions that this component can accept.
|
|
|
|
|
|
|
|
|
|
:return: List of all action strings that can be passed to this component.
|
|
|
|
|
:rtype: List[Dict[str]]
|
|
|
|
|
"""
|
|
|
|
|
action_list = ActionManager # TODO: extract possible actions? how to do this neatly?
|
|
|
|
|
|
2023-08-03 13:09:04 +01:00
|
|
|
def apply_action(self, action: List[str], context: Dict = {}) -> None:
|
2023-07-28 14:49:21 +01:00
|
|
|
"""
|
|
|
|
|
Apply an action to a simulation component. Action data is passed in as a 'namespaced' list of strings.
|
|
|
|
|
|
|
|
|
|
If the list only has one element, the action is intended to be applied directly to this object. If the list has
|
|
|
|
|
multiple entries, the action is passed to the child of this object specified by the first one or two entries.
|
|
|
|
|
This is essentially a namespace.
|
|
|
|
|
|
|
|
|
|
For example, ["turn_on",] is meant to apply an action of 'turn on' to this component.
|
|
|
|
|
|
|
|
|
|
However, ["services", "email_client", "turn_on"] is meant to 'turn on' this component's email client service.
|
|
|
|
|
|
|
|
|
|
:param action: List describing the action to apply to this object.
|
|
|
|
|
:type action: List[str]
|
|
|
|
|
"""
|
2023-08-03 13:09:04 +01:00
|
|
|
if self.action_manager is None:
|
|
|
|
|
return
|
|
|
|
|
self.action_manager.process_request(action, context)
|
2023-07-31 11:39:33 +01:00
|
|
|
|
2023-07-31 20:05:36 +01:00
|
|
|
def apply_timestep(self, timestep: int) -> None:
|
2023-07-31 11:39:33 +01:00
|
|
|
"""
|
|
|
|
|
Apply a timestep evolution to this component.
|
|
|
|
|
|
|
|
|
|
Override this method with anything that happens automatically in the component such as scheduled restarts or
|
|
|
|
|
sending data.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
2023-07-31 20:05:36 +01:00
|
|
|
|
2023-08-10 13:33:32 +01:00
|
|
|
def reset_component_for_episode(self, episode: int):
|
2023-07-31 20:05:36 +01:00
|
|
|
"""
|
|
|
|
|
Reset this component to its original state for a new episode.
|
|
|
|
|
|
|
|
|
|
Override this method with anything that needs to happen within the component for it to be reset.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
2023-08-23 14:41:30 +01:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def parent(self) -> "SimComponent":
|
|
|
|
|
"""Reference to the parent object which manages this object.
|
|
|
|
|
|
|
|
|
|
:return: Parent object.
|
|
|
|
|
:rtype: SimComponent
|
|
|
|
|
"""
|
|
|
|
|
return self._parent
|
|
|
|
|
|
|
|
|
|
@parent.setter
|
2023-08-25 17:56:05 +01:00
|
|
|
def parent(self, new_parent: Union["SimComponent", None]) -> None:
|
|
|
|
|
if self._parent and new_parent:
|
|
|
|
|
msg = f"Overwriting parent of {self.uuid}. Old parent: {self._parent.uuid}, New parent: {new_parent.uuid}"
|
2023-08-23 14:41:30 +01:00
|
|
|
_LOGGER.warn(msg)
|
|
|
|
|
raise RuntimeWarning(msg)
|
|
|
|
|
self._parent = new_parent
|