Key/Value States#
Key/Value states are a particular State that acts like a standard python dictionary or dataclass.
There is a use case for defining at runtime the names and values of Actor states,
but this is not supported through the class definition syntax. The
DictionaryState
solves this problem by allowing
a runtime ingest of a dictionary of arbitrary keys and values. The other state,
DataclassState
lets you supply a dataclass type
to the state.
These state types allow recording of entries or attributes of the state, while most other states cannot record on internal changes. See Complex States for more information about that issue.
In the case of a dictionary, you can type hint the values (the keys must be strings). The dataclass types will also be known, and both will be checked during runtime whenever you set an attribute or entry.
Note
These states record data as statename.attribute
, but do not go
deeper into sub-attributes when recording. They work best when the
values are not themselves key/value-like objects.
To summarize, the main reasons for using these states are:
1. Type checking attributes or values during runtime 3. Direct recording of attributes on a per-attribute basis 4. Runtime creation of recordable states
DictionaryState#
A dictionary state is created in the same way as other states, and works with data recording. Note that the data recording functions will be given the entire dictionary for the state, not just the single entry being recorded.
import upstage_des.api as UP
def total_recorder(time: float, value: dict[str, int]) -> int:
return sum(value.values())
class Usher(UP.Actor):
people_seen = UP.DictionaryState[int](
recording=True,
recording_functions=[(total_recorder, "total_customers")],
)
class TicketTaking(UP.Task):
def task(self, *, actor: Usher):
for customer in ["adult", "adult", "child", "adult", "child"]:
actor.people_seen[customer] += 1
yield UP.Wait(0.1)
Then it can be instantiated and run:
with UP.EnvironmentContext() as env:
ush = User(name="Ticketeer", people_seen={"adult": 0, "child": 0})
TicketTaking().run(actor=ush)
env.run()
print(env.now)
>>> 0.5
print(ush._state_histories)
>>> {
>>> 'people_seen.adult': [(0.0, 0), (0.0, 1), (0.1, 2), (0.3, 3)],
>>> 'total_customers': [(0.0, 0), (0.0, 1), (0.1, 2), (0.2, 3), (0.3, 4), (0.4, 5)],
>>> 'people_seen.child': [(0.0, 0), (0.2, 1), (0.4, 2)]
>>> }
Dictionary states allow you to add more entries to the dictionary explicitly, but they will behave like regular dictionaries in that unseen keys will cause errors. You can mitigate this in the usual way:
class TicketTaking(UP.Task):
def task(self, *, actor: Usher):
for customer in ["adult", "adult", "child", "adult", "child", "vip"]:
curr = actor.people_seen.setdefault(customer, 0)
actor.people_seen[customer] += 1
yield UP.Wait(0.1)
When you create a data table from the sim, the results come out naturally with
the pattern of <state_name>.<key>
and the value you assigned. If you used
complicated objects as values in the dictionary, those will be processed as they
would be in any other circumstance. Note that the following example would fail
a mypy
check since it can’t interpret data["other"]
.
from upstage_des.data_utils import create_table
class Usher(UP.Actor):
data = UP.DictionaryState[dict](recording=True)
tracker = UP.DictionaryState[int](recording=True)
with UP.EnvironmentContext():
ush = Usher(name="Ticketeer", data={"group": {}, "other": 3}, tracker={"value": 1})
ush.data["group"]["this_key"] = 1
ush.data["other"] += 1
ush.data["group"]["new_key"] = {"another": "dictionary"}
ush.tracker["value"] += 1
rows, cols = create_table()
print(rows)
>>> ('Ticketeer', 'Usher', 'data.group.this_key', 0.0, 1, None)
>>> ('Ticketeer', 'Usher', 'data.group.new_key', 0.0, {'another': 'dictionary'}, None)
>>> ('Ticketeer', 'Usher', 'data.other', 0.0, 3, None)
>>> ('Ticketeer', 'Usher', 'data.other', 0.0, 4, None)
>>> ('Ticketeer', 'Usher', 'tracker.value', 0.0, 1, None)
>>> ('Ticketeer', 'Usher', 'tracker.value', 0.0, 2, None)
The create_table()
function will also recognize the DictionaryState
if it
save_static=True
and output any non-recorded values in the same format.
DataclassState#
A dataclass state is created in the same way as other states, and works with data recording. Note that the data recording functions will be given the entire dataclass for the state, not just the single attribute being updated.
The following example shows how to use, type hint, and examine a dataclass state.
from dataclasses import dataclass, fields
import upstage_des.api as UP
from upstage_des.type_help import TASK_GEN
@dataclass
class TestDC:
a: int
b: float
def recorder(time: float, value: TestDC) -> float:
return value.a + value.b
class ExampleActor(UP.Actor):
dc_state = UP.DataclassState[TestDC](
valid_types=TestDC,
recording=True,
recording_functions=[(recorder, "total_of_data")],
)
class SomeTask(UP.Task):
def task(self, *, actor: ExampleActor) -> TASK_GEN:
actor.dc_state.a += 1
actor.dc_state.b += 4
yield UP.Wait(0.1)
actor.dc_state.b += 4
yield UP.Wait(0.1)
actor.dc_state.a -= 3
with UP.EnvironmentContext() as env:
ea = ExampleActor(name="Exam", dc_state=TestDC(0, 0.0))
task = SomeTask()
task.run(actor=ea)
env.run()
# fields() works:
fs = fields(ea.dc_state)
assert [f.name for f in fs] == ["a", "b"]
# This will error
ea.dc_state.a = "cause error"
# let's check histories
assert len(ea._state_histories) == 3
assert ea._state_histories["dc_state.a"] == [(0.0, 0), (0.0, 1), (0.2, -2)]
assert ea._state_histories["dc_state.b"] == [(0.0, 0.0), (0.0, 4.0), (0.1, 8.0)]
assert ea._state_histories["total_of_data"] == [
(0.0, 0.0),
(0.0, 1.0),
(0.0, 5.0),
(0.1, 9.0),
(0.2, 6.0),
]
Type Hinting#
Dictionary states, if untyped, allow for any kind of value. If you define
valid_types
the state will check that any input to a dictionary value
matches one of those types. The dictionary state does not do per-key typing. This
means you will need to check if the types vary in how they can be operated on.
Warning
For stability, UPSTAGE assumes all DictionaryState
dictionaries only
have strings for keys.
As usual, make sure the type hint for the state matches valid_types so that your static type checker and the internal state type checking match.
import upstage_des.api as UP
class Usher(UP.Actor):
people_seen = UP.DictionaryState[int | float](valid_types=(int, float))
with UP.EnvironmentContext():
ush = User(name="Ticketeer", people_seen={"Customer": 1.0})
ush.people_seen["boss"] = 1
# This will error
ush.people_seen["boss"] = "Boss' Name"
Dataclasses will also type check using the __annotations__
information
from the dataclass object. For dataclasses, supply the class object to valid_types
to enable that feature.
Warning
The runtime type checking of these values does not recurse. Keeping the
dictionary value types simple: valid_types = (int, str, dict)
, e.g.
is