"""
zones
=======================
Here we implement the generic ``Zone`` class, as well as a series of
``ZoneCollection`` classes that are used to combine several zones
Note
----
the actual implementation of zones are in other modules
See Also
--------
atomsmltr.environment.zones.limits
atomsmltr.environment.zones.volumes
"""
# % IMPORTS
import numpy as np
from abc import abstractmethod
from copy import copy, deepcopy
# % LOCAL IMPORTS
from ..envbase import EnvObject
from ...utils.misc import check_position_speed_array
from ...utils.infostring import InfoString
# % ABSTRACT CLASSES
IMPLEMENTED_ACTIONS = ["stop", "ignore"]
IMPLEMENTED_TARGETS = ["position", "speed"]
# % -------------------------------
# % SIMPLE ZONES
# % -------------------------------
[docs]
class Zone(EnvObject):
"""A generic Zone object
Parameters
----------
target : str, optional
the target for the zone, can be "position" or "speed", by default "position"
action : str, optional
the action associated to the zone.
implemented actions = ["stop", "ignore"], default is "stop"
tag : str, optional
the zone tag
in_tag : str, optional
tag for an object inside the zone, by default None
out_tag : str, optional
tag for an object inside the zone, by default None
"""
def __init__(
self,
target: str = "position",
action: str = "stop",
tag: str = None,
in_tag: str = None,
out_tag: str = None,
):
super(Zone, self).__init__(tag)
self.inverted = False
self.target = target
self.action = action
self.in_tag = in_tag
self.out_tag = out_tag
# -- GETTERS & SETTERS
@property
def vector(self):
return False
@property
def inverted(self):
"""bool: if inverted, the zone logic is inverted"""
return self.__inverted
@inverted.setter
def inverted(self, value):
if not isinstance(value, bool):
raise TypeError("'inverted' should be a boolean")
self.__inverted = value
@property
def target(self):
"""str: the target for the zone. Can be "position" or "speed" """
return self.__target
@target.setter
def target(self, value):
if value not in IMPLEMENTED_TARGETS:
raise ValueError(f"implemented targets are : {IMPLEMENTED_TARGETS}")
self.__target = value
@property
def action(self):
"""str: the action associated with the zone. implemented actions = ["stop", "ignore"]."""
return self.__action
@action.setter
def action(self, value):
if value not in IMPLEMENTED_ACTIONS:
raise ValueError(f"implemented actions are : {IMPLEMENTED_ACTIONS}")
self.__action = value
@property
def in_tag(self) -> str:
"""str: tag for a object inside the zone"""
return self._in_tag
@in_tag.setter
def in_tag(self, value: str) -> None:
if not isinstance(value, str) and value is not None:
raise TypeError("'in_tag' should be a string or None")
self._in_tag = value
@property
def out_tag(self) -> str:
"""str: tag for a object outside the zone"""
return self._out_tag
@out_tag.setter
def out_tag(self, value: str) -> None:
if not isinstance(value, str) and value is not None:
raise TypeError("'out_tag' should be a string or None")
self._out_tag = value
# -- functions
[docs]
def invert(self):
"""toggles the 'inverted' status"""
self.__inverted = not self.__inverted
[docs]
def inverted_copy(self):
"""Returns an inverted copy of the object"""
new_object = deepcopy(self)
new_object.invert()
return new_object
# -- METHODS
[docs]
def get_value(self, vector: np.ndarray, nocheck: bool = False) -> np.ndarray:
"""Evaluates whether 'vector' is in the zone
Parameters
----------
vector : array of shape (3,) or (n1, n2, ..., 3)
cartesian coordinates of the vectors in the lab frame
nocheck : bool, optional
if set to True, function will not check that the shape of position
matches requirements, by default False
Returns
-------
value : array of shape (1,) or (n1, n2, ..., 1)
wheter the vector is 'in the zone'
Notes
-----
``vector`` should be an array of shape (...,3), where last axis contains
the coordinates to evaluate.
if the ``inverted`` property is set to true, ``get_value`` will return
True outside the zone
"""
vector = self._check_position_array(vector, nocheck)
res = self._in_zone(vector)
if self.inverted:
res = np.logical_not(res)
return res
@abstractmethod
def _in_zone(self, vector):
"""actual implementationf of 'get_value'"""
# -- OPERATORS OVERLOADING
def __and__(self, object):
if isinstance(object, Zone):
new_collection = ANDCollection()
new_collection.add_zone(deepcopy(self))
new_collection.add_zone(deepcopy(object))
return new_collection
else:
raise TypeError("only 'Zones' objects can be combined")
def __or__(self, object):
if isinstance(object, Zone):
new_collection = ORCollection()
new_collection.add_zone(deepcopy(self))
new_collection.add_zone(deepcopy(object))
return new_collection
else:
raise TypeError("only 'Zones' objects can be combined")
def __xor__(self, object):
if isinstance(object, Zone):
new_collection = XORCollection()
new_collection.add_zone(deepcopy(self))
new_collection.add_zone(deepcopy(object))
return new_collection
else:
raise TypeError("only 'Zones' objects can be combined")
# % -------------------------------
# % ZONES COLLECTIONS
# % -------------------------------
[docs]
class ZoneCollection(Zone):
def __init__(self, *args, **kwargs):
self.__zones = []
super(ZoneCollection, self).__init__(*args, **kwargs)
# -- METHODS AND PROPERTIES
@property
def type(self):
return "Zone Collection"
@property # readonly
def zones(self) -> list:
"""list: a list of the zones included in the collection"""
return self.__zones
[docs]
def add_zone(self, zone: Zone):
"""adds a zone to the current collection
Parameters
----------
zone : Zone
the zone to add
"""
if not isinstance(zone, Zone):
raise TypeError("'zone' should be a zone object")
self.__zones.append(zone)
[docs]
def reset(self):
"""Resets the zone list"""
self.__zones = []
# -- INFOSTRING
[docs]
def gen_infostring_obj(self):
"""Generates an info string object"""
title = self.type
title = title[:1].upper() + title[1:] # capitalize first letter
info = InfoString(title=title)
info.add_section("Parameters")
info.add_element("type", self.type)
info.add_element("tag", self.tag)
info.add_element("in_tag", self.in_tag)
info.add_element("out_tag", self.out_tag)
info.add_element("target", self.target)
info.add_element("action", self.action)
info.add_element(f"zones", f"{[z.tag for z in self.zones]}")
info.add_element(f"inverted", f"{self.inverted}")
return info
# -- PLOT
def plot1D(self, ax=None):
pass
def plot2D(self, ax=None):
pass
def plot3D(self, ax=None):
pass
# -- OPERATORS OVERLOADING
def __add__(self, object):
# then operator acts on a new collection
collection = self.__class__()
for z in self.zones:
collection.add_zone(deepcopy(z))
return self.__add_operator__(object, collection)
def __iadd__(self, object):
"""let's handle additions between zonecollections
add behaves as a shorthand for "add_zones"
we will only allow collections of same type to be added
"""
# then operator acts on self
# - recursive add if list
if isinstance(object, (list, tuple)):
for element in object:
self.__add_operator__(element, self)
return self
# - otherwise
return self.__add_operator__(object, self)
def __add_operator__(self, object, coll):
"""a function to factor the __add__ and __iadd__ operators"""
# case 1 > same type of zone, then we add all the zones
if object:
if isinstance(object, self.__class__):
for z in object.zones:
new_zone = deepcopy(z)
if object.inverted:
new_zone.invert()
coll.add_zone(new_zone)
return coll
# case 2 > it is a zone, not a collection
elif isinstance(object, Zone) and not isinstance(object, ZoneCollection):
coll.add_zone(deepcopy(object))
return coll
else:
raise TypeError(
"a ZoneCollection can only be added with a Zone or another ZoneCollection of same type"
)
[docs]
class ANDCollection(ZoneCollection):
# -- METHODS AND PROPERTIES
@property
def type(self):
return "AND Zone Collection"
def _in_zone(self, vector):
res_list = [zone.get_value(vector) for zone in self.zones]
return np.logical_and.reduce(res_list)
[docs]
class ORCollection(ZoneCollection):
# -- METHODS AND PROPERTIES
@property
def type(self):
return "OR Zone Collection"
def _in_zone(self, vector):
res_list = [zone.get_value(vector) for zone in self.zones]
return np.logical_or.reduce(res_list)
[docs]
class XORCollection(ZoneCollection):
# -- METHODS AND PROPERTIES
@property
def type(self):
return "XOR Zone Collection"
def _in_zone(self, vector):
res_list = [zone.get_value(vector) for zone in self.zones]
return np.logical_xor.reduce(res_list)
# % -------------------------------
# % SUPER ZONE
# % -------------------------------
IMPLEMENTED_LOGIC = ["OR", "XOR", "AND"]
[docs]
class SuperZone(ZoneCollection):
"""SuperZone is a zone collection for position/speed vectors
Parameters
----------
zones : list, optional
list of zones to add at object creation, by default []
logic : str, optional
the logic of zone combination. Can be "OR", "AND", "XOR", by default "AND"
action : str, optional
the action to trigger
implemented actions = ["stop", "ignore"], default is "stop"
tag : str, optional
the tag of the zone, by default None
in_tag : str, optional
tag for an object inside the zone, by default None
out_tag : str, optional
tag for an object inside the zone, by default None
"""
def __init__(
self,
zones: list = [],
logic: str = "AND",
action: str = "stop",
tag: str = None,
in_tag: str = None,
out_tag: str = None,
):
super(SuperZone, self).__init__(
action=action, tag=tag, in_tag=in_tag, out_tag=out_tag
)
self.logic = logic
self.__iadd__(zones)
# -- PROPERTIES
@property
def type(self):
return "Super Zone collection"
# - target is irrelevant
@property
def target(self):
"""not used in this case"""
return None
@target.setter
def target(self, value):
pass
# - logic
@property
def logic(self):
"""str: the logic for the SuperZone combination ("OR", "XOR", "AND")"""
return self.__logic
@logic.setter
def logic(self, value: str):
if value not in IMPLEMENTED_LOGIC:
raise ValueError(f"'logic' should be in {IMPLEMENTED_LOGIC}")
self.__logic = value
match value:
case "OR":
self.__logical_op = np.logical_or
case "AND":
self.__logical_op = np.logical_and
case "XOR":
self.__logical_op = np.logical_xor
# -- METHODS
def add_zones(self, zones: Zone | list):
self.__iadd__(zones)
[docs]
def get_value(self, vector: np.ndarray, nocheck: bool = False) -> np.ndarray:
"""Evaluates whether 'vector' is in the zone
Parameters
----------
vector : array of shape (6,) or (n1, n2, ..., 6)
cartesian coordinates of the vectors in the lab frame
nocheck : bool, optional
if set to True, function will not check that the shape of position
matches requirements, by default False
Returns
-------
value : array of shape (1,) or (n1, n2, ..., 1)
whether the vector is 'in the zone'
Notes
-----
This is a ``SuperZone`` object, so it acts on **position-speed** vectors
of dimensions 6 !
``vector`` should be an array of shape (6,) or (n1, n2, .., 6), where last axis contains
the coordinates (position & speed) to evaluate.
In all cases, the last dimension contains cordinates (x, y, z, vx, vy, vz),
if the ``inverted`` property is set to true, ``get_value`` will return
True outside the zone
"""
vector = check_position_speed_array(vector, nocheck)
res = self._in_zone(vector)
if self.inverted:
res = np.logical_not(res)
return res
def _in_zone(self, vector: np.ndarray) -> np.ndarray:
"""The actual implementation of the ``in_zone`` method"""
# -- separate speeds & positions
x, y, z, vx, vy, vz = vector.T
position = np.array([x, y, z]).T
speed = np.array([vx, vy, vz]).T
# -- separate zones according to target
speed_zones = []
position_zones = []
for zone in self.zones:
if zone.target == "speed":
speed_zones.append(zone)
elif zone.target == "position":
position_zones.append(zone)
# -- evaluate
res_list = [zone.get_value(position) for zone in position_zones]
res_list += [zone.get_value(speed) for zone in speed_zones]
if res_list:
res = self.__logical_op.reduce(res_list)
else:
res = x.T == x.T
return res
[docs]
def gen_infostring_obj(self):
info = super().gen_infostring_obj()
info.add_element("logic", self.logic)
info.rm_element("target")
return info