Skip to content

Commit

Permalink
Merge pull request #11 from gtri/6-daily-time
Browse files Browse the repository at this point in the history
New time features, docs, and tests.
  • Loading branch information
JamesArruda authored Dec 12, 2024
2 parents 0737538 + 9615faa commit d8f2f96
Show file tree
Hide file tree
Showing 13 changed files with 210 additions and 27 deletions.
3 changes: 3 additions & 0 deletions docs/source/user_guide/how_tos/stage_variables.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ and are listed below:
* "stage_model": A model to use for Geodetic calculations. See :doc:`geography` for more.
* "intersection_model": A model to use for motion manager. See :doc:`geography` and :doc:`motion_manager` for more.
* "time_unit": Units of time. See :py:func:`~upstage_des.units.convert.unit_convert` for a list.
* "daily_time_count": For non-standard time values, such as "ticks", this number is used to create logging outputs with "Days".

If they are not set and you use a feature that needs them, you'll get a warning about not being able to find a stage variable.

For more information about time units, see :doc:`times`.


Accessing Stage through UpstageBase
===================================
Expand Down
42 changes: 42 additions & 0 deletions docs/source/user_guide/how_tos/times.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
==========
Time Units
==========

At the base level, the UPSTAGE clock (the SimPy clock, really) only cares about the number, and does not care
about units. This is not people-friendly, especially for debug logging or inputting times in some cases.

UPSTAGE has a few features for dealing with units of time.

The stage variable ``time_unit`` defines what each increment of the clock means. If you don't set it, UPSTAGE assumes
you mean "hours". When you call for ``pretty_now`` from anything inheriting :py:class:`~upstage_des.base.UpstageBase`,
you will get a timestamp string like: "Day 3 - 13:06:21". The actor debug logging uses that method on every log action.

If you give a time that isn't standard, such as "clock ticks", you'll get back something like "123.000 clock ticks" if the
environment time is 123. For non-standard clock times, you can also define how much time passage constitutes a "day". This
is just a way to track long time passage in a simpler way. If you're simulating logistics on 2D world where there are 125
"clock ticks" in a day, then setting the stage variable ``daily_time_count`` to 125 will lead to ``pretty_now`` outputs such
as: "Day 1 - 30 clock ticks" for an environment time of 155.

Wait with units
===============

The only feature UPSTAGE currently has that uses the time units for controlling the clock is
the :py:class:`~upstage_des.events.Wait` event. That event takes a ``timeout_unit`` argument that will convert the
given timeout value from the ``timeout_unit`` into the units defined in ``time_unit`` on the stage. If the unit isn't
compatible, then an error will be thrown.

Allowable time units
====================

Time units that UPSTAGE can convert are: seconds, minutes, hours, days, and weeks. The "standard" time values are just
seconds, minutes, and hours. Any other time unit won't use ``pretty_now`` to output "Day - H:M:S" style. Units that
aren't part of those times won't work with the ``Wait`` feature for ``timeout_unit``.

UPSTAGE tries to lowercase your time units, and allow for some flexibility in saying "s", "second", "hr", "hour", "hours",
and the like. While there are libraries for doing unit conversions, UPSTAGE prefers to have no dependencies other than
SimPy, so it is restricted in that way.

See the docstring on :py:func:`~upstage_des.units.convert.unit_convert` for more.

However, all time units are convertible into a single unit on initialization or input data processing, and this is the
recommended way to run your simulations to ensure your units are correct and consistent.
1 change: 1 addition & 0 deletions docs/source/user_guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ how_tos/active_states.rst
how_tos/mimic_states.rst
how_tos/nucleus.rst
how_tos/stage_variables.rst
how_tos/times.rst
how_tos/events.rst
how_tos/geography.rst
how_tos/decision_tasks.rst
Expand Down
8 changes: 5 additions & 3 deletions docs/source/user_guide/tutorials/first_simulation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ The ``scan_speed`` state is defined to require a ``float`` type (UPSTAGE will th
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, and the user must ensure that all times are properly defined. If you set a stage variable of ``time_unit``,
it will correct the time for debug logging strings (into hours) only.
There is no explicit time dimension in upstage_des. The clock units are up to the user,
and the user must ensure that all times are properly defined. See :doc:`Time Units </user_guide/how_tos/times>`
for more, including using time units in :py:class:`~upstage_des.events.Wait`.


Then you will later instantiate a cashier with [#f1]_:
Expand All @@ -74,7 +75,8 @@ Then you will later instantiate a cashier with [#f1]_:
)
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.
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.

Expand Down
8 changes: 4 additions & 4 deletions src/upstage_des/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,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: list[str] = []
self._debug_log: list[tuple[float | int, str]] = []

self._state_histories: dict[str, list[tuple[float, Any]]] = {}

Expand Down Expand Up @@ -861,7 +861,7 @@ def clone(
clone._is_rehearsing = True
return clone

def log(self, msg: str | None = None) -> list[str] | None:
def log(self, msg: str | None = None) -> list[tuple[float | int, str]] | None:
"""Add to the log or return it.
Only adds to log if debug_logging is True.
Expand All @@ -875,12 +875,12 @@ def log(self, msg: str | None = None) -> list[str] | None:
if msg and self._debug_logging:
ts = self.pretty_now
msg = f"{ts} {msg}"
self._debug_log += [msg]
self._debug_log += [(self.env.now, msg)]
elif msg is None:
return self._debug_log
return None

def get_log(self) -> list[str]:
def get_log(self) -> list[tuple[float | int, str]]:
"""Get the debug log.
Returns:
Expand Down
50 changes: 40 additions & 10 deletions src/upstage_des/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from simpy import Environment as SimpyEnv

from upstage_des.geography import INTERSECTION_LOCATION_CALLABLE, EarthProtocol
from upstage_des.units import unit_convert
from upstage_des.units.convert import STANDARD_TIMES, TIME_ALTERNATES, unit_convert

CONTEXT_ERROR_MSG = "Undefined context variable: use EnvironmentContext"

Expand Down Expand Up @@ -87,12 +87,27 @@ def intersection_model(self) -> INTERSECTION_LOCATION_CALLABLE:

@property
def time_unit(self) -> str:
"""Time unit, Treated as 'hr' if not set."""
"""Time unit, Treated as 'hr' if not set.
This value modifies ``pretty_now`` from ``UpstageBase``,
and can be used to modfy ``Wait`` timeouts.
"""

@property
def random(self) -> Random:
"""Random number generator."""

@property
def daily_time_count(self) -> float | int:
"""The number of time_units in a "day".
This value only modifies ``pretty_now`` from ``UpstageBase``.
This is only used if the time_unit is not
s, min, or hr. In that case, 24 hour days are
assumed.
"""

if TYPE_CHECKING:

def __getattr__(self, key: str) -> Any: ...
Expand Down Expand Up @@ -270,18 +285,33 @@ def get_all_entity_groups(self) -> dict[str, list["NamedUpstageEntity"]]:
def pretty_now(self) -> str:
"""A well-formatted string of the sim time.
Tries to account for generic names for time, such as 'ticks'.
Returns:
str: The sim time
"""
now = self.env.now
time_unit = self.stage.get("time_unit", "hr")
if time_unit != "s" and time_unit.endswith("s"):
time_unit = time_unit[:-1]
now_hrs = unit_convert(now, time_unit, "hr")
day = floor(now_hrs / 24)
ts = "[Day {:3.0f} - {} | h+{:06.2f}]".format(
day, strftime("%H:%M:%S", gmtime(now_hrs * 3600)), now_hrs
)
time_unit = self.stage.get("time_unit", None)
# If it's explicitly set to None, still treat it as hours.
time_unit = "hr" if time_unit is None else time_unit
standard = TIME_ALTERNATES.get(time_unit.lower(), time_unit)

ts: str
if standard in STANDARD_TIMES:
now_hrs = unit_convert(now, time_unit, "hr")
day = floor(now_hrs / 24)
rem_hours = now_hrs - (day * 24)
hms = strftime("%H:%M:%S", gmtime(rem_hours * 3600))
ts = f"[Day {day:4.0f} - {hms:s}]"
else:
day_unit_count = self.stage.get("daily_time_count", None)
if day_unit_count is None:
ts = f"[{now:.3f} {time_unit}]"
else:
days = int(floor(now / day_unit_count))
rem = now - (days * day_unit_count)
ts = f"[Day {days:4d} - {rem:.3f} {time_unit}]"

return ts


Expand Down
31 changes: 29 additions & 2 deletions src/upstage_des/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from .base import SimulationError, UpstageBase, UpstageError
from .constants import PLANNING_FACTOR_OBJECT
from .units import unit_convert

__all__ = (
"All",
Expand Down Expand Up @@ -162,23 +163,43 @@ class Wait(BaseEvent):
"""

def _convert_time(self, time: float | int, unit: str | None) -> float:
"""Convert a time to the stage time.
Args:
time (float | int): The current time
unit (str): Units the time is in
Returns:
float: Time in stage units
"""
base_unit = self.stage.get("time_unit")
if base_unit is not None and unit is not None:
return unit_convert(time, unit, base_unit)
return time

def __init__(
self,
timeout: float | int,
timeout_unit: str | None = None,
*,
rehearsal_time_to_complete: float | int | None = None,
) -> None:
"""Create a timeout event.
The timeout can be a single value, or two values to draw randomly between.
If timeout_unit is specified, UPSTAGE will try to convert it to the
time_unit set in the stage. Otherwise, it defaults to that time unit.
Args:
timeout (float | int): Time to wait.
timeout_unit (str, optional): Units of time
rehearsal_time_to_complete (float | int, optional): The rehearsal time
to complete. Defaults to None (the timeout given).
"""
if not isinstance(timeout, float | int):
raise SimulationError("Bad timeout. Did you mean to use from_random_uniform?")
timeout = self._convert_time(timeout, timeout_unit)
self._time_to_complete = timeout
self.timeout = timeout
if self._time_to_complete < 0:
Expand All @@ -191,13 +212,19 @@ def from_random_uniform(
cls,
low: float | int,
high: float | int,
timeout_unit: str | None = None,
*,
rehearsal_time_to_complete: float | int | None = None,
) -> "Wait":
"""Create a wait from a random uniform time.
If timeout_unit is specified, UPSTAGE will try to convert it to the
time_unit set in the stage. Otherwise, it defaults to that time unit.
Args:
low (float): Lower bounds of random draw
high (float): Upper bounds of random draw
timeout_unit (str, optional): Units of time
rehearsal_time_to_complete (float | int, optional): The rehearsal time
to complete. Defaults to None - meaning the random value drawn.
Expand All @@ -206,7 +233,7 @@ def from_random_uniform(
"""
rng = UpstageBase().stage.random
timeout = rng.uniform(low, high)
return cls(timeout, rehearsal_time_to_complete)
return cls(timeout, timeout_unit, rehearsal_time_to_complete=rehearsal_time_to_complete)

def as_event(self) -> SIM.Timeout:
"""Cast Wait event as a simpy Timeout event.
Expand Down
3 changes: 1 addition & 2 deletions src/upstage_des/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,7 @@ def _handle_interruption(
raise SimulationError("No interrupt behavior returned from `on_interrupt`")

if _interrupt_action in (InterruptStates.END, InterruptStates.RESTART):
if actor._debug_logging:
actor.log(f"Interrupted by {interrupt}.")
actor.log(f"Interrupted by {interrupt}.")
actor.deactivate_all_states(task=self)
actor.deactivate_all_mimic_states(task=self)
if isinstance(next_event, BaseEvent):
Expand Down
38 changes: 38 additions & 0 deletions src/upstage_des/test/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,41 @@ def test_multiproc_stability() -> None:
res = pool.map(a_simulation, inputs)

assert res == inputs


@pytest.mark.parametrize(
["unit", "mult"],
[
("min", 60),
("Minutes", 60),
("s", 3600),
("second", 3600),
("hours", 1),
("hr", 1),
(None, 1),
],
)
def test_pretty_times(unit: str, mult: int) -> None:
"""Test that if we do time in regular ways, we get standard logging."""
times_in_hours = [3, 28, 24 * 15 + 6.5]
times_in_hours = [x * mult for x in times_in_hours]
with EnvironmentContext(initial_time=times_in_hours[0]) as env:
add_stage_variable("time_unit", unit)
base = UpstageBase()
assert base.pretty_now == "[Day 0 - 03:00:00]"
env.run(until=times_in_hours[1])
assert base.pretty_now == "[Day 1 - 04:00:00]"
env.run(until=times_in_hours[2])
assert base.pretty_now == "[Day 15 - 06:30:00]"


@pytest.mark.parametrize("unit", ["ticks", "week", "day", "microseconds"])
def test_pretty_time_nonstandard(unit: str) -> None:
with EnvironmentContext() as env:
add_stage_variable("time_unit", unit)
base = UpstageBase()
assert base.pretty_now == f"[0.000 {unit}]"
add_stage_variable("daily_time_count", 100)
assert base.pretty_now == f"[Day 0 - 0.000 {unit}]"
env.run(until=223)
assert base.pretty_now == f"[Day 2 - 23.000 {unit}]"
16 changes: 14 additions & 2 deletions src/upstage_des/test/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
from simpy.resources.container import ContainerGet, ContainerPut
from simpy.resources.store import StoreGet, StorePut

from upstage_des.api import Actor, EnvironmentContext, SimulationError, State, Task
from upstage_des.api import (
Actor,
EnvironmentContext,
SimulationError,
State,
Task,
add_stage_variable,
)
from upstage_des.events import (
All,
Any,
Expand Down Expand Up @@ -49,9 +56,14 @@ def test_wait_event() -> None:
assert isinstance(ret, SIM.Timeout), "Wait doesn't return a simpy timeout"
assert ret._delay == timeout, "Incorrect timeout time"

with EnvironmentContext() as env:
add_stage_variable("time_unit", "minutes")
wait = Wait(timeout=1.1, timeout_unit="hours")
assert wait.timeout == 1.1 * 60

with EnvironmentContext(initial_time=init_time) as env:
timeout_2 = [1, 3]
wait = Wait.from_random_uniform(*timeout_2)
wait = Wait.from_random_uniform(timeout_2[0], timeout_2[1])
ret = wait.as_event()
assert isinstance(ret, SIM.Timeout), "Wait doesn't return a simpy timeout"
assert timeout_2[0] <= ret._delay <= timeout_2[1], "Incorrect timeout time"
Expand Down
4 changes: 4 additions & 0 deletions src/upstage_des/test/test_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
UpstageBase,
UpstageError,
add_stage_variable,
get_stage,
get_stage_variable,
)
from upstage_des.base import clear_top_context, create_top_context
Expand Down Expand Up @@ -48,6 +49,9 @@ def test_contextless_stage() -> None:

assert get_stage_variable("example") == 1.234

stage = get_stage()
assert stage.example == 1.234

# dropping into a new context ignores the above
with EnvironmentContext():
add_stage_variable("example", 8.675)
Expand Down
Loading

0 comments on commit d8f2f96

Please sign in to comment.