Skip to content

Commit

Permalink
Update main to release version 0.3.0 (#46)
Browse files Browse the repository at this point in the history
* Docs for data gathering. Updates for usability an clarity.

* Updating NamedEntity and Monitoring resources to assist data gathering.

* Data gathering updates. Changes to context, active state, monitoring stores. Added data gathering functions.

* New docs for new features. Updated other docs for new features.

* Making states init to default right away to correct data gathering bugs. (#23)

* Location data object improvements. (#24)

* #26 - Added None as allowable default to states. (#31)

* 30 knowledge utilities (#32)

* #29 - Documentation for yielding processes added.

* Data recording help for key:value-like states.

* Matching Wait/Get/Put re-yield to simpy's behavior  (#42)

* Non-recording state output in data reports. (#43)

* Zero time decision task upgrades and docs. (#44)

* Bump prefix-dev/setup-pixi from 0.8.1 to 0.8.3 in the dependencies group (#39)

* Updated pixi and envs

* Version bump to 0.3.0 (#45)
  • Loading branch information
JamesArruda authored Mar 6, 2025
1 parent fed5051 commit 40f1e92
Show file tree
Hide file tree
Showing 24 changed files with 3,222 additions and 1,779 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/[email protected].1
- uses: prefix-dev/[email protected].3
with:
manifest-path: pixi.toml
pixi-version: v0.39.0
pixi-version: v0.41.0
cache: true
- run: pixi run -e py312 build_html_docs
- name: Deploy to GitHub Pages
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ jobs:
environment: [py312]
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/[email protected].1
- uses: prefix-dev/[email protected].3
with:
environments: ${{ matrix.environment }}
manifest-path: pixi.toml
pixi-version: v0.39.0
pixi-version: v0.41.0
cache: true
- run: pixi run -e ${{ matrix.environment }} lint-check

Expand All @@ -34,10 +34,10 @@ jobs:
environment: [py311, py312, py313]
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/[email protected].1
- uses: prefix-dev/[email protected].3
with:
environments: ${{ matrix.environment }}
manifest-path: pixi.toml
pixi-version: v0.39.0
pixi-version: v0.41.0
cache: true
- run: pixi run -e ${{ matrix.environment }} test
127 changes: 123 additions & 4 deletions docs/source/user_guide/how_tos/decision_tasks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
Decision Tasks
==============

Decision tasks are :py:class:`~upstage_des.task.Task`s that take zero time and were briefly demonstrated in :doc:`Rehearsal </tutorials/rehearsal>`. The purpose of a
Decision task is to allow decision making and :py:class:`~upstage_des.task_networks.TaskNetwork` routing without moving the simulation clock and do so inside of a Task Network.
Decision tasks are :py:class:`~upstage_des.task.Task` s that take zero time and were briefly demonstrated in
:doc:`Rehearsal </user_guide/tutorials/rehearsal>`. The purpose of a Decision task is to allow decision making and
:py:class:`~upstage_des.task_networks.TaskNetwork` routing without moving the simulation clock and do so
inside of a Task Network.

A decision task must implement two methods:

Expand All @@ -16,5 +18,122 @@ Neither method outputs anything. The expectation is that inside these methods yo
* :py:meth:`upstage_des.actor.Actor.set_task_queue`: Add tasks to an empty queue (by string name) - you must empty the queue first.
* :py:meth:`upstage_des.actor.Actor.set_knowledge`: Modify knowledge

The difference between making and rehearsing the decision is covered in the tutorial. The former method is called during normal operations of UPSTAGE, and the latter is called during a
rehearsal of the task or network. It is up the user to ensure that no side-effects occur during the rehearsal that would touch non-rehearsing state, actors, or other data.
The difference between making and rehearsing the decision is covered in the tutorial. The former method
is called during normal operations of UPSTAGE, and the latter is called during a
rehearsal of the task or network. It is up the user to ensure that no side-effects occur during the
rehearsal that would touch non-rehearsing state, actors, or other data.

There is one class variable that can be set on each subclass of the decision task, which is ``DO_NOT_HOLD``:

.. code-block:: python
class Thinker(UP.DecisionTask):
DO_NOT_HOLD = True # default is False
def make_decision(): ...
This feature lets the user turn off zero time holding on decision tasks, which causes decision
tasks to move right into the next task without allowing anything else to run. For examples and
reasoning, see the following section.

This feature is most applicable for avoiding race conditions in decision making where a
follow-on task can alter the simulation with its first yield such that other decisions
make would become incorrect [#f1]_. This would only occur for equally-timed decision processes,
and only for the first yield in a Task that follows the decision. For example, deciding
which ``Store`` to queue on may result in an Actor waiting when no wait was expected.

Zero Time Considerations
------------------------

Decision tasks are meant to not advance the clock, but they do cause a zero-time timeout to be
created. This is done to provide other events in the queue the chance to complete at the same
time step before the task network proceeds for the current actor.

Here is a short example of the default behavior:

.. code-block:: python
import upstage_des.api as UP
class Waiter(UP.Task):
def task(self, *, actor):
print(f"{self.env.now:.1f} >> {actor.name} in Waiter")
yield UP.Wait(1.0)
class Runner(UP.Task):
def task(self, *, actor):
print(f"{self.env.now:.1f} >> {actor.name} in Runner")
yield UP.Wait(2.0)
class Thinker(UP.DecisionTask):
def make_decision(self, *, actor):
print(f"{self.env.now:.1f} >> {actor.name} in Thinker")
if "one" in actor.name:
self.set_actor_task_queue(actor, ["Waiter"])
else:
self.set_actor_task_queue(actor, ["Runner"])
net = UP.TaskNetworkFactory(
name="Example Net",
task_classes={"Waiter": Waiter, "Runner":Runner, "Thinker":Thinker},
task_links={
"Waiter":UP.TaskLinks(default="Thinker", allowed=["Thinker"]),
"Thinker":UP.TaskLinks(default="", allowed=["Waiter", "Runner"]),
"Runner":UP.TaskLinks(default="Thinker", allowed=["Thinker"]),
},
)
with UP.EnvironmentContext() as env:
a = UP.Actor(name="Actor one", debug_log=True)
b = UP.Actor(name="Actor two", debug_log=True)
for actor in [a,b]:
n = net.make_network()
actor.add_task_network(n)
actor.start_network_loop(n.name, "Waiter")
env.run(until=2)
The result is:

.. code-block:: python
>>> 0.0 >> Actor one in Waiter
>>> 0.0 >> Actor two in Waiter
>>> 1.0 >> Actor one in Thinker
>>> 1.0 >> Actor two in Thinker
>>> 1.0 >> Actor one in Waiter
>>> 1.0 >> Actor two in Runner
Even though ``Actor one`` gets to the decision task first, the internal timeout
preserves ordering of the stops. This would happen even if there was no timeout,
because UPSTAGE yields on the decision task as a simpy process.

If we were to skip yielding on the process of a ``DecisionTask``, then this ordering
of output would result:

.. code-block:: python
...
# The only modification is to add DO_NOT_HOLD = True
class Thinker(UP.DecisionTask):
DO_NOT_HOLD = True
def make_decision(self, *, actor):
...
>>> 0.0 >> Actor one in Waiter
>>> 0.0 >> Actor two in Waiter
>>> 1.0 >> Actor one in Thinker
>>> 1.0 >> Actor one in Waiter
>>> 1.0 >> Actor two in Thinker
>>> 1.0 >> Actor two in Runner
Note that ``Actor one`` starts the ``Waiter`` task (and stops at the first yield inside)
before ``Actor two`` gets to its decision task.

Turning off the hold using ``DO_NOT_HOLD = True`` gives a guarantee to ``Actor two`` that
the simulation they see in ``Thinker`` is what they will encounter in the first yield in
``Runner``.


.. [#f1] Needing this feature may be a code smell, depending on the situation. Take care
to check that other ways of deciding and queueing might be better suited.
3 changes: 3 additions & 0 deletions docs/source/user_guide/how_tos/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ One use case is the knowledge event, which enables a way to publish and event to
subordinate: UP.Actor = actor.subordinates[0]
subordinate.succeed_knowledge_event(name="pause", some_data={...})
The Event also has a "payload", which is created from keyword arguments to the :py:meth:`~upstage_des.evetns.Event.succeed` method.
The payload can be retrieved using :py:meth:`~upstage_des.evetns.Event.get_payload`.


:py:class:`~upstage_des.events.Wait`
------------------------------------
Expand Down
133 changes: 133 additions & 0 deletions docs/source/user_guide/how_tos/knowledge.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
=========
Knowledge
=========

Knowledge is a property of an :py:meth:`~upstage_des.actor.Actor` that is intended to be a
temporary space for storing information about the Actor's goals or perception. While many
actions that use knowledge could be accomplished with :doc:`States <states>`, knowledge is
created separately to include other checks and debug logging support.

While you can use knowledge for anything you want, a typical pattern is to use knowledge to support task
network flow. A knowledge entry could be a list of activities to do. A :py:class:`~upstage_des.task.DecisionTask` could
pop entries from a knowledge list and re-plan the network.

Knowledge is also used to store events that are known only to an Actor to support some process
continuation patterns, described farther below.

Knowledge is accessed and updated through:

* :py:meth:`upstage_des.actor.Actor.get_knowledge`

* ``name``: The name of the knowledge.

* ``must_exist``: Boolean for raising an exception if the knowledge does not exist.

* :py:meth:`upstage_des.task.Task.get_actor_knowledge`

* ``actor``: The actor that has the knowledge.

* ``name`` and ``must_exist``, as above.

* :py:meth:`upstage_des.actor.Actor.set_knowledge`

* ``name``: The name of the knowledge to set.

* ``value``: Any object to set as the value.

* ``overwrite``: Boolean for allowing an existing value to be changed. Defaults to False, and will raise an exception if not allowed to overwrite.

* ``caller``: Optional information - through a string - of who is calling the knowledge set method. This records to the actor debug log, if enabled.

* :py:meth:`upstage_des.task.Task.set_actor_knowledge`

* ``actor``: The actor that you want to set knowledge on.

* All other inputs as above, except that ``caller`` is filled out for you.

* :py:meth:`upstage_des.actor.Actor.clear_knowledge`

* ``name``: The name of the knowledge to delete.

* ``caller``: Same as above.

* :py:meth:`upstage_des.task.Task.clear_actor_knowledge`

* ``actor``: The actor to delete knowledge from.

* All other inputs as above, except that ``caller`` is filled out for you.


The actor knowledge can be set and retrieved from the actor itself, and the ``Task`` convenience methods are there
to provide data to the actor debug log (if ``debug_logging=True`` is set on the Actor) to help trace where an actor's
information came from.

For convenience, you can get and remove knowledge in one method using:

* :py:meth:`~upstage_des.actor.Actor.get_and_clear_knowledge` on the Actor.
* :py:meth:`~upstage_des.task.Task.get_and_clear_actor_knowledge` on the Task.


Bulk Knowledge
--------------

All the above methods can be operated on in bulk:

1. :py:meth:`~upstage_des.actor.Actor.set_bulk_knowledge`: Set knowledge using a dictionary.
2. :py:meth:`~upstage_des.actor.Actor.get_bulk_knowledge`: Get knowledge using an iterable of names.
3. :py:meth:`~upstage_des.actor.Actor.clear_bulk_knowledge`: Clear knowledge using an iterable of names.
4. :py:meth:`~upstage_des.actor.Actor.get_and_clear_bulk_knowledge`: Get a dictionary of knowledge and clear it.

The tasks contain similarly named methods:

1. :py:meth:`~upstage_des.task.Task.set_actor_bulk_knowledge`
2. :py:meth:`~upstage_des.task.Task.get_actor_bulk_knowledge`
3. :py:meth:`~upstage_des.task.Task.clear_actor_bulk_knowledge`
4. :py:meth:`~upstage_des.task.Task.get_and_clear_actor_bulk_knowledge`

This is most useful for initializing or passing large amounts of information to an actor.


Knowledge Events
----------------

It is often times useful to hold an actor in a task until an event succeeds. UPSTAGE Actors
have a :py:meth:`~upstage_des.actor.Actor.create_knowledge_event` and :py:meth:`~upstage_des.actor.Actor.succeed_knowledge_event`
method to support this activity (also described in :doc:`Events </user_guide/how_tos/events>`)

.. code-block:: python
HAIRCUT_DONE = "haircut is done"
class Chair(UP.Actor):
sitting = UP.ResourceState[UP.SelfMonitoringStore]()
class Customer(UP.Actor):
hair_length = UP.State[float](recording=True)
class Haircut(UP.Task):
def task(self, *, actor: Customer):
assigned_chair = self.get_actor_knowledge(
actor,
name="chair",
must_exist=True,
)
evt = actor.create_knowledge_event(name=HAIRCUT_DONE)
yield UP.Put(assigned_chair.sitting, actor)
yield evt
print(evt.get_payload())
class DoHaircut(UP.Task):
def task(self, *, actor: Chair):
customer = yield UP.Get(actor.sitting)
yield UP.Wait(30.0)
customer.hair_length *= 0.5
customer.succeed_knowledge_event(name=HAIRCUT_DONE, data="Have a nice day!")
The above simplified example shows how UPSTAGE tasks can work with knowledge events to
support simple releases from other tasks without adding stores or other signaling mechanisms.

The succeed event method also clears the event from the knowledge.
46 changes: 46 additions & 0 deletions docs/source/user_guide/how_tos/states.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
======
States
======

States are a core UPSTAGE feature that gives :py:meth:`~upstage_des.actor.Actor` classes their useful and
changeable data. There are advanced state features, such as :doc:`Active States <active_states>` and
:doc:`Resource States <resource_states>`.

The :py:class:`~upstage_des.states.State` class is a python
`descriptor <https://docs.python.org/3/howto/descriptor.html>`_. This provides hooks for getting and setting
values stored on the Actor instance under the name of the state while allowing configuration, data recording,
and other features.

Plain states are created as follows, with nearly all the arguments shown for the state creation:

.. code-block:: python
import ustage_des.api as UP
class Cashier(UP.Actor):
friendliness = UP.State[float](
valid_types=(float,),
recording=True,
default=1.0,
frozen = False,
record_duplicates = False,
allow_none_default = False,
)
Note the typing of the ``State``, which tells your IDE and ``mypy`` what to expect out.

State inputs:

1. ``valid_types``: Types that the state can take for runtime checks. This may be removed in the future.
2. ``recording``: If the state records every time its value changes.
3. ``default``: A default value to use for the state. This allows the actor to be instantiated without it.
4. ``frozen``: If ``True``, any attempt to set the state value throws an exception (default ``False``).
5. ``record_duplicates``: If recording, allow duplicates to be recorded (default ``False``).
6. ``allow_none_default``: If ``True``, the state can have no default value set and not throw the exception.
7. ``default_factory``: Not shown, but provide a function to create the default value. Useful for mutable defaults.

The ``allow_none_default`` input is useful if you won't have access to the information needed to set a state when
your Actor is instantiated. This is common when you need actors to have mutual references to each other, for example.

If you set a default and a default factory, UPSTAGE will use the default and ignore the factory.
Loading

0 comments on commit 40f1e92

Please sign in to comment.