From 5f995c9b336010002d12bd585f4988204c8da43a Mon Sep 17 00:00:00 2001 From: James Arruda Date: Wed, 18 Dec 2024 22:38:58 -0500 Subject: [PATCH 1/4] Docs for data gathering. Updates for usability an clarity. --- docs/source/class_refs.txt | 28 +-- docs/source/user_guide/how_tos/geography.rst | 11 +- docs/source/user_guide/how_tos/resources.rst | 83 +++++++ docs/source/user_guide/index.md | 2 + docs/source/user_guide/tutorials/data.rst | 233 +++++++++++++++++++ src/upstage_des/actor.py | 26 ++- src/upstage_des/api.py | 8 +- src/upstage_des/base.py | 10 + src/upstage_des/resources/__init__.py | 8 +- src/upstage_des/resources/monitoring.py | 8 +- src/upstage_des/resources/reserve.py | 16 +- src/upstage_des/resources/sorted.py | 8 +- src/upstage_des/states.py | 15 +- src/upstage_des/test/test_api.py | 4 +- src/upstage_des/test/test_stores.py | 13 +- 15 files changed, 416 insertions(+), 57 deletions(-) create mode 100644 docs/source/user_guide/how_tos/resources.rst create mode 100644 docs/source/user_guide/tutorials/data.rst diff --git a/docs/source/class_refs.txt b/docs/source/class_refs.txt index 14783b5..3b68252 100644 --- a/docs/source/class_refs.txt +++ b/docs/source/class_refs.txt @@ -1,17 +1,17 @@ .. _SimPy: https://simpy.readthedocs.io/ -.. |Actor| replace:: :py:class:`~upstage.actor.Actor` -.. |State| replace:: :py:class:`~upstage.states.State` -.. |Task| replace:: :py:class:`~upstage.task.Task` -.. |TaskNetwork| replace:: :py:class:`~upstage.task_network.TaskNetwork` -.. |TaskNetworks| replace:: :py:class:`TaskNetworks ` -.. |EnvironmentContext| replace:: :py:class:`~upstage.base.EnvironmentContext` -.. |UpstageBase| replace:: :py:class:`~upstage.base.UpstageBase` -.. |NamedEntity| replace:: :py:class:`~upstage.base.NamedUpstageEntity` -.. |LinearChangingState| replace:: :py:class:`~upstage.states.LinearChangingState` -.. |CartesianLocation| replace:: :py:class:`~upstage.data_types.CartesianLocation` -.. |GeodeticLocationChangingState| replace:: :py:class:`~upstage.states.GeodeticLocationChangingState` -.. |ResourceState| replace:: :py:class:`~upstage.states.ResourceState` -.. |SelfMonitoringStore| replace:: :py:class:`~upstage.stores.SelfMonitoringStore` -.. |DecisionTask| replace:: :py:class:`~upstage.task.DecisionTask` +.. |Actor| replace:: :py:class:`~upstage_des.actor.Actor` +.. |State| replace:: :py:class:`~upstage_des.states.State` +.. |Task| replace:: :py:class:`~upstage_des.task.Task` +.. |TaskNetwork| replace:: :py:class:`~upstage_des.task_network.TaskNetwork` +.. |TaskNetworks| replace:: :py:class:`TaskNetworks ` +.. |EnvironmentContext| replace:: :py:class:`~upstage_des.base.EnvironmentContext` +.. |UpstageBase| replace:: :py:class:`~upstage_des.base.UpstageBase` +.. |NamedEntity| replace:: :py:class:`~upstage_des.base.NamedUpstageEntity` +.. |LinearChangingState| replace:: :py:class:`~upstage_des.states.LinearChangingState` +.. |CartesianLocation| replace:: :py:class:`~upstage_des.data_types.CartesianLocation` +.. |GeodeticLocationChangingState| replace:: :py:class:`~upstage_des.states.GeodeticLocationChangingState` +.. |ResourceState| replace:: :py:class:`~upstage_des.states.ResourceState` +.. |SelfMonitoringStore| replace:: :py:class:`~upstage_des.stores.SelfMonitoringStore` +.. |DecisionTask| replace:: :py:class:`~upstage_des.task.DecisionTask` diff --git a/docs/source/user_guide/how_tos/geography.rst b/docs/source/user_guide/how_tos/geography.rst index 4ca06ee..570b804 100644 --- a/docs/source/user_guide/how_tos/geography.rst +++ b/docs/source/user_guide/how_tos/geography.rst @@ -110,7 +110,8 @@ Both distances are within .07% of UPSTAGE's calculations. :py:class:`~upstage_des.states.GeodeticLocationChangingState` ------------------------------------------------------------- -This is a State that allows activation and movement along great-circle waypoints with altitude changing along the waypoints. When initializing, it accepts a ``GeodeticLocation`` object, and it returns those when you ask it for +This is a State that allows activation and movement along great-circle waypoints with altitude changing along the +waypoints. When initializing, it accepts a ``GeodeticLocation`` object, and it returns those when you ask it for the state's value. Here is its basic usage: .. code-block:: python @@ -118,7 +119,7 @@ the state's value. Here is its basic usage: from upstage_des.utils import waypoint_time_and_dist class Plane(UP.Actor): - location: UP.GeodeticLocation = UP.GeodeticLocationChangingState(recording=True) + location = UP.GeodeticLocationChangingState(recording=True) speed = UP.State[float](valid_types=float, default=100.0) class Fly(UP.Task): @@ -147,7 +148,8 @@ the state's value. Here is its basic usage: ) ... -The :py:func:`~upstage_des.utils.waypoint_time_and_dist` function is a convenience function that gets the great circle distance and time over a set of waypoints to help schedule the arrival time. +The :py:func:`~upstage_des.utils.waypoint_time_and_dist` function is a convenience function that gets the great +circle distance and time over a set of waypoints to help schedule the arrival time. Cartesian Locations @@ -192,7 +194,8 @@ The distance is always implied to be in ``distance_units``, without setting it. :py:class:`~upstage_des.states.CartesianLocationChangingState` -------------------------------------------------------------- -This active state works the exact same as the ``GeodeticLocationChangingState`` , except that it requires waypoints to be ``CartesianLocation`` s. +This active state works the exact same as the ``GeodeticLocationChangingState`` , except that it requires +waypoints to be ``CartesianLocation`` s. Geography Sub-Module diff --git a/docs/source/user_guide/how_tos/resources.rst b/docs/source/user_guide/how_tos/resources.rst new file mode 100644 index 0000000..87d5d9d --- /dev/null +++ b/docs/source/user_guide/how_tos/resources.rst @@ -0,0 +1,83 @@ +============== +Resource Types +============== + +UPSTAGE comes with new resource types in addition to the SimPy resources: + +1. :py:class:`~upstage_des.resources.container.ContinuousContainer` +2. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringStore` +3. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringFilterStore` +4. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringContainer` +5. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringContinuousContainer` +6. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringSortedFilterStore` +7. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringReserveContainer` +8. :py:class:`~upstage_des.resources.reserve.ReserveContainer` +9. :py:class:`~upstage_des.resources.sorted.SortedFilterStore` + +The self-monitoring stores are discussed in :doc:`the simulation data section `. +They enable recording of data within the store or container over time, with an optional input for a function to +evaluate when recording on the items in the stores. + +ContinuousContainer +=================== + +This container accepts gets and puts that act continuously, requiring both a rate and time to get or pull at that rate: + +.. code:: python + + tank = UP.ContinuousContainer(env, capacity=10, init=0) + tank.put(rate=3.0, time=2.5) + env.run(until=3.0) + print(tank.level) + >>> 7.5 + +The gets and puts can be done simultaneously, and the container will determine the current level when asked for it. The +container will also, by default, raise errors when it has reach capacity or when it is empty. + +SortedFilterStore +================= + +This store behaves similar to the SimPy ``FilterStore``, except that it also accepts a function that prioritizes the +items in the store. + +.. code:: python + + with UP.EnvironmentContext() as env: + shelf = UP.SortedFilterStore(env) + # pre-load items + shelf.items.extend([(1, "a"), (2, "b"), (1, "b"), (1, "B")]) + + def _proc() -> tuple[float, str]: + ans = yield shelf.get( + filter=lambda x: x[1] == x[1].lower(), + sorter=lambda x: (x[1], -x[0]), + reverse=True, + ) + return ans + + p = env.process(_proc()) + env.run() + print(p.value) + >>> (1, 'b') + +In the above, we filter items to have lower-case letters. Then we sort by ascending alphabetical and +descending numerical. Note the use of ``reverse=True`` and the ``-x[0]`` to do this. That gives us the +tie-breaker between ``(1, "a")`` and ``(1, "b")`` that ignores ``(1, "B")``. + +ReserveContainer +================ + +The reserve container is not a true Container, in that it doesn't hold on queues. It is used to hold first-come +reservations for something numeric. Those requests can be timed out, and then checked on later by the +requestor. This is useful if you want to reserve access to a limited resource, but don't want or need to +hold in a line to do so. + +The public methods on the ``ReserveContainer`` are: + +1. ``reserve(requestor, quantity, expiration=None)``: Hold an amount +2. ``cancel_request(requestor)``: Cancel a hold +3. ``take(requestor)``: Get the amount held - or fail if request expired +4. ``put(amount, capacity_increase=False)``: Put something in the container, optionally increasing capacity. + +The workflow with this resource is to resever, take, then put back when done - if the resource represented isn't +consumable. diff --git a/docs/source/user_guide/index.md b/docs/source/user_guide/index.md index a0702e2..51f2e95 100644 --- a/docs/source/user_guide/index.md +++ b/docs/source/user_guide/index.md @@ -24,6 +24,7 @@ tutorials/first_simulation tutorials/interrupts tutorials/rehearsal tutorials/best_practices +tutorials/data tutorials/simpy_compare.rst ``` @@ -51,6 +52,7 @@ These pages detail the specific activities that can be accomplished using UPSTAG :caption: How-Tos :maxdepth: 1 +how_tos/resources.rst how_tos/resource_states.rst how_tos/active_states.rst how_tos/mimic_states.rst diff --git a/docs/source/user_guide/tutorials/data.rst b/docs/source/user_guide/tutorials/data.rst new file mode 100644 index 0000000..08a437b --- /dev/null +++ b/docs/source/user_guide/tutorials/data.rst @@ -0,0 +1,233 @@ +=============== +Simulation Data +=============== + +UPSTAGE has three features for data recording: + +1. Use `Actor.log()` to log a string message at a given time. + + * Note that on Actor creation, ``debug_log=True`` must be given. + +2. Use ``a_state = UP.State(recording=True)``. + + * Access the data with ``actor._state_histories["a_state"]`` + +3. Use a ``SelfMonitoring<>`` Store or Container. + + * Access the data with ``a_store._quantities`` + +Each feature has a list of tuples of (time, value) pairs. + +Actor Logging +============= + +The actor debug logging records data about the flow of the actor through its task networks. It's designed +for debugging and seeing how your actors are behaving, but it can be a place to add additional data if +you want to look it up later. + +.. code:: python + + with UP.EnvironmentContext(): + cashier = Cashier( + name="Bob", + debug_log=True, + ) + # Log with an argument logs a message + cashier.log("A message") + # Log without an argument returns the log + print(cashier.log()) + >>> [(0.0, '[Day 0 - 00:00:00] A message')] + +The logging comes a list of tuples. The first entry is the time as a float - useful for filtering. The second +entry is a string of the message along with a formatted time. The time is based on :doc:`Time Units `. + +If you set the stage variable ``debug_log_time`` to ``False`` (it behaves as ``True`` by default), then the actor will +not log the time, and only put the message as the second entry. This message is typed to be a string, but since this +is python, if you aren't running a static type checker on your own code, you can put anything you like there. +If ``debug_log_time`` is ``True``, UPSTAGE will attempt to format it as a string, so make sure it's set to ``False``. + +On a per-actor level, you can set ``debug_log_time`` as well, and that value will take priority over the stage value. + +.. code:: python + + with UP.EnvironmentContext(): + cashier = Cashier( + name="Bob", + debug_log=True, + debug_log_time=False, + ) + cashier.log("A message") + print(cashier.log()) + >>> [(0.0, 'A message')] + + cashier2 = Cashier( + name="Betty", + debug_log=True, + ) + UP.set_stage_variable("debug_log_time", False) + cashier2.log({'data': 1}) + print(cashier2.log()) + >>> [(0.0, {'data': 1})] + + +State Recording +=============== + +Nearly every state is recordable in UPSTAGE. The :py:class:`~upstage_des.states.ResourceState` is an exception covered +in the next section. To enable state recording, set ``recording=True``. After running the sim, use the ``_state_histories`` +attribute on the actor to get the data. + +.. code:: python + + class Cashier(UP.Actor): + items_scanned = UP.State[int](recording=True) + + with UP.EnvironmentContext() as env: + cash = Cashier(name="Ertha", items_scanned=0) + cash.items_scanned += 1 + env.run(until=1) + cash.items_scanned += 2 + env.run(until=2) + cash.items_scanned += 1 + env.run(until=3) + cash.items_scanned = -1 + + print(cash._state_histories["items_scanned"]) + >>> [(0.0, 0), (0.0, 1), (1.0, 3), (2.0, 4), (3.0, -1)] + +That returns a list of (time, value) tuples. This works for simple data types, but not mutable types: + +.. code:: python + + from collections import Counter + + class Cashier(UP.Actor): + people_seen = UP.State[str](default="", recording=True) + items = UP.State[Counter[str, int]](default_factory=Counter, recording=True) + + with UP.EnvironmentContext() as env: + cash = Cashier(name="Ertha") + cash.people_seen = "James" + cash.items["bread"] = 1 + env.run(until=0.75) + cash.people_seen = "Janet" + cash.items["bread"] += 2 + + print(cash._state_histories) + >>>{'people_seen': [(0.0, 'James'), (0.75, 'Janet')]} + +Note that the string State of ``people_seen`` acts as a way to record data, even if we don't care in +the moment the name of the last scanned person. This lets states behave as carriers of current or past +information, depending on your needs. + +The ``items`` value doesn't record, because the state doesn't see ``cash.items = ...``. For objects like that, +you should: + +.. code:: python + + from collections import Counter + + class Cashier(UP.Actor): + items = UP.State[Counter[str, int]](default_factory=Counter, recording=True) + + with UP.EnvironmentContext() as env: + cash = Cashier(name="Ertha") + cash.items["bread"] = 1 + cash.items = cash.items # <- Tell the state it's been changed explicitly + env.run(until=0.75) + cash.items["bread"] += 2 + cash.items["milk"] += 3 + cash.items = cash.items + + print(cash._state_histories) + >>>{'items': [(0.0, Counter({'bread': 1})), (0.75, Counter({'bread': 3, 'milk': 3}))]} + +This is clunky, but the ``Counter`` object has no way of knowing it belongs in a ``State`` to get the +recording to work. In the future, UPSTAGE may monkey-patch objects with ``__set__`` methods, but for +now this is the workaround. + +Note also that UPSTAGE deep-copies the value in the state history, so any data should be compatible with that +operation. + +Geographic Types +---------------- + +State recording of the built-in geographic states (cartesian and geodetic) is compatible +with the data objects. This for both the active state versions and the typical ``UP.State[CartesianLocation]()`` +ways of creating the state. + +Resource Recording +================== + +If you have a state that is a simpy resource, UPSTAGE won't know how to record that state. For the reasons +discussed above, there's no way to link the changes in the referenced value of the state to the recording +mechanism. Even if there was, there's not an implicit understanding of the nature of the resource. + +UPSTAGE comes with resource types, based on the SimPy types, that automatically record: + +1. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringStore` +2. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringFilterStore` +3. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringContainer` +4. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringContinuousContainer` +5. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringSortedFilterStore` +6. :py:class:`~upstage_des.resources.monitoring.SelfMonitoringReserveContainer` + +Each resource understands the kind of data it can hold, and records it appropriately. Containers are simpler, +and just record the level that they are at. + +The ``SelfMonitoring<>Store`` resources accept an optional ``item_func`` argument, the result of which is put into +the recorded data. By default, the number of items in the store is used. + +The following example shows how to use a monitoring store and get data back from it. The ``_quantities`` attribute +on the state is used to hold the data. + +.. code:: python + + class CheckoutLane(UP.Actor): + belt = UP.ResourceState(default=UP.SelfMonitoringStore) + + with UP.EnvironmentContext() as env: + check = CheckoutLane(name="Lane 1: 10 Items or Fewer") + + # Mix simpy with UPSTAGE for simple processes + def _proc(): + yield check.belt.put("Bread") # simpy event + yield env.timeout(1.0) + yield UP.Put(check.belt, "Milk").as_event() # UPSTAGE event as simpy + yield UP.Put(check.belt, "Pizza").as_event() + + env.process(_proc()) + env.run() + print(check.belt._quantities) + >>> [(0.0, 0), (0.0, 1), (1.0, 2), (1.0, 3)] + +Here's how to set your own item function, omitting the middle portion which stays the same: + +.. code:: python + + from collections import Counter + + class CheckoutLane(UP.Actor): + belt = UP.ResourceState( + default=UP.SelfMonitoringStore, + default_kwargs={"item_func":lambda x: Counter(x)}, + ) + + ... + + print(check.belt._quantities) + >>> [ + (0.0, Counter()), + (0.0, Counter({'Bread': 1})), + (1.0, Counter({'Bread': 1, 'Milk': 1})), + (1.0, Counter({'Bread': 1, 'Milk': 1, 'Pizza': 1})) + ] + +Or use the actor init to pass the item function: + +.. code:: python + + check = CheckoutLane( + name = "Lane 2", + belt = {"item_func":lambda x: Counter(x)}, + ) diff --git a/src/upstage_des/actor.py b/src/upstage_des/actor.py index e2b40ac..8cfd832 100644 --- a/src/upstage_des/actor.py +++ b/src/upstage_des/actor.py @@ -98,12 +98,21 @@ def __init_states(self, **states: Any) -> None: if "log" in seen: raise UpstageError("Do not name a state `log`") - def __init__(self, *, name: str, debug_log: bool = True, **states: Any) -> None: + def __init__( + self, + *, + name: str, + debug_log: bool = True, + debug_log_time: bool | None = None, + **states: Any, + ) -> None: """Create an Actor. Args: name (str): The name of the actor. debug_log (bool, optional): Whether to write to debug log. Defaults to True. + debug_log_time (bool, optional): If time is logged in debug messages. + Defaults to None (uses Stage value), otherwise local value is used. states (Any): Values for each state as kwargs. """ self.name = name @@ -126,6 +135,7 @@ def __init__(self, *, name: str, debug_log: bool = True, **states: Any) -> None: self._is_rehearsing: bool = False self._debug_logging: bool = debug_log + self._debug_log_time = debug_log_time self._debug_log: list[tuple[float | int, str]] = [] self._state_histories: dict[str, list[tuple[float, Any]]] = {} @@ -824,6 +834,7 @@ def clone( clone = self.__class__( name=self.name + f" [CLONE {self._num_clones}]", debug_log=self._debug_logging, + debug_log_time=self._debug_log_time, **states, ) clone.env = new_env @@ -872,12 +883,15 @@ def log(self, msg: str | None = None) -> list[tuple[float | int, str]] | None: Returns: list[str] | None: The log if no message is given. None otherwise. """ - if msg and self._debug_logging: - ts = self.pretty_now - msg = f"{ts} {msg}" - self._debug_log += [(self.env.now, msg)] - elif msg is None: + if msg is None: return self._debug_log + elif self._debug_logging: + dlt = self._debug_log_time + do_time = dlt if dlt is not None else self.stage.get("debug_log_time", True) + if do_time: + ts = self.pretty_now + msg = f"{ts} {msg}" + self._debug_log += [(self.env.now, msg)] return None def get_log(self) -> list[tuple[float | int, str]]: diff --git a/src/upstage_des/api.py b/src/upstage_des/api.py index f69ae3b..8408184 100644 --- a/src/upstage_des/api.py +++ b/src/upstage_des/api.py @@ -57,11 +57,11 @@ SelfMonitoringContainer, SelfMonitoringContinuousContainer, SelfMonitoringFilterStore, - SelfMonitoringReserveStore, + SelfMonitoringReserveContainer, SelfMonitoringSortedFilterStore, SelfMonitoringStore, ) -from upstage_des.resources.reserve import ReserveStore +from upstage_des.resources.reserve import ReserveContainer from upstage_des.resources.sorted import SortedFilterGet, SortedFilterStore # Nucleus-friendly states @@ -117,9 +117,9 @@ "SelfMonitoringContinuousContainer", "SelfMonitoringFilterStore", "SelfMonitoringSortedFilterStore", - "SelfMonitoringReserveStore", + "SelfMonitoringReserveContainer", "SelfMonitoringStore", - "ReserveStore", + "ReserveContainer", "SortedFilterStore", "CartesianLocation", "GeodeticLocation", diff --git a/src/upstage_des/base.py b/src/upstage_des/base.py index d4a318a..982461a 100644 --- a/src/upstage_des/base.py +++ b/src/upstage_des/base.py @@ -108,6 +108,16 @@ def daily_time_count(self) -> float | int: assumed. """ + @property + def debug_log_time(self) -> bool: + """Whether or not times are logged as a string in the debug logs. + + Can be modified at the individual actor level with debug_log_time. + + Returns: + bool: If time is logged. + """ + if TYPE_CHECKING: def __getattr__(self, key: str) -> Any: ... diff --git a/src/upstage_des/resources/__init__.py b/src/upstage_des/resources/__init__.py index f6daab0..299ecb2 100644 --- a/src/upstage_des/resources/__init__.py +++ b/src/upstage_des/resources/__init__.py @@ -14,11 +14,11 @@ SelfMonitoringContainer, SelfMonitoringContinuousContainer, SelfMonitoringFilterStore, - SelfMonitoringReserveStore, + SelfMonitoringReserveContainer, SelfMonitoringSortedFilterStore, SelfMonitoringStore, ) -from .reserve import ReserveStore +from .reserve import ReserveContainer from .sorted import SortedFilterGet, SortedFilterStore __all__ = [ @@ -31,8 +31,8 @@ "SelfMonitoringContainer", "SelfMonitoringContinuousContainer", "SelfMonitoringSortedFilterStore", - "SelfMonitoringReserveStore", - "ReserveStore", + "SelfMonitoringReserveContainer", + "ReserveContainer", "SortedFilterStore", "SortedFilterGet", ] diff --git a/src/upstage_des/resources/monitoring.py b/src/upstage_des/resources/monitoring.py index e87dcc2..3a36bcb 100644 --- a/src/upstage_des/resources/monitoring.py +++ b/src/upstage_des/resources/monitoring.py @@ -12,7 +12,7 @@ from simpy.resources.store import FilterStoreGet, StoreGet, StorePut from .container import ContinuousContainer -from .reserve import ReserveStore +from .reserve import ReserveContainer from .sorted import SortedFilterStore, _SortedFilterStoreGet __all__ = ( @@ -21,7 +21,7 @@ "SelfMonitoringContainer", "SelfMonitoringContinuousContainer", "SelfMonitoringSortedFilterStore", - "SelfMonitoringReserveStore", + "SelfMonitoringReserveContainer", ) RECORDER_FUNC = Callable[[list[Any]], int] @@ -212,8 +212,8 @@ def _do_get(self, event: _SortedFilterStoreGet) -> bool: # type: ignore [overri return ans -class SelfMonitoringReserveStore(ReserveStore): - """A self-monitoring version of the ReserveStore.""" +class SelfMonitoringReserveContainer(ReserveContainer): + """A self-monitoring version of the ReserveContainer.""" def __init__(self, env: Environment, capacity: float = float("inf"), init: float = 0.0) -> None: """Create a store-like object that allows reservations, and records. diff --git a/src/upstage_des/resources/reserve.py b/src/upstage_des/resources/reserve.py index bc91b42..da42554 100644 --- a/src/upstage_des/resources/reserve.py +++ b/src/upstage_des/resources/reserve.py @@ -2,7 +2,7 @@ # Licensed under the BSD 3-Clause License. # See the LICENSE file in the project root for complete license terms and disclaimers. -"""This file contains a Store that allows reservations.""" +"""This file contains a Container that allows reservations.""" from collections.abc import Generator from typing import Any @@ -11,13 +11,13 @@ from ..task import process -__all__ = ("ReserveStore",) +__all__ = ("ReserveContainer",) -class ReserveStore: +class ReserveContainer: """A store that allows requests to be scheduled in advance. - This is not a true store (you can't yield on a reserve slot!). + This is not a true container (you can't yield on a reserve slot!). """ def __init__( @@ -26,9 +26,9 @@ def __init__( init: float = 0.0, capacity: float = float("inf"), ) -> None: - """Create a store-like object that allows reservations. + """Create a container-like object that allows reservations. - Note that this store doesn't actually yield to SimPy when requesting. + Note that this container doesn't actually yield to SimPy when requesting. Use it to determine if anything is avaiable for reservation, but there is no queue for getting a reservation. @@ -46,7 +46,7 @@ def __init__( @property def remaining(self) -> float: - """Return the amount remaining in the store. + """Return the amount remaining in the container. Returns: float: Amount remaining @@ -55,7 +55,7 @@ def remaining(self) -> float: @property def available(self) -> float: - """Return the amount remaining in the store. + """Return the amount remaining in the container. Returns: float: Amount remaining. diff --git a/src/upstage_des/resources/sorted.py b/src/upstage_des/resources/sorted.py index 9612eb6..e90671c 100644 --- a/src/upstage_des/resources/sorted.py +++ b/src/upstage_des/resources/sorted.py @@ -45,9 +45,11 @@ def __init__( resource: "SortedFilterStore", filter: Callable[[Any], bool] = lambda item: True, sorter: Callable[[Any], tuple[Any, ...]] | None = None, + reverse: bool = False, ): self.filter = filter self.sorter = sorter + self.reverse = reverse super().__init__(resource) @@ -86,8 +88,7 @@ def _do_get(self, event: _SortedFilterStoreGet) -> bool: # type: ignore[overrid if event.filter(item): if event.sorter is not None: val = event.sorter(item) - # force conversion to tuple - if min_val is None or val < min_val: + if min_val is None or (val > min_val if event.reverse else val < min_val): min_item = item min_val = val else: @@ -107,6 +108,7 @@ def __init__( get_location: SortedFilterStore, filter: Callable[[Any], bool] = lambda item: True, sorter: Callable[[Any], tuple[Any, ...]] | None = None, + reverse: bool = False, rehearsal_time_to_complete: float = 0.0, ) -> None: """Create a Get request on a SortedFilterStore. @@ -119,6 +121,7 @@ def __init__( get_location (SIM.Store | SIM.Container): The place for the Get request filter (Callable[[Any], bool]): The function that filters items in the store. sorter (Callable[[Any], Any]): The function that returns values to sort an item on. + reverse (bool, optional): Whether to reverse the sort to be ascending. rehearsal_time_to_complete (float, optional): _description_. Defaults to 0.0. """ super().__init__( @@ -126,4 +129,5 @@ def __init__( rehearsal_time_to_complete=rehearsal_time_to_complete, filter=filter, sorter=sorter, + reverse=reverse, ) diff --git a/src/upstage_des/states.py b/src/upstage_des/states.py index 7c579c0..3b45dd8 100644 --- a/src/upstage_des/states.py +++ b/src/upstage_des/states.py @@ -56,6 +56,7 @@ def __init__( frozen: bool = False, valid_types: type | tuple[type, ...] | None = None, recording: bool = False, + record_duplicates: bool = False, default_factory: Callable[[], ST] | None = None, ) -> None: """Create a state descriptor for an Actor. @@ -69,14 +70,19 @@ def __init__( The valid_types input will type-check when you initialize an actor. Recording enables logging the values of the state whenever they change, along - with the simulation time. This value isn't deepcopied, so it may behave poorly - for mutable types. + with the simulation time. This attempts to deepcopy the value. + + When a state is a mutable type, such as a dictionary or Counter, state + changes won't be recorded because the descriptor itself won't be modified + through the __set__ call. Args: default (Any | None, optional): Default value of the state. Defaults to None. frozen (bool, optional): If the state is allowed to change. Defaults to False. valid_types (type | tuple[type, ...] | None, optional): Types allowed. Defaults to None. recording (bool, optional): If the state records itself. Defaults to False. + record_duplicates (bool, optional): If the state records duplicate values. + Defaults to False. default_factory (Callable[[], type] | None, optional): Default from function. Defaults to None. """ @@ -86,6 +92,7 @@ def __init__( self._default = default self._frozen = frozen self._recording = recording + self._record_duplicates = record_duplicates self._recording_callbacks: dict[Any, CALLBACK_FUNC] = {} self._types: tuple[type, ...] @@ -111,10 +118,10 @@ def _do_record(self, instance: "Actor", value: ST) -> None: f"Actor {instance} does not have an `env` attribute for state {self.name}" ) # get the instance time here - to_append = (env.now, value) + to_append = (env.now, deepcopy(value)) if self.name not in instance._state_histories: instance._state_histories[self.name] = [to_append] - elif to_append != instance._state_histories[self.name][-1]: + elif self._record_duplicates or to_append != instance._state_histories[self.name][-1]: instance._state_histories[self.name].append(to_append) def _do_callback(self, instance: "Actor", value: ST) -> None: diff --git a/src/upstage_des/test/test_api.py b/src/upstage_des/test/test_api.py index 6702342..6482c36 100644 --- a/src/upstage_des/test/test_api.py +++ b/src/upstage_des/test/test_api.py @@ -38,9 +38,9 @@ def test_api() -> None: "SelfMonitoringContinuousContainer", "SelfMonitoringFilterStore", "SelfMonitoringSortedFilterStore", - "SelfMonitoringReserveStore", + "SelfMonitoringReserveContainer", "SelfMonitoringStore", - "ReserveStore", + "ReserveContainer", "SortedFilterStore", "Location", "CartesianLocation", diff --git a/src/upstage_des/test/test_stores.py b/src/upstage_des/test/test_stores.py index 39af4b1..d90f3be 100644 --- a/src/upstage_des/test/test_stores.py +++ b/src/upstage_des/test/test_stores.py @@ -15,11 +15,11 @@ from upstage_des.resources.monitoring import ( SelfMonitoringContainer, SelfMonitoringFilterStore, - SelfMonitoringReserveStore, + SelfMonitoringReserveContainer, SelfMonitoringSortedFilterStore, SelfMonitoringStore, ) -from upstage_des.resources.reserve import ReserveStore +from upstage_des.resources.reserve import ReserveContainer from upstage_des.resources.sorted import SortedFilterGet, SortedFilterStore from upstage_des.type_help import SIMPY_GEN @@ -43,12 +43,14 @@ def sorted_filter_getter( wait: float, filter: Callable[[Any], bool], sorter: Callable[[Any], Any] | None = None, + reverse: bool = False, ) -> SIMPY_GEN: yield env.timeout(wait) evt = SortedFilterGet( store, filter, sorter, + reverse, ) item = yield evt.as_event() return item @@ -131,7 +133,8 @@ def get_proc() -> SIMPY_GEN: env, store=store, filter=lambda x: isinstance(x, int | float), - sorter=lambda x: (-x,), + sorter=lambda x: x, + reverse=True, wait=0.0, ) ) @@ -152,7 +155,7 @@ def sim() -> SIMPY_GEN: def test_reserve_store() -> None: with EnvironmentContext() as env: - store = ReserveStore( + store = ReserveContainer( env=env, capacity=10, init=10, @@ -259,7 +262,7 @@ def test_self_monitoring_store() -> None: def test_self_monitoring_reserve_store() -> None: with EnvironmentContext() as env: - SelfMonitoringReserveStore(env) + SelfMonitoringReserveContainer(env) env.run(until=MAX_RUN_TIME) From 11c3d6728df4503861edbdba651abc2d74d6108e Mon Sep 17 00:00:00 2001 From: James Arruda Date: Thu, 19 Dec 2024 11:36:56 -0500 Subject: [PATCH 2/4] Updating NamedEntity and Monitoring resources to assist data gathering. --- src/upstage_des/base.py | 20 ++++++++++++-------- src/upstage_des/data_utils.py | 1 + src/upstage_des/resources/monitoring.py | 24 ++++++++++++++++++------ 3 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 src/upstage_des/data_utils.py diff --git a/src/upstage_des/base.py b/src/upstage_des/base.py index 982461a..b553d5e 100644 --- a/src/upstage_des/base.py +++ b/src/upstage_des/base.py @@ -342,18 +342,22 @@ def __init_subclass__( cls, entity_groups: Iterable[str] | str | None = None, add_to_entity_groups: bool = True, + skip_classname: bool = False, ) -> None: if not add_to_entity_groups: return - if entity_groups is None: - entity_groups = [cls.__name__] - else: - if isinstance(entity_groups, str): - entity_groups = [entity_groups] - entity_groups = list(entity_groups) + [cls.__name__] - entity_group = [cls.__name__] if entity_groups is None else entity_groups - entity_group = list(set(entity_group)) + entity_groups = [] if entity_groups is None else entity_groups + + if isinstance(entity_groups, str): + entity_groups = [entity_groups] + + entity_groups = list(entity_groups) + + if cls.__name__ not in entity_groups and not skip_classname: + entity_groups.append(cls.__name__) + + entity_group = list(set(entity_groups)) old_init = cls.__init__ @wraps(old_init) diff --git a/src/upstage_des/data_utils.py b/src/upstage_des/data_utils.py new file mode 100644 index 0000000..9116140 --- /dev/null +++ b/src/upstage_des/data_utils.py @@ -0,0 +1 @@ +"""Utilities for gathering all recorded simulation data.""" diff --git a/src/upstage_des/resources/monitoring.py b/src/upstage_des/resources/monitoring.py index 3a36bcb..764c432 100644 --- a/src/upstage_des/resources/monitoring.py +++ b/src/upstage_des/resources/monitoring.py @@ -11,6 +11,8 @@ from simpy.resources.container import ContainerGet, ContainerPut from simpy.resources.store import FilterStoreGet, StoreGet, StorePut +from upstage_des.base import NamedUpstageEntity + from .container import ContinuousContainer from .reserve import ReserveContainer from .sorted import SortedFilterStore, _SortedFilterStoreGet @@ -27,7 +29,9 @@ RECORDER_FUNC = Callable[[list[Any]], int] -class SelfMonitoringStore(Store): +class SelfMonitoringStore( + Store, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True +): """A self-monitoring version of the SimPy Store.""" def __init__( @@ -76,7 +80,9 @@ def _do_get(self, event: StoreGet) -> None: self._record("get") -class SelfMonitoringFilterStore(FilterStore): +class SelfMonitoringFilterStore( + FilterStore, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True +): """A self-monitoring version of the SimPy FilterStore.""" def __init__( @@ -125,7 +131,9 @@ def _do_get(self, event: FilterStoreGet) -> None: # type: ignore[override] self._record("get") -class SelfMonitoringContainer(Container): +class SelfMonitoringContainer( + Container, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True +): """A self-monitoring version of the SimPy Container.""" def __init__(self, env: Environment, capacity: float = float("inf"), init: float = 0.0) -> None: @@ -163,7 +171,9 @@ def _do_get(self, event: ContainerGet) -> None: self._record() -class SelfMonitoringContinuousContainer(ContinuousContainer): +class SelfMonitoringContinuousContainer( + ContinuousContainer, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True +): """A self-monitoring version of the Continuous Container.""" def __init__( @@ -202,7 +212,7 @@ def _set_level(self) -> float: return amt -class SelfMonitoringSortedFilterStore(SortedFilterStore, SelfMonitoringStore): +class SelfMonitoringSortedFilterStore(SortedFilterStore, SelfMonitoringStore, skip_classname=True): """A self-monitoring version of the SortedFilterStore.""" def _do_get(self, event: _SortedFilterStoreGet) -> bool: # type: ignore [override] @@ -212,7 +222,9 @@ def _do_get(self, event: _SortedFilterStoreGet) -> bool: # type: ignore [overri return ans -class SelfMonitoringReserveContainer(ReserveContainer): +class SelfMonitoringReserveContainer( + ReserveContainer, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True +): """A self-monitoring version of the ReserveContainer.""" def __init__(self, env: Environment, capacity: float = float("inf"), init: float = 0.0) -> None: From c7f35c08201b3323b1a5f7b496e27b4b558becef Mon Sep 17 00:00:00 2001 From: James Arruda Date: Fri, 20 Dec 2024 20:57:38 -0500 Subject: [PATCH 3/4] WIP for data gathering. Changes to context, active state, monitoring stores. Added data gathering functions. --- src/upstage_des/actor.py | 15 ++ src/upstage_des/base.py | 89 ++++++--- src/upstage_des/data_utils.py | 210 ++++++++++++++++++++ src/upstage_des/resources/monitoring.py | 76 ++++++- src/upstage_des/states.py | 53 ++++- src/upstage_des/test/test_data_reporting.py | 172 ++++++++++++++++ src/upstage_des/test/test_motion.py | 50 ++++- src/upstage_des/test/test_stepped_motion.py | 4 + 8 files changed, 614 insertions(+), 55 deletions(-) create mode 100644 src/upstage_des/test/test_data_reporting.py diff --git a/src/upstage_des/actor.py b/src/upstage_des/actor.py index 8cfd832..37953a0 100644 --- a/src/upstage_des/actor.py +++ b/src/upstage_des/actor.py @@ -17,6 +17,7 @@ from upstage_des.events import Event from .base import ( + SPECIAL_ENTITY_CONTEXT_VAR, MockEnvironment, NamedUpstageEntity, SettableEnv, @@ -183,6 +184,16 @@ def __init_subclass__( e.add_note("Failure likely due to repeated state name in inherited actor") raise e + def _add_special_group(self) -> None: + """Add self to the actor context list. + + Called by the NamedUpstageEntity on group inits. + """ + ans = SPECIAL_ENTITY_CONTEXT_VAR.get().actors + if self in ans: + return + ans.append(self) + def _lock_state(self, *, state: str, task: Task) -> None: """Lock one of the actor's states by a given task. @@ -255,6 +266,10 @@ def activate_state( self._set_active_state_data(state_name=state, started_at=self.env.now, task=task, **kwargs) # any initialization in the state needs to be called via attribute access getattr(self, state) + # The activation handles getattr + _state = self._state_defs[state] + assert isinstance(_state, ActiveState) + _state.activate(self, task=task) def activate_linear_state(self, *, state: str, rate: float, task: Task) -> None: """Shortcut for activating a LinearChangingState. diff --git a/src/upstage_des/base.py b/src/upstage_des/base.py index b553d5e..be10c33 100644 --- a/src/upstage_des/base.py +++ b/src/upstage_des/base.py @@ -8,6 +8,7 @@ from collections import defaultdict from collections.abc import Iterable from contextvars import ContextVar, Token +from dataclasses import dataclass, field from functools import wraps from math import floor from random import Random @@ -23,6 +24,11 @@ CONTEXT_ERROR_MSG = "Undefined context variable: use EnvironmentContext" +if TYPE_CHECKING: + from upstage_des.actor import Actor + from upstage_des.resources.monitoring import MonitoringMixin + + class DotDict(dict): """A dictionary that supports dot notation as well as dictionary access notation. @@ -195,13 +201,21 @@ def run(cls, until: float | int) -> None: raise UpstageError("You tried to use `run` on a mock environment") +@dataclass +class SpecialContexts: + """Accessible lists of typed objects for contexts.""" + + actors: list["Actor"] = field(default_factory=list) + monitored: list["MonitoringMixin"] = field(default_factory=list) + + ENV_CONTEXT_VAR: ContextVar[SimpyEnv] = ContextVar("Environment") -ACTOR_CONTEXT_VAR: ContextVar[list["NamedUpstageEntity"]] = ContextVar("Actors") +SPECIAL_ENTITY_CONTEXT_VAR: ContextVar[SpecialContexts] = ContextVar("SpecialContexts") ENTITY_CONTEXT_VAR: ContextVar[dict[str, list["NamedUpstageEntity"]]] = ContextVar("Entities") STAGE_CONTEXT_VAR: ContextVar[DotDict] = ContextVar("Stage") -SKIP_GROUPS: list[str] = ["Task", "Location", "CartesianLocation", "GeodeticLocation"] +SKIP_GROUPS: list[str] = ["Actor", "Task", "Location", "CartesianLocation", "GeodeticLocation"] class UpstageBase: @@ -247,15 +261,15 @@ def stage(self) -> StageProtocol: raise UpstageError("No stage found or set.") return stage - def get_actors(self) -> list["NamedUpstageEntity"]: + def get_actors(self) -> list["Actor"]: """Return all actors that the director knows. Returns: list[NamedUpstageEntity]: List of actors in the simulation. """ - ans: list[NamedUpstageEntity] = [] + ans: list[Actor] = [] try: - ans = ACTOR_CONTEXT_VAR.get() + ans = SPECIAL_ENTITY_CONTEXT_VAR.get().actors except LookupError: raise UpstageError(CONTEXT_ERROR_MSG) return ans @@ -277,6 +291,19 @@ def get_entity_group(self, group_name: str) -> list["NamedUpstageEntity"]: raise UpstageError(CONTEXT_ERROR_MSG) return ans + def get_monitored(self) -> list["MonitoringMixin"]: + """Return entities that inherit from the MonitoringMixin. + + Returns: + list[MonitoringMixin]: List of entitites that are monitoring. + """ + ans: list[MonitoringMixin] = [] + try: + ans = SPECIAL_ENTITY_CONTEXT_VAR.get().monitored + except LookupError: + raise UpstageError(CONTEXT_ERROR_MSG) + return ans + def get_all_entity_groups(self) -> dict[str, list["NamedUpstageEntity"]]: """Get all entity groups. @@ -367,16 +394,30 @@ def the_actual_init(inst: NamedUpstageEntity, *args: Any, **kwargs: Any) -> None setattr(cls, "__init__", the_actual_init) - def _add_as_actor(self) -> None: - """Add self to the actor context list.""" + def _add_to_group(self, group_name: str) -> None: + """Add to a single group. + + Args: + group_name (str): Group name + """ try: - ans = ACTOR_CONTEXT_VAR.get() - if self in ans: - raise UpstageError(f"Actor: {self} already recorded in the environment") - ans.append(self) + ans = ENTITY_CONTEXT_VAR.get() + ans.setdefault(group_name, []) + if self in ans[group_name]: + raise UpstageError(f"Entity: {self} already recorded in the environment") + ans[group_name].append(self) except LookupError: - actor_list: list[NamedUpstageEntity] = [self] - ACTOR_CONTEXT_VAR.set(actor_list) + entity_groups = {group_name: [self]} + ENTITY_CONTEXT_VAR.set(entity_groups) + + def _add_special_group(self) -> None: + """Add to a special group. + + Sub-classable for type help. + + Make sure that whatever entity group name this goes to is in SKIP_GROUPS. + """ + ... def _add_entity(self, group_names: list[str]) -> None: """Add self to an entity group(s). @@ -387,18 +428,8 @@ def _add_entity(self, group_names: list[str]) -> None: for group_name in group_names: if group_name in SKIP_GROUPS: continue - if group_name == "Actor": - self._add_as_actor() - continue - try: - ans = ENTITY_CONTEXT_VAR.get() - ans.setdefault(group_name, []) - if self in ans[group_name]: - raise UpstageError(f"Entity: {self} already recorded in the environment") - ans[group_name].append(self) - except LookupError: - entity_groups = {group_name: [self]} - ENTITY_CONTEXT_VAR.set(entity_groups) + self._add_to_group(group_name) + self._add_special_group() class SettableEnv(UpstageBase): @@ -483,11 +514,11 @@ def __init__( random_gen (Any | None, optional): RNG object. Defaults to None. """ self.env_ctx = ENV_CONTEXT_VAR - self.actor_ctx = ACTOR_CONTEXT_VAR + self.special_ctx = SPECIAL_ENTITY_CONTEXT_VAR self.entity_ctx = ENTITY_CONTEXT_VAR self.stage_ctx = STAGE_CONTEXT_VAR self.env_token: Token[SimpyEnv] - self.actor_token: Token[list[NamedUpstageEntity]] + self.special_token: Token[SpecialContexts] self.entity_token: Token[dict[str, list[NamedUpstageEntity]]] self.stage_token: Token[DotDict] self._env: SimpyEnv | None = None @@ -503,7 +534,7 @@ def __enter__(self) -> SimpyEnv: """ self._env = SimpyEnv(initial_time=self._initial_time) self.env_token = self.env_ctx.set(self._env) - self.actor_token = self.actor_ctx.set([]) + self.special_token = self.special_ctx.set(SpecialContexts()) self.entity_token = self.entity_ctx.set(defaultdict(list)) stage = DotDict() self.stage_token = self.stage_ctx.set(stage) @@ -517,7 +548,7 @@ def __enter__(self) -> SimpyEnv: def __exit__(self, *_: Any) -> None: """Leave the context.""" self.env_ctx.reset(self.env_token) - self.actor_ctx.reset(self.actor_token) + self.special_ctx.reset(self.special_token) self.entity_ctx.reset(self.entity_token) self.stage_ctx.reset(self.stage_token) self._env = None diff --git a/src/upstage_des/data_utils.py b/src/upstage_des/data_utils.py index 9116140..ddb4ee1 100644 --- a/src/upstage_des/data_utils.py +++ b/src/upstage_des/data_utils.py @@ -1 +1,211 @@ """Utilities for gathering all recorded simulation data.""" + +from typing import Any, cast + +from upstage_des.actor import Actor +from upstage_des.base import UpstageBase +from upstage_des.data_types import CartesianLocation, GeodeticLocation +from upstage_des.states import ( + ActiveState, + ActiveStatus, + CartesianLocationChangingState, + GeodeticLocationChangingState, + ResourceState, +) + +ACTUAL_LOCATION = GeodeticLocation | CartesianLocation +LOCATION_TYPES = ACTUAL_LOCATION | GeodeticLocationChangingState, CartesianLocationChangingState + + +STATE_DATA_ROW = tuple[str, str, str, float, Any, str | None] +LOCATION_DATA_ROW = tuple[str, str, str, float, float, float, float, str | None] +COLUMN_NAMES = ["Entity Name", "Entity Type", "State Name", "Time"] +ACTIVATION_STATUS_COL = "Activation Status" + + +def _state_history_to_table( + actor_name: str, + actor_kind: str, + state_name: str, + is_active: bool, + hist: list[tuple[float, Any]], +) -> list[STATE_DATA_ROW]: + """Create a state history table from an actor. + + The final entry is a way to flag if a variable is becoming active or not. + + Args: + actor_name (str): Actor name + actor_kind (str): Actor kind + state_name (str): State name + is_active (bool): If the state is an active type + hist (list[tuple[float, Any]]): History from _quantities or _state_histories + + Returns: + list[STATE_DATA_ROW]: A long-form data table of state data. + """ + data: list[STATE_DATA_ROW] = [] + active_value = "inactive" if is_active else None + for time, value in hist: + if isinstance(value, ActiveStatus): + row = data.pop(-1) + row = tuple(list(row[:-1]) + [value.name]) + active_value = "active" if value.name == "activating" else "inactive" + else: + row = (actor_name, actor_kind, state_name, time, value, active_value) + data.append(row) + return data + + +def _actor_state_data( + actor: Actor, skip_locations: bool = True +) -> tuple[list[STATE_DATA_ROW], list[Any]]: + """Gather actor recorded data. + + Args: + actor (Actor): The actor. + skip_locations (bool, optional): If location states should be ignored. + Defaults to True. + + Returns: + list[STATE_INFO]: List of state information + list[Any]: List of monitoring objects to ignore in a global search. + """ + data: list[STATE_DATA_ROW] = [] + resources: list[Any] = [] + name, kind = actor.name, actor.__class__.__name__ + + for state_name, state in actor._state_defs.items(): + if skip_locations and isinstance(state, LOCATION_TYPES): + break + _value = actor.__dict__[state_name] # skips data recording after the fact + is_active = isinstance(state, ActiveState) + if state_name in actor._state_histories: + data.extend( + _state_history_to_table( + name, kind, state_name, is_active, actor._state_histories[state_name] + ) + ) + elif isinstance(state, ResourceState) or hasattr(_value, "_quantities"): + resources.append(_value) + data.extend(_state_history_to_table(name, kind, state_name, False, _value._quantities)) + + return data, resources + + +def _actor_location_data(actor: Actor) -> tuple[list[LOCATION_DATA_ROW], list[str]]: + """Get actor location data, if it exists. + + The actor needs to have recording Location states: + * CartesianLocation(ChangingState) + * GeodeticLocation(ChangingState) + + Args: + actor (Actor): The actor. + + Returns: + list[LOCATION_DATA_ROW]: Time and XYZ/LLA data. + list[str]: name of XYZ/LLA + """ + data: list[LOCATION_DATA_ROW] = [] + is_xyz = True + name, kind = actor.name, actor.__class__.__name__ + for state_name, state_data in actor._state_histories.items(): + _state = actor._state_defs[state_name] + if not isinstance(_state, LOCATION_TYPES): + continue + value: ACTUAL_LOCATION | ActiveStatus + is_active = isinstance(_state, ActiveState) + active_value = "inactive" if is_active else None + for time, value in state_data: + if isinstance(value, ActiveStatus): + _row = data.pop(-1) + row = tuple(list(_row[:-1]) + [value.name]) + active_value = "active" if value.name == "activating" else "inactive" + elif isinstance(value, GeodeticLocation): + row = (name, kind, state_name, time, value.lat, value.lon, value.alt, active_value) + is_xyz = False + elif isinstance(value, CartesianLocation): + row = (name, kind, state_name, time, value.x, value.y, value.z, active_value) + data.append(cast(LOCATION_DATA_ROW, row)) + cols = ["X", "Y", "Z"] if is_xyz else ["Lat", "Lon", "Alt"] + return data, cols + + +def create_table(skip_locations: bool = True) -> tuple[list[STATE_DATA_ROW], list[str]]: + """Create a data table of everything UPSTAGE has recorded. + + This uses the current environment context. + + The data columns are: + Time, Entity Name, Entity Type, State Name, State Value + + For SelfMonitoring<> resources that are not part of an actor, the name + is pulled from the name entry to the resource. The Entity Type is the + class name, and the State Name is "Resource State". + + Usage: + + >>> import pandas as pd + >>> with UP.EnvironmentContext() as env: + >>> ... + >>> env.run() + >>> table, cols = create_table() + >>> df = pd.DataFrame(table, cols) + + Args: + skip_locations (bool, optional): If location states should be ignored. + Defaults to True. + + Returns: + list[STATE_DATA_ROW]: Data table + list[str]]: Column names. + """ + _base = UpstageBase() + data: list[tuple[Any, ...]] = [] + seen_resources: list[Any] = [] + for actor in _base.get_actors(): + name = actor.name + kind = actor.__class__.__name__ + _data, _resources = _actor_state_data(actor, skip_locations=skip_locations) + seen_resources.extend(_resources) + data.extend(_data) + + for monitoring in _base.get_monitored(): + if monitoring in seen_resources: + continue + name = f"{monitoring.name}" + kind = f"{monitoring.__class__.__name__}" + rows = [(name, kind, "Resource", t, value, None) for t, value in monitoring._quantities] + data.extend(rows) + + colnames = COLUMN_NAMES + ["Value", ACTIVATION_STATUS_COL] + return data, colnames + + +def create_location_table() -> tuple[list[LOCATION_DATA_ROW], list[str]]: + """Create a data table of every location UPSTAGE has recorded. + + Assumes that all location types are the same. + + This uses the current environment context. + + Usage: + + >>> import pandas as pd + >>> with UP.EnvironmentContext() as env: + >>> ... + >>> env.run() + >>> table, cols = create_location_table() + >>> df = pd.DataFrame(table, cols) + + Returns: + list[LOCATION_DATA_ROW]: Data table + list[str]]: Column names. + """ + _base = UpstageBase() + data: list[LOCATION_DATA_ROW] = [] + for actor in _base.get_actors(): + _data, cols = _actor_location_data(actor) + data.extend(_data) + return data, COLUMN_NAMES + cols + [ACTIVATION_STATUS_COL] diff --git a/src/upstage_des/resources/monitoring.py b/src/upstage_des/resources/monitoring.py index 764c432..5b7ca3e 100644 --- a/src/upstage_des/resources/monitoring.py +++ b/src/upstage_des/resources/monitoring.py @@ -11,7 +11,7 @@ from simpy.resources.container import ContainerGet, ContainerPut from simpy.resources.store import FilterStoreGet, StoreGet, StorePut -from upstage_des.base import NamedUpstageEntity +from upstage_des.base import SPECIAL_ENTITY_CONTEXT_VAR, NamedUpstageEntity from .container import ContinuousContainer from .reserve import ReserveContainer @@ -24,13 +24,34 @@ "SelfMonitoringContinuousContainer", "SelfMonitoringSortedFilterStore", "SelfMonitoringReserveContainer", + "MonitoringMixin", ) RECORDER_FUNC = Callable[[list[Any]], int] +MONITORING_ENTITY_GROUP = "monitored" + + +class MonitoringMixin(NamedUpstageEntity, skip_classname=True): + """Base class for matching Monitored types.""" + + name: str | None + _quantities: list[tuple[float, Any]] + + def _add_special_group(self) -> None: + """Add self the the monitored context group. + + Called by the NamedUpstageEntity on group inits. + """ + ans = SPECIAL_ENTITY_CONTEXT_VAR.get().monitored + if self in ans: + return + ans.append(self) class SelfMonitoringStore( - Store, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True + Store, + MonitoringMixin, + skip_classname=True, ): """A self-monitoring version of the SimPy Store.""" @@ -39,6 +60,7 @@ def __init__( env: Environment, capacity: float | int = float("inf"), item_func: RECORDER_FUNC | None = None, + name: str | None = None, ) -> None: """A monitoring version of the SimPy Store. @@ -51,8 +73,11 @@ def __init__( capacity (float | int, optional): Capacity of the store. Defaults to float("inf"). item_func (RECORDER_FUNC | None, optional): Function to create recorded values. Defaults to None. + name (str, optional): The name of the store, if it doesn't exist as a state. + Defaults to None. """ super().__init__(env, capacity=capacity) + self.name = name self.item_func = item_func if item_func is not None else len self._quantities = [(self._env.now, self.item_func(self.items))] @@ -81,7 +106,9 @@ def _do_get(self, event: StoreGet) -> None: class SelfMonitoringFilterStore( - FilterStore, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True + FilterStore, + MonitoringMixin, + skip_classname=True, ): """A self-monitoring version of the SimPy FilterStore.""" @@ -90,6 +117,7 @@ def __init__( env: Environment, capacity: float | int = float("inf"), item_func: RECORDER_FUNC | None = None, + name: str | None = None, ) -> None: """A monitoring version of the SimPy FilterStore. @@ -102,8 +130,11 @@ def __init__( capacity (float | int, optional): Capacity of the store. Defaults to float("inf"). item_func (RECORDER_FUNC | None, optional): Function to create recorded values. Defaults to None. + name (str, optional): The name of the store, if it doesn't exist as a state. + Defaults to None. """ super().__init__(env, capacity=capacity) + self.name = name self.item_func = item_func if item_func is not None else len self._quantities = [(self._env.now, self.item_func(self.items))] @@ -132,19 +163,30 @@ def _do_get(self, event: FilterStoreGet) -> None: # type: ignore[override] class SelfMonitoringContainer( - Container, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True + Container, + MonitoringMixin, + skip_classname=True, ): """A self-monitoring version of the SimPy Container.""" - def __init__(self, env: Environment, capacity: float = float("inf"), init: float = 0.0) -> None: + def __init__( + self, + env: Environment, + capacity: float = float("inf"), + init: float = 0.0, + name: str | None = None, + ) -> None: """A monitoring version of a SimPy container. Args: env (Environment): SimPy environment. capacity (float, optional): Capacity of the container. Defaults to float("inf"). init (float, optional): Initial amount. Defaults to 0.0. + name (str, optional): The name of the store, if it doesn't exist as a state. + Defaults to None. """ super().__init__(env, capacity=capacity, init=init) + self.name = name self._quantities: list[tuple[float, float]] = [(self._env.now, self._level)] def _record(self) -> None: @@ -172,7 +214,9 @@ def _do_get(self, event: ContainerGet) -> None: class SelfMonitoringContinuousContainer( - ContinuousContainer, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True + ContinuousContainer, + MonitoringMixin, + skip_classname=True, ): """A self-monitoring version of the Continuous Container.""" @@ -183,6 +227,7 @@ def __init__( init: int | float = 0.0, error_empty: bool = True, error_full: bool = True, + name: str | None = None, ) -> None: """A monitoring version of the Continuous container. @@ -194,9 +239,11 @@ def __init__( init (int | float, optional): Initial amount. Defaults to 0.0. error_empty (bool, optional): Error when it gets empty. Defaults to True. error_full (bool, optional): Error when it gets full. Defaults to True. - + name (str, optional): The name of the store, if it doesn't exist as a state. + Defaults to None. """ super().__init__(env, capacity, init, error_empty, error_full) + self.name = name self._quantities = [(self._env.now, self._level)] def _set_level(self) -> float: @@ -223,11 +270,19 @@ def _do_get(self, event: _SortedFilterStoreGet) -> bool: # type: ignore [overri class SelfMonitoringReserveContainer( - ReserveContainer, NamedUpstageEntity, entity_groups=["_monitored"], skip_classname=True + ReserveContainer, + MonitoringMixin, + skip_classname=True, ): """A self-monitoring version of the ReserveContainer.""" - def __init__(self, env: Environment, capacity: float = float("inf"), init: float = 0.0) -> None: + def __init__( + self, + env: Environment, + capacity: float = float("inf"), + init: float = 0.0, + name: str | None = None, + ) -> None: """Create a store-like object that allows reservations, and records. Note that this store doesn't actually yield to SimPy when requesting. @@ -239,8 +294,11 @@ def __init__(self, env: Environment, capacity: float = float("inf"), init: float env (Environment): The SimPy Environment init (float, optional): Initial amount available. Defaults to 0.0. capacity (float, optional): Total capacity. Defaults to float("inf"). + name (str, optional): The name of the store, if it doesn't exist as a state. + Defaults to None. """ super().__init__(env, init, capacity) + self.name = name self._quantities = [(env.now, init)] def _record(self) -> None: diff --git a/src/upstage_des/states.py b/src/upstage_des/states.py index 3b45dd8..a5d1d2c 100644 --- a/src/upstage_des/states.py +++ b/src/upstage_des/states.py @@ -7,6 +7,7 @@ from collections.abc import Callable from copy import deepcopy +from enum import Enum from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from simpy import Container, Store @@ -35,6 +36,29 @@ ST = TypeVar("ST") +class ActiveStatus(Enum): + activating: str = "ACTIVATING" + deactivating: str = "DEACTIVATING" + + +def _compare(a: Any, b: Any) -> bool: + """Function for comparing any two objects. + + If an equality test fails, assume not equal. + + Args: + a (Any): Anything + b (Any): Also anything + + Returns: + bool: Are they the same + """ + try: + return cast(bool, a == b) + except Exception: + return False + + class State(Generic[ST]): """The particular condition that something is in at a specific time. @@ -105,23 +129,28 @@ def __init__( self._types = valid_types self.IGNORE_LOCK: bool = False - def _do_record(self, instance: "Actor", value: ST) -> None: + def _do_record(self, instance: "Actor", value: ST, override: Any = None) -> None: """Record the value of the state. Args: instance (Actor): The actor holding the state value (ST): State value + override (Any, optional): If given, record the override value """ + if not self._recording: + return env = getattr(instance, "env", None) if env is None: raise SimulationError( f"Actor {instance} does not have an `env` attribute for state {self.name}" ) - # get the instance time here - to_append = (env.now, deepcopy(value)) + use = value if override is None else override + to_append = (env.now, deepcopy(use)) if self.name not in instance._state_histories: instance._state_histories[self.name] = [to_append] - elif self._record_duplicates or to_append != instance._state_histories[self.name][-1]: + elif self._record_duplicates or not _compare( + to_append, instance._state_histories[self.name][-1] + ): instance._state_histories[self.name].append(to_append) def _do_callback(self, instance: "Actor", value: ST) -> None: @@ -150,7 +179,7 @@ def _broadcast_change(self, instance: "Actor", name: str, value: ST) -> None: # because all the operations seem to happen *after* the get # NOTE: Lists also have the same issue that def __set__(self, instance: "Actor", value: ST) -> None: - """Set eh state's value. + """Set the state's value. Args: instance (Actor): The actor holding the state @@ -169,8 +198,7 @@ def __set__(self, instance: "Actor", value: ST) -> None: instance.__dict__[self.name] = value - if self._recording: - self._do_record(instance, value) + self._do_record(instance, value) self._do_callback(instance, value) self._broadcast_change(instance, self.name, value) @@ -324,6 +352,16 @@ def get_activity_data(self, instance: "Actor") -> dict[str, Any]: res["value"] = instance.__dict__[self.name] return res + def activate(self, instance: "Actor", task: Task | None = None) -> None: + """Method to run when a state is activated. + + Used to help record the right data about the active state. + + Use this with __super__ for motion states to deactivate their motion from + the motion manager. + """ + self._do_record(instance, None, override=ActiveStatus.activating) + def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: """Optional method to override that is called when a state is deactivated. @@ -334,6 +372,7 @@ def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: """ # Returns if the state should be ignored # A False means the state is completely deactivated + self._do_record(instance, None, override=ActiveStatus.deactivating) return False diff --git a/src/upstage_des/test/test_data_reporting.py b/src/upstage_des/test/test_data_reporting.py new file mode 100644 index 0000000..43e1b0b --- /dev/null +++ b/src/upstage_des/test/test_data_reporting.py @@ -0,0 +1,172 @@ +"""Test the data recording/reporting capabilities.""" + +from collections import Counter + +import upstage_des.api as UP +from upstage_des.data_utils import create_location_table, create_table + + +class Cashier(UP.Actor): + items_scanned = UP.State[int](recording=True) + other = UP.State[float]() + cue = UP.State[UP.SelfMonitoringStore]() + cue2 = UP.ResourceState[UP.SelfMonitoringContainer](default=UP.SelfMonitoringContainer) + time_working = UP.LinearChangingState(default=0.0, recording=True, record_duplicates=True) + + +class Cart(UP.Actor): + location = UP.CartesianLocationChangingState(recording=True) + location_two = UP.CartesianLocationChangingState(recording=True) + + +def test_data_reporting() -> None: + with UP.EnvironmentContext() as env: + t = UP.Task() + cash = Cashier( + name="Ertha", + other=0.0, + items_scanned=0, + cue=UP.SelfMonitoringStore(env), + ) + + cash2 = Cashier( + name="Bertha", + other=0.0, + items_scanned=0, + cue=UP.SelfMonitoringStore(env), + ) + store = UP.SelfMonitoringFilterStore(env, name="Store Test") + cart = Cart( + name="Wobbly Wheel", + location=UP.CartesianLocation(1.0, 1.0), + location_two=UP.CartesianLocation(1.0, 1.0), + ) + + for c in [cash, cash2]: + c.items_scanned += 1 + c.cue.put("A") + c.cue2.put(10) + c.other = 3.0 + c.time_working = 0.0 + + cart.activate_location_state( + state="location", + speed=2.0, + waypoints=[UP.CartesianLocation(7.0, 6.0)], + task=t, + ) + cart.activate_location_state( + state="location_two", + speed=2.0, + waypoints=[UP.CartesianLocation(-7.0, -6.0)], + task=t, + ) + + env.run(until=0.1) + for c in [cash, cash2]: + c.activate_linear_state( + state="time_working", + rate=1.0, + task=t, + ) + + env.run(until=1) + cart.location + cart.location_two + cash.items_scanned += 2 + store.put("XYZ") + + for c in [cash, cash2]: + c.cue.put("B") + c.cue2.put(3) + c.time_working + + env.run(until=2) + cart.location + cash.items_scanned += 1 + env.run(until=3) + cart.location + cart.location_two + + cart.deactivate_state(state="location", task=t) + cart.deactivate_state(state="location_two", task=t) + + cash2.deactivate_state(state="time_working", task=t) + + for c in [cash, cash2]: + c.cue.get() + c.cue2.get(2) + c.time_working + + cash.items_scanned = -1 + env.run(until=3.3) + cart.location + cart.location_two = UP.CartesianLocation(-1.0, -1.0) + store.put("ABC") + env.run() + cart.location + cart.location_two + for c in [cash, cash2]: + c.time_working + + state_table, cols = create_table() + all_state_table, all_cols = create_table(skip_locations=False) + loc_state_table, loc_cols = create_location_table() + + ctr = Counter([row[:3] for row in state_table]) + assert ctr[("Ertha", "Cashier", "items_scanned")] == 5 + assert ctr[("Ertha", "Cashier", "cue")] == 4 + assert ctr[("Ertha", "Cashier", "cue2")] == 4 + assert ctr[("Ertha", "Cashier", "time_working")] == 5 + assert ctr[("Bertha", "Cashier", "items_scanned")] == 2 + assert ctr[("Bertha", "Cashier", "cue")] == 4 + assert ctr[("Bertha", "Cashier", "cue2")] == 4 + assert ctr[("Bertha", "Cashier", "time_working")] == 4 + assert ctr[("Store Test", "SelfMonitoringFilterStore", "Resource")] == 3 + assert not any(x[0] == "Wobbly Wheel" for x in state_table) + assert len(state_table) == 35 + assert cols == all_cols + assert cols == [ + "Entity Name", + "Entity Type", + "State Name", + "Time", + "Value", + "Activation Status", + ] + + ctr = Counter([row[:3] for row in all_state_table]) + assert ctr[("Ertha", "Cashier", "items_scanned")] == 5 + assert ctr[("Ertha", "Cashier", "cue")] == 4 + assert ctr[("Ertha", "Cashier", "cue2")] == 4 + assert ctr[("Ertha", "Cashier", "time_working")] == 5 + assert ctr[("Bertha", "Cashier", "items_scanned")] == 2 + assert ctr[("Bertha", "Cashier", "cue")] == 4 + assert ctr[("Bertha", "Cashier", "cue2")] == 4 + assert ctr[("Bertha", "Cashier", "time_working")] == 4 + assert ctr[("Store Test", "SelfMonitoringFilterStore", "Resource")] == 3 + assert ctr[("Wobbly Wheel", "Cart", "location")] == 4 + assert ctr[("Wobbly Wheel", "Cart", "location_two")] == 4 + assert len(all_state_table) == 35 + 8 + + assert loc_cols == [ + "Entity Name", + "Entity Type", + "State Name", + "Time", + "X", + "Y", + "Z", + "Activation Status", + ] + assert len(loc_state_table) == 8 + assert loc_state_table[-1] == ( + "Wobbly Wheel", + "Cart", + "location_two", + 3.3, + -1.0, + -1.0, + 0.0, + "inactive", + ) diff --git a/src/upstage_des/test/test_motion.py b/src/upstage_des/test/test_motion.py index e39fc3d..dfeb588 100644 --- a/src/upstage_des/test/test_motion.py +++ b/src/upstage_des/test/test_motion.py @@ -18,6 +18,20 @@ LOC = TypeVar("LOC", bound=UP.CartesianLocation | UP.GeodeticLocation) +def close(a: float, b: float) -> bool: + """Test if two numbers are close. + + Args: + a (float): number + b (float): other number + + Returns: + bool: if they are close + """ + d = abs(a - b) + return d <= 1e-8 + + class DummySensor(Generic[LOC]): """A simple sensor for testing purposes.""" @@ -442,10 +456,12 @@ def test_motion_coordination_cli() -> None: assert len(motion._debug_data[mover]) == 3 for i, data in enumerate(motion._debug_data[mover]): sense, kinds, times, inters = data - loc = inters[0] - assert times[0] == mover._state_histories["loc"][i * 2 + 1][0] - assert times[1] == mover._state_histories["loc"][i * 2 + 2][0] - assert abs(loc - mover._state_histories["loc"][i * 2 + 1][1]) <= 1e-12 + mover_at_time_0 = [x[1] for x in mover._state_histories["loc"] if close(x[0], times[0])] + mover_at_time_1 = [x[1] for x in mover._state_histories["loc"] if close(x[0], times[1])] + assert mover_at_time_0 + assert mover_at_time_1 + assert close(mover_at_time_0[0], inters[0]) + assert close(mover_at_time_1[0], inters[1]) def test_background_motion() -> None: @@ -744,9 +760,16 @@ def test_motion_coordination_gi() -> None: assert len(motion._debug_data[geo_mover]) == 3 for i, data in zip([1, 3, 5], motion._debug_data[geo_mover]): sense, kinds, times, inters = data - loc = inters[0] - assert times[0] == geo_mover._state_histories["loc"][i][0] - assert abs(loc - geo_mover._state_histories["loc"][i][1]) <= 1e-12 + mover_at_time_0 = [ + x[1] for x in geo_mover._state_histories["loc"] if close(x[0], times[0]) + ] + mover_at_time_1 = [ + x[1] for x in geo_mover._state_histories["loc"] if close(x[0], times[1]) + ] + assert mover_at_time_0 + assert mover_at_time_1 + assert close(mover_at_time_0[0], inters[0]) + assert close(mover_at_time_1[0], inters[1]) def test_motion_setup_agi() -> None: @@ -847,9 +870,16 @@ def test_motion_coordination_agi() -> None: assert len(motion._debug_data[geo_mover]) == 3 for i, data in zip([1, 3, 5], motion._debug_data[geo_mover]): sense, kinds, times, inters = data - loc = inters[0] - assert times[0] == geo_mover._state_histories["loc"][i][0] - assert abs(loc - geo_mover._state_histories["loc"][i][1]) <= 0.5 # nm + mover_at_time_0 = [ + x[1] for x in geo_mover._state_histories["loc"] if close(x[0], times[0]) + ] + mover_at_time_1 = [ + x[1] for x in geo_mover._state_histories["loc"] if close(x[0], times[1]) + ] + assert mover_at_time_0 + assert mover_at_time_1 + assert abs(mover_at_time_0[0] - inters[0]) <= 0.5 # nmi + assert abs(mover_at_time_1[0] - inters[1]) <= 0.7 # nmi def test_analytical_intersection() -> None: diff --git a/src/upstage_des/test/test_stepped_motion.py b/src/upstage_des/test/test_stepped_motion.py index 43a8a75..c28469b 100644 --- a/src/upstage_des/test/test_stepped_motion.py +++ b/src/upstage_des/test/test_stepped_motion.py @@ -216,3 +216,7 @@ def test_detectability_change() -> None: assert move2.history[1][0] == 2.9 assert move2.history[2][0] == 3.1 assert pytest.approx(move2.history[2][0], abs=0.01) == 3.1 + + +if __name__ == "__main__": + test_basic_functions() From 88ce8b120e98edbfcb980c5c8dfc6280b204e3d0 Mon Sep 17 00:00:00 2001 From: James Arruda Date: Fri, 20 Dec 2024 22:32:07 -0500 Subject: [PATCH 4/4] New docs for new features. Updated other docs for new features. --- .../user_guide/how_tos/active_states.rst | 12 +- .../user_guide/how_tos/entity_naming.rst | 19 +- .../source/user_guide/how_tos/environment.rst | 53 +++++ .../user_guide/how_tos/random_numbers.rst | 12 +- docs/source/user_guide/index.md | 4 +- .../user_guide/tutorials/best_practices.rst | 78 +++++--- docs/source/user_guide/tutorials/data.rst | 186 ++++++++++++++++-- .../tutorials/data_creation_example.rst | 8 + .../user_guide/tutorials/first_simulation.rst | 147 +++++++++----- 9 files changed, 412 insertions(+), 107 deletions(-) create mode 100644 docs/source/user_guide/how_tos/environment.rst create mode 100644 docs/source/user_guide/tutorials/data_creation_example.rst diff --git a/docs/source/user_guide/how_tos/active_states.rst b/docs/source/user_guide/how_tos/active_states.rst index 2e551c8..58f8d4b 100644 --- a/docs/source/user_guide/how_tos/active_states.rst +++ b/docs/source/user_guide/how_tos/active_states.rst @@ -2,12 +2,15 @@ Active States ============= -Active States are an UPSTAGE feature where states are told how to update themselves when requested, while not having to modify or alter the timeout they are changing during. +Active States are an UPSTAGE feature where states are told how to update themselves when requested, +while not having to modify or alter the timeout they are changing during. -For example, a fuel depot may dispense fuel at a given rate for some amount of time. An employee may monitor that level at certain times. UPSTAGE allows the state to hold its own +For example, a fuel depot may dispense fuel at a given rate for some amount of time. +An employee may monitor that level at certain times. UPSTAGE allows the state to hold its own update logic, rather than the employee code needing to know when the fuel started changing, at what rate, etc. -Active states are stopped and started with :py:meth:`~upstage_des.actor.Actor.activate_state` and :py:meth:`~upstage_des.actor.Actor.deactivate_state`. +Active states are stopped and started with :py:meth:`~upstage_des.actor.Actor.activate_state` and +:py:meth:`~upstage_des.actor.Actor.deactivate_state`. Active states are automatically stopped when a Task is interrupted. @@ -248,4 +251,5 @@ Another option is to make a subclass that hints for you: >>> 220.0 -Note that state activation doesn't require a task. It's just the best place to do it, because task interrupts automatically deactivate all states. +Note that state activation doesn't require a task. It's just the best place to do it, +because task interrupts automatically deactivate all states. diff --git a/docs/source/user_guide/how_tos/entity_naming.rst b/docs/source/user_guide/how_tos/entity_naming.rst index 4cac60a..5e149ea 100644 --- a/docs/source/user_guide/how_tos/entity_naming.rst +++ b/docs/source/user_guide/how_tos/entity_naming.rst @@ -2,12 +2,18 @@ Named Entities ============== -Named entities are an :py:class:`~upstage_des.base.EnvironmentContext` and :py:class:`~upstage_des.base.NamedUpstageEntity` enabled feature where you can store instances in particular "entity groups" to gather -them from later. UPSTAGE's :py:class:`~upstage_des.actor.Actor` inherits from :py:class:`~upstage_des.base.NamedUpstageEntity`, giving all Actors the feature. +Named entities are an :py:class:`~upstage_des.base.EnvironmentContext` and +:py:class:`~upstage_des.base.NamedUpstageEntity` enabled feature where you +can store instances in particular "entity groups" to gather them from later. +UPSTAGE's :py:class:`~upstage_des.actor.Actor` inherits from :py:class:`~upstage_des.base.NamedUpstageEntity`, +giving all Actors the feature. Similarly, the ``SelfMonitoring<>`` resources +do the same to enable quick access to recorded simulation data. -All Actors are retrievable with the :py:meth:`~upstage_des.base.UpstageBase.get_actors` method if they inherit from Actor. +All Actors are retrievable with the :py:meth:`~upstage_des.base.UpstageBase.get_actors` +method if they inherit from Actor. -Entities are retrievable with :py:meth:`~upstage_des.base.UpstageBase.get_all_entity_groups` and :py:meth:`~upstage_des.base.UpstageBase.get_entity_group`. +Entities are retrievable with :py:meth:`~upstage_des.base.UpstageBase.get_all_entity_groups` +and :py:meth:`~upstage_des.base.UpstageBase.get_entity_group`. Defining a named entity is done in the class definition: @@ -29,7 +35,7 @@ Defining a named entity is done in the class definition: ... -Once you are in an environment context you get the actual instances. +Once you are in an environment context you can get the actual instances. .. code-block:: python @@ -59,5 +65,6 @@ Once you are in an environment context you get the actual instances. print(different) >>> [<__main__.Different object at 0x000001FFAB28BE10>] -Note that entity groups are inheritable, that you can inherit from ``NamedUpstageEntity`` and retrieve the instance without needing an Actor, and that it's simple to create an instance of +Note that entity groups are inheritable and that you can inherit from ``NamedUpstageEntity`` +and retrieve the instance without needing an Actor. You may also create an instance of ``UpstageBase`` to get access to the required methods. diff --git a/docs/source/user_guide/how_tos/environment.rst b/docs/source/user_guide/how_tos/environment.rst new file mode 100644 index 0000000..889c65e --- /dev/null +++ b/docs/source/user_guide/how_tos/environment.rst @@ -0,0 +1,53 @@ +=================== +Environment Context +=================== + +UPSTAGE uses Python's [context variable](https://docs.python.org/3/library/contextvars.html) +capabilities to safely manage "global" state information while not polluting the module +level data with run-specific information. + +The context manager accepts three arguments: + +1. Simulation start time (passes through to ``simpy.Environment``) +2. A random seed for ``random.Random`` +3. A random number generator object, if different than ``random.Random`` + +For more about the random numbers, see :doc:`Random Numbers `. + +.. note:: + + If you get a warning or error about not finding an environment, you have likely + tried to instantiate an actor, task, or other UPSTAGE object outside of an + environment context. + + +Creating Contexts +================= + +Use the ``EnvironmentContext`` context manager: + +.. code:: python + + impoprt upstage_des.api as UP + + with UP.EnvironmentContext() as env: + ... + # everything in here can find that environment + ... + env.run() + +Or, create a context at the current scope: + +.. code:: python + + from upstage_des.base import create_top_context, clear_top_context + + ctx = create_top_context() + env = ctx.env_ctx.get() + ... + env.run() + + clear_top_context(ctx) + +This way is friendlier to Jupyter notebooks, where you might run a simulation and want to +explore the data without needing to remain in the context manager. diff --git a/docs/source/user_guide/how_tos/random_numbers.rst b/docs/source/user_guide/how_tos/random_numbers.rst index e09483a..303f36f 100644 --- a/docs/source/user_guide/how_tos/random_numbers.rst +++ b/docs/source/user_guide/how_tos/random_numbers.rst @@ -4,9 +4,11 @@ Random Numbers Random numbers are not supplied by UPSTAGE, you are responsible for rolling dice on your own. -However, UPSTAGE does use them in one area, which is in :py:class:`~upstage_des.events.Wait`, in the :py:meth:`~upstage_des.events.Wait.from_random_uniform` method. +However, UPSTAGE does use them in one area, which is in :py:class:`~upstage_des.events.Wait`, +in the :py:meth:`~upstage_des.events.Wait.from_random_uniform` method. -The built-in python ``random`` module is used by default, and you can find it on ``stage.random``. It can be instantiated in a few ways: +The built-in python ``random`` module is used by default, and you can find it on +``stage.random``. It can be instantiated in a few ways: .. code-block:: python @@ -31,6 +33,8 @@ The built-in python ``random`` module is used by default, and you can find it on print(num) >>> 2.348057489610457 -If you want to use your own random number generator, just supply it to the ``random_gen`` input, or as its own variable with ``UP.add_stage_variable``. +If you want to use your own random number generator, just supply it to the ``random_gen`` +input, or as its own variable with ``UP.add_stage_variable``. -If you supply it as ``random_gen``, ensure that it has a ``uniform`` method so that the Wait event can use it. +If you supply it as ``random_gen``, ensure that it has a ``uniform`` method so that the +Wait event can use it. diff --git a/docs/source/user_guide/index.md b/docs/source/user_guide/index.md index 51f2e95..d26e9b3 100644 --- a/docs/source/user_guide/index.md +++ b/docs/source/user_guide/index.md @@ -25,7 +25,7 @@ tutorials/interrupts tutorials/rehearsal tutorials/best_practices tutorials/data -tutorials/simpy_compare.rst +tutorials/simpy_compare ``` It is also recommended that you familiarize yourself with how [SimPy runs by itself](https://simpy.readthedocs.io/en/latest/), since @@ -42,6 +42,7 @@ These are complete examples for some of the above tutorials. tutorials/first_sim_full.rst tutorials/rehearsal_sim.rst tutorials/complex_cashier.rst +tutorials/data_creation_example.rst ``` ## How-to Guides @@ -52,6 +53,7 @@ These pages detail the specific activities that can be accomplished using UPSTAG :caption: How-Tos :maxdepth: 1 +how_tos/environment.rst how_tos/resources.rst how_tos/resource_states.rst how_tos/active_states.rst diff --git a/docs/source/user_guide/tutorials/best_practices.rst b/docs/source/user_guide/tutorials/best_practices.rst index 8211621..4edb6a0 100644 --- a/docs/source/user_guide/tutorials/best_practices.rst +++ b/docs/source/user_guide/tutorials/best_practices.rst @@ -5,75 +5,91 @@ Best Practices Actors ====== -Use knowledge when you want to add built-in enforcement/overwrite checking. States don't have that by default, so you'd have to write more validation rules in tasks -rather than mostly business logic. +Use knowledge when you want to add built-in enforcement/overwrite checking. +States don't have that by default, so you'd have to write more validation +rules in tasks rather than mostly business logic. -There's built-in signature building for Actors based on the states, but it only works in the interpreter. +There's built-in signature building for Actors based on the states, but +it only works in the interpreter. Tasks ===== -Keep tasks as small as possible. This makes handling interrupts much easier. Use the Task Networks to compose smaller tasks, and use decision tasks to navigate the network. +Keep tasks as small as possible. This makes handling interrupts much easier. +Use the Task Networks to compose smaller tasks, and use decision tasks to navigate the network. -When doing interrupts, don't be afraid to throw exceptions everywhere. It's hard to predict what might cause an interrupt (depending), so always give yourself as much information -as you can. +When doing interrupts, don't be afraid to throw exceptions everywhere. +It's hard to predict what might cause an interrupt (depending), so always +give yourself as much information as you can. -Mixing nucleus and ``set_knowledge_event`` for task running might get confusing. Choose nucleus features for task networks that have multiple sources of interrupts. For simpler -holding events (waiting for a job to do, e.g.) that single entity will command to start, knowledge events are better. +Mixing nucleus and ``set_knowledge_event`` for task running might get confusing. +Choose nucleus features for task networks that have multiple sources of interrupts. For +simpler holding events (waiting for a job to do, e.g.) that single entity will command +to start, knowledge events are better. Testing ======= -Write tests for your individual tasks to make sure you see the expected changes. Use ``Task().run(actor=actor)`` in an EnvironmentContext to do that. +Write tests for your individual tasks to make sure you see the expected changes. Use +``Task().run(actor=actor)`` in an EnvironmentContext to do that. The more clearly defined your stores/interfaces are, the easier it is to test. Actor Interactions ================== -Interaction between different actors is sometimes easier to accomplish with a Task operated by a higher-level actor that waits for enough actors to say they are ready -(usually via a store). Then the higher-level actor can add knowledge, modify task queues, and send the actors on their way. +Interaction between different actors is sometimes easier to accomplish with a Task operated +by a higher-level actor that waits for enough actors to say they are ready +(usually via a store). Then the higher-level actor can add knowledge, modify task queues, +and send the actors on their way. -Even if the behavior being modeled would be decided mutually by the actors (no strict command hierarchy, e.g.), it can be much easier in DES to run that as a separate -process. +Even if the behavior being modeled would be decided mutually by the actors (no strict command +hierarchy, e.g.), it can be much easier in DES to run that as a separate process. -Yielding on a Get is nice for comms and commands, but that usually needs to be a separate task network with tasks that: +Yielding on a Get is nice for comms and commands, but that usually needs to be a separate task +network with tasks that: 1. Wait for the message 2. Get the message, decide what to do 3. Analyze the actor's current state 4. Interrupt and recommand as needed -There are edge cases when you re-command a Task Network, but the re-command/interrupt is sorted later in the event queue (even with zero-time waits). To mitigate this -problem, put very small (but non-zero) waits after a message is received to give some time for the new task networks to change, so they are ready for new interrupts if -a message immediately follows another. - +There are edge cases when you re-command a Task Network, but the re-command/interrupt is sorted +later in the event queue (even with zero-time waits). To mitigate this problem, put very small +(but non-zero) waits after a message is received to give some time for the new task networks to +change, so they are ready for new interrupts if a message immediately follows another. Geography ========= -The ``GeodeticLocationChangingState`` isn't perfectly accurate when it reaches its destination. Floating point errors and the like will make it be slightly off the destination. +The ``GeodeticLocationChangingState`` isn't perfectly accurate when it reaches its destination. +Floating point errors and the like will make it be slightly off the destination. -THe amount of difference will be very small, and practically shouldn't matter in most cases. Be aware of this, and set locations explicitly after deactivating them if you need the -precision. +THe amount of difference will be very small, and practically shouldn't matter in most cases. +Be aware of this, and set locations explicitly after deactivating them if you need the precision. Simulation Determinism ====================== -While Python 3.10+ generally guarantee that all dictionaries act in an insertion-ordered manner, that order might change from run to run, even if the random seed is the same. -If your simulations are not deterministic even with a controlled random seed, it is likely due to lack of determinism in dictionary access or sorting. +While Python 3.10+ generally guarantee that all dictionaries act in an insertion-ordered manner, +that order might change from run to run, even if the random seed is the same. If your simulations +are not deterministic even with a controlled random seed, it is likely due to lack of determinism +in dictionary access or sorting. -To mitigate this, you'll need to implement some kind of sorting or hashing that is dependent on something that isn't based on ``id``. +To mitigate this, you'll need to implement some kind of sorting or hashing that is dependent on +something that isn't based on ``id``. -This issue arises frequently in management logic, where actors are selected from lists or dictionaries to perform some task. +This issue arises frequently in management logic, where actors are selected from lists or dictionaries +to perform some task. Rehearsal ========= -When testing for ``PLANNING_FACTOR_OBJECT``, do so in a method on the task that streamlines the business logic of the main task. For example: +When testing for ``PLANNING_FACTOR_OBJECT``, do so in a method on the task that streamlines the +business logic of the main task. For example: .. code-block:: python @@ -90,9 +106,11 @@ When testing for ``PLANNING_FACTOR_OBJECT``, do so in a method on the task that time = self._get_time(item) yield UP.Wait(time) -Rehearsals can get very complicated, and tasks that have lots of process interaction expectations may not rehearse well. Rehearsal is best done for -simpler, streamlined tasks. Make sure there is a clear code path for rehearsing, and following the advice in the Tasks section of this page will go +Rehearsals can get very complicated, and tasks that have lots of process interaction expectations +may not rehearse well. Rehearsal is best done for simpler, streamlined tasks. Make sure there +is a clear code path for rehearsing, and following the advice in the Tasks section of this page will go a long way to making rehearsals better. -Rehearsal currently only works for one Actor at a time, and while the Actor is clone-able without affecting the rest of the sim, the ``stage`` is not cloned. -If a task references ``stage``, or looks to other actors, events, stores, etc. the rehearsal may cause side-effects in the actual sim. +Rehearsal currently only works for one Actor at a time, and while the Actor is clone-able without +affecting the rest of the sim, the ``stage`` is not cloned. If a task references ``stage``, or +looks to other actors, events, stores, etc. the rehearsal may cause side-effects in the actual sim. diff --git a/docs/source/user_guide/tutorials/data.rst b/docs/source/user_guide/tutorials/data.rst index 08a437b..fe80204 100644 --- a/docs/source/user_guide/tutorials/data.rst +++ b/docs/source/user_guide/tutorials/data.rst @@ -1,22 +1,29 @@ -=============== -Simulation Data -=============== +======================================== +Simulation Data Gathering and Processing +======================================== UPSTAGE has three features for data recording: -1. Use `Actor.log()` to log a string message at a given time. +1. Use ``Actor.log()`` to log a string message at a given time. * Note that on Actor creation, ``debug_log=True`` must be given. 2. Use ``a_state = UP.State(recording=True)``. * Access the data with ``actor._state_histories["a_state"]`` + * The data will be in the form ``tuple[time, value]`` + * For :doc:`ActiveStates `, the ``value`` may be + a special ``Enum`` saying if the state is being activated, deactivated, + or is active/inactive. 3. Use a ``SelfMonitoring<>`` Store or Container. * Access the data with ``a_store._quantities`` + * The data will be in the form ``tuple[time, value]`` + +UPSTAGE also has utility methods for pulling all of the available data into a +tabular format, along with providing column headers. -Each feature has a list of tuples of (time, value) pairs. Actor Logging ============= @@ -73,9 +80,9 @@ On a per-actor level, you can set ``debug_log_time`` as well, and that value wil State Recording =============== -Nearly every state is recordable in UPSTAGE. The :py:class:`~upstage_des.states.ResourceState` is an exception covered -in the next section. To enable state recording, set ``recording=True``. After running the sim, use the ``_state_histories`` -attribute on the actor to get the data. +Nearly every state is recordable in UPSTAGE. The :py:class:`~upstage_des.states.ResourceState` +is an exception covered in the next section. To enable state recording, set ``recording=True``. +After running the sim, use the ``_state_histories`` attribute on the actor to get the data. .. code:: python @@ -95,7 +102,8 @@ attribute on the actor to get the data. print(cash._state_histories["items_scanned"]) >>> [(0.0, 0), (0.0, 1), (1.0, 3), (2.0, 4), (3.0, -1)] -That returns a list of (time, value) tuples. This works for simple data types, but not mutable types: +That returns a list of (time, value) tuples. This works for simple data types, +but not mutable types: .. code:: python @@ -120,8 +128,8 @@ Note that the string State of ``people_seen`` acts as a way to record data, even the moment the name of the last scanned person. This lets states behave as carriers of current or past information, depending on your needs. -The ``items`` value doesn't record, because the state doesn't see ``cash.items = ...``. For objects like that, -you should: +The ``items`` value doesn't record, because the state doesn't see ``cash.items = ...``. +For objects like that, you should: .. code:: python @@ -156,6 +164,59 @@ State recording of the built-in geographic states (cartesian and geodetic) is co with the data objects. This for both the active state versions and the typical ``UP.State[CartesianLocation]()`` ways of creating the state. +It's recommended, since UPSTAGE does not store much data about the motion of geographic states, to poll or ensure you +get the state value whenever you want to know where it is. While activating and deactivating will record the value, +if an actor is moving along waypoints, each waypoint doesn't record itself unless asked. + +Active State Recording +====================== + +Active states record in the same way, but extra information is given to tell the user if the state +was activated or not and if it was switching to/from active or inactive. + +The state history will still be ``(time, value)`` pairs, but on activation and deactivation an ``Enum`` +value is placed in the history to indicated which has taken place. The state value isn't recorded in +that row of the history because it will have been calculated immediately prior and recorded. + +.. code:: python + + class Cashier(UP.Actor): + time_worked = UP.LinearChangingState(default=0.0, recording=True) + + with UP.EnvironmentContext() as env: + cash = Cashier(name="Ertha") + + cash.activate_linear_state( + state="time_worked", + rate=1.0, + task=None, # this is fine to do outside of a task. + ) + + env.run(until=1) + cash.time_worked + env.run(until=3) + cash.time_worked + cash.deactivate_state(state="time_worked", task=None) + env.run(until=4) + cash.time_worked = 5.0 + + print(cash._state_histories["time_worked"]) + >>> [ + (0.0, 0.0), + (0.0, ), + (1.0, 1.0), + (3.0, 3.0), + (3.0, ), + (4.0, 5.0), + ] + +The built-in data gathering will account for this for you, but if you are manually processing +the active state histories, the (de)activation signal in the history should always come +after a recording at the same time value. + +Remember that if you never ask for the value of ``time_worked``, it will only report it on +activation and deactivation. + Resource Recording ================== @@ -231,3 +292,106 @@ Or use the actor init to pass the item function: name = "Lane 2", belt = {"item_func":lambda x: Counter(x)}, ) + + +Data Gathering +============== + +There are two functions for gathering data from UPSTAGE: + +1. :py:func:`upstage_des.data_utils.create_table` + + * Finds all actors and their recording states + * Finds all ``SelfMonitoring<>`` resources that are not attached + to actors. + * Ignores location states by default + * Reports actor name, actor type, state name, state value, and + if the state has an active status. + * If ``skip_locations`` is set to ``False``, then location objects + will go into the state value column. + * Data are in long-form, meaning rows may share a timestamp. + +2. :py:func:`upstage_des.data_utils.create_location_table` + + * Finds all location states on Actors + * Reports location data as individual columns for the dimensions + of the location (XYZ or LLA). + * Reports on active/inactive state data. + * Data are not completely in long-form. XYZ are on a single row, but + rows can have the same timestamp if they are different states. + +Using the example in :doc:`Data Gathering Example `, the +following table (a partial amount shown) would be obtained from the ``create_table`` function: + +.. table:: + + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Entity Name| Entity Type | State Name |Time|Value|Activation Status| + +===========+=========================+=============+====+=====+=================+ + |Ertha |Cashier |items_scanned| 0| 0.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Ertha |Cashier |items_scanned| 3| -1.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Ertha |Cashier |cue | 3| 1.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Ertha |Cashier |cue2 | 3| 11.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Ertha |Cashier |time_working | 3| 2.9|active | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Bertha |Cashier |cue | 0| 0.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Bertha |Cashier |cue2 | 0| 0.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Bertha |Cashier |time_working | 0| 0.0|inactive | + +-----------+-------------------------+-------------+----+-----+-----------------+ + |Store Test |SelfMonitoringFilterStore|Resource | 0| 0.0| | + +-----------+-------------------------+-------------+----+-----+-----------------+ + +The location table will look like the following table. Now how the active states can be +"activating", "active", or "deactivating". Not shown is the "inactive" value, which +is used for when an active state value is changed, but not because it has been set +to change automatically. + +.. table:: + + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Entity Name |Entity Type| State Name |Time| X | Y |Z|Activation Status| + +============+===========+============+====+=======+=======+=+=================+ + |Wobbly Wheel|Cart |location | 0| 1.0000| 1.0000|0|activating | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Wobbly Wheel|Cart |location | 1| 2.5364| 2.2803|0|active | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Wobbly Wheel|Cart |location | 2| 4.0728| 3.5607|0|active | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Wobbly Wheel|Cart |location | 3| 5.6093| 4.8411|0|deactivating | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Wobbly Wheel|Cart |location_two| 0| 1.0000| 1.0000|0|activating | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Wobbly Wheel|Cart |location_two| 1|-0.5051|-0.3170|0|active | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + |Wobbly Wheel|Cart |location_two| 3|-3.5154|-2.9510|0|deactivating | + +------------+-----------+------------+----+-------+-------+-+-----------------+ + +If you were to have ``pandas`` installed, a dataframe could be created with: + +.. code:: python + + import pandas as pd + import upstage_des.api as UP + from upstage_des.data_utils import create_table + + with UP.EnvironmentContext() as env: + ... + env.run() + + table, header = create_table() + df = pd.DataFrame(table, columns=header) + +.. note:: + + The table creation methods must be called within the context, but + the resulting data does not need to stay in the context. + + The exception is that if a state has a value that uses the environment + or the stage, you may see a warning if you try to access attributes or + methods on that object. diff --git a/docs/source/user_guide/tutorials/data_creation_example.rst b/docs/source/user_guide/tutorials/data_creation_example.rst new file mode 100644 index 0000000..d45c514 --- /dev/null +++ b/docs/source/user_guide/tutorials/data_creation_example.rst @@ -0,0 +1,8 @@ +================================== +Data Gathering Example Full Source +================================== + +.. literalinclude:: ../../../../src/upstage_des/test/test_data_reporting.py + + +This file is auto-generated. diff --git a/docs/source/user_guide/tutorials/first_simulation.rst b/docs/source/user_guide/tutorials/first_simulation.rst index 001ca0c..036fb96 100644 --- a/docs/source/user_guide/tutorials/first_simulation.rst +++ b/docs/source/user_guide/tutorials/first_simulation.rst @@ -3,14 +3,16 @@ First UPSTAGE Simulation ======================== .. include:: ../../class_refs.txt -This simulation will demonstrate the primary features of UPSTAGE in a very simple scenario. The goal is demonstrate not just the core UPSTAGE features, but the +This simulation will demonstrate the primary features of UPSTAGE in a very +simple scenario. The goal is demonstrate not just the core UPSTAGE features, but the interaction of UPSTAGE with SimPy. -------- Scenario -------- -A single cashier works at grocery store. They go to the checkout line, scan groceries, take breaks, and come back to the line. +A single cashier works at grocery store. They go to the checkout line, +scan groceries, take breaks, and come back to the line. The code for the full example can be :doc:`found here `. @@ -32,9 +34,11 @@ We prefer this syntax for importing UPSTAGE and SimPy: Define an Actor with State -------------------------- -An UPSTAGE Actor is a container for State, along with methods for modifying the states, for changing tasks, and recording data. +An UPSTAGE Actor is a container for State, along with methods for modifying the states, +for changing tasks, and recording data. -Let's imagine our Cashier has the ability to scan items at a certain speed, and some time until they get a break. We begin by subclassing |Actor| and including two |State| class variables: +Let's imagine our Cashier has the ability to scan items at a certain speed, and some +time until they get a break. We begin by subclassing |Actor| and including two |State| class variables: .. code-block:: python @@ -52,10 +56,12 @@ Let's imagine our Cashier has the ability to scan items at a certain speed, and ) -Our Cashier is very simple, it contains two states that are primarily data containers for attributes of the cashier. This is typical for an UPSTAGE Actor. +Our Cashier is very simple, it contains two states that are primarily data containers +for attributes of the cashier. This is typical for an UPSTAGE Actor. -The ``scan_speed`` state is defined to require a ``float`` type (UPSTAGE will throw an error otherwise), and is ``frozen``, meaning that it cannot be changed once defined. The ``time_until_break`` -state is similar, except that a default value of 120 minutes is supplied. +The ``scan_speed`` state is defined to require a ``float`` type (UPSTAGE will throw +an error otherwise), and is ``frozen``, meaning that it cannot be changed once defined. +The ``time_until_break`` state is similar, except that a default value of 120 minutes is supplied. .. note:: There is no explicit time dimension in upstage_des. The clock units are up to the user, @@ -74,13 +80,17 @@ Then you will later instantiate a cashier with [#f1]_: debug_log=True, ) -Note that the `name` attribute is required for all UPSTAGE Actors. Also, all inputs are keyword-argument only for an Actor. The ``debug_log`` input is ``False`` by default, -and when ``True``, you can call ``cashier.log()`` to retrieve an UPSTAGE-generated log of what the actor has been doing. The same method, when -given a string, will record the message into the log, along with the default logging that UPSTAGE does. +Note that the `name` attribute is required for all UPSTAGE Actors. Also, all inputs are +keyword-argument only for an Actor. The ``debug_log`` input is ``False`` by default, +and when ``True``, you can call ``cashier.log()`` to retrieve an UPSTAGE-generated log +of what the actor has been doing. The same method, when given a string, will record +the message into the log, along with the default logging that UPSTAGE does. -States are just Python descriptors, so you may access them the same as you would any instance attribute: ``cashier.scan_speed```, e.g. +States are just Python descriptors, so you may access them the same as you would any +instance attribute: ``cashier.scan_speed```, e.g. -We want to keep track of the number of items scanned, so let's add a state that records the time at which items are scanned. +We want to keep track of the number of items scanned, so let's add a state that records +the time at which items are scanned. .. code-block:: python @@ -108,7 +118,8 @@ We want to keep track of the number of items scanned, so let's add a state that ) -Note that the keyword-argument ``recording`` has been set to ``True``. Now, whenever that state is modified, the time and value will be recorded. +Note that the keyword-argument ``recording`` has been set to ``True``. Now, +whenever that state is modified, the time and value will be recorded. .. code-block:: python @@ -123,13 +134,18 @@ Note that the keyword-argument ``recording`` has been set to ``True``. Now, when >>> [(0.0, 1), (1.2, 4)] -UPSTAGE creates the recording attribute on the instance with ``__history`` to store the tuples of ``(time, value)`` for the state on all recorded states. This is compatible with +UPSTAGE creates the recording attribute on the instance with ``_state_histories[]`` +to store the tuples of ``(time, value)`` for the state on all recorded states. This is compatible with all states, including Locations, Resources, and states that are lists, tuples, or dicts (UPSTAGE makes deep copies). -Note that now we have created a SimPy ``Environment`` in ``env`` using the |EnvironmentContext| context manager. This gives Actor instances access to the simulation clock (``env.now``). The -environment context and features will be covered more in depth later. +For more information on UPSTAGE's data recording, see :doc:`/user_guide/tutorials/data` -When we run the environment forward and change the ``items_scanned`` state, the value is recorded at the current simulation time. +Note that now we have created a SimPy ``Environment`` in ``env`` using the |EnvironmentContext| +context manager. This gives Actor instances access to the simulation clock (``env.now``). The +environment context and features are covered :doc:`here `. + +When we run the environment forward and change the ``items_scanned`` state, the value is +recorded at the current simulation time. Let's also make an Actor for the checkout lane, so we have a simple location to store customer queueing: @@ -147,12 +163,17 @@ Let's also make an Actor for the checkout lane, so we have a simple location to } ) -Here we use the built-in |ResourceState| to use a |SelfMonitoringStore| as an Actor state. The self-monitoring store is a subclass of the SimPy ``Store`` that records the number of items -in the store whenever there is a get or put. The ``ResourceState`` could accept a default and not require a definition in the instantiation, but here we are demonstrating how to instantiate -a ``ResourceState`` in a way that lets you parameterize the store's values (in this case, the kind and the capacity). Other resources, such as containers, will have capacities and initial values. +Here we use the built-in |ResourceState| to use a |SelfMonitoringStore| as an Actor state. The +self-monitoring store is a subclass of the SimPy ``Store`` that records the number of items +in the store whenever there is a get or put. The ``ResourceState`` could accept a default and +not require a definition in the instantiation, but here we are demonstrating how to instantiate +a ``ResourceState`` in a way that lets you parameterize the store's values (in this case, the +kind and the capacity). Other resources, such as containers, will have capacities and initial values. -Actors also have ``knowledge``, which is a simple dictionary attached to the actor that has an interface through the actor and tasks. This allows actors to hold runtime-dependent information -that isn't tied to a state. Knowledge can be set and accessed with error-throwing checks for its existence, or for checks that it doesn't already have a value. An example is given later. +Actors also have ``knowledge``, which is a simple dictionary attached to the actor that has an +interface through the actor and tasks. This allows actors to hold runtime-dependent information +that isn't tied to a state. Knowledge can be set and accessed with error-throwing checks for +its existence, or for checks that it doesn't already have a value. An example is given later. ---------------------------- Define Tasks for the Cashier @@ -234,23 +255,28 @@ Let's step through the task definitions line-by-line. * Line 5: Task subclasses must implement ``task`` that takes a single keyword argument: ``actor``. -* Line 7-11: Assume the cashier has some "knowledge" about the checkout lane they are going to (the store manager will give this to them). +* Line 7-11: Assume the cashier has some "knowledge" about the checkout lane + they are going to (the store manager will give this to them). * The knowledge has the name "checkout_lane", and we assume it must exist, or else throw an error. -* Line 12: Create a ``Get`` event that waits to get a customer from the lane's ResourceState. Note that we aren't yielding on this event yet. +* Line 12: Create a ``Get`` event that waits to get a customer from the lane's ResourceState. + Note that we aren't yielding on this event yet. * Line 14-18: Get information about the actor's break time. - * We could use ``actor.get_knowledge``, but using the task's method puts extra information into the actor's log, if you have it enabled. + * We could use ``actor.get_knowledge``, but using the task's method puts extra information + into the actor's log, if you have it enabled. -* Line 19-21: Get the time left in the sim until it's a break, and create a simple ``Wait`` event to succeed at that time. +* Line 19-21: Get the time left in the sim until it's a break, and create a simple ``Wait`` + event to succeed at that time. * Line 23: Yield an ``Any`` event, which succeeds when the first of its sub-events succeeds. * Line 25: Test if the customer event succeeded first with the ``Event`` method ``is_complete``. -* Line 26-27: If it did succeed, call ``get_value`` on the ``Get`` event to get customer information and add it to our knowledge. +* Line 26-27: If it did succeed, call ``get_value`` on the ``Get`` event to get customer information + and add it to our knowledge. * Here we just treat the customer information as an integer number of items. It could be anything. @@ -268,7 +294,8 @@ Let's step through the task definitions line-by-line. * Line 37-41: Retrieve the knowledge we set in the previous task. - * Notice how knowledge lets us be flexible about what our Actors can do, and how ``must_exist`` will help us ensure our tasks are doing the right thing. + * Notice how knowledge lets us be flexible about what our Actors can do, and how ``must_exist`` will + help us ensure our tasks are doing the right thing. * Line 43-47: Activate a linear changing state, which increases its value according to ``rate`` as the simulation runs. @@ -280,9 +307,11 @@ Let's step through the task definitions line-by-line. * Line 53: Assume some follow-on wait for customer payment. -This is the foundation of how UPSTAGE manages behaviors. The simulation designer creates ``Tasks`` that can be chained together to perform actions, modify data, and make decisions. +This is the foundation of how UPSTAGE manages behaviors. The simulation designer creates ``Tasks`` that +can be chained together to perform actions, modify data, and make decisions. -There is one other kind of Task, a |DecisionTask|, which does not consume the environment clock, and will not yield any events [#f2]_. +There is one other kind of Task, a |DecisionTask|, which does not consume the environment clock, +and will not yield any events [#f2]_. .. code-block:: python @@ -298,16 +327,19 @@ There is one other kind of Task, a |DecisionTask|, which does not consume the en self.set_actor_task_queue(actor, ["ShortBreak"]) -That task has the ``make_decision`` method that needs to be sublcassed. The purpose of a `DecisionTask` is to set and clear actor knowledge, and modify the task queue without consuming the clock. -It has additional benefits for rehearsal, which will be covered later. +That task has the ``make_decision`` method that needs to be sublcassed. The purpose of a +`DecisionTask` is to set and clear actor knowledge, and modify the task queue without +consuming the clock. It has additional benefits for rehearsal, which will be covered later. A note on UPSTAGE Events ------------------------ -UPSTAGE Events are custom wrappers around SimPy events that allow for accessing data about that event, handling the ``Task`` internal event loop, and for rehearsal. +UPSTAGE Events are custom wrappers around SimPy events that allow for accessing data about +that event, handling the ``Task`` internal event loop, and for rehearsal. -All ``Task`` s should yield UPSTAGE events, with one exception. A SimPy ``Process`` can be yielded out as well, but this will warn the user, and is generally not recommended. +All ``Task`` s should yield UPSTAGE events, with one exception. A SimPy ``Process`` can be +yielded out as well, but this will warn the user, and is generally not recommended. The event types are: @@ -334,7 +366,8 @@ The event types are: Define a TaskNetwork for the Cashier ------------------------------------ -The flow of Tasks is controlled by a TaskNetwork, and the setting of the queue within tasks. A Task Network is defined by the nodes and the links: +The flow of Tasks is controlled by a TaskNetwork, and the setting of the queue within +tasks. A Task Network is defined by the nodes and the links: .. code-block:: python @@ -364,13 +397,17 @@ The flow of Tasks is controlled by a TaskNetwork, and the setting of the queue w task_links=task_links, ) -The task classes are given names, and those strings are used to define the default and allowable task ordering. The task ordering need to know the default task (can be None) and the allowed tasks. -Allowed tasks must be supplied. If no default is given, an error will be thrown if no task ordering is given when a new task is selected. If the default or the set task queue violates the -allowed rule, an error will be thrown. +The task classes are given names, and those strings are used to define the default and +allowable task ordering. The task ordering need to know the default task (can be None) +and the allowed tasks. Allowed tasks must be supplied. If no default is given, an error +will be thrown if no task ordering is given when a new task is selected. If the default +or the set task queue violates the allowed rule, an error will be thrown. -The task network forms the backbone of flexible behavior definitions, while a ``DecisionTask`` helps control the path through the network. +The task network forms the backbone of flexible behavior definitions, while a ``DecisionTask`` +helps control the path through the network. -The ``cashier_task_network`` is a factory that creates network instances from the definition that actors can use (one per actor/per network). +The ``cashier_task_network`` is a factory that creates network instances from the definition +that actors can use (one per actor/per network). To start a task network on an actor with the factory: @@ -393,24 +430,30 @@ You can either start a loop on a single task, or define an initial queue through A note on TaskNetworkFactory ---------------------------- -The :py:class:`~upstage_des.task_network.TaskNetworkFactory` class has some convience methods for creating factories from typical use cases: - -#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_looping`: From a single task, make a network that loops itself. +The :py:class:`~upstage_des.task_network.TaskNetworkFactory` class has some convience methods +for creating factories from typical use cases: - * Useful for a Singleton task that, for example, receives communications and farms them out or manages other task networks. +#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_looping`: From a single + task, make a network that loops itself. -#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_terminating`: A network that does one task, then freezes for the rest of the simulation. + * Useful for a Singleton task that, for example, receives communications and farms them out + or manages other task networks. -#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_ordered_looping`: A series of tasks with no branching that loops. +#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_terminating`: A network + that does one task, then freezes for the rest of the simulation. -#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_looping`: A series of tasks with no branching that terminates at the end. +#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_ordered_looping`: A series of + tasks with no branching that loops. +#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_looping`: A series of tasks + with no branching that terminates at the end. -------------------- Setting up Customers -------------------- -To complete the simulation, we need to make customers arrive at the checkout lanes. This can be done using a standard SimPy process: +To complete the simulation, we need to make customers arrive at the checkout lanes. This can +be done using a standard SimPy process: .. code-block:: python @@ -518,7 +561,7 @@ Since only one cashier is assigned, you can examine the backlog on the lanes (an >>> (1136.5736387094469, 8), >>> (1188.3694502822516, 9)] - print(cashier._items_scanned_history) + print(cashier._state_histories["items_scanned"]) >>> ... >>> (683.5134932373091, 15), >>> (683.6134932373092, 16), @@ -532,9 +575,11 @@ Since only one cashier is assigned, you can examine the backlog on the lanes (an >>> ... -Your run may be different, due to the calls to ``stage.random`` (a passthrough for ``random.Random()``). See :doc:`Random Numbers ` for more. +Your run may be different, due to the calls to ``stage.random`` (a passthrough for ``random.Random()``). +See :doc:`Random Numbers ` for more. -Notice how lane 1 takes customers right away, but lane 2 stacks up. Also notice how the ``SelfMonitoringStore`` creates the ``._quantities`` datatype that shows the time history of number of +Notice how lane 1 takes customers right away, but lane 2 stacks up. Also notice how the +``SelfMonitoringStore`` creates the ``._quantities`` datatype that shows the time history of number of items in the store. If it was a Container, instead of a Store, it would record the level. .. [#f1] You can run this now and ignore the warning about an environment.