From 0079fb8a6d0e224ea64a3b8688fbbb13638216d4 Mon Sep 17 00:00:00 2001 From: James Arruda Date: Tue, 10 Dec 2024 13:17:14 -0500 Subject: [PATCH 1/3] Renaming library for pypi --- .github/workflows/documentation.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish-to-pypi.yml | 2 +- CONTRIBUTING.md | 8 +- docs/source/conf.py | 8 +- docs/source/modules.rst | 2 +- docs/source/upstage.communications.rst | 29 - docs/source/upstage.geography.rst | 45 - docs/source/upstage.motion.rst | 53 - docs/source/upstage.resources.rst | 45 - docs/source/upstage.rst | 129 - docs/source/upstage.units.rst | 21 - docs/source/upstage_des.communications.rst | 29 + docs/source/upstage_des.geography.rst | 53 + docs/source/upstage_des.motion.rst | 53 + docs/source/upstage_des.resources.rst | 45 + docs/source/upstage_des.rst | 137 ++ docs/source/upstage_des.units.rst | 21 + docs/source/user_guide/how_tos/nucleus.rst | 2 +- .../user_guide/tutorials/complex_cashier.rst | 2 +- .../user_guide/tutorials/first_sim_full.rst | 2 +- .../user_guide/tutorials/rehearsal_sim.rst | 2 +- pyproject.toml | 20 +- src/{upstage => upstage_des}/__init__.py | 0 src/{upstage => upstage_des}/_version.py | 0 src/{upstage => upstage_des}/actor.py | 2106 ++++++++--------- src/{upstage => upstage_des}/api.py | 306 +-- src/{upstage => upstage_des}/base.py | 4 +- .../communications/__init__.py | 0 .../communications/comms.py | 614 ++--- .../communications/processes.py | 4 +- src/{upstage => upstage_des}/constants.py | 0 src/{upstage => upstage_des}/data_types.py | 1196 +++++----- src/{upstage => upstage_des}/events.py | 1670 ++++++------- .../geography/__init__.py | 0 .../geography/conversions.py | 0 .../geography/geo_types.py | 0 .../geography/intersections.py | 4 +- .../geography/spherical.py | 4 +- .../geography/wgs84.py | 2 +- src/{upstage => upstage_des}/math_utils.py | 222 +- .../motion/__init__.py | 0 .../motion/cartesian_model.py | 6 +- .../motion/geodetic_model.py | 8 +- .../motion/great_circle_calcs.py | 288 +-- src/{upstage => upstage_des}/motion/motion.py | 986 ++++---- .../motion/stepped_motion.py | 640 ++--- src/{upstage => upstage_des}/nucleus.py | 6 +- src/{upstage => upstage_des}/py.typed | 0 .../resources/__init__.py | 0 .../resources/container.py | 816 +++---- .../resources/monitoring.py | 0 .../resources/reserve.py | 258 +- .../resources/sorted.py | 0 src/{upstage => upstage_des}/state_sharing.py | 8 +- src/{upstage => upstage_des}/states.py | 2002 ++++++++-------- src/{upstage => upstage_des}/task.py | 1170 ++++----- src/{upstage => upstage_des}/task_network.py | 642 ++--- src/{upstage => upstage_des}/test/__init__.py | 0 src/{upstage => upstage_des}/test/conftest.py | 2 +- .../test/test_actor.py | 8 +- src/{upstage => upstage_des}/test/test_api.py | 164 +- .../test/test_base.py | 2 +- .../test/test_comms.py | 8 +- .../test/test_container.py | 8 +- .../test/test_data_types.py | 4 +- .../test/test_docs_examples/__init__.py | 0 .../test/test_docs_examples/test_cashier.py | 458 ++-- .../test_cashier_complex.py | 642 ++--- .../test_nucleus_sharing.py | 4 +- .../test_rehearsing_example.py | 6 +- .../test/test_event.py | 932 ++++---- .../test/test_geography/__init__.py | 0 .../test/test_geography/conftest.py | 0 .../test/test_geography/test_conversions.py | 4 +- .../test/test_geography/test_intersections.py | 4 +- .../test/test_geography/test_spherical.py | 2 +- .../test/test_geography/test_wsg84.py | 2 +- .../test/test_great_circle_calcs.py | 4 +- .../test/test_integration.py | 14 +- .../test/test_locations.py | 10 +- .../test/test_motion.py | 1734 +++++++------- .../test/test_network_qol.py | 4 +- .../test/test_nucleus.py | 4 +- .../test/test_nucleus_state_share/__init__.py | 0 .../test/test_nucleus_state_share/flyer.py | 4 +- .../test_nucleus_state_share/mothership.py | 150 +- .../test/test_nucleus_state_share/mover.py | 4 +- .../test_refuel_example.py | 4 +- .../test/test_parallel_task_network.py | 6 +- .../test/test_sim_wide_tracking.py | 2 +- .../test/test_stage.py | 2 +- .../test/test_state.py | 12 +- .../test/test_state_and_task_sharing.py | 8 +- .../test/test_state_piggyback.py | 10 +- .../test/test_stepped_motion.py | 8 +- .../test/test_stores.py | 14 +- .../test/test_task.py | 14 +- .../test/test_task_network.py | 1664 ++++++------- .../test/test_units.py | 2 +- src/{upstage => upstage_des}/type_help.py | 32 +- .../units/__init__.py | 0 src/{upstage => upstage_des}/units/convert.py | 0 src/{upstage => upstage_des}/utils.py | 0 104 files changed, 9823 insertions(+), 9807 deletions(-) delete mode 100644 docs/source/upstage.communications.rst delete mode 100644 docs/source/upstage.geography.rst delete mode 100644 docs/source/upstage.motion.rst delete mode 100644 docs/source/upstage.resources.rst delete mode 100644 docs/source/upstage.rst delete mode 100644 docs/source/upstage.units.rst create mode 100644 docs/source/upstage_des.communications.rst create mode 100644 docs/source/upstage_des.geography.rst create mode 100644 docs/source/upstage_des.motion.rst create mode 100644 docs/source/upstage_des.resources.rst create mode 100644 docs/source/upstage_des.rst create mode 100644 docs/source/upstage_des.units.rst rename src/{upstage => upstage_des}/__init__.py (100%) rename src/{upstage => upstage_des}/_version.py (100%) rename src/{upstage => upstage_des}/actor.py (97%) rename src/{upstage => upstage_des}/api.py (71%) rename src/{upstage => upstage_des}/base.py (99%) rename src/{upstage => upstage_des}/communications/__init__.py (100%) rename src/{upstage => upstage_des}/communications/comms.py (95%) rename src/{upstage => upstage_des}/communications/processes.py (90%) rename src/{upstage => upstage_des}/constants.py (100%) rename src/{upstage => upstage_des}/data_types.py (96%) rename src/{upstage => upstage_des}/events.py (97%) rename src/{upstage => upstage_des}/geography/__init__.py (100%) rename src/{upstage => upstage_des}/geography/conversions.py (100%) rename src/{upstage => upstage_des}/geography/geo_types.py (100%) rename src/{upstage => upstage_des}/geography/intersections.py (98%) rename src/{upstage => upstage_des}/geography/spherical.py (99%) rename src/{upstage => upstage_des}/geography/wgs84.py (99%) rename src/{upstage => upstage_des}/math_utils.py (96%) rename src/{upstage => upstage_des}/motion/__init__.py (100%) rename src/{upstage => upstage_des}/motion/cartesian_model.py (97%) rename src/{upstage => upstage_des}/motion/geodetic_model.py (97%) rename src/{upstage => upstage_des}/motion/great_circle_calcs.py (95%) rename src/{upstage => upstage_des}/motion/motion.py (96%) rename src/{upstage => upstage_des}/motion/stepped_motion.py (95%) rename src/{upstage => upstage_des}/nucleus.py (96%) rename src/{upstage => upstage_des}/py.typed (100%) rename src/{upstage => upstage_des}/resources/__init__.py (100%) rename src/{upstage => upstage_des}/resources/container.py (96%) rename src/{upstage => upstage_des}/resources/monitoring.py (100%) rename src/{upstage => upstage_des}/resources/reserve.py (96%) rename src/{upstage => upstage_des}/resources/sorted.py (100%) rename src/{upstage => upstage_des}/state_sharing.py (96%) rename src/{upstage => upstage_des}/states.py (96%) rename src/{upstage => upstage_des}/task.py (97%) rename src/{upstage => upstage_des}/task_network.py (96%) rename src/{upstage => upstage_des}/test/__init__.py (100%) rename src/{upstage => upstage_des}/test/conftest.py (98%) rename src/{upstage => upstage_des}/test/test_actor.py (97%) rename src/{upstage => upstage_des}/test/test_api.py (95%) rename src/{upstage => upstage_des}/test/test_base.py (99%) rename src/{upstage => upstage_des}/test/test_comms.py (96%) rename src/{upstage => upstage_des}/test/test_container.py (97%) rename src/{upstage => upstage_des}/test/test_data_types.py (96%) rename src/{upstage => upstage_des}/test/test_docs_examples/__init__.py (100%) rename src/{upstage => upstage_des}/test/test_docs_examples/test_cashier.py (96%) rename src/{upstage => upstage_des}/test/test_docs_examples/test_cashier_complex.py (96%) rename src/{upstage => upstage_des}/test/test_docs_examples/test_nucleus_sharing.py (98%) rename src/{upstage => upstage_des}/test/test_docs_examples/test_rehearsing_example.py (97%) rename src/{upstage => upstage_des}/test/test_event.py (96%) rename src/{upstage => upstage_des}/test/test_geography/__init__.py (100%) rename src/{upstage => upstage_des}/test/test_geography/conftest.py (100%) rename src/{upstage => upstage_des}/test/test_geography/test_conversions.py (84%) rename src/{upstage => upstage_des}/test/test_geography/test_intersections.py (94%) rename src/{upstage => upstage_des}/test/test_geography/test_spherical.py (97%) rename src/{upstage => upstage_des}/test/test_geography/test_wsg84.py (98%) rename src/{upstage => upstage_des}/test/test_great_circle_calcs.py (97%) rename src/{upstage => upstage_des}/test/test_integration.py (96%) rename src/{upstage => upstage_des}/test/test_locations.py (94%) rename src/{upstage => upstage_des}/test/test_motion.py (96%) rename src/{upstage => upstage_des}/test/test_network_qol.py (97%) rename src/{upstage => upstage_des}/test/test_nucleus.py (96%) rename src/{upstage => upstage_des}/test/test_nucleus_state_share/__init__.py (100%) rename src/{upstage => upstage_des}/test/test_nucleus_state_share/flyer.py (97%) rename src/{upstage => upstage_des}/test/test_nucleus_state_share/mothership.py (94%) rename src/{upstage => upstage_des}/test/test_nucleus_state_share/mover.py (96%) rename src/{upstage => upstage_des}/test/test_nucleus_state_share/test_refuel_example.py (99%) rename src/{upstage => upstage_des}/test/test_parallel_task_network.py (96%) rename src/{upstage => upstage_des}/test/test_sim_wide_tracking.py (99%) rename src/{upstage => upstage_des}/test/test_stage.py (97%) rename src/{upstage => upstage_des}/test/test_state.py (97%) rename src/{upstage => upstage_des}/test/test_state_and_task_sharing.py (97%) rename src/{upstage => upstage_des}/test/test_state_piggyback.py (96%) rename src/{upstage => upstage_des}/test/test_stepped_motion.py (97%) rename src/{upstage => upstage_des}/test/test_stores.py (95%) rename src/{upstage => upstage_des}/test/test_task.py (97%) rename src/{upstage => upstage_des}/test/test_task_network.py (96%) rename src/{upstage => upstage_des}/test/test_units.py (93%) rename src/{upstage => upstage_des}/type_help.py (89%) rename src/{upstage => upstage_des}/units/__init__.py (100%) rename src/{upstage => upstage_des}/units/convert.py (100%) rename src/{upstage => upstage_des}/utils.py (100%) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 8870ab6..f358dbf 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: pip install .[docs] - name: Build autodocs - run: sphinx-apidoc -o ./docs/source ./src/upstage ./src/upstage/test + run: sphinx-apidoc -o ./docs/source ./src/upstage_des ./src/upstage_des/test - name: Sphinx build run: sphinx-build -b html docs/source _build - name: Deploy to GitHub Pages diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0598c7f..6ebc533 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,4 +24,4 @@ jobs: - name: Run Ruff linter run: ruff check --output-format=github src - name: Run mypy - run: mypy --show-error-codes -p upstage + run: mypy --show-error-codes -p upstage_des diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 9aa58ad..33885ae 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/upstage-des # Replace with your PyPI project name + url: https://pypi.org/p/upstage-des permissions: id-token: write # IMPORTANT: mandatory for trusted publishing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce085db..0b678a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ python -m pip install --upgrade pip Next, clone the repo locally: ```bash -cd /path/to/upstage_dev +cd /path/to/upstage_des git clone https://github.com/gtri/upstage.git cd upstage ``` @@ -64,12 +64,12 @@ pyproject-fmt pyproject.toml ssort src ruff format src ruff check --fix src -mypy --show-error-codes -p upstage +mypy --show-error-codes -p upstage_des ``` ### Testing -To run the unit tests in `src/upstage/test`, run: +To run the unit tests in `src/upstage_des/test`, run: ```bash pytest @@ -86,7 +86,7 @@ Documentation is built from autodocs first, then the source build. From the top level of the repo: ```bash -sphinx-apidoc -o ./docs/source ./src/upstage ./src/upstage/test +sphinx-apidoc -o ./docs/source ./src/upstage_des ./src/upstage_des/test sphinx-build -b html ./docs/source ./build/docs ``` diff --git a/docs/source/conf.py b/docs/source/conf.py index e582abb..0f2c09e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -7,7 +7,7 @@ from datetime import datetime -import upstage +import upstage_des sys.path.insert(0, os.path.abspath("../../src")) @@ -15,9 +15,9 @@ # https://www.sphinx-doc.org/en/mast er/usage/configuration.html#project-information project = "UPSTAGE" -copyright = f"{datetime.now().year}, {upstage.__authors__}" -author = upstage.__authors__ -release = upstage.__version__ +copyright = f"{datetime.now().year}, {upstage_des.__authors__}" +author = upstage_des.__authors__ +release = upstage_des.__version__ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/source/modules.rst b/docs/source/modules.rst index db9eec1..bd9d052 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -4,4 +4,4 @@ UPSTAGE API .. toctree:: :maxdepth: 3 - upstage + upstage_des diff --git a/docs/source/upstage.communications.rst b/docs/source/upstage.communications.rst deleted file mode 100644 index b6a90a9..0000000 --- a/docs/source/upstage.communications.rst +++ /dev/null @@ -1,29 +0,0 @@ -upstage.communications package -============================== - -Submodules ----------- - -upstage.communications.comms module ------------------------------------ - -.. automodule:: upstage.communications.comms - :members: - :undoc-members: - :show-inheritance: - -upstage.communications.processes module ---------------------------------------- - -.. automodule:: upstage.communications.processes - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage.communications - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage.geography.rst b/docs/source/upstage.geography.rst deleted file mode 100644 index 1e4d8ca..0000000 --- a/docs/source/upstage.geography.rst +++ /dev/null @@ -1,45 +0,0 @@ -upstage.geography package -========================= - -Submodules ----------- - -upstage.geography.conversions module ------------------------------------- - -.. automodule:: upstage.geography.conversions - :members: - :undoc-members: - :show-inheritance: - -upstage.geography.intersections module --------------------------------------- - -.. automodule:: upstage.geography.intersections - :members: - :undoc-members: - :show-inheritance: - -upstage.geography.spherical module ----------------------------------- - -.. automodule:: upstage.geography.spherical - :members: - :undoc-members: - :show-inheritance: - -upstage.geography.wgs84 module ------------------------------- - -.. automodule:: upstage.geography.wgs84 - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage.geography - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage.motion.rst b/docs/source/upstage.motion.rst deleted file mode 100644 index 73f4920..0000000 --- a/docs/source/upstage.motion.rst +++ /dev/null @@ -1,53 +0,0 @@ -upstage.motion package -====================== - -Submodules ----------- - -upstage.motion.cartesian\_model module --------------------------------------- - -.. automodule:: upstage.motion.cartesian_model - :members: - :undoc-members: - :show-inheritance: - -upstage.motion.geodetic\_model module -------------------------------------- - -.. automodule:: upstage.motion.geodetic_model - :members: - :undoc-members: - :show-inheritance: - -upstage.motion.great\_circle\_calcs module ------------------------------------------- - -.. automodule:: upstage.motion.great_circle_calcs - :members: - :undoc-members: - :show-inheritance: - -upstage.motion.motion module ----------------------------- - -.. automodule:: upstage.motion.motion - :members: - :undoc-members: - :show-inheritance: - -upstage.motion.stepped\_motion module -------------------------------------- - -.. automodule:: upstage.motion.stepped_motion - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage.motion - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage.resources.rst b/docs/source/upstage.resources.rst deleted file mode 100644 index 86f48a7..0000000 --- a/docs/source/upstage.resources.rst +++ /dev/null @@ -1,45 +0,0 @@ -upstage.resources package -========================= - -Submodules ----------- - -upstage.resources.container module ----------------------------------- - -.. automodule:: upstage.resources.container - :members: - :undoc-members: - :show-inheritance: - -upstage.resources.monitoring module ------------------------------------ - -.. automodule:: upstage.resources.monitoring - :members: - :undoc-members: - :show-inheritance: - -upstage.resources.reserve module --------------------------------- - -.. automodule:: upstage.resources.reserve - :members: - :undoc-members: - :show-inheritance: - -upstage.resources.sorted module -------------------------------- - -.. automodule:: upstage.resources.sorted - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage.resources - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage.rst b/docs/source/upstage.rst deleted file mode 100644 index 26e3e7e..0000000 --- a/docs/source/upstage.rst +++ /dev/null @@ -1,129 +0,0 @@ -upstage package -=============== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - upstage.communications - upstage.geography - upstage.motion - upstage.resources - upstage.units - -Submodules ----------- - -upstage.actor module --------------------- - -.. automodule:: upstage.actor - :members: - :undoc-members: - :show-inheritance: - -upstage.api module ------------------- - -.. automodule:: upstage.api - :members: - :undoc-members: - :show-inheritance: - -upstage.base module -------------------- - -.. automodule:: upstage.base - :members: - :undoc-members: - :show-inheritance: - -upstage.constants module ------------------------- - -.. automodule:: upstage.constants - :members: - :undoc-members: - :show-inheritance: - -upstage.data\_types module --------------------------- - -.. automodule:: upstage.data_types - :members: - :undoc-members: - :show-inheritance: - -upstage.events module ---------------------- - -.. automodule:: upstage.events - :members: - :undoc-members: - :show-inheritance: - -upstage.math\_utils module --------------------------- - -.. automodule:: upstage.math_utils - :members: - :undoc-members: - :show-inheritance: - -upstage.nucleus module ----------------------- - -.. automodule:: upstage.nucleus - :members: - :undoc-members: - :show-inheritance: - -upstage.state\_sharing module ------------------------------ - -.. automodule:: upstage.state_sharing - :members: - :undoc-members: - :show-inheritance: - -upstage.states module ---------------------- - -.. automodule:: upstage.states - :members: - :undoc-members: - :show-inheritance: - -upstage.task module -------------------- - -.. automodule:: upstage.task - :members: - :undoc-members: - :show-inheritance: - -upstage.task\_network module ----------------------------- - -.. automodule:: upstage.task_network - :members: - :undoc-members: - :show-inheritance: - -upstage.utils module --------------------- - -.. automodule:: upstage.utils - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage.units.rst b/docs/source/upstage.units.rst deleted file mode 100644 index 5c764a2..0000000 --- a/docs/source/upstage.units.rst +++ /dev/null @@ -1,21 +0,0 @@ -upstage.units package -===================== - -Submodules ----------- - -upstage.units.convert module ----------------------------- - -.. automodule:: upstage.units.convert - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage.units - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage_des.communications.rst b/docs/source/upstage_des.communications.rst new file mode 100644 index 0000000..84f9c0b --- /dev/null +++ b/docs/source/upstage_des.communications.rst @@ -0,0 +1,29 @@ +upstage\_des.communications package +=================================== + +Submodules +---------- + +upstage\_des.communications.comms module +---------------------------------------- + +.. automodule:: upstage_des.communications.comms + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.communications.processes module +-------------------------------------------- + +.. automodule:: upstage_des.communications.processes + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: upstage_des.communications + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/upstage_des.geography.rst b/docs/source/upstage_des.geography.rst new file mode 100644 index 0000000..6ecdb51 --- /dev/null +++ b/docs/source/upstage_des.geography.rst @@ -0,0 +1,53 @@ +upstage\_des.geography package +============================== + +Submodules +---------- + +upstage\_des.geography.conversions module +----------------------------------------- + +.. automodule:: upstage_des.geography.conversions + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.geography.geo\_types module +---------------------------------------- + +.. automodule:: upstage_des.geography.geo_types + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.geography.intersections module +------------------------------------------- + +.. automodule:: upstage_des.geography.intersections + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.geography.spherical module +--------------------------------------- + +.. automodule:: upstage_des.geography.spherical + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.geography.wgs84 module +----------------------------------- + +.. automodule:: upstage_des.geography.wgs84 + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: upstage_des.geography + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/upstage_des.motion.rst b/docs/source/upstage_des.motion.rst new file mode 100644 index 0000000..e89000f --- /dev/null +++ b/docs/source/upstage_des.motion.rst @@ -0,0 +1,53 @@ +upstage\_des.motion package +=========================== + +Submodules +---------- + +upstage\_des.motion.cartesian\_model module +------------------------------------------- + +.. automodule:: upstage_des.motion.cartesian_model + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.motion.geodetic\_model module +------------------------------------------ + +.. automodule:: upstage_des.motion.geodetic_model + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.motion.great\_circle\_calcs module +----------------------------------------------- + +.. automodule:: upstage_des.motion.great_circle_calcs + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.motion.motion module +--------------------------------- + +.. automodule:: upstage_des.motion.motion + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.motion.stepped\_motion module +------------------------------------------ + +.. automodule:: upstage_des.motion.stepped_motion + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: upstage_des.motion + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/upstage_des.resources.rst b/docs/source/upstage_des.resources.rst new file mode 100644 index 0000000..a4cd774 --- /dev/null +++ b/docs/source/upstage_des.resources.rst @@ -0,0 +1,45 @@ +upstage\_des.resources package +============================== + +Submodules +---------- + +upstage\_des.resources.container module +--------------------------------------- + +.. automodule:: upstage_des.resources.container + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.resources.monitoring module +---------------------------------------- + +.. automodule:: upstage_des.resources.monitoring + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.resources.reserve module +------------------------------------- + +.. automodule:: upstage_des.resources.reserve + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.resources.sorted module +------------------------------------ + +.. automodule:: upstage_des.resources.sorted + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: upstage_des.resources + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/upstage_des.rst b/docs/source/upstage_des.rst new file mode 100644 index 0000000..073eb57 --- /dev/null +++ b/docs/source/upstage_des.rst @@ -0,0 +1,137 @@ +upstage\_des package +==================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + upstage_des.communications + upstage_des.geography + upstage_des.motion + upstage_des.resources + upstage_des.units + +Submodules +---------- + +upstage\_des.actor module +------------------------- + +.. automodule:: upstage_des.actor + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.api module +----------------------- + +.. automodule:: upstage_des.api + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.base module +------------------------ + +.. automodule:: upstage_des.base + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.constants module +----------------------------- + +.. automodule:: upstage_des.constants + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.data\_types module +------------------------------- + +.. automodule:: upstage_des.data_types + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.events module +-------------------------- + +.. automodule:: upstage_des.events + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.math\_utils module +------------------------------- + +.. automodule:: upstage_des.math_utils + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.nucleus module +--------------------------- + +.. automodule:: upstage_des.nucleus + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.state\_sharing module +---------------------------------- + +.. automodule:: upstage_des.state_sharing + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.states module +-------------------------- + +.. automodule:: upstage_des.states + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.task module +------------------------ + +.. automodule:: upstage_des.task + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.task\_network module +--------------------------------- + +.. automodule:: upstage_des.task_network + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.type\_help module +------------------------------ + +.. automodule:: upstage_des.type_help + :members: + :undoc-members: + :show-inheritance: + +upstage\_des.utils module +------------------------- + +.. automodule:: upstage_des.utils + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: upstage_des + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/upstage_des.units.rst b/docs/source/upstage_des.units.rst new file mode 100644 index 0000000..bf082f4 --- /dev/null +++ b/docs/source/upstage_des.units.rst @@ -0,0 +1,21 @@ +upstage\_des.units package +========================== + +Submodules +---------- + +upstage\_des.units.convert module +--------------------------------- + +.. automodule:: upstage_des.units.convert + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: upstage_des.units + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/user_guide/how_tos/nucleus.rst b/docs/source/user_guide/how_tos/nucleus.rst index 711e915..3ff4f3f 100644 --- a/docs/source/user_guide/how_tos/nucleus.rst +++ b/docs/source/user_guide/how_tos/nucleus.rst @@ -146,4 +146,4 @@ it is just running on SimPy and you can do what you like. Here are some issues/c * Using a ``DecisionTask`` helps avoid an ``if`` statement in the ``CPUProcess`` task to add the network to the nucleus * The business logic of the task is overpowered by assistance code, which UPSTAGE tries to avoid as much as possible. -.. literalinclude:: ../../../../src/upstage/test/test_docs_examples/test_nucleus_sharing.py +.. literalinclude:: ../../../../src/upstage_des/test/test_docs_examples/test_nucleus_sharing.py diff --git a/docs/source/user_guide/tutorials/complex_cashier.rst b/docs/source/user_guide/tutorials/complex_cashier.rst index 5c1c5fb..b18efda 100644 --- a/docs/source/user_guide/tutorials/complex_cashier.rst +++ b/docs/source/user_guide/tutorials/complex_cashier.rst @@ -2,7 +2,7 @@ Complex Cashier Full Source ============================ -.. literalinclude:: ../../../../src/upstage/test/test_docs_examples/test_cashier_complex.py +.. literalinclude:: ../../../../src/upstage_des/test/test_docs_examples/test_cashier_complex.py This file is auto-generated. diff --git a/docs/source/user_guide/tutorials/first_sim_full.rst b/docs/source/user_guide/tutorials/first_sim_full.rst index c4489de..7a041d5 100644 --- a/docs/source/user_guide/tutorials/first_sim_full.rst +++ b/docs/source/user_guide/tutorials/first_sim_full.rst @@ -2,7 +2,7 @@ First Simulation Full Source ============================ -.. literalinclude:: ../../../../src/upstage/test/test_docs_examples/test_cashier.py +.. literalinclude:: ../../../../src/upstage_des/test/test_docs_examples/test_cashier.py This file is auto-generated. diff --git a/docs/source/user_guide/tutorials/rehearsal_sim.rst b/docs/source/user_guide/tutorials/rehearsal_sim.rst index 5e79a0e..dbec0fe 100644 --- a/docs/source/user_guide/tutorials/rehearsal_sim.rst +++ b/docs/source/user_guide/tutorials/rehearsal_sim.rst @@ -2,7 +2,7 @@ Rehearsal Simulation Example ============================ -.. literalinclude:: ../../../../src/upstage/test/test_docs_examples/test_rehearsing_example.py +.. literalinclude:: ../../../../src/upstage_des/test/test_docs_examples/test_rehearsing_example.py This file is auto-generated. diff --git a/pyproject.toml b/pyproject.toml index 76b23e7..2e1ca03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ ] [project] -name = "upstage" +name = "upstage-des" version = "0.1.1" description = "A library for behavior-driven discrete event simulation." readme = "README.md" @@ -86,10 +86,10 @@ lint.select = [ lint.ignore = [ "D105", ] -lint.per-file-ignores."src/upstage/test/__*.py" = [ +lint.per-file-ignores."src/upstage_des/test/__*.py" = [ "D", ] -lint.per-file-ignores."src/upstage/test/test*.py" = [ +lint.per-file-ignores."src/upstage_des/test/test*.py" = [ "D", ] lint.pydocstyle.convention = "google" @@ -99,7 +99,7 @@ junit_family = "xunit2" cache_dir = "build/.pytest_cache" addopts = [ "--pyargs", - "upstage", + "upstage_des", # for contributors "--cov-report=term-missing:skip-covered", "--color=yes", @@ -110,7 +110,7 @@ addopts = [ "--cov-report=xml:build/reports/coverage.xml", "--cov-context=test", # coverage - "--cov=upstage", + "--cov=upstage_des", "--no-cov-on-fail", # for robots "--junitxml=build/reports/pytest.xunit.xml", @@ -125,16 +125,16 @@ data_file = "build/reports/.coverage" omit = [ "*/test/test*.py", "*/test/test_nucleus_state_share/*.py", - "*/upstage/utils.py", + "*/upstage_des/utils.py", ] [tool.coverage.html] show_contexts = true [tool.coverage.paths] -upstage = [ - "src/upstage", - "*/src/upstage", +upstage_des = [ + "src/upstage_des", + "*/src/upstage_des", ] [tool.coverage.report] @@ -166,7 +166,7 @@ warn_unused_ignores = true disable_error_code = "type-abstract" # disallow_any_unimported = true #exclude = [ -# "^.+/upstage/test.+\\.py$", +# "^.+/upstage_des/test.+\\.py$", #] [[tool.mypy.overrides]] diff --git a/src/upstage/__init__.py b/src/upstage_des/__init__.py similarity index 100% rename from src/upstage/__init__.py rename to src/upstage_des/__init__.py diff --git a/src/upstage/_version.py b/src/upstage_des/_version.py similarity index 100% rename from src/upstage/_version.py rename to src/upstage_des/_version.py diff --git a/src/upstage/actor.py b/src/upstage_des/actor.py similarity index 97% rename from src/upstage/actor.py rename to src/upstage_des/actor.py index f896c60..2d83fd3 100644 --- a/src/upstage/actor.py +++ b/src/upstage_des/actor.py @@ -1,1053 +1,1053 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""This file contains the fundamental Actor class for UPSTAGE.""" - -from collections import defaultdict -from collections.abc import Callable, Iterable -from copy import copy, deepcopy -from dataclasses import dataclass -from inspect import Parameter, signature -from typing import TYPE_CHECKING, Any, Self - -from simpy import Process - -from upstage.events import Event - -from .base import ( - MockEnvironment, - NamedUpstageEntity, - SettableEnv, - SimulationError, - UpstageError, -) -from .data_types import CartesianLocation, GeodeticLocation -from .states import ( - ActiveState, - CartesianLocationChangingState, - DetectabilityState, - GeodeticLocationChangingState, - ResourceState, - State, -) -from .task import Task -from .task_network import TaskNetwork, TaskNetworkFactory -from .utils import get_caller_info, get_caller_object - -__all__ = ("Actor",) - -if TYPE_CHECKING: - from .nucleus import TaskNetworkNucleus - -LOC_STATE = GeodeticLocationChangingState | CartesianLocationChangingState -LOCATIONS = GeodeticLocation | CartesianLocation -LOC_LIST = list[GeodeticLocation] | list[CartesianLocation] - - -@dataclass -class TaskData: - name: str - process: Process - - -class Actor(SettableEnv, NamedUpstageEntity): - """Actors perform tasks and are composed of states. - - You can subclass, but do not overwrite __init_subclass__. - - Always super().__init__(). - - Parameters - ---------- - name : str - The name of the actor. This is a required attribute. - debug_log : bool, optional - Run the debug logger which captures runtime information about the - actor. - **states - Keyword arguments to set the values of the actor's states. - - Raises: - ------ - ValueError - If the keys of the states passed as keyword arguments do not match the - names of the actor's states. - - """ - - def __init_states(self, **states: Any) -> None: - seen = set() - for state, value in states.items(): - if state in self._state_defs: - seen.add(state) - setattr(self, state, value) - else: - raise UpstageError(f"Input to {self} was not expected: {state}={value}") - exist = set(self._state_defs.keys()) - unseen = exist - seen - for state_name in unseen: - if self._state_defs[state_name].has_default(): - seen.add(state_name) - if len(seen) != len(exist): - raise UpstageError( - f"Missing values for states! These states need values: " - f"{exist - seen} to be specified for '{self.name}'." - ) - if "log" in seen: - raise UpstageError("Do not name a state `log`") - - def __init__(self, *, name: str, debug_log: bool = True, **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. - states (Any): Values for each state as kwargs. - """ - self.name = name - super().__init__() - - self._active_states: dict[str, dict[str, Any]] = {} - self._num_clones: int = 0 - self._state_defs: dict[str, State] = getattr(self.__class__, "_state_defs", {}) - - self._mimic_states: dict[str, tuple[Actor, str]] = {} # has to be before other calls - self._mimic_states_by_task: dict[Task, set[str]] = defaultdict(set) - - self._states_by_task: dict[Task, set[str]] = defaultdict(set) - self._tasks_by_state: dict[str, set[Task]] = defaultdict(set) - - self._task_networks: dict[str, TaskNetwork] = {} - self._task_queue: dict[str, list[str]] = {} - - self._knowledge: dict[str, Any] = {} - self._is_rehearsing: bool = False - - self._debug_logging: bool = debug_log - self._debug_log: list[str] = [] - - self._state_histories: dict[str, list[tuple[float, Any]]] = {} - - # Task Network Nucleus hook-ins - self._state_listener: TaskNetworkNucleus | None = None - - self.__init_states(**states) - - def __init_subclass__( - cls, - *args: Any, - entity_groups: Iterable[str] | str | None = None, - **kwargs: Any, - ) -> None: - super().__init_subclass__(entity_groups=entity_groups) - # get the states - states = {} - all_states = {} - # This ensures that newer classes overwrite older states - for base_class in cls.mro()[::-1]: - for state_name, state in base_class.__dict__.items(): - if isinstance(state, State): - if base_class == cls: - states[state_name] = state - state.name = state_name - all_states[state_name] = state - cls._state_defs = all_states - - nxt = cls.mro()[1] - if nxt is object: - raise UpstageError(f"Actor has bad inheritance, MRO: {cls.mro()}") - - sig = signature(cls.__init__) - params = list(sig.parameters.values()) - # Find the "states=" parameter of the signature and remove it. - state_parameter = [x for x in params if x.name == "states"] - if state_parameter: - params.remove(state_parameter[0]) - for state in states: - params.insert(-1, Parameter(state, Parameter.KEYWORD_ONLY)) - try: - setattr(cls.__init__, "__signature__", sig.replace(parameters=params)) - except ValueError as e: - e.add_note("Failure likely due to repeated state name in inherited actor") - raise e - - def _lock_state(self, *, state: str, task: Task) -> None: - """Lock one of the actor's states by a given task. - - Args: - state (str): The name of the state to lock - task (Task): The task that is locking the state - """ - the_state = self._state_defs[state] - if not the_state.IGNORE_LOCK: - # single-task only, so no task should - # be associated with this state - if self._tasks_by_state[state]: - raise SimulationError( - f"State '{state}' cannot be used by '{task}' because it is " - f"locked by {self._tasks_by_state[state]}" - ) - else: - # We can have multiple locks, but make sure we are repeating a lock - if task in self._tasks_by_state[state]: - raise SimulationError( - f"State '{state}' already locked by '{task}'. " - "Did you forget to unlock/deactivate it?" - ) - self._states_by_task[task].add(state) - self._tasks_by_state[state].add(task) - - def _set_active_state_data( - self, - state_name: str, - started_at: float | None = None, - **data: Any, - ) -> None: - """Set the data for an active state. - - Args: - state_name (str): Name of the state - started_at (Optional[float], optional): Time the data is set at. Defaults to None. - **data (Any): key:values as kwargs for the state data. - """ - # Rule: underscored active data will get remembered - started_at = self.env.now if started_at is None else started_at - old_data = self._active_states.get(state_name, {}) - new_data = {"started_at": started_at, **data} - keep_old = {k: v for k, v in old_data.items() if k not in new_data and "_" == k[0]} - new_data.update(keep_old) - self._active_states[state_name] = new_data - - def activate_state( - self, - *, - state: str, - task: Task, - **kwargs: Any, - ) -> None: - """Set a state as active. - - Note: - This method is used by the tasks for activating states they use/modify. - - TODO: on init, create `activate_` methods that type-hint the inputs - - Args: - state (str): The name of the state to set as active. - task (Task): The task that is activating the state. - **kwargs (Any): key:values as kwargs for the state activation. - """ - if state not in self._state_defs: - raise SimulationError(f"No state named '{state}' to activate") - self._lock_state(state=state, task=task) - 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) - - def activate_linear_state(self, *, state: str, rate: float, task: Task) -> None: - """Shortcut for activating a LinearChangingState. - - Args: - state (str): The name of the LinearChangingState to set as active. - rate (float): The rate of the change - task (Task): The task that is activating - """ - self.activate_state(state=state, task=task, rate=rate) - - def activate_location_state( - self, *, state: str, speed: float, waypoints: LOC_LIST, task: Task - ) -> None: - """Shortcut for activating a (Cartesian|Geodetic)LocationChangingState. - - Args: - state (str): State name - speed (float): The speed to move at - waypoints (LOC_LIST): Waypoints to move over - task (Task): The task that the state is activated during. - """ - self.activate_state( - state=state, - speed=speed, - waypoints=waypoints, - task=task, - ) - - def _unlock_state(self, *, state: str, task: Task) -> None: - """Release a task's lock of a state. - - Args: - state (str): The name of the state to lock - task (Task): The task that is locking the state - """ - the_state = self._state_defs[state] - if not the_state.IGNORE_LOCK: - # single-task only, so only one task should - # be associated with this state - if task not in self._tasks_by_state[state]: - raise SimulationError( - f"State `{state}` isn't locked by '{task}', but it's trying to be unlocked." - ) - self._states_by_task[task].remove(state) - self._tasks_by_state[state].remove(task) - elif task in self._tasks_by_state[state]: - self._states_by_task[task].remove(state) - self._tasks_by_state[state].remove(task) - else: - raise UpstageError(f"State '{state}' was not activated by '{task}', cannot deactivate") - - def deactivate_states(self, *, states: str | Iterable[str], task: Task) -> None: - """Set a list of active states to not active. - - Args: - states (str | Iterable[str]): The names of the states to deactivate. - task (Task): The task that is deactivating the states. - """ - if isinstance(states, str): - states = [states] - - for state in states: - self.deactivate_state(state=state, task=task) - - def deactivate_state(self, *, state: str, task: Task) -> None: - """Deactivate a specific state. - - Args: - state (str): The name of the state to deactivate. - task (Task): The task that is deactivating the state. - """ - self._unlock_state(state=state, task=task) - - # the deactivated state may need to be updated - getattr(self, state) - # and then deactivate it, only if it was unlocked - the_state = self._state_defs[state] - if not isinstance(the_state, ActiveState): - raise UpstageError(f"Stage {state} is not an active type state.") - ignore = the_state.deactivate(self, task=task) - if state in self._active_states and not ignore: - del self._active_states[state] - - def deactivate_all_states(self, *, task: Task) -> None: - """Deactivate all states in the actor for a given task. - - Args: - task (Task): The task that is deactivating the states. - """ - state_names = list(self._states_by_task[task]) - self.deactivate_states(states=state_names, task=task) - - def get_active_state_data( - self, state_name: str, without_update: bool = False - ) -> dict[str, Any]: - """Get the data for a specific state. - - Args: - state_name (str): The name of the state for which to retrieve the data. - without_update (bool): Whether or not to update the state to the current - sim time. Defaults to True - - Returns: - dict[str, Any]: The state data. - """ - if not without_update: - getattr(self, state_name) - ans: dict[str, Any] = self._active_states.get(state_name, {}) - return ans - - def _mimic_state_name(self, self_state: str) -> str: - """Create a mimic state name. - - Args: - self_state (str): The name of the state - - Returns: - str: Mimic-safe name - """ - return f"{id(self)}-{self_state}" - - def activate_mimic_state( - self, - *, - self_state: str, - mimic_state: str, - mimic_actor: "Actor", - task: Task, - ) -> None: - """Activate a state to mimic a state on another actor. - - Args: - self_state (str): State name to be the mimic - mimic_state (str): State on the other actor to be mimiced - mimic_actor (Actor): The other actor. - task (Task): The task during which the state is mimiced. - """ - caller = get_caller_object() - if isinstance(caller, Task) and caller._rehearsing: - raise UpstageError("Mimic state activated on rehearsal. This is unsupported/unstable") - if self_state in self._mimic_states: - raise UpstageError(f"{self_state} already mimicked") - self._mimic_states[self_state] = (mimic_actor, mimic_state) - self._mimic_states_by_task[task].add(self_state) - - state = self._state_defs[self_state] - self_state_name = self._mimic_state_name(self_state) - if state.is_recording: - - def recorder(instance: Actor, value: Any) -> None: - if instance is mimic_actor: - state._do_record(self, value) - - mimic_actor._add_callback_to_state(self_state_name, recorder, mimic_state) - - def deactivate_mimic_state(self, *, self_state: str, task: Task) -> None: - """Deactivate a mimicking state. - - Args: - self_state (str): State name - task (Task): Task it's running in. - """ - getattr(self, self_state) - mimic_actor, mimic_state = self._mimic_states[self_state] - state = self._state_defs[self_state] - self_state_name = self._mimic_state_name(self_state) - if state.is_recording: - mimic_actor._remove_callback_from_state(self_state_name, mimic_state) - del self._mimic_states[self_state] - self._mimic_states_by_task[task].remove(self_state) - - def deactivate_all_mimic_states(self, *, task: Task) -> None: - """Deactivate all mimicking states in the actor for a given task. - - Args: - task (Task): The task where states are mimicking others. - """ - for state in list(self._mimic_states): - self.deactivate_mimic_state(self_state=state, task=task) - - def _add_callback_to_state( - self, - source: Any, - callback: Callable[["Actor", Any], Any], - state_name: str, - ) -> None: - """Add a callback to a state for recording. - - Args: - source (Any): The source for keying the callback (unused, but for the key) - callback (Callable[[Actor, Any], Any]): Takes the actor and state value - state_name (str): _description_ - """ - state: State = self._state_defs[state_name] - state._add_callback(source, callback) - - def _remove_callback_from_state( - self, - source: Any, - state_name: str, - ) -> None: - """Remove a state callback based on the source key. - - Args: - source (Any): Callback key - state_name (str): Name of the state with the callback. - """ - state = self._state_defs[state_name] - state._remove_callback(source) - - def get_knowledge(self, name: str, must_exist: bool = False) -> Any: - """Get a knowledge value from the actor. - - Args: - name (str): The name of the knowledge - must_exist (bool): Raise an error if the knowledge isn't present. Defaults to false. - - Returns: - Any: The knowledge value. None if the name doesn't exist. - """ - if must_exist and name not in self._knowledge: - raise SimulationError(f"Knowledge '{name}' does not exist in {self}") - return self._knowledge.get(name, None) - - def _log_caller( - self, - method_name: str = "", - caller_level: int = 1, - caller_name: str | None = None, - ) -> None: - """Log information about who is calling this method. - - If no caller_name is given, it is searched for in the stack. - - Args: - method_name (str, optional): Method name for logging. Defaults to "". - caller_level (int, optional): Level to look up for the caller. Defaults to 1. - caller_name (Optional[str], optional): Name of the caller. Defaults to None. - """ - if caller_name is None: - info = get_caller_info(caller_level=caller_level + 1) - else: - info = caller_name - self.log(f"method '{method_name}' called by '{info}'") - - def set_knowledge( - self, - name: str, - value: Any, - overwrite: bool = False, - caller: str | None = None, - ) -> None: - """Set a knowledge value. - - Raises an error if the knowledge exists and overwrite is False. - - Args: - name (str): The name of the knowledge item. - value (Any): The value to store for the knowledge. - overwrite (bool, Optional): Allow the knowledge to be changed if it exits. - Defaults to False. - caller (str, Optional): The name of the object that called the method. - """ - self._log_caller(f"set_knowledge '{name}={value}'", caller_name=caller) - if name in self._knowledge and not overwrite: - raise SimulationError( - f"Actor {self} overwriting existing knowledge {name} " - f"without override permission. \n" - f"Current: {self._knowledge[name]}, New: {value}" - ) - else: - self._knowledge[name] = value - - def clear_knowledge(self, name: str, caller: str | None = None) -> None: - """Clear a knowledge value. - - Raises an error if the knowledge does not exist. - - Args: - name (str): The name of the knowledge item to clear. - caller (str): The name of the Task that called the method. - Used for debug logging purposes. - - """ - self._log_caller(f"clear_knowledge '{name}'", caller_name=caller) - if name not in self._knowledge: - raise SimulationError(f"Actor {self} does not have knowledge: {name}") - else: - del self._knowledge[name] - - def add_task_network(self, network: TaskNetwork) -> None: - """Add a task network to the actor. - - Args: - network (TaskNetwork): The task network to add to the actor. - """ - network_name = network.name - if network_name in self._task_networks: - raise SimulationError(f"Task network{network_name} already in {self}") - self._task_networks[network_name] = network - self._task_queue[network_name] = [] - - def clear_task_queue(self, network_name: str) -> None: - """Empty the actor's task queue. - - This will cause the task network to be used for task flow. - - Args: - network_name (str): The name of the network to clear the task queue. - """ - self._log_caller("clear_task_queue") - self._task_queue[network_name] = [] - - def set_task_queue(self, network_name: str, task_list: list[str]) -> None: - """Initialize an actor's empty task queue. - - Args: - network_name (str): Task Network name - task_list (list[str]): List of task names to queue. - - Raises: - SimulationError: _description_ - """ - self._log_caller("set_task_queue") - if self._task_queue[network_name]: - raise SimulationError(f"Task queue on {self.name} is already set. Use append or clear.") - self._task_queue[network_name] = list(task_list) - - def get_task_queue(self, network_name: str) -> list[str]: - """Get the actor's task queue on a single network. - - Args: - network_name (str): The network name - - Returns: - list[str]: List of task names in the queue - """ - return self._task_queue[network_name] - - def get_all_task_queues(self) -> dict[str, list[str]]: - """Get the task queues for all running networks. - - Returns: - dict[str, list[str]]: Task names, keyed on task network name. - """ - queues: dict[str, list[str]] = {} - for name in self._task_networks.keys(): - queues[name] = self.get_task_queue(name) - return queues - - def get_next_task(self, network_name: str) -> None | str: - """Return the next task the actor has been told if there is one. - - This does not clear the task, it's information only. - - Args: - network_name (str): The name of the network - - Returns: - None | str: The name of the next task, None if no next task. - """ - queue = self._task_queue[network_name] - queue_length = len(queue) - return None if queue_length == 0 else queue[0] - - def _clear_task(self, network_name: str) -> None: - """Clear a task from the queue. - - Useful for rehearsal. - """ - self._task_queue[network_name].pop(0) - - def _begin_next_task(self, network_name: str, task_name: str) -> None: - """Clear the first task in the task queue. - - The task name is required to check that the next task follows the actor's plan. - - Args: - network_name (str): The task network name - task_name (str): The name of the task to start - """ - queue = self._task_queue.get(network_name) - if queue and queue[0] != task_name: - raise SimulationError( - f"Actor {self.name} commanded to perform '{task_name}' " - f"but '{queue[0]}' is expected" - ) - elif not queue: - self.set_task_queue(network_name, [task_name]) - self.log(f"begin_next_task: Starting {task_name} task") - self._task_queue[network_name].pop(0) - - def start_network_loop( - self, - network_name: str, - init_task_name: str | None = None, - ) -> None: - """Start a task network looping/running on an actor. - - If no task name is given, it will default to following the queue. - - Args: - network_name (str): Network name. - init_task_name (str, optional): Task to start with. Defaults to None. - """ - network = self._task_networks[network_name] - network.loop(actor=self, init_task_name=init_task_name) - - def get_running_task(self, network_name: str) -> TaskData | None: - """Return name and process reference of a task on this Actor's task network. - - Useful for finding a process to call interrupt() on. - - Args: - network_name (str): Network name. - - Returns: - TaskData: Dataclass of name and process for the current task. - {"name": Name, "process": the Process simpy is holding.} - """ - if network_name not in self._task_networks: - raise SimulationError(f"{self} does not have a task networked named {network_name}") - net = self._task_networks[network_name] - if net._current_task_proc is not None: - assert net._current_task_name is not None - assert net._current_task_proc is not None - task_data = TaskData(name=net._current_task_name, process=net._current_task_proc) - return task_data - return None - - def get_running_tasks(self) -> dict[str, TaskData]: - """Get all running task data. - - Returns: - dict[str, dict[str, TaskData]]: Dictionary of all running tasks. - Keyed on network name, then {"name": Name, "process": ...} - """ - tasks: dict[str, TaskData] = {} - for name, net in self._task_networks.items(): - if net._current_task_proc is not None: - assert net._current_task_name is not None - tasks[name] = TaskData(name=net._current_task_name, process=net._current_task_proc) - return tasks - - def interrupt_network(self, network_name: str, **interrupt_kwargs: Any) -> None: - """Interrupt a running task network. - - Args: - network_name (str): The name of the network. - interrupt_kwargs (Any): kwargs to pass to the interrupt. - """ - data = self.get_running_task(network_name) - if data is None: - raise UpstageError(f"No processes named {network_name} is running.") - data.process.interrupt(**interrupt_kwargs) - - def has_task_network(self, network_id: Any) -> bool: - """Test if a network id exists. - - Args: - network_id (Any): Typically a string for the network name. - - Returns: - bool: If the task network is on this actor. - """ - return network_id in self._task_networks - - def suggest_network_name(self, factory: TaskNetworkFactory) -> str: - """Deconflict names of task networks by suggesting a new name. - - Used for creating multiple parallel task networks. - - Args: - factory (TaskNetworkFactory): The factory from which you will create the network. - - Returns: - str: The network name to use - """ - new_name = factory.name - if new_name not in self._task_networks: - return new_name - i = 0 - while new_name in self._task_networks: - i += 1 - new_name = f"{factory.name}_{i}" - return new_name - - def delete_task_network(self, network_id: Any) -> None: - """Deletes a task network reference. - - Be careful, the network may still be running! - - Do any interruptions on your own. - - Args: - network_id (Any): Typically a string for the network name. - """ - if not self.has_task_network(network_id): - raise SimulationError(f"No networked with id: {network_id} to delete") - del self._task_networks[network_id] - - def rehearse_network( - self, - network_name: str, - task_name_list: list[str], - knowledge: dict[str, Any] | None = None, - end_task: str | None = None, - ) -> Self: - """Rehearse a network on this actor. - - Supply the network name, the tasks to rehearse from this state, and - any knowledge to apply to the cloned actor. - - Args: - network_name (str): Network name - task_name_list (list[str]): Tasks to rehearse on the network. - knowledge (dict[str, Any], optional): knowledge to give to the cloned - actor. Defaults to None. - end_task (str, optional): A task to end on once reached. - - Returns: - Actor: The cloned actor after rehearsing the network. - """ - knowledge = {} if knowledge is None else knowledge - net = self._task_networks[network_name] - understudy = net.rehearse_network( - actor=self, - task_name_list=task_name_list, - knowledge=knowledge, - end_task=end_task, - ) - return understudy - - def clone( - self, - new_env: MockEnvironment | None = None, - knowledge: dict[str, Any] | None = None, - **new_states: Any, - ) -> Self: - """Clones an actor and assigns it a new environment. - - Note: - This function is useful when testing if an actor can accomplish a - task. - - In general, cloned actor are referred to as ``understudy`` - to keep with the theater analogy. - - The clones' names are appended with the label ``'[CLONE #]'`` where - ``'#'`` indicates the number of clones of the actor. - - Args: - new_env (Optional[MockEnvironment], optional): Environment for cloning. - Defaults to None. - knowledge (Optional[dict[str, Any]], optional): Knowledge for the clone. - Defaults to None. - new_states (Any): New states to add to the actor when cloning. - - Returns: - Actor: The cloned actor of the same type - """ - knowledge = {} if knowledge is None else knowledge - new_env = MockEnvironment.mock(self.env) if new_env is None else new_env - - states: dict[str, Any] = {} - for state in self.states: - state_obj = self._state_defs[state] - if isinstance(state_obj, ResourceState): - states[state] = state_obj._make_clone(self, getattr(self, state)) - else: - states[state] = copy(getattr(self, state)) - states.update(new_states) - - self._num_clones += 1 - - clone = self.__class__( - name=self.name + f" [CLONE {self._num_clones}]", - debug_log=self._debug_logging, - **states, - ) - clone.env = new_env - - ignored_attributes = list(states.keys()) + ["env", "stage"] - - for attribute_name, attribute in self.__class__.__dict__.items(): - if not any( - ( - attribute_name in ignored_attributes, - attribute_name.startswith("_"), - callable(attribute), - ) - ): - setattr(clone, attribute_name, attribute) - - # update the state histories - for state_name in self._state_defs: - if state_name in self._state_histories: - clone._state_histories[state_name] = deepcopy(self._state_histories[state_name]) - - clone._knowledge = {} - for name, data in self._knowledge.items(): - clone._knowledge[name] = copy(data) - - for name, data in knowledge.items(): - clone._knowledge[name] = copy(data) - - clone._task_queue = copy(self._task_queue) - clone._task_networks = copy(self._task_networks) - - if clone._debug_logging: - clone._debug_log = list(self._debug_log) - - clone._is_rehearsing = True - return clone - - def log(self, msg: str | None = None) -> list[str] | None: - """Add to the log or return it. - - Only adds to log if debug_logging is True. - - Args: - msg (str, Optional): The message to log. - - 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 += [msg] - elif msg is None: - return self._debug_log - return None - - def get_log(self) -> list[str]: - """Get the debug log. - - Returns: - list[str]: List of log messages. - """ - return self._debug_log - - @property - def states(self) -> tuple[str, ...]: - """Get the names of the actor's states. - - Returns: - tuple[str]: State names - """ - return tuple(self._state_defs.keys()) - - @property - def state_values(self) -> dict[str, Any]: - """Get the state names and values. - - Returns: - dict[str, Any]: State name:value pairs. - """ - return {k: getattr(self, k) for k in self.states} - - def _get_detection_state(self) -> None | str: - """Find the name of a state is of type DetectabilityState. - - Returns: - None | str: The name of the state (None if none found). - """ - detection = [k for k, v in self._state_defs.items() if isinstance(v, DetectabilityState)] - if len(detection) > 1: - raise NotImplementedError("Only 1 state of type DetectabilityState allowed for now") - return None if not detection else detection[0] - - def _match_attr(self, name: str) -> str | None: - """Test if self has a matching attribute name. - - Args: - name (str): The attribute to find - - Returns: - str | None: The name if it has it, None otherwise. - """ - if not hasattr(self, name): - return None - return name - - def _get_matching_state( - self, - state_class: type[State], - attr_matches: dict[str, Any] | None = None, - ) -> str | None: - """Find a state that matches the class and optional attributes and return its name. - - For multiple states with the same class, this returns the first available. - - Args: - state_class (State): The class of state to search for - attr_matches (Optional[dict[str, Any]], optional): Attributes and values - to match. Defaults to None. - - Returns: - str | None: The name of the state (for getattr) - """ - - def match_tester(nm: str, val: Any, state: State) -> bool: - if hasattr(state, nm): - matching: bool = getattr(state, nm) == val - return matching - return False - - for name, state in self._state_defs.items(): - if not isinstance(state, state_class): - continue - - if attr_matches is None: - return self._match_attr(name) - - has_attribute_matches = all( - match_tester(nm, val, state) for nm, val in attr_matches.items() - ) - if has_attribute_matches: - return self._match_attr(name) - return None - - def create_knowledge_event( - self, - *, - name: str, - rehearsal_time_to_complete: float = 0.0, - ) -> Event: - """Create an event and store it in knowledge. - - Useful for creating simple hold points in Tasks that can be succeeded by - other processes. - - Example: - >>> def task(self, actor): - >>> evt = actor.create_knowledge_event(name="hold") - >>> yield evt - >>> ... # do things - ... - >>> def other_task(self, actor): - >>> if condition: - >>> actor.succeed_knowledge_event(name="hold") - - Args: - name (str): Name of the knowledge slot to store the event in. - rehearsal_time_to_complete (float, optional): The event's expected - time to complete. Defaults to 0.0. - - Returns: - Event: The event to yield on - """ - event = Event(rehearsal_time_to_complete=rehearsal_time_to_complete) - # Rehearsals on this method won't clear the event, so save the user some trouble. - overwrite = True if self._is_rehearsing else False - self.set_knowledge(name, event, overwrite=overwrite) - return event - - def succeed_knowledge_event(self, *, name: str, **kwargs: Any) -> None: - """Succeed and clear an event stored in the actor's knowledge. - - See "create_knowledge_event" for usage example. - - Args: - name (str): Event knowledge name. - **kwargs (Any): Any payload to send to the event. Defaults to None - """ - event = self.get_knowledge(name) - if event is None: - raise SimulationError(f"No knowledge named {name} to succeed") - if not isinstance(event, Event): - raise SimulationError(f"Knowledge {name} is not an Event.") - self.clear_knowledge(name, "actor.succeed_knowledge_event") - event.succeed(**kwargs) - - def get_remaining_waypoints( - self, location_state: str - ) -> list[GeodeticLocation] | list[CartesianLocation]: - """Convenience method for interacting with LocationChangingStates. - - Primary use case is when restarting a Task that has a motion element to - allow updating waypoint knowledge easily. - - Args: - location_state (str): The name of the - - Returns: - list[Location]: List of waypoints yet to be reached - """ - loc_state = self._state_defs[location_state] - assert isinstance(loc_state, GeodeticLocationChangingState | CartesianLocationChangingState) - wypts = loc_state._get_remaining_waypoints(self) - return wypts - - def get_nucleus(self) -> "TaskNetworkNucleus": - """Return the actor's nucleus. - - Returns: - TaskNetworkNucleus: The nucleus on the actor. - """ - if self._state_listener is None: - raise SimulationError("Expected a nucleus, but none found.") - return self._state_listener - - def __repr__(self) -> str: - return f"{self.__class__.__name__}: {self.name}" +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""This file contains the fundamental Actor class for UPSTAGE.""" + +from collections import defaultdict +from collections.abc import Callable, Iterable +from copy import copy, deepcopy +from dataclasses import dataclass +from inspect import Parameter, signature +from typing import TYPE_CHECKING, Any, Self + +from simpy import Process + +from upstage_des.events import Event + +from .base import ( + MockEnvironment, + NamedUpstageEntity, + SettableEnv, + SimulationError, + UpstageError, +) +from .data_types import CartesianLocation, GeodeticLocation +from .states import ( + ActiveState, + CartesianLocationChangingState, + DetectabilityState, + GeodeticLocationChangingState, + ResourceState, + State, +) +from .task import Task +from .task_network import TaskNetwork, TaskNetworkFactory +from .utils import get_caller_info, get_caller_object + +__all__ = ("Actor",) + +if TYPE_CHECKING: + from .nucleus import TaskNetworkNucleus + +LOC_STATE = GeodeticLocationChangingState | CartesianLocationChangingState +LOCATIONS = GeodeticLocation | CartesianLocation +LOC_LIST = list[GeodeticLocation] | list[CartesianLocation] + + +@dataclass +class TaskData: + name: str + process: Process + + +class Actor(SettableEnv, NamedUpstageEntity): + """Actors perform tasks and are composed of states. + + You can subclass, but do not overwrite __init_subclass__. + + Always super().__init__(). + + Parameters + ---------- + name : str + The name of the actor. This is a required attribute. + debug_log : bool, optional + Run the debug logger which captures runtime information about the + actor. + **states + Keyword arguments to set the values of the actor's states. + + Raises: + ------ + ValueError + If the keys of the states passed as keyword arguments do not match the + names of the actor's states. + + """ + + def __init_states(self, **states: Any) -> None: + seen = set() + for state, value in states.items(): + if state in self._state_defs: + seen.add(state) + setattr(self, state, value) + else: + raise UpstageError(f"Input to {self} was not expected: {state}={value}") + exist = set(self._state_defs.keys()) + unseen = exist - seen + for state_name in unseen: + if self._state_defs[state_name].has_default(): + seen.add(state_name) + if len(seen) != len(exist): + raise UpstageError( + f"Missing values for states! These states need values: " + f"{exist - seen} to be specified for '{self.name}'." + ) + if "log" in seen: + raise UpstageError("Do not name a state `log`") + + def __init__(self, *, name: str, debug_log: bool = True, **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. + states (Any): Values for each state as kwargs. + """ + self.name = name + super().__init__() + + self._active_states: dict[str, dict[str, Any]] = {} + self._num_clones: int = 0 + self._state_defs: dict[str, State] = getattr(self.__class__, "_state_defs", {}) + + self._mimic_states: dict[str, tuple[Actor, str]] = {} # has to be before other calls + self._mimic_states_by_task: dict[Task, set[str]] = defaultdict(set) + + self._states_by_task: dict[Task, set[str]] = defaultdict(set) + self._tasks_by_state: dict[str, set[Task]] = defaultdict(set) + + self._task_networks: dict[str, TaskNetwork] = {} + self._task_queue: dict[str, list[str]] = {} + + self._knowledge: dict[str, Any] = {} + self._is_rehearsing: bool = False + + self._debug_logging: bool = debug_log + self._debug_log: list[str] = [] + + self._state_histories: dict[str, list[tuple[float, Any]]] = {} + + # Task Network Nucleus hook-ins + self._state_listener: TaskNetworkNucleus | None = None + + self.__init_states(**states) + + def __init_subclass__( + cls, + *args: Any, + entity_groups: Iterable[str] | str | None = None, + **kwargs: Any, + ) -> None: + super().__init_subclass__(entity_groups=entity_groups) + # get the states + states = {} + all_states = {} + # This ensures that newer classes overwrite older states + for base_class in cls.mro()[::-1]: + for state_name, state in base_class.__dict__.items(): + if isinstance(state, State): + if base_class == cls: + states[state_name] = state + state.name = state_name + all_states[state_name] = state + cls._state_defs = all_states + + nxt = cls.mro()[1] + if nxt is object: + raise UpstageError(f"Actor has bad inheritance, MRO: {cls.mro()}") + + sig = signature(cls.__init__) + params = list(sig.parameters.values()) + # Find the "states=" parameter of the signature and remove it. + state_parameter = [x for x in params if x.name == "states"] + if state_parameter: + params.remove(state_parameter[0]) + for state in states: + params.insert(-1, Parameter(state, Parameter.KEYWORD_ONLY)) + try: + setattr(cls.__init__, "__signature__", sig.replace(parameters=params)) + except ValueError as e: + e.add_note("Failure likely due to repeated state name in inherited actor") + raise e + + def _lock_state(self, *, state: str, task: Task) -> None: + """Lock one of the actor's states by a given task. + + Args: + state (str): The name of the state to lock + task (Task): The task that is locking the state + """ + the_state = self._state_defs[state] + if not the_state.IGNORE_LOCK: + # single-task only, so no task should + # be associated with this state + if self._tasks_by_state[state]: + raise SimulationError( + f"State '{state}' cannot be used by '{task}' because it is " + f"locked by {self._tasks_by_state[state]}" + ) + else: + # We can have multiple locks, but make sure we are repeating a lock + if task in self._tasks_by_state[state]: + raise SimulationError( + f"State '{state}' already locked by '{task}'. " + "Did you forget to unlock/deactivate it?" + ) + self._states_by_task[task].add(state) + self._tasks_by_state[state].add(task) + + def _set_active_state_data( + self, + state_name: str, + started_at: float | None = None, + **data: Any, + ) -> None: + """Set the data for an active state. + + Args: + state_name (str): Name of the state + started_at (Optional[float], optional): Time the data is set at. Defaults to None. + **data (Any): key:values as kwargs for the state data. + """ + # Rule: underscored active data will get remembered + started_at = self.env.now if started_at is None else started_at + old_data = self._active_states.get(state_name, {}) + new_data = {"started_at": started_at, **data} + keep_old = {k: v for k, v in old_data.items() if k not in new_data and "_" == k[0]} + new_data.update(keep_old) + self._active_states[state_name] = new_data + + def activate_state( + self, + *, + state: str, + task: Task, + **kwargs: Any, + ) -> None: + """Set a state as active. + + Note: + This method is used by the tasks for activating states they use/modify. + + TODO: on init, create `activate_` methods that type-hint the inputs + + Args: + state (str): The name of the state to set as active. + task (Task): The task that is activating the state. + **kwargs (Any): key:values as kwargs for the state activation. + """ + if state not in self._state_defs: + raise SimulationError(f"No state named '{state}' to activate") + self._lock_state(state=state, task=task) + 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) + + def activate_linear_state(self, *, state: str, rate: float, task: Task) -> None: + """Shortcut for activating a LinearChangingState. + + Args: + state (str): The name of the LinearChangingState to set as active. + rate (float): The rate of the change + task (Task): The task that is activating + """ + self.activate_state(state=state, task=task, rate=rate) + + def activate_location_state( + self, *, state: str, speed: float, waypoints: LOC_LIST, task: Task + ) -> None: + """Shortcut for activating a (Cartesian|Geodetic)LocationChangingState. + + Args: + state (str): State name + speed (float): The speed to move at + waypoints (LOC_LIST): Waypoints to move over + task (Task): The task that the state is activated during. + """ + self.activate_state( + state=state, + speed=speed, + waypoints=waypoints, + task=task, + ) + + def _unlock_state(self, *, state: str, task: Task) -> None: + """Release a task's lock of a state. + + Args: + state (str): The name of the state to lock + task (Task): The task that is locking the state + """ + the_state = self._state_defs[state] + if not the_state.IGNORE_LOCK: + # single-task only, so only one task should + # be associated with this state + if task not in self._tasks_by_state[state]: + raise SimulationError( + f"State `{state}` isn't locked by '{task}', but it's trying to be unlocked." + ) + self._states_by_task[task].remove(state) + self._tasks_by_state[state].remove(task) + elif task in self._tasks_by_state[state]: + self._states_by_task[task].remove(state) + self._tasks_by_state[state].remove(task) + else: + raise UpstageError(f"State '{state}' was not activated by '{task}', cannot deactivate") + + def deactivate_states(self, *, states: str | Iterable[str], task: Task) -> None: + """Set a list of active states to not active. + + Args: + states (str | Iterable[str]): The names of the states to deactivate. + task (Task): The task that is deactivating the states. + """ + if isinstance(states, str): + states = [states] + + for state in states: + self.deactivate_state(state=state, task=task) + + def deactivate_state(self, *, state: str, task: Task) -> None: + """Deactivate a specific state. + + Args: + state (str): The name of the state to deactivate. + task (Task): The task that is deactivating the state. + """ + self._unlock_state(state=state, task=task) + + # the deactivated state may need to be updated + getattr(self, state) + # and then deactivate it, only if it was unlocked + the_state = self._state_defs[state] + if not isinstance(the_state, ActiveState): + raise UpstageError(f"Stage {state} is not an active type state.") + ignore = the_state.deactivate(self, task=task) + if state in self._active_states and not ignore: + del self._active_states[state] + + def deactivate_all_states(self, *, task: Task) -> None: + """Deactivate all states in the actor for a given task. + + Args: + task (Task): The task that is deactivating the states. + """ + state_names = list(self._states_by_task[task]) + self.deactivate_states(states=state_names, task=task) + + def get_active_state_data( + self, state_name: str, without_update: bool = False + ) -> dict[str, Any]: + """Get the data for a specific state. + + Args: + state_name (str): The name of the state for which to retrieve the data. + without_update (bool): Whether or not to update the state to the current + sim time. Defaults to True + + Returns: + dict[str, Any]: The state data. + """ + if not without_update: + getattr(self, state_name) + ans: dict[str, Any] = self._active_states.get(state_name, {}) + return ans + + def _mimic_state_name(self, self_state: str) -> str: + """Create a mimic state name. + + Args: + self_state (str): The name of the state + + Returns: + str: Mimic-safe name + """ + return f"{id(self)}-{self_state}" + + def activate_mimic_state( + self, + *, + self_state: str, + mimic_state: str, + mimic_actor: "Actor", + task: Task, + ) -> None: + """Activate a state to mimic a state on another actor. + + Args: + self_state (str): State name to be the mimic + mimic_state (str): State on the other actor to be mimiced + mimic_actor (Actor): The other actor. + task (Task): The task during which the state is mimiced. + """ + caller = get_caller_object() + if isinstance(caller, Task) and caller._rehearsing: + raise UpstageError("Mimic state activated on rehearsal. This is unsupported/unstable") + if self_state in self._mimic_states: + raise UpstageError(f"{self_state} already mimicked") + self._mimic_states[self_state] = (mimic_actor, mimic_state) + self._mimic_states_by_task[task].add(self_state) + + state = self._state_defs[self_state] + self_state_name = self._mimic_state_name(self_state) + if state.is_recording: + + def recorder(instance: Actor, value: Any) -> None: + if instance is mimic_actor: + state._do_record(self, value) + + mimic_actor._add_callback_to_state(self_state_name, recorder, mimic_state) + + def deactivate_mimic_state(self, *, self_state: str, task: Task) -> None: + """Deactivate a mimicking state. + + Args: + self_state (str): State name + task (Task): Task it's running in. + """ + getattr(self, self_state) + mimic_actor, mimic_state = self._mimic_states[self_state] + state = self._state_defs[self_state] + self_state_name = self._mimic_state_name(self_state) + if state.is_recording: + mimic_actor._remove_callback_from_state(self_state_name, mimic_state) + del self._mimic_states[self_state] + self._mimic_states_by_task[task].remove(self_state) + + def deactivate_all_mimic_states(self, *, task: Task) -> None: + """Deactivate all mimicking states in the actor for a given task. + + Args: + task (Task): The task where states are mimicking others. + """ + for state in list(self._mimic_states): + self.deactivate_mimic_state(self_state=state, task=task) + + def _add_callback_to_state( + self, + source: Any, + callback: Callable[["Actor", Any], Any], + state_name: str, + ) -> None: + """Add a callback to a state for recording. + + Args: + source (Any): The source for keying the callback (unused, but for the key) + callback (Callable[[Actor, Any], Any]): Takes the actor and state value + state_name (str): _description_ + """ + state: State = self._state_defs[state_name] + state._add_callback(source, callback) + + def _remove_callback_from_state( + self, + source: Any, + state_name: str, + ) -> None: + """Remove a state callback based on the source key. + + Args: + source (Any): Callback key + state_name (str): Name of the state with the callback. + """ + state = self._state_defs[state_name] + state._remove_callback(source) + + def get_knowledge(self, name: str, must_exist: bool = False) -> Any: + """Get a knowledge value from the actor. + + Args: + name (str): The name of the knowledge + must_exist (bool): Raise an error if the knowledge isn't present. Defaults to false. + + Returns: + Any: The knowledge value. None if the name doesn't exist. + """ + if must_exist and name not in self._knowledge: + raise SimulationError(f"Knowledge '{name}' does not exist in {self}") + return self._knowledge.get(name, None) + + def _log_caller( + self, + method_name: str = "", + caller_level: int = 1, + caller_name: str | None = None, + ) -> None: + """Log information about who is calling this method. + + If no caller_name is given, it is searched for in the stack. + + Args: + method_name (str, optional): Method name for logging. Defaults to "". + caller_level (int, optional): Level to look up for the caller. Defaults to 1. + caller_name (Optional[str], optional): Name of the caller. Defaults to None. + """ + if caller_name is None: + info = get_caller_info(caller_level=caller_level + 1) + else: + info = caller_name + self.log(f"method '{method_name}' called by '{info}'") + + def set_knowledge( + self, + name: str, + value: Any, + overwrite: bool = False, + caller: str | None = None, + ) -> None: + """Set a knowledge value. + + Raises an error if the knowledge exists and overwrite is False. + + Args: + name (str): The name of the knowledge item. + value (Any): The value to store for the knowledge. + overwrite (bool, Optional): Allow the knowledge to be changed if it exits. + Defaults to False. + caller (str, Optional): The name of the object that called the method. + """ + self._log_caller(f"set_knowledge '{name}={value}'", caller_name=caller) + if name in self._knowledge and not overwrite: + raise SimulationError( + f"Actor {self} overwriting existing knowledge {name} " + f"without override permission. \n" + f"Current: {self._knowledge[name]}, New: {value}" + ) + else: + self._knowledge[name] = value + + def clear_knowledge(self, name: str, caller: str | None = None) -> None: + """Clear a knowledge value. + + Raises an error if the knowledge does not exist. + + Args: + name (str): The name of the knowledge item to clear. + caller (str): The name of the Task that called the method. + Used for debug logging purposes. + + """ + self._log_caller(f"clear_knowledge '{name}'", caller_name=caller) + if name not in self._knowledge: + raise SimulationError(f"Actor {self} does not have knowledge: {name}") + else: + del self._knowledge[name] + + def add_task_network(self, network: TaskNetwork) -> None: + """Add a task network to the actor. + + Args: + network (TaskNetwork): The task network to add to the actor. + """ + network_name = network.name + if network_name in self._task_networks: + raise SimulationError(f"Task network{network_name} already in {self}") + self._task_networks[network_name] = network + self._task_queue[network_name] = [] + + def clear_task_queue(self, network_name: str) -> None: + """Empty the actor's task queue. + + This will cause the task network to be used for task flow. + + Args: + network_name (str): The name of the network to clear the task queue. + """ + self._log_caller("clear_task_queue") + self._task_queue[network_name] = [] + + def set_task_queue(self, network_name: str, task_list: list[str]) -> None: + """Initialize an actor's empty task queue. + + Args: + network_name (str): Task Network name + task_list (list[str]): List of task names to queue. + + Raises: + SimulationError: _description_ + """ + self._log_caller("set_task_queue") + if self._task_queue[network_name]: + raise SimulationError(f"Task queue on {self.name} is already set. Use append or clear.") + self._task_queue[network_name] = list(task_list) + + def get_task_queue(self, network_name: str) -> list[str]: + """Get the actor's task queue on a single network. + + Args: + network_name (str): The network name + + Returns: + list[str]: List of task names in the queue + """ + return self._task_queue[network_name] + + def get_all_task_queues(self) -> dict[str, list[str]]: + """Get the task queues for all running networks. + + Returns: + dict[str, list[str]]: Task names, keyed on task network name. + """ + queues: dict[str, list[str]] = {} + for name in self._task_networks.keys(): + queues[name] = self.get_task_queue(name) + return queues + + def get_next_task(self, network_name: str) -> None | str: + """Return the next task the actor has been told if there is one. + + This does not clear the task, it's information only. + + Args: + network_name (str): The name of the network + + Returns: + None | str: The name of the next task, None if no next task. + """ + queue = self._task_queue[network_name] + queue_length = len(queue) + return None if queue_length == 0 else queue[0] + + def _clear_task(self, network_name: str) -> None: + """Clear a task from the queue. + + Useful for rehearsal. + """ + self._task_queue[network_name].pop(0) + + def _begin_next_task(self, network_name: str, task_name: str) -> None: + """Clear the first task in the task queue. + + The task name is required to check that the next task follows the actor's plan. + + Args: + network_name (str): The task network name + task_name (str): The name of the task to start + """ + queue = self._task_queue.get(network_name) + if queue and queue[0] != task_name: + raise SimulationError( + f"Actor {self.name} commanded to perform '{task_name}' " + f"but '{queue[0]}' is expected" + ) + elif not queue: + self.set_task_queue(network_name, [task_name]) + self.log(f"begin_next_task: Starting {task_name} task") + self._task_queue[network_name].pop(0) + + def start_network_loop( + self, + network_name: str, + init_task_name: str | None = None, + ) -> None: + """Start a task network looping/running on an actor. + + If no task name is given, it will default to following the queue. + + Args: + network_name (str): Network name. + init_task_name (str, optional): Task to start with. Defaults to None. + """ + network = self._task_networks[network_name] + network.loop(actor=self, init_task_name=init_task_name) + + def get_running_task(self, network_name: str) -> TaskData | None: + """Return name and process reference of a task on this Actor's task network. + + Useful for finding a process to call interrupt() on. + + Args: + network_name (str): Network name. + + Returns: + TaskData: Dataclass of name and process for the current task. + {"name": Name, "process": the Process simpy is holding.} + """ + if network_name not in self._task_networks: + raise SimulationError(f"{self} does not have a task networked named {network_name}") + net = self._task_networks[network_name] + if net._current_task_proc is not None: + assert net._current_task_name is not None + assert net._current_task_proc is not None + task_data = TaskData(name=net._current_task_name, process=net._current_task_proc) + return task_data + return None + + def get_running_tasks(self) -> dict[str, TaskData]: + """Get all running task data. + + Returns: + dict[str, dict[str, TaskData]]: Dictionary of all running tasks. + Keyed on network name, then {"name": Name, "process": ...} + """ + tasks: dict[str, TaskData] = {} + for name, net in self._task_networks.items(): + if net._current_task_proc is not None: + assert net._current_task_name is not None + tasks[name] = TaskData(name=net._current_task_name, process=net._current_task_proc) + return tasks + + def interrupt_network(self, network_name: str, **interrupt_kwargs: Any) -> None: + """Interrupt a running task network. + + Args: + network_name (str): The name of the network. + interrupt_kwargs (Any): kwargs to pass to the interrupt. + """ + data = self.get_running_task(network_name) + if data is None: + raise UpstageError(f"No processes named {network_name} is running.") + data.process.interrupt(**interrupt_kwargs) + + def has_task_network(self, network_id: Any) -> bool: + """Test if a network id exists. + + Args: + network_id (Any): Typically a string for the network name. + + Returns: + bool: If the task network is on this actor. + """ + return network_id in self._task_networks + + def suggest_network_name(self, factory: TaskNetworkFactory) -> str: + """Deconflict names of task networks by suggesting a new name. + + Used for creating multiple parallel task networks. + + Args: + factory (TaskNetworkFactory): The factory from which you will create the network. + + Returns: + str: The network name to use + """ + new_name = factory.name + if new_name not in self._task_networks: + return new_name + i = 0 + while new_name in self._task_networks: + i += 1 + new_name = f"{factory.name}_{i}" + return new_name + + def delete_task_network(self, network_id: Any) -> None: + """Deletes a task network reference. + + Be careful, the network may still be running! + + Do any interruptions on your own. + + Args: + network_id (Any): Typically a string for the network name. + """ + if not self.has_task_network(network_id): + raise SimulationError(f"No networked with id: {network_id} to delete") + del self._task_networks[network_id] + + def rehearse_network( + self, + network_name: str, + task_name_list: list[str], + knowledge: dict[str, Any] | None = None, + end_task: str | None = None, + ) -> Self: + """Rehearse a network on this actor. + + Supply the network name, the tasks to rehearse from this state, and + any knowledge to apply to the cloned actor. + + Args: + network_name (str): Network name + task_name_list (list[str]): Tasks to rehearse on the network. + knowledge (dict[str, Any], optional): knowledge to give to the cloned + actor. Defaults to None. + end_task (str, optional): A task to end on once reached. + + Returns: + Actor: The cloned actor after rehearsing the network. + """ + knowledge = {} if knowledge is None else knowledge + net = self._task_networks[network_name] + understudy = net.rehearse_network( + actor=self, + task_name_list=task_name_list, + knowledge=knowledge, + end_task=end_task, + ) + return understudy + + def clone( + self, + new_env: MockEnvironment | None = None, + knowledge: dict[str, Any] | None = None, + **new_states: Any, + ) -> Self: + """Clones an actor and assigns it a new environment. + + Note: + This function is useful when testing if an actor can accomplish a + task. + + In general, cloned actor are referred to as ``understudy`` + to keep with the theater analogy. + + The clones' names are appended with the label ``'[CLONE #]'`` where + ``'#'`` indicates the number of clones of the actor. + + Args: + new_env (Optional[MockEnvironment], optional): Environment for cloning. + Defaults to None. + knowledge (Optional[dict[str, Any]], optional): Knowledge for the clone. + Defaults to None. + new_states (Any): New states to add to the actor when cloning. + + Returns: + Actor: The cloned actor of the same type + """ + knowledge = {} if knowledge is None else knowledge + new_env = MockEnvironment.mock(self.env) if new_env is None else new_env + + states: dict[str, Any] = {} + for state in self.states: + state_obj = self._state_defs[state] + if isinstance(state_obj, ResourceState): + states[state] = state_obj._make_clone(self, getattr(self, state)) + else: + states[state] = copy(getattr(self, state)) + states.update(new_states) + + self._num_clones += 1 + + clone = self.__class__( + name=self.name + f" [CLONE {self._num_clones}]", + debug_log=self._debug_logging, + **states, + ) + clone.env = new_env + + ignored_attributes = list(states.keys()) + ["env", "stage"] + + for attribute_name, attribute in self.__class__.__dict__.items(): + if not any( + ( + attribute_name in ignored_attributes, + attribute_name.startswith("_"), + callable(attribute), + ) + ): + setattr(clone, attribute_name, attribute) + + # update the state histories + for state_name in self._state_defs: + if state_name in self._state_histories: + clone._state_histories[state_name] = deepcopy(self._state_histories[state_name]) + + clone._knowledge = {} + for name, data in self._knowledge.items(): + clone._knowledge[name] = copy(data) + + for name, data in knowledge.items(): + clone._knowledge[name] = copy(data) + + clone._task_queue = copy(self._task_queue) + clone._task_networks = copy(self._task_networks) + + if clone._debug_logging: + clone._debug_log = list(self._debug_log) + + clone._is_rehearsing = True + return clone + + def log(self, msg: str | None = None) -> list[str] | None: + """Add to the log or return it. + + Only adds to log if debug_logging is True. + + Args: + msg (str, Optional): The message to log. + + 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 += [msg] + elif msg is None: + return self._debug_log + return None + + def get_log(self) -> list[str]: + """Get the debug log. + + Returns: + list[str]: List of log messages. + """ + return self._debug_log + + @property + def states(self) -> tuple[str, ...]: + """Get the names of the actor's states. + + Returns: + tuple[str]: State names + """ + return tuple(self._state_defs.keys()) + + @property + def state_values(self) -> dict[str, Any]: + """Get the state names and values. + + Returns: + dict[str, Any]: State name:value pairs. + """ + return {k: getattr(self, k) for k in self.states} + + def _get_detection_state(self) -> None | str: + """Find the name of a state is of type DetectabilityState. + + Returns: + None | str: The name of the state (None if none found). + """ + detection = [k for k, v in self._state_defs.items() if isinstance(v, DetectabilityState)] + if len(detection) > 1: + raise NotImplementedError("Only 1 state of type DetectabilityState allowed for now") + return None if not detection else detection[0] + + def _match_attr(self, name: str) -> str | None: + """Test if self has a matching attribute name. + + Args: + name (str): The attribute to find + + Returns: + str | None: The name if it has it, None otherwise. + """ + if not hasattr(self, name): + return None + return name + + def _get_matching_state( + self, + state_class: type[State], + attr_matches: dict[str, Any] | None = None, + ) -> str | None: + """Find a state that matches the class and optional attributes and return its name. + + For multiple states with the same class, this returns the first available. + + Args: + state_class (State): The class of state to search for + attr_matches (Optional[dict[str, Any]], optional): Attributes and values + to match. Defaults to None. + + Returns: + str | None: The name of the state (for getattr) + """ + + def match_tester(nm: str, val: Any, state: State) -> bool: + if hasattr(state, nm): + matching: bool = getattr(state, nm) == val + return matching + return False + + for name, state in self._state_defs.items(): + if not isinstance(state, state_class): + continue + + if attr_matches is None: + return self._match_attr(name) + + has_attribute_matches = all( + match_tester(nm, val, state) for nm, val in attr_matches.items() + ) + if has_attribute_matches: + return self._match_attr(name) + return None + + def create_knowledge_event( + self, + *, + name: str, + rehearsal_time_to_complete: float = 0.0, + ) -> Event: + """Create an event and store it in knowledge. + + Useful for creating simple hold points in Tasks that can be succeeded by + other processes. + + Example: + >>> def task(self, actor): + >>> evt = actor.create_knowledge_event(name="hold") + >>> yield evt + >>> ... # do things + ... + >>> def other_task(self, actor): + >>> if condition: + >>> actor.succeed_knowledge_event(name="hold") + + Args: + name (str): Name of the knowledge slot to store the event in. + rehearsal_time_to_complete (float, optional): The event's expected + time to complete. Defaults to 0.0. + + Returns: + Event: The event to yield on + """ + event = Event(rehearsal_time_to_complete=rehearsal_time_to_complete) + # Rehearsals on this method won't clear the event, so save the user some trouble. + overwrite = True if self._is_rehearsing else False + self.set_knowledge(name, event, overwrite=overwrite) + return event + + def succeed_knowledge_event(self, *, name: str, **kwargs: Any) -> None: + """Succeed and clear an event stored in the actor's knowledge. + + See "create_knowledge_event" for usage example. + + Args: + name (str): Event knowledge name. + **kwargs (Any): Any payload to send to the event. Defaults to None + """ + event = self.get_knowledge(name) + if event is None: + raise SimulationError(f"No knowledge named {name} to succeed") + if not isinstance(event, Event): + raise SimulationError(f"Knowledge {name} is not an Event.") + self.clear_knowledge(name, "actor.succeed_knowledge_event") + event.succeed(**kwargs) + + def get_remaining_waypoints( + self, location_state: str + ) -> list[GeodeticLocation] | list[CartesianLocation]: + """Convenience method for interacting with LocationChangingStates. + + Primary use case is when restarting a Task that has a motion element to + allow updating waypoint knowledge easily. + + Args: + location_state (str): The name of the + + Returns: + list[Location]: List of waypoints yet to be reached + """ + loc_state = self._state_defs[location_state] + assert isinstance(loc_state, GeodeticLocationChangingState | CartesianLocationChangingState) + wypts = loc_state._get_remaining_waypoints(self) + return wypts + + def get_nucleus(self) -> "TaskNetworkNucleus": + """Return the actor's nucleus. + + Returns: + TaskNetworkNucleus: The nucleus on the actor. + """ + if self._state_listener is None: + raise SimulationError("Expected a nucleus, but none found.") + return self._state_listener + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.name}" diff --git a/src/upstage/api.py b/src/upstage_des/api.py similarity index 71% rename from src/upstage/api.py rename to src/upstage_des/api.py index 75b533b..f69ae3b 100644 --- a/src/upstage/api.py +++ b/src/upstage_des/api.py @@ -1,153 +1,153 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""The elements in the UPSTAGE Application Programmable Interface.""" - -# Core -# Director, stage, and Exceptions -# Actor -from upstage.actor import Actor -from upstage.base import ( - EnvironmentContext, - MotionAndDetectionError, - NamedUpstageEntity, - RulesError, - SimulationError, - UpstageBase, - UpstageError, - add_stage_variable, - get_stage, - get_stage_variable, -) - -# Comms -from upstage.communications.comms import CommsManager, Message, MessageContent - -# Constants -from upstage.constants import PLANNING_FACTOR_OBJECT - -# Data types -from upstage.data_types import ( - CartesianLocation, - CartesianLocationData, - GeodeticLocation, - GeodeticLocationData, - Location, -) - -# Events -from upstage.events import All, Any, Event, FilterGet, Get, Put, ResourceHold, Wait - -# Motion -from upstage.motion import SensorMotionManager, SteppedMotionManager - -# Task network nucleus -from upstage.nucleus import NucleusInterrupt, TaskNetworkNucleus - -# Resources -from upstage.resources.container import ( - ContainerEmptyError, - ContainerError, - ContainerFullError, - ContinuousContainer, -) -from upstage.resources.monitoring import ( - SelfMonitoringContainer, - SelfMonitoringContinuousContainer, - SelfMonitoringFilterStore, - SelfMonitoringReserveStore, - SelfMonitoringSortedFilterStore, - SelfMonitoringStore, -) -from upstage.resources.reserve import ReserveStore -from upstage.resources.sorted import SortedFilterGet, SortedFilterStore - -# Nucleus-friendly states -from upstage.state_sharing import SharedLinearChangingState - -# States -from upstage.states import ( - CartesianLocationChangingState, - CommunicationStore, - DetectabilityState, - GeodeticLocationChangingState, - LinearChangingState, - ResourceState, - State, -) - -# Task -from upstage.task import DecisionTask, InterruptStates, Task, TerminalTask, process - -# Task Networks -from upstage.task_network import TaskLinks, TaskNetwork, TaskNetworkFactory - -# Conversion -from upstage.units import unit_convert - -__all__ = [ - "UpstageError", - "SimulationError", - "MotionAndDetectionError", - "RulesError", - "Actor", - "PLANNING_FACTOR_OBJECT", - "UpstageBase", - "NamedUpstageEntity", - "EnvironmentContext", - "add_stage_variable", - "get_stage_variable", - "get_stage", - "All", - "Any", - "Event", - "Get", - "FilterGet", - "SortedFilterGet", - "Put", - "ResourceHold", - "Wait", - "ContainerEmptyError", - "ContainerError", - "ContainerFullError", - "ContinuousContainer", - "SelfMonitoringContainer", - "SelfMonitoringContinuousContainer", - "SelfMonitoringFilterStore", - "SelfMonitoringSortedFilterStore", - "SelfMonitoringReserveStore", - "SelfMonitoringStore", - "ReserveStore", - "SortedFilterStore", - "CartesianLocation", - "GeodeticLocation", - "Location", - "CartesianLocationData", - "GeodeticLocationData", - "LinearChangingState", - "CartesianLocationChangingState", - "State", - "GeodeticLocationChangingState", - "DetectabilityState", - "ResourceState", - "CommunicationStore", - "DecisionTask", - "Task", - "process", - "InterruptStates", - "TerminalTask", - "TaskNetwork", - "TaskNetworkFactory", - "TaskLinks", - "TaskNetworkNucleus", - "NucleusInterrupt", - "SharedLinearChangingState", - "CommsManager", - "Message", - "MessageContent", - "SensorMotionManager", - "SteppedMotionManager", - "unit_convert", -] +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""The elements in the UPSTAGE Application Programmable Interface.""" + +# Core +# Director, stage, and Exceptions +# Actor +from upstage_des.actor import Actor +from upstage_des.base import ( + EnvironmentContext, + MotionAndDetectionError, + NamedUpstageEntity, + RulesError, + SimulationError, + UpstageBase, + UpstageError, + add_stage_variable, + get_stage, + get_stage_variable, +) + +# Comms +from upstage_des.communications.comms import CommsManager, Message, MessageContent + +# Constants +from upstage_des.constants import PLANNING_FACTOR_OBJECT + +# Data types +from upstage_des.data_types import ( + CartesianLocation, + CartesianLocationData, + GeodeticLocation, + GeodeticLocationData, + Location, +) + +# Events +from upstage_des.events import All, Any, Event, FilterGet, Get, Put, ResourceHold, Wait + +# Motion +from upstage_des.motion import SensorMotionManager, SteppedMotionManager + +# Task network nucleus +from upstage_des.nucleus import NucleusInterrupt, TaskNetworkNucleus + +# Resources +from upstage_des.resources.container import ( + ContainerEmptyError, + ContainerError, + ContainerFullError, + ContinuousContainer, +) +from upstage_des.resources.monitoring import ( + SelfMonitoringContainer, + SelfMonitoringContinuousContainer, + SelfMonitoringFilterStore, + SelfMonitoringReserveStore, + SelfMonitoringSortedFilterStore, + SelfMonitoringStore, +) +from upstage_des.resources.reserve import ReserveStore +from upstage_des.resources.sorted import SortedFilterGet, SortedFilterStore + +# Nucleus-friendly states +from upstage_des.state_sharing import SharedLinearChangingState + +# States +from upstage_des.states import ( + CartesianLocationChangingState, + CommunicationStore, + DetectabilityState, + GeodeticLocationChangingState, + LinearChangingState, + ResourceState, + State, +) + +# Task +from upstage_des.task import DecisionTask, InterruptStates, Task, TerminalTask, process + +# Task Networks +from upstage_des.task_network import TaskLinks, TaskNetwork, TaskNetworkFactory + +# Conversion +from upstage_des.units import unit_convert + +__all__ = [ + "UpstageError", + "SimulationError", + "MotionAndDetectionError", + "RulesError", + "Actor", + "PLANNING_FACTOR_OBJECT", + "UpstageBase", + "NamedUpstageEntity", + "EnvironmentContext", + "add_stage_variable", + "get_stage_variable", + "get_stage", + "All", + "Any", + "Event", + "Get", + "FilterGet", + "SortedFilterGet", + "Put", + "ResourceHold", + "Wait", + "ContainerEmptyError", + "ContainerError", + "ContainerFullError", + "ContinuousContainer", + "SelfMonitoringContainer", + "SelfMonitoringContinuousContainer", + "SelfMonitoringFilterStore", + "SelfMonitoringSortedFilterStore", + "SelfMonitoringReserveStore", + "SelfMonitoringStore", + "ReserveStore", + "SortedFilterStore", + "CartesianLocation", + "GeodeticLocation", + "Location", + "CartesianLocationData", + "GeodeticLocationData", + "LinearChangingState", + "CartesianLocationChangingState", + "State", + "GeodeticLocationChangingState", + "DetectabilityState", + "ResourceState", + "CommunicationStore", + "DecisionTask", + "Task", + "process", + "InterruptStates", + "TerminalTask", + "TaskNetwork", + "TaskNetworkFactory", + "TaskLinks", + "TaskNetworkNucleus", + "NucleusInterrupt", + "SharedLinearChangingState", + "CommsManager", + "Message", + "MessageContent", + "SensorMotionManager", + "SteppedMotionManager", + "unit_convert", +] diff --git a/src/upstage/base.py b/src/upstage_des/base.py similarity index 99% rename from src/upstage/base.py rename to src/upstage_des/base.py index c63cc11..4978fb3 100644 --- a/src/upstage/base.py +++ b/src/upstage_des/base.py @@ -17,8 +17,8 @@ from simpy import Environment as SimpyEnv -from upstage.geography import INTERSECTION_LOCATION_CALLABLE, EarthProtocol -from upstage.units import unit_convert +from upstage_des.geography import INTERSECTION_LOCATION_CALLABLE, EarthProtocol +from upstage_des.units import unit_convert CONTEXT_ERROR_MSG = "Undefined context variable: use EnvironmentContext" diff --git a/src/upstage/communications/__init__.py b/src/upstage_des/communications/__init__.py similarity index 100% rename from src/upstage/communications/__init__.py rename to src/upstage_des/communications/__init__.py diff --git a/src/upstage/communications/comms.py b/src/upstage_des/communications/comms.py similarity index 95% rename from src/upstage/communications/comms.py rename to src/upstage_des/communications/comms.py index 1af6692..ee5f8a4 100644 --- a/src/upstage/communications/comms.py +++ b/src/upstage_des/communications/comms.py @@ -1,307 +1,307 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""Comms message and commander classes.""" - -from collections.abc import Generator -from dataclasses import dataclass -from typing import Any -from uuid import uuid4 - -from simpy import Event as SimpyEvent -from simpy import Store - -from upstage.actor import Actor -from upstage.base import ENV_CONTEXT_VAR, SimulationError, UpstageBase -from upstage.events import Put -from upstage.states import CommunicationStore -from upstage.task import process - - -@dataclass -class MessageContent: - """Message content data object.""" - - data: dict - message: str | None = None - - -@dataclass -class Message: - """A message data object.""" - - sender: Actor - content: MessageContent - destination: Actor - - header: str | None = None - time_sent: float | None = None - time_received: float | None = None - - def __post_init__(self) -> None: - self.uid = uuid4() - self.time_created = ENV_CONTEXT_VAR.get().now - - def __hash__(self) -> int: - return hash(self.uid) - - -class CommsManager(UpstageBase): - """A class to manage point to point transfer of communications. - - Works through simpy.Store or similar interfaces. Allows for degraded comms and comms retry. - - If an Actor contains a `CommunicationStore`, this object will detect that - and use it as a destination. In that case, you also do not need to connect - the actor to this object. - - Example: - >>> class Talker(UP.Actor): - >>> comms = UP.ResourceState[SIM.Store](default=SIM.Store) - >>> - >>> talker1 = Talker(name='MacReady') - >>> talker2 = Talker(name='Childs') - >>> - >>> comm_station = UP.CommsManager(name="Outpost 31", mode="voice") - >>> comm_station.connect(talker1, talker1.comms) - >>> comm_station.connect(talker2, talker2.comms) - >>> - >>> comm_station.run() - >>> - >>> # Typically, do this inside a task or somewhere else - >>> putter = comm_station.make_put( - >>> message="Grab your flamethrower!", - >>> source=talker1, - >>> destination=talker2, - >>> rehearsal_time_to_complete=0.0, - >>> ) - >>> yield putter - ... - >>> env.run() - >>> talker2.comms.items - [Message(sender=Talker: MacReady, message='Grab your flamethrower!', - destination=Talker: Childs)] - """ - - def __init__( - self, - *, - name: str, - mode: str | None = None, - init_entities: list[tuple[Actor, str]] | None = None, - send_time: float = 0.0, - retry_max_time: float = 1.0, - retry_rate: float = 0.166667, - debug_logging: bool = False, - ) -> None: - """Create a comms transfer manager. - - Parameters - ---------- - name : str - Give the instance a unique name for logging purposes - mode: str - The name of the mode comms are occurring over. Used for automated - detection of actor comms interfaces. - Default is None, which requires explicit connections. - init_entities : List[Tuple(instance, str)], optional - Entities who have a comms store to let the manager know about. The - tuples are (entity_instance, entity's comms input store's name), by default None - send_time : float, optional - Time to send a message, by default 0.0 - retry_max_time : float, optional - Amount of time (in sim units) to try resending a message, by default 1 - retry_rate : float, optional - How often (in sim units) to try re-sending a message, by default 10/60 - debug_logging : bool, optional - Turn on or off logging, by default False - """ - super().__init__() - self.name = name - self.mode = mode - self.comms_degraded: bool = False - self.retry_max_time = retry_max_time - self.retry_rate = retry_rate - self.send_time = send_time - self.incoming = Store(env=self.env) - self.connected: dict[Actor, str] = {} - self.blocked_links: list[tuple[Actor, Actor]] = [] - if init_entities is not None: - for entity, comms_store_name in init_entities: - self.connect(entity, comms_store_name) - self.debug_log: list[str | dict] = [] - self.debug_logging: bool = debug_logging - - @staticmethod - def clean_message(message: str | Message) -> MessageContent: - """Test to see if an object is a message. - - If it is, return the message contents only. Otherwise return the message. - - Args: - message (str | Message): The message to clean - - Returns: - MessageContent: The message as a message content object. - """ - if isinstance(message, Message): - return message.content - return MessageContent(data={"message": message}) - - def connect(self, entity: Actor, comms_store_name: str) -> None: - """Connect an actor and its comms store to this comms manager. - - Args: - entity (Actor): The actor that will send/receive. - comms_store_name (str): The store state name for receiving - """ - self.connected[entity] = comms_store_name - - def store_from_actor(self, actor: Actor) -> Store: - """Retrieve a communications store from an actor. - - Args: - actor (Actor): The actor - - Returns: - Store: A Comms store. - """ - if actor not in self.connected: - try: - msg_store_name = actor._get_matching_state(CommunicationStore, {"_mode": self.mode}) - except SimulationError as e: - e.add_note(f"No comms destination on actor {actor}") - raise e - else: - msg_store_name = self.connected[actor] - - if msg_store_name is None: - raise SimulationError(f"No comms store on {actor}") - store: Store | None = getattr(actor, msg_store_name) - if store is None: - raise SimulationError(f"Bad comms store name: {msg_store_name} on {actor}") - return store - - def make_put( - self, - message: str | Message | MessageContent | dict, - source: Actor, - destination: Actor, - rehearsal_time_to_complete: float = 0.0, - ) -> Put: - """Create a Put request for a message into the CommsManager. - - Parameters - ---------- - source : - The message sender - destination : - The message receiver, who must be connected to the CommsManager - message : - Arbitrary data to send - rehearsal_time_to_complete : float, optional - Planning time to complete the event (see Put), by default 0.0 - - Returns: - ------- - Put - UPSTAGE Put event object to yield from a task - """ - use: Message - if isinstance(message, Message): - use = message - elif isinstance(message, MessageContent): - use = Message(sender=source, content=message, destination=destination) - else: - content = ( - MessageContent(data=message) - if isinstance(message, dict) - else MessageContent(data={}, message=message) - ) - use = Message(sender=source, content=content, destination=destination) - - return Put( - self.incoming, - use, - rehearsal_time_to_complete=rehearsal_time_to_complete, - ) - - @process - def _do_transmit( - self, message: Message, destination: Actor - ) -> Generator[SimpyEvent, None, None]: - start_time = self.env.now - while self.comms_degraded or self.test_if_link_is_blocked(message): - if self.debug_logging: - msg = { - "time": self.env.now, - "event": "Can't sent, waiting", - "message": message, - "destination": destination, - } - self.debug_log.append(msg) - - elapsed_time = self.env.now - start_time - if elapsed_time > self.retry_max_time: - if self.debug_logging: - msg = { - "time": self.env.now, - "event": "Stopped trying to send", - "message": message, - "destination": destination, - } - self.debug_log.append(msg) - return - - yield self.env.timeout(self.retry_rate) - - if self.send_time > 0: - yield self.env.timeout(self.send_time) - - if self.debug_logging: - msg = { - "time": self.env.now, - "event": "Sent message", - "message": message, - "destination": destination, - } - self.debug_log.append(msg) - - # update the send time - message.time_sent = self.env.now - store = self.store_from_actor(destination) - yield store.put(message) - - @process - def run(self) -> Generator[SimpyEvent, Any, None]: - """Run the communications message passing. - - Yields: - Generator[SimpyEvent, Any, None]: Simpy Process - """ - while True: - message = yield self.incoming.get() - dest = message.destination - self._do_transmit(message, dest) - - def _link_compare(self, a_test: Actor, b_test: Actor) -> bool: - # python fails at comparing existence and tries a different equality test - for a, b in self.blocked_links: - if a_test is a and b_test is b: - return True - return False - - def test_if_link_is_blocked(self, message: Message) -> bool: - """Test if a link is blocked. - - Args: - message (Message): Message with sender/destination data. - - Returns: - bool: If the link is blocked. - """ - if self._link_compare(message.sender, message.destination): - return True - return False +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""Comms message and commander classes.""" + +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +from simpy import Event as SimpyEvent +from simpy import Store + +from upstage_des.actor import Actor +from upstage_des.base import ENV_CONTEXT_VAR, SimulationError, UpstageBase +from upstage_des.events import Put +from upstage_des.states import CommunicationStore +from upstage_des.task import process + + +@dataclass +class MessageContent: + """Message content data object.""" + + data: dict + message: str | None = None + + +@dataclass +class Message: + """A message data object.""" + + sender: Actor + content: MessageContent + destination: Actor + + header: str | None = None + time_sent: float | None = None + time_received: float | None = None + + def __post_init__(self) -> None: + self.uid = uuid4() + self.time_created = ENV_CONTEXT_VAR.get().now + + def __hash__(self) -> int: + return hash(self.uid) + + +class CommsManager(UpstageBase): + """A class to manage point to point transfer of communications. + + Works through simpy.Store or similar interfaces. Allows for degraded comms and comms retry. + + If an Actor contains a `CommunicationStore`, this object will detect that + and use it as a destination. In that case, you also do not need to connect + the actor to this object. + + Example: + >>> class Talker(UP.Actor): + >>> comms = UP.ResourceState[SIM.Store](default=SIM.Store) + >>> + >>> talker1 = Talker(name='MacReady') + >>> talker2 = Talker(name='Childs') + >>> + >>> comm_station = UP.CommsManager(name="Outpost 31", mode="voice") + >>> comm_station.connect(talker1, talker1.comms) + >>> comm_station.connect(talker2, talker2.comms) + >>> + >>> comm_station.run() + >>> + >>> # Typically, do this inside a task or somewhere else + >>> putter = comm_station.make_put( + >>> message="Grab your flamethrower!", + >>> source=talker1, + >>> destination=talker2, + >>> rehearsal_time_to_complete=0.0, + >>> ) + >>> yield putter + ... + >>> env.run() + >>> talker2.comms.items + [Message(sender=Talker: MacReady, message='Grab your flamethrower!', + destination=Talker: Childs)] + """ + + def __init__( + self, + *, + name: str, + mode: str | None = None, + init_entities: list[tuple[Actor, str]] | None = None, + send_time: float = 0.0, + retry_max_time: float = 1.0, + retry_rate: float = 0.166667, + debug_logging: bool = False, + ) -> None: + """Create a comms transfer manager. + + Parameters + ---------- + name : str + Give the instance a unique name for logging purposes + mode: str + The name of the mode comms are occurring over. Used for automated + detection of actor comms interfaces. + Default is None, which requires explicit connections. + init_entities : List[Tuple(instance, str)], optional + Entities who have a comms store to let the manager know about. The + tuples are (entity_instance, entity's comms input store's name), by default None + send_time : float, optional + Time to send a message, by default 0.0 + retry_max_time : float, optional + Amount of time (in sim units) to try resending a message, by default 1 + retry_rate : float, optional + How often (in sim units) to try re-sending a message, by default 10/60 + debug_logging : bool, optional + Turn on or off logging, by default False + """ + super().__init__() + self.name = name + self.mode = mode + self.comms_degraded: bool = False + self.retry_max_time = retry_max_time + self.retry_rate = retry_rate + self.send_time = send_time + self.incoming = Store(env=self.env) + self.connected: dict[Actor, str] = {} + self.blocked_links: list[tuple[Actor, Actor]] = [] + if init_entities is not None: + for entity, comms_store_name in init_entities: + self.connect(entity, comms_store_name) + self.debug_log: list[str | dict] = [] + self.debug_logging: bool = debug_logging + + @staticmethod + def clean_message(message: str | Message) -> MessageContent: + """Test to see if an object is a message. + + If it is, return the message contents only. Otherwise return the message. + + Args: + message (str | Message): The message to clean + + Returns: + MessageContent: The message as a message content object. + """ + if isinstance(message, Message): + return message.content + return MessageContent(data={"message": message}) + + def connect(self, entity: Actor, comms_store_name: str) -> None: + """Connect an actor and its comms store to this comms manager. + + Args: + entity (Actor): The actor that will send/receive. + comms_store_name (str): The store state name for receiving + """ + self.connected[entity] = comms_store_name + + def store_from_actor(self, actor: Actor) -> Store: + """Retrieve a communications store from an actor. + + Args: + actor (Actor): The actor + + Returns: + Store: A Comms store. + """ + if actor not in self.connected: + try: + msg_store_name = actor._get_matching_state(CommunicationStore, {"_mode": self.mode}) + except SimulationError as e: + e.add_note(f"No comms destination on actor {actor}") + raise e + else: + msg_store_name = self.connected[actor] + + if msg_store_name is None: + raise SimulationError(f"No comms store on {actor}") + store: Store | None = getattr(actor, msg_store_name) + if store is None: + raise SimulationError(f"Bad comms store name: {msg_store_name} on {actor}") + return store + + def make_put( + self, + message: str | Message | MessageContent | dict, + source: Actor, + destination: Actor, + rehearsal_time_to_complete: float = 0.0, + ) -> Put: + """Create a Put request for a message into the CommsManager. + + Parameters + ---------- + source : + The message sender + destination : + The message receiver, who must be connected to the CommsManager + message : + Arbitrary data to send + rehearsal_time_to_complete : float, optional + Planning time to complete the event (see Put), by default 0.0 + + Returns: + ------- + Put + UPSTAGE Put event object to yield from a task + """ + use: Message + if isinstance(message, Message): + use = message + elif isinstance(message, MessageContent): + use = Message(sender=source, content=message, destination=destination) + else: + content = ( + MessageContent(data=message) + if isinstance(message, dict) + else MessageContent(data={}, message=message) + ) + use = Message(sender=source, content=content, destination=destination) + + return Put( + self.incoming, + use, + rehearsal_time_to_complete=rehearsal_time_to_complete, + ) + + @process + def _do_transmit( + self, message: Message, destination: Actor + ) -> Generator[SimpyEvent, None, None]: + start_time = self.env.now + while self.comms_degraded or self.test_if_link_is_blocked(message): + if self.debug_logging: + msg = { + "time": self.env.now, + "event": "Can't sent, waiting", + "message": message, + "destination": destination, + } + self.debug_log.append(msg) + + elapsed_time = self.env.now - start_time + if elapsed_time > self.retry_max_time: + if self.debug_logging: + msg = { + "time": self.env.now, + "event": "Stopped trying to send", + "message": message, + "destination": destination, + } + self.debug_log.append(msg) + return + + yield self.env.timeout(self.retry_rate) + + if self.send_time > 0: + yield self.env.timeout(self.send_time) + + if self.debug_logging: + msg = { + "time": self.env.now, + "event": "Sent message", + "message": message, + "destination": destination, + } + self.debug_log.append(msg) + + # update the send time + message.time_sent = self.env.now + store = self.store_from_actor(destination) + yield store.put(message) + + @process + def run(self) -> Generator[SimpyEvent, Any, None]: + """Run the communications message passing. + + Yields: + Generator[SimpyEvent, Any, None]: Simpy Process + """ + while True: + message = yield self.incoming.get() + dest = message.destination + self._do_transmit(message, dest) + + def _link_compare(self, a_test: Actor, b_test: Actor) -> bool: + # python fails at comparing existence and tries a different equality test + for a, b in self.blocked_links: + if a_test is a and b_test is b: + return True + return False + + def test_if_link_is_blocked(self, message: Message) -> bool: + """Test if a link is blocked. + + Args: + message (Message): Message with sender/destination data. + + Returns: + bool: If the link is blocked. + """ + if self._link_compare(message.sender, message.destination): + return True + return False diff --git a/src/upstage/communications/processes.py b/src/upstage_des/communications/processes.py similarity index 90% rename from src/upstage/communications/processes.py rename to src/upstage_des/communications/processes.py index 8d9e8c6..5a69d27 100644 --- a/src/upstage/communications/processes.py +++ b/src/upstage_des/communications/processes.py @@ -9,8 +9,8 @@ from simpy import Event, Process, Store -from upstage.communications.comms import CommsManager, Message, MessageContent -from upstage.task import process +from upstage_des.communications.comms import CommsManager, Message, MessageContent +from upstage_des.task import process def generate_comms_wait( diff --git a/src/upstage/constants.py b/src/upstage_des/constants.py similarity index 100% rename from src/upstage/constants.py rename to src/upstage_des/constants.py diff --git a/src/upstage/data_types.py b/src/upstage_des/data_types.py similarity index 96% rename from src/upstage/data_types.py rename to src/upstage_des/data_types.py index 639ee5e..3554f6b 100644 --- a/src/upstage/data_types.py +++ b/src/upstage_des/data_types.py @@ -1,598 +1,598 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""Data types for common operations. Currently just locations.""" - -from dataclasses import FrozenInstanceError -from math import degrees, radians, sqrt -from typing import Any - -from upstage.base import UpstageBase -from upstage.math_utils import _vector_norm, _vector_subtract -from upstage.units import unit_convert - -__all__ = ( - "CartesianLocation", - "GeodeticLocation", - "Location", - "CartesianLocationData", - "GeodeticLocationData", -) - - -class Location(UpstageBase): - """An abstract class for representing a location in a space.""" - - def copy(self) -> "Location": - """Copy the location.""" - raise NotImplementedError("Subclass must implement copy.") - - @property - def _repred_attrs(self) -> dict[str, Any]: - """A way to customize how attributes are represented by __repr__. - - Returns: - dict[str, Any]: Relevant attributes with name:value pairs. - """ - return { - k: v - for k, v in self.__dict__.items() - if not k.startswith("_") and k not in ["env", "stage"] - } - - def _key(self) -> tuple[float, ...]: - """A key used for hashing.""" - raise NotImplementedError("Location is intended to be subclassed.") - - def _to_tuple(self) -> tuple[float, ...]: - """Return a tuple of the location. - - To be implemented in a subclass. - - Returns: - tuple[float, ...]: Tuple of numbers describing a location. - """ - raise NotImplementedError("Subclass must implement tuple.") - - def straight_line_distance(self, other: object) -> float: - """Straight line distances b/w locations. - - Args: - other (Location): Another location - """ - raise NotImplementedError( - "Subclass must implement a subtraction operator to calculate distance between Locations" - ) - - def __setattr__(self, name: str, value: Any) -> None: - """Locations should be frozen, so this setattr restricts changing the values. - - Args: - name (str): Attribute name - value (Any): Attribute value - """ - if hasattr(self, "_no_override") and name in self._no_override: - raise FrozenInstanceError(f"Locations are disallowed from setting {name}") - return super().__setattr__(name, value) - - def __sub__(self, other: object) -> float: - """Subtract location. - - Args: - other (Location): Another location - """ - raise NotImplementedError( - "Subclass must implement a subtraction operator to calculate distance between Locations" - ) - - def __eq__(self, other: object) -> bool: - """Test for equality with another location. - - Args: - other (object): The other location object - - Returns: - bool: If it is equal. - """ - raise NotImplementedError("Subclass must implement a equality comparison") - - def __hash__(self) -> int: - raise NotImplementedError("Location is not intended for solo use.") - - def __repr__(self) -> str: - """Customized the printable representation of the object. - - Does this by: - 1. Using all the dataclass fields that have `repr` set to True - 2. Representation for attributes can be customized by defining a - `_get_repred_attrs` method that returns a dictionary of attributes - keys and repr'ed values. - """ - clean_attrs = [f"{key}={value}" for key, value in self._repred_attrs.items()] - return f"{self.__class__.__name__}({', '.join(clean_attrs)})" - - -class CartesianLocation(Location): - """A location that can be mapped to a 3-dimensional cartesian space.""" - - def __init__( - self, - x: float, - y: float, - z: float = 0.0, - *, - use_altitude_units: bool = False, - ) -> None: - """A Cartesian (3D space) location. - - use_altitude_units, when false, means Z distance uses the "distance_units" unit. - - When true, it uses "altitude_units". - - Use UP.add_stage_variable("altitude_units", "ft"), e.g. - - Args: - x (float): X dimension location - y (float): Y dimension location - z (float, optional): Z location. Defaults to 0.0. - use_altitude_units (bool, optional): Use the sim's altitude units. Defaults to False. - """ - super().__init__() - self.x = x - self.y = y - self.z = z - self.use_altitude_units = use_altitude_units - self._no_override = ["x", "y", "z", "use_altitude_units"] - - @property - def _repred_attrs(self) -> dict[str, str]: - """Allows for attributes to be repr'ed based on the state of the units. - - Returns: - dict[str, str]: Strings of attributes. - """ - attrs = {} - try: - hor_units = self.stage.distance_units - except AttributeError: - hor_units = "" - try: - alt_units = ( - self.stage.altitude_units if self.use_altitude_units else self.stage.distance_units - ) - except AttributeError: - alt_units = "" - for coordinate in ("x", "y"): - attrs[coordinate] = f"{getattr(self, coordinate):,}{hor_units}" - attrs["z"] = f"{self.z:,}{alt_units}" - return attrs - - def _as_array(self) -> tuple[float, float, float]: - """Make an array of consistent units for all dimensions. - - Returns: - tuple[float, float, float]: A 1-D array of (x, y, z) - """ - if self.use_altitude_units: - height = unit_convert(self.z, self.stage.altitude_units, self.stage.distance_units) - else: - height = self.z - return (self.x, self.y, height) - - def _to_tuple(self) -> tuple[float, float, float]: - """Return a tuple of the location. - - Returns: - tuple[float, float, float]: Latitude, longitude, altitude. - """ - return self._as_array() - - def copy(self) -> "CartesianLocation": - """Return a copy of the location. - - Returns: - CartesianLocation - """ - return self.__class__( - x=self.x, y=self.y, z=self.z, use_altitude_units=self.use_altitude_units - ) - - def _key(self) -> tuple[float, float, float, bool]: - """Key for hashing. - - Returns: - tuple[float, float, float, bool] - """ - return (self.x, self.y, self.z, self.use_altitude_units) - - def straight_line_distance(self, other: object) -> float: - """Get the straight line distance between this and another location. - - Args: - other (object): The other CartesianLocation point. - """ - return self - other - - def __getitem__(self, idx: int) -> float: - """Convenience way to get an xyz position by index. - - Args: - idx (int): Index (xyz) - - Returns: - float: Value at index - """ - if not 0 <= idx <= 2: - raise ValueError(f"CartesianLocation only has 3 indices (x, y, z), not a {idx}th index") - return [self.x, self.y, self.z][idx] - - def __sub__(self, other: object) -> float: - """Subtract one cartesian location from another. - - Distance is straight lint. - - Args: - other (CartesianLocation): Another location - - Returns: - float: Distance between this and another location. - """ - if isinstance(other, CartesianLocation): - sum_sq = sum((a - b) ** 2 for a, b in zip(self._as_array(), other._as_array())) - return sqrt(sum_sq) - else: - raise ValueError(f"Cannot subtract {other.__class__.__name__} from a CartesianLocation") - - def __eq__(self, other: object) -> bool: - """Test if two positions are the same. - - Uses a tolerance so this will be True for very close positions. - - Args: - other (CartesianLocation): Another location - - Returns: - bool: Is equal or not - """ - if not isinstance(other, CartesianLocation): - raise ValueError(f"Cannot compare {other.__class__.__name__} to a CartesianLocation") - dist = self - other - return bool(abs(dist) <= 0.00001) - - def __hash__(self) -> int: - """Hash based on the key. - - Returns: - int: The hash. - """ - return hash(self._key()) - - -class GeodeticLocation(Location): - """A Location that can be mapped to the surface of a spheroid (a.k.a. ellipsoid). - - More specifically, a Location representing somewhere on an ellipsoid, with Latitude, - Longitude, and Altitude that uses the geodetic datum (or geodetic system). Can be - used to define a location on Earth or other planetary bodies. - - Units for the horizontal datum (i.e., `lat` and `lon`) can be Decimal Degrees or - Radians, depending on the value of `units`, the vertical datum (i.e., `alt`) is - assumed to be in meters. - - Subtraction represents a great circle distance, NOT a true 3D straight-line distance. - - Speeds used for this location type will represent ground speed, which allows - the class to ignore solving the exact altitude change in the path. - - Units are an input to the location type. - - The ellipsoid model must have a `.distance(Location1, Location2)` method - that looks for .lat and .lon. - - `altitude` is 0.0 by default. - - `lat` (latitude) and `lon` (longitude) are in degrees by default. - if using radians, set `in_radians` to `True`. - - """ - - def __init__( - self, - lat: float, - lon: float, - alt: float = 0.0, - *, - in_radians: bool = False, - ) -> None: - """A location on a geodetic (Earth). - - Altitude uses the "altitude_units" stage variable. - - Args: - lat (float): Latitude (North/South) - lon (float): Longitude (East/West) - alt (float, optional): Altitude. Defaults to 0.0. - in_radians (bool, optional): If the lat/lon are in radians or degrees. - Defaults to False. - - Returns: - GeodeticLocation - """ - super().__init__() - self.lat = lat - self.lon = lon - self.alt = alt - self.in_radians = in_radians - self._no_override = ["lat", "lon", "alt", "in_radians"] - - @property - def _repred_attrs(self) -> dict[str, str]: - """Allows for attributes to be repr'ed based on the state of the units. - - Returns: - dict[str, str]: Strings of attributes. - """ - attrs = {} - units = "rad" if self.in_radians else "°" - try: - alt_units = self.stage.altitude_units - except AttributeError: - alt_units = "" - for coordinate in ("lat", "lon"): - attrs[coordinate] = f"{getattr(self, coordinate)}{units}" - attrs["alt"] = f"{self.alt:,}{alt_units}" - return attrs - - def _to_tuple(self) -> tuple[float, float, float]: - """Return a tuple of the location. - - Returns: - tuple[float, float, float]: Latitude, longitude, altitude. - """ - return (self.lat, self.lon, self.alt) - - def latlon(self) -> tuple[float, float]: - """Return a tuple of just lat/lon as degrees. - - Returns: - tuple[float, float]: Latitude and longitude in degrees. - """ - s = self.to_degrees() - return (s.lat, s.lon) - - def to_radians(self) -> "GeodeticLocation": - """Convert to radians, if already in radians, return self. - - Returns: - GeodeticLocation - """ - if self.in_radians: - return self - kwargs: dict[str, float | bool] = {"alt": self.alt} - for coordinate in ("lat", "lon"): - kwargs[coordinate] = radians(getattr(self, coordinate)) - kwargs["in_radians"] = True - return self.__class__(**kwargs) # type: ignore [arg-type] - - def to_degrees(self) -> "GeodeticLocation": - """Convert to degrees, if already in degrees, return self. - - Returns: - GeodeticLocation - """ - if not self.in_radians: - return self - kwargs: dict[str, float | bool] = {"alt": self.alt} - for coordinate in ("lat", "lon"): - kwargs[coordinate] = degrees(getattr(self, coordinate)) - kwargs["in_radians"] = False - return self.__class__(**kwargs) # type: ignore [arg-type] - - def _key(self) -> tuple[float, float, float, bool]: - """A key for hashing. - - Returns: - tuple[float, float, float, bool]: All values. - """ - return (self.lat, self.lon, self.alt, self.in_radians) - - def copy(self) -> "GeodeticLocation": - """Copy the location. - - Returns: - GeodeticLocation - """ - return self.__class__( - lat=self.lat, - lon=self.lon, - alt=self.alt, - in_radians=self.in_radians, - ) - - def dist_with_altitude(self, other: "GeodeticLocation") -> float: - """Get the distance between two points with an altitude component. - - Args: - other (GeodeticLocation): The other point - - Returns: - float: Distance - pythagorean of great-circle and altitude - """ - dist = self - other - alt = abs(self.alt - other.alt) - alt = unit_convert(alt, self.stage.altitude_units, self.stage.distance_units) - full_dist: float = sqrt(alt**2 + dist**2) - return full_dist - - def straight_line_distance(self, other: object) -> float: - """Straight-line distance, using ECEF. - - This won't account for horizon. - - Args: - other (GeodeticLocation): The other point - - Returns: - float: Distance - """ - if not isinstance(other, GeodeticLocation): - raise TypeError(f"Cannot subtract a {other.__class__.__name__} from a GeodeticLocation") - lat, lon, alt = self.to_degrees()._to_tuple() - alt = unit_convert(alt, self.stage.altitude_units, "m") - ecef_self = self.stage.stage_model.lla2ecef([(lat, lon, alt)])[0] - - lat, lon, alt = other.to_degrees()._to_tuple() - alt = unit_convert(alt, self.stage.altitude_units, "m") - ecef_other = self.stage.stage_model.lla2ecef([(lat, lon, alt)])[0] - - dist_meters = float(_vector_norm(_vector_subtract(ecef_other, ecef_self))) - dist_units = unit_convert(dist_meters, "m", self.stage.distance_units) - return dist_units - - def __getitem__(self, idx: int) -> float: - """Convenience way to get an xyz position by index. - - Args: - idx (int): Index (lat/lon/alt) - - Returns: - float: Value at index - """ - if not 0 <= idx <= 2: - raise IndexError( - f"GeodeticLocation only has 3 indices (lat, lon, alt), not a {idx}th index" - ) - return [self.lat, self.lon, self.alt][idx] - - def __sub__(self, other: object) -> float: - """Find the great circle distance between Geodetic points. - - Args: - other (GeodeticLocation): Another location - - Returns: - float: distance in stage "distance_units" units. - """ - if not isinstance(other, GeodeticLocation): - raise ValueError( - f"Cannot subtract a {other.__class__.__name__} from a GeodeticLocation" - ) - # distances presume positions are in degrees - dlat, dlon = self.to_degrees()._key()[:2] - olat, olon = other.to_degrees()._key()[:2] - dist = self.stage.stage_model.distance( - (dlat, dlon), - (olat, olon), - units=self.stage.distance_units, - ) - return dist - - def __eq__(self, other: object) -> bool: - """Test if two locations are the same. - - No tolerance is applied here. - - Args: - other (GeodeticLocation): Another location - - Returns: - bool: Close enough or not. - """ - if not isinstance(other, GeodeticLocation): - raise ValueError(f"Cannot compare a {other.__class__.__name__} to a GeodeticLocation") - if other.in_radians != self.in_radians: - if self.in_radians: - other = other.to_radians() - else: - other = other.to_degrees() - return all( - getattr(self, dimension) == getattr(other, dimension) - for dimension in ["lat", "lon", "alt"] - ) - - def __hash__(self) -> int: - """Hash based on the key. - - Returns: - int: The hash. - """ - return hash(self._key()) - - -class CartesianLocationData: - """Object for storing caretesian data without an environment.""" - - def __init__( - self, - x: float, - y: float, - z: float = 0.0, - *, - use_altitude_units: bool = True, - ) -> None: - """Cartesian data storage. - - Args: - x (float): _description_ - y (float): _description_ - z (float, optional): _description_. Defaults to 0.0. - use_altitude_units (bool, optional): _description_. Defaults to True. - """ - self.x = x - self.y = y - self.z = z - self.use_altitude_unts = use_altitude_units - - def make_location(self) -> CartesianLocation: - """Create a location from the data. - - Do this inside an environment contex.t - - Returns: - CartesianLocation: The location object. - """ - return CartesianLocation( - x=self.x, y=self.y, z=self.z, use_altitude_units=self.use_altitude_unts - ) - - -class GeodeticLocationData: - """Object for storing geodetic data without an environment.""" - - def __init__( - self, - lat: float, - lon: float, - alt: float = 0.0, - *, - in_radians: bool = False, - ) -> None: - """Geodetic data storage. - - Args: - lat (float): _description_ - lon (float): _description_ - alt (float, optional): _description_. Defaults to 0.0. - in_radians (bool, optional): _description_. Defaults to False. - """ - self.lat = lat - self.lon = lon - self.alt = alt - self.in_radians = in_radians - - def make_location(self) -> GeodeticLocation: - """Create a location from the data. - - Do this inside an environment contex.t - - Returns: - GeodeticLocation: The location object. - """ - return GeodeticLocation( - lat=self.lat, - lon=self.lon, - alt=self.alt, - in_radians=self.in_radians, - ) +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""Data types for common operations. Currently just locations.""" + +from dataclasses import FrozenInstanceError +from math import degrees, radians, sqrt +from typing import Any + +from upstage_des.base import UpstageBase +from upstage_des.math_utils import _vector_norm, _vector_subtract +from upstage_des.units import unit_convert + +__all__ = ( + "CartesianLocation", + "GeodeticLocation", + "Location", + "CartesianLocationData", + "GeodeticLocationData", +) + + +class Location(UpstageBase): + """An abstract class for representing a location in a space.""" + + def copy(self) -> "Location": + """Copy the location.""" + raise NotImplementedError("Subclass must implement copy.") + + @property + def _repred_attrs(self) -> dict[str, Any]: + """A way to customize how attributes are represented by __repr__. + + Returns: + dict[str, Any]: Relevant attributes with name:value pairs. + """ + return { + k: v + for k, v in self.__dict__.items() + if not k.startswith("_") and k not in ["env", "stage"] + } + + def _key(self) -> tuple[float, ...]: + """A key used for hashing.""" + raise NotImplementedError("Location is intended to be subclassed.") + + def _to_tuple(self) -> tuple[float, ...]: + """Return a tuple of the location. + + To be implemented in a subclass. + + Returns: + tuple[float, ...]: Tuple of numbers describing a location. + """ + raise NotImplementedError("Subclass must implement tuple.") + + def straight_line_distance(self, other: object) -> float: + """Straight line distances b/w locations. + + Args: + other (Location): Another location + """ + raise NotImplementedError( + "Subclass must implement a subtraction operator to calculate distance between Locations" + ) + + def __setattr__(self, name: str, value: Any) -> None: + """Locations should be frozen, so this setattr restricts changing the values. + + Args: + name (str): Attribute name + value (Any): Attribute value + """ + if hasattr(self, "_no_override") and name in self._no_override: + raise FrozenInstanceError(f"Locations are disallowed from setting {name}") + return super().__setattr__(name, value) + + def __sub__(self, other: object) -> float: + """Subtract location. + + Args: + other (Location): Another location + """ + raise NotImplementedError( + "Subclass must implement a subtraction operator to calculate distance between Locations" + ) + + def __eq__(self, other: object) -> bool: + """Test for equality with another location. + + Args: + other (object): The other location object + + Returns: + bool: If it is equal. + """ + raise NotImplementedError("Subclass must implement a equality comparison") + + def __hash__(self) -> int: + raise NotImplementedError("Location is not intended for solo use.") + + def __repr__(self) -> str: + """Customized the printable representation of the object. + + Does this by: + 1. Using all the dataclass fields that have `repr` set to True + 2. Representation for attributes can be customized by defining a + `_get_repred_attrs` method that returns a dictionary of attributes + keys and repr'ed values. + """ + clean_attrs = [f"{key}={value}" for key, value in self._repred_attrs.items()] + return f"{self.__class__.__name__}({', '.join(clean_attrs)})" + + +class CartesianLocation(Location): + """A location that can be mapped to a 3-dimensional cartesian space.""" + + def __init__( + self, + x: float, + y: float, + z: float = 0.0, + *, + use_altitude_units: bool = False, + ) -> None: + """A Cartesian (3D space) location. + + use_altitude_units, when false, means Z distance uses the "distance_units" unit. + + When true, it uses "altitude_units". + + Use UP.add_stage_variable("altitude_units", "ft"), e.g. + + Args: + x (float): X dimension location + y (float): Y dimension location + z (float, optional): Z location. Defaults to 0.0. + use_altitude_units (bool, optional): Use the sim's altitude units. Defaults to False. + """ + super().__init__() + self.x = x + self.y = y + self.z = z + self.use_altitude_units = use_altitude_units + self._no_override = ["x", "y", "z", "use_altitude_units"] + + @property + def _repred_attrs(self) -> dict[str, str]: + """Allows for attributes to be repr'ed based on the state of the units. + + Returns: + dict[str, str]: Strings of attributes. + """ + attrs = {} + try: + hor_units = self.stage.distance_units + except AttributeError: + hor_units = "" + try: + alt_units = ( + self.stage.altitude_units if self.use_altitude_units else self.stage.distance_units + ) + except AttributeError: + alt_units = "" + for coordinate in ("x", "y"): + attrs[coordinate] = f"{getattr(self, coordinate):,}{hor_units}" + attrs["z"] = f"{self.z:,}{alt_units}" + return attrs + + def _as_array(self) -> tuple[float, float, float]: + """Make an array of consistent units for all dimensions. + + Returns: + tuple[float, float, float]: A 1-D array of (x, y, z) + """ + if self.use_altitude_units: + height = unit_convert(self.z, self.stage.altitude_units, self.stage.distance_units) + else: + height = self.z + return (self.x, self.y, height) + + def _to_tuple(self) -> tuple[float, float, float]: + """Return a tuple of the location. + + Returns: + tuple[float, float, float]: Latitude, longitude, altitude. + """ + return self._as_array() + + def copy(self) -> "CartesianLocation": + """Return a copy of the location. + + Returns: + CartesianLocation + """ + return self.__class__( + x=self.x, y=self.y, z=self.z, use_altitude_units=self.use_altitude_units + ) + + def _key(self) -> tuple[float, float, float, bool]: + """Key for hashing. + + Returns: + tuple[float, float, float, bool] + """ + return (self.x, self.y, self.z, self.use_altitude_units) + + def straight_line_distance(self, other: object) -> float: + """Get the straight line distance between this and another location. + + Args: + other (object): The other CartesianLocation point. + """ + return self - other + + def __getitem__(self, idx: int) -> float: + """Convenience way to get an xyz position by index. + + Args: + idx (int): Index (xyz) + + Returns: + float: Value at index + """ + if not 0 <= idx <= 2: + raise ValueError(f"CartesianLocation only has 3 indices (x, y, z), not a {idx}th index") + return [self.x, self.y, self.z][idx] + + def __sub__(self, other: object) -> float: + """Subtract one cartesian location from another. + + Distance is straight lint. + + Args: + other (CartesianLocation): Another location + + Returns: + float: Distance between this and another location. + """ + if isinstance(other, CartesianLocation): + sum_sq = sum((a - b) ** 2 for a, b in zip(self._as_array(), other._as_array())) + return sqrt(sum_sq) + else: + raise ValueError(f"Cannot subtract {other.__class__.__name__} from a CartesianLocation") + + def __eq__(self, other: object) -> bool: + """Test if two positions are the same. + + Uses a tolerance so this will be True for very close positions. + + Args: + other (CartesianLocation): Another location + + Returns: + bool: Is equal or not + """ + if not isinstance(other, CartesianLocation): + raise ValueError(f"Cannot compare {other.__class__.__name__} to a CartesianLocation") + dist = self - other + return bool(abs(dist) <= 0.00001) + + def __hash__(self) -> int: + """Hash based on the key. + + Returns: + int: The hash. + """ + return hash(self._key()) + + +class GeodeticLocation(Location): + """A Location that can be mapped to the surface of a spheroid (a.k.a. ellipsoid). + + More specifically, a Location representing somewhere on an ellipsoid, with Latitude, + Longitude, and Altitude that uses the geodetic datum (or geodetic system). Can be + used to define a location on Earth or other planetary bodies. + + Units for the horizontal datum (i.e., `lat` and `lon`) can be Decimal Degrees or + Radians, depending on the value of `units`, the vertical datum (i.e., `alt`) is + assumed to be in meters. + + Subtraction represents a great circle distance, NOT a true 3D straight-line distance. + + Speeds used for this location type will represent ground speed, which allows + the class to ignore solving the exact altitude change in the path. + + Units are an input to the location type. + + The ellipsoid model must have a `.distance(Location1, Location2)` method + that looks for .lat and .lon. + + `altitude` is 0.0 by default. + + `lat` (latitude) and `lon` (longitude) are in degrees by default. + if using radians, set `in_radians` to `True`. + + """ + + def __init__( + self, + lat: float, + lon: float, + alt: float = 0.0, + *, + in_radians: bool = False, + ) -> None: + """A location on a geodetic (Earth). + + Altitude uses the "altitude_units" stage variable. + + Args: + lat (float): Latitude (North/South) + lon (float): Longitude (East/West) + alt (float, optional): Altitude. Defaults to 0.0. + in_radians (bool, optional): If the lat/lon are in radians or degrees. + Defaults to False. + + Returns: + GeodeticLocation + """ + super().__init__() + self.lat = lat + self.lon = lon + self.alt = alt + self.in_radians = in_radians + self._no_override = ["lat", "lon", "alt", "in_radians"] + + @property + def _repred_attrs(self) -> dict[str, str]: + """Allows for attributes to be repr'ed based on the state of the units. + + Returns: + dict[str, str]: Strings of attributes. + """ + attrs = {} + units = "rad" if self.in_radians else "°" + try: + alt_units = self.stage.altitude_units + except AttributeError: + alt_units = "" + for coordinate in ("lat", "lon"): + attrs[coordinate] = f"{getattr(self, coordinate)}{units}" + attrs["alt"] = f"{self.alt:,}{alt_units}" + return attrs + + def _to_tuple(self) -> tuple[float, float, float]: + """Return a tuple of the location. + + Returns: + tuple[float, float, float]: Latitude, longitude, altitude. + """ + return (self.lat, self.lon, self.alt) + + def latlon(self) -> tuple[float, float]: + """Return a tuple of just lat/lon as degrees. + + Returns: + tuple[float, float]: Latitude and longitude in degrees. + """ + s = self.to_degrees() + return (s.lat, s.lon) + + def to_radians(self) -> "GeodeticLocation": + """Convert to radians, if already in radians, return self. + + Returns: + GeodeticLocation + """ + if self.in_radians: + return self + kwargs: dict[str, float | bool] = {"alt": self.alt} + for coordinate in ("lat", "lon"): + kwargs[coordinate] = radians(getattr(self, coordinate)) + kwargs["in_radians"] = True + return self.__class__(**kwargs) # type: ignore [arg-type] + + def to_degrees(self) -> "GeodeticLocation": + """Convert to degrees, if already in degrees, return self. + + Returns: + GeodeticLocation + """ + if not self.in_radians: + return self + kwargs: dict[str, float | bool] = {"alt": self.alt} + for coordinate in ("lat", "lon"): + kwargs[coordinate] = degrees(getattr(self, coordinate)) + kwargs["in_radians"] = False + return self.__class__(**kwargs) # type: ignore [arg-type] + + def _key(self) -> tuple[float, float, float, bool]: + """A key for hashing. + + Returns: + tuple[float, float, float, bool]: All values. + """ + return (self.lat, self.lon, self.alt, self.in_radians) + + def copy(self) -> "GeodeticLocation": + """Copy the location. + + Returns: + GeodeticLocation + """ + return self.__class__( + lat=self.lat, + lon=self.lon, + alt=self.alt, + in_radians=self.in_radians, + ) + + def dist_with_altitude(self, other: "GeodeticLocation") -> float: + """Get the distance between two points with an altitude component. + + Args: + other (GeodeticLocation): The other point + + Returns: + float: Distance - pythagorean of great-circle and altitude + """ + dist = self - other + alt = abs(self.alt - other.alt) + alt = unit_convert(alt, self.stage.altitude_units, self.stage.distance_units) + full_dist: float = sqrt(alt**2 + dist**2) + return full_dist + + def straight_line_distance(self, other: object) -> float: + """Straight-line distance, using ECEF. + + This won't account for horizon. + + Args: + other (GeodeticLocation): The other point + + Returns: + float: Distance + """ + if not isinstance(other, GeodeticLocation): + raise TypeError(f"Cannot subtract a {other.__class__.__name__} from a GeodeticLocation") + lat, lon, alt = self.to_degrees()._to_tuple() + alt = unit_convert(alt, self.stage.altitude_units, "m") + ecef_self = self.stage.stage_model.lla2ecef([(lat, lon, alt)])[0] + + lat, lon, alt = other.to_degrees()._to_tuple() + alt = unit_convert(alt, self.stage.altitude_units, "m") + ecef_other = self.stage.stage_model.lla2ecef([(lat, lon, alt)])[0] + + dist_meters = float(_vector_norm(_vector_subtract(ecef_other, ecef_self))) + dist_units = unit_convert(dist_meters, "m", self.stage.distance_units) + return dist_units + + def __getitem__(self, idx: int) -> float: + """Convenience way to get an xyz position by index. + + Args: + idx (int): Index (lat/lon/alt) + + Returns: + float: Value at index + """ + if not 0 <= idx <= 2: + raise IndexError( + f"GeodeticLocation only has 3 indices (lat, lon, alt), not a {idx}th index" + ) + return [self.lat, self.lon, self.alt][idx] + + def __sub__(self, other: object) -> float: + """Find the great circle distance between Geodetic points. + + Args: + other (GeodeticLocation): Another location + + Returns: + float: distance in stage "distance_units" units. + """ + if not isinstance(other, GeodeticLocation): + raise ValueError( + f"Cannot subtract a {other.__class__.__name__} from a GeodeticLocation" + ) + # distances presume positions are in degrees + dlat, dlon = self.to_degrees()._key()[:2] + olat, olon = other.to_degrees()._key()[:2] + dist = self.stage.stage_model.distance( + (dlat, dlon), + (olat, olon), + units=self.stage.distance_units, + ) + return dist + + def __eq__(self, other: object) -> bool: + """Test if two locations are the same. + + No tolerance is applied here. + + Args: + other (GeodeticLocation): Another location + + Returns: + bool: Close enough or not. + """ + if not isinstance(other, GeodeticLocation): + raise ValueError(f"Cannot compare a {other.__class__.__name__} to a GeodeticLocation") + if other.in_radians != self.in_radians: + if self.in_radians: + other = other.to_radians() + else: + other = other.to_degrees() + return all( + getattr(self, dimension) == getattr(other, dimension) + for dimension in ["lat", "lon", "alt"] + ) + + def __hash__(self) -> int: + """Hash based on the key. + + Returns: + int: The hash. + """ + return hash(self._key()) + + +class CartesianLocationData: + """Object for storing caretesian data without an environment.""" + + def __init__( + self, + x: float, + y: float, + z: float = 0.0, + *, + use_altitude_units: bool = True, + ) -> None: + """Cartesian data storage. + + Args: + x (float): _description_ + y (float): _description_ + z (float, optional): _description_. Defaults to 0.0. + use_altitude_units (bool, optional): _description_. Defaults to True. + """ + self.x = x + self.y = y + self.z = z + self.use_altitude_unts = use_altitude_units + + def make_location(self) -> CartesianLocation: + """Create a location from the data. + + Do this inside an environment contex.t + + Returns: + CartesianLocation: The location object. + """ + return CartesianLocation( + x=self.x, y=self.y, z=self.z, use_altitude_units=self.use_altitude_unts + ) + + +class GeodeticLocationData: + """Object for storing geodetic data without an environment.""" + + def __init__( + self, + lat: float, + lon: float, + alt: float = 0.0, + *, + in_radians: bool = False, + ) -> None: + """Geodetic data storage. + + Args: + lat (float): _description_ + lon (float): _description_ + alt (float, optional): _description_. Defaults to 0.0. + in_radians (bool, optional): _description_. Defaults to False. + """ + self.lat = lat + self.lon = lon + self.alt = alt + self.in_radians = in_radians + + def make_location(self) -> GeodeticLocation: + """Create a location from the data. + + Do this inside an environment contex.t + + Returns: + GeodeticLocation: The location object. + """ + return GeodeticLocation( + lat=self.lat, + lon=self.lon, + alt=self.alt, + in_radians=self.in_radians, + ) diff --git a/src/upstage/events.py b/src/upstage_des/events.py similarity index 97% rename from src/upstage/events.py rename to src/upstage_des/events.py index 8ef6b0d..19e44ce 100644 --- a/src/upstage/events.py +++ b/src/upstage_des/events.py @@ -1,835 +1,835 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""Classes for UPSTAGE events that feed to simpy.""" - -from collections.abc import Callable -from typing import Any as tyAny -from warnings import warn - -import simpy as SIM -from simpy.resources.container import ContainerGet, ContainerPut -from simpy.resources.resource import Release, Request -from simpy.resources.store import StoreGet, StorePut - -from .base import SimulationError, UpstageBase, UpstageError -from .constants import PLANNING_FACTOR_OBJECT - -__all__ = ( - "All", - "Any", - "BaseEvent", - "Event", - "Get", - "FilterGet", - "MultiEvent", - "Put", - "ResourceHold", - "Wait", -) - -SIM_REQ_EVTS = ContainerGet | ContainerPut | StoreGet | StorePut | Request | Release - - -class BaseEvent(UpstageBase): - """Base class for framework events.""" - - def __init__(self, *, rehearsal_time_to_complete: float = 0.0): - """Create a base event with a notion of rehearsal time. - - Args: - rehearsal_time_to_complete (float, optional): Time to simulate passing - on rehearsal. Defaults to 0.0. - """ - super().__init__() - self._simpy_event: SIM.Event | None = None - self._rehearsing: bool = False - self._done_rehearsing: bool = False - - self.created_at: float = self.now - self.rehearsal_time_to_complete = rehearsal_time_to_complete - - @property - def now(self) -> float: - """Current sim time. - - Returns: - float: sim time - """ - return self.env.now - - def calculate_time_to_complete(self) -> float: - """Calculate the time elapsed until the event is triggered. - - Returns: - float: The time until the event triggers. - """ - return self.rehearsal_time_to_complete - - def as_event(self) -> SIM.Event: - """Convert UPSTAGE event to a simpy Event. - - Returns: - SIM.Event: The upstage event as a simpy event. - """ - raise NotImplementedError( - "Events must specify how to convert to :class:`simpy.events.Event`" - ) - - def is_complete(self) -> bool: - """Is the event complete? - - Returns: - bool: If it's complete or not. - """ - if self._rehearsing: - return self._done_rehearsing - if self._simpy_event is None: - raise UpstageError("Event has no simpy equivalent made.") - return self._simpy_event.processed - - def cancel(self) -> None: - """Cancel an event.""" - raise NotImplementedError("Implement custom event cancelling") - - @property - def rehearsing(self) -> bool: - """If the event is rehearsing. - - Returns: - bool - """ - return self._rehearsing - - @property - def done_rehearsing(self) -> bool: - """If the event is done rehearsing. - - Returns: - bool - """ - return self._done_rehearsing - - def _start_rehearsal(self) -> None: - """Set the event to testing mode.""" - self._rehearsing = True - self._done_rehearsing = False - - def _finish_rehearsal(self, complete: bool) -> None: - """Finish rehearsing the event. - - Args: - complete (bool): Indicates if the event was successful during the test. - """ - if not self._rehearsing: - raise SimulationError( - "Trying to finish testing event but event testing was not started`" - ) - self._done_rehearsing = complete - - def rehearse(self) -> tuple[float, tyAny | None]: - """Run the event in 'rehearsal' mode without changing the real environment. - - This is used by the task rehearsal functions. - - Returns: - tuple[float, Any | None]: The time to complete and the event's response. - """ - self._start_rehearsal() - time_advance = self.calculate_time_to_complete() - self._finish_rehearsal(complete=True) - - event_response = None - - return time_advance, event_response - - -class Wait(BaseEvent): - """Wait a specified or random uniformly distributed amount of time. - - Return a timeout. If time is a list of length 2, choose a random time - between the interval given. - - Rehearsal time is given by the maximum time of the interval, if given. - - Parameters - ---------- - timeout : int, float, list, tuple - Amount of time to wait. If it is a list or a tuple of length 2, a - random uniform value between the two values will be used. - - """ - - def __init__( - self, - timeout: float | int, - 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. - - Args: - timeout (float | int): Time to wait. - 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?") - self._time_to_complete = timeout - self.timeout = timeout - if self._time_to_complete < 0: - raise SimulationError(f"Negative timeout in Wait: {self._time_to_complete}") - rehearse = timeout if rehearsal_time_to_complete is None else rehearsal_time_to_complete - super().__init__(rehearsal_time_to_complete=rehearse) - - @classmethod - def from_random_uniform( - cls, - low: float | int, - high: float | int, - rehearsal_time_to_complete: float | int | None = None, - ) -> "Wait": - """Create a wait from a random uniform time. - - Args: - low (float): Lower bounds of random draw - high (float): Upper bounds of random draw - rehearsal_time_to_complete (float | int, optional): The rehearsal time - to complete. Defaults to None - meaning the random value drawn. - - Returns: - Wait: The timeout event - """ - rng = UpstageBase().stage.random - timeout = rng.uniform(low, high) - return cls(timeout, rehearsal_time_to_complete) - - def as_event(self) -> SIM.Timeout: - """Cast Wait event as a simpy Timeout event. - - Returns: - SIM.Timeout - """ - assert isinstance(self.env, SIM.Environment) - self._simpy_event = self.env.timeout(self._time_to_complete) - return self._simpy_event - - def cancel(self) -> None: - """Cancel the timeout. - - There's no real meaning to cancelling a timeout. It sits in simpy's queue either way. - """ - assert self._simpy_event is not None - try: - self._simpy_event.defused = True - except RuntimeError as exc: - warn(f"Runtime error when cancelling '{self}', Error: {exc}!") - - -class BaseRequestEvent(BaseEvent): - """Base class for Request Events. - - Requests are things like Get and Put that wait in a queue. - """ - - def __init__(self, rehearsal_time_to_complete: float = 0.0) -> None: - """Create a request event. - - Args: - rehearsal_time_to_complete (float, optional): Estimated time to complete. - Defaults to 0.0. - """ - super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) - self._request_event: SIM_REQ_EVTS | None = None - - def cancel(self) -> None: - """Cancel the Request.""" - if self._request_event is None: - return - if not self.is_complete(): - self._request_event.cancel() - # TODO: Do we put it back? - - def is_complete(self) -> bool: - """Test if the request is finished. - - Returns: - bool - """ - if self.rehearsing: - if self.done_rehearsing is None: - raise SimulationError( - f"Event '{self}' rehearsal started, but completion was" - "not set as incomplete, i.e., to `False`!" - ) - return self.done_rehearsing - assert self._request_event is not None - return self._request_event.processed - - -class Put(BaseRequestEvent): - """Wrap the ``simpy`` Put event. - - This is an event that puts an object into a ``simpy`` store or puts - an amount into a container. - - """ - - def __init__( - self, - put_location: SIM.Container | SIM.Store, - put_object: float | int | tyAny, - rehearsal_time_to_complete: float = 0.0, - ) -> None: - """Create a Put request for a store or container. - - Args: - put_location (SIM.Container | SIM.Store): Any container, store, or subclass. - put_object (float | int | Any): The amount (float | int) or object (Any) to put. - rehearsal_time_to_complete (float, optional): Estimated time for the put to finish. - Defaults to 0.0. - """ - super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) - - if not issubclass(put_location.__class__, SIM.Container | SIM.Store): - raise SimulationError( - f"put_location must be a subclass of Container " - f"or Store, not {put_location.__class__}" - ) - - self.put_location = put_location - self.put_object = put_object - - def as_event(self) -> ContainerPut | StorePut: - """Convert event to a ``simpy`` Event. - - Returns: - --------- - :obj:`simpy.events.Event` - Put request as a simpy event. - - """ - self._request_event = self.put_location.put(self.put_object) - return self._request_event - - -class MultiEvent(BaseEvent): - """A base class for evaluating multiple events. - - Note: - Subclasses of MultiEvent must define these methods: - * aggregation_function: Callable[[list[float]], float] - * simpy_equivalent: simpy.Event - - For an example, refer to :class:`~Any` and :class:`~All`. - """ - - def __init__(self, *events: BaseEvent | SIM.Process) -> None: - """Create a multi-event based on a list of events. - - Args: - *events (BaseEvent): The events that comprise the multi-event. - """ - super().__init__() - - for event in events: - if not issubclass(event.__class__, BaseEvent): - warn( - f"Event '{event}' is not an upstage Event. " - f"All events in a MultiEvent must be an " - f"instance of upstage BaseEvent if you are going " - f"to rehearse the task that contains this MultiEvent.", - UserWarning, - ) - self.events = events - self._simpy_event = None - - @staticmethod - def aggregation_function(times: list[float]) -> float: - """Aggregate event times to one single time. - - Args: - times (list[float]): Event rehearsal times - - Returns: - float: The aggregated time - """ - raise NotImplementedError("Implement in subclass") - - @staticmethod - def simpy_equivalent(env: SIM.Environment, events: list[SIM.Event]) -> SIM.Event: - """Return the simpy equivalent event. - - Args: - env (SIM.Environment): The SimPy environment. - events (list[BaseEvent]): Events to turn into multi-event. - - Returns: - SIM.Event: The aggregate event. - """ - raise NotImplementedError("Implement in subclass") - - def _make_event(self, event: BaseEvent | SIM.Process) -> SIM.Event: - # handle a process in the MultiEvent for non-rehearsal uses - if isinstance(event, SIM.Process): - return event - return event.as_event() - - def as_event(self) -> SIM.Event: - """Convert the UPSTAGE event to simpy. - - Returns: - SIM.Event: typically an Any or All - """ - sub_events = [self._make_event(event) for event in self.events] - assert isinstance(self.env, SIM.Environment) - self._simpy_event = self.simpy_equivalent(self.env, sub_events) - return self._simpy_event - - def cancel(self) -> None: - """Cancel the multi event and propagate it to the sub-events.""" - if self._simpy_event is None: - raise UpstageError("Can't cancel a nonexistent event.") - self._simpy_event.defused = True - self._simpy_event.fail(Exception("defused")) - for event in self.events: - if isinstance(event, BaseEvent): - event.cancel() - - def calculate_time_to_complete( - self, - ) -> float: - """Compute time required to complete the multi-event. - - Args: - return_sub_events (bool, Optional): Whether to return all times or not. - Defaults to False. - """ - event_times = { - event: event.calculate_time_to_complete() - for event in self.events - if isinstance(event, BaseEvent) - } - - time_to_complete = self.aggregation_function(list(event_times.values())) - - return time_to_complete - - def calc_time_to_complete_with_sub(self) -> tuple[float, dict[BaseEvent, float]]: - """Compute time required for MultiEvent and get sub-event times. - - Returns: - tuple[float, dict[BaseEvent, float]]: Aggregate and individual times. - """ - event_times = { - event: event.calculate_time_to_complete() - for event in self.events - if isinstance(event, BaseEvent) - } - time_to_complete = self.aggregation_function(list(event_times.values())) - - return time_to_complete, event_times - - def _start_rehearsal(self) -> None: - """Start rehearsing all the sub-events.""" - super()._start_rehearsal() - for event in self.events: - if not hasattr(event, "_start_rehearsal"): - raise SimulationError( - f"Event '{event}' is not an upstage Event. " - f"All events in a MultiEvent must be an " - f"instance of upstage BaseEvent if you are going" - f"to rehearse the task that contains this MultiEvent." - ) - event._start_rehearsal() - - def rehearse(self) -> tuple[float, tyAny]: - """Run the event in 'trial' mode without changing the real environment. - - Returns: - tuple[float, Any]: The time to complete and the event's response. - - Note: - This is used by the task rehearsal functions. - """ - self._start_rehearsal() - - event_response = None - time_to_finish, event_times = self.calc_time_to_complete_with_sub() - - for event, event_end_time in event_times.items(): - event._finish_rehearsal(complete=event_end_time <= time_to_finish) - - self._finish_rehearsal(complete=True) - - return time_to_finish, event_response - - -class Any(MultiEvent): - """An event that requires one event to succeed before succeeding.""" - - @staticmethod - def aggregation_function(times: list[float]) -> float: - """Aggregation function for rehearsal time. - - Args: - times (list[float]): List of rehearsal times - - Returns: - float: Aggregated time (the minimum) - """ - return min(times) - - @staticmethod - def simpy_equivalent(env: SIM.Environment, events: list[SIM.Event]) -> SIM.Event: - """Return the SimPy version of the UPSTAGE Any event. - - Args: - env (SIM.Environment): SimPy Environment. - events (list[SIM.Event]): List of events. - - Returns: - SIM.Event: A simpy AnyOf event. - """ - return SIM.AnyOf(env, events) - - -class Get(BaseRequestEvent): - """Wrap the ``simpy`` Get event. - - Event that gets an object from a ``simpy`` store or gets an amount from a - container. - """ - - def __init__( - self, - get_location: SIM.Store | SIM.Container, - *get_args: tyAny, - rehearsal_time_to_complete: float = 0.0, - **get_kwargs: tyAny, - ) -> None: - """Create a Get request on a store, container, or subclass of those. - - Args: - get_location (SIM.Store | SIM.Container): The place for the Get request - rehearsal_time_to_complete (float, optional): _description_. Defaults to 0.0. - get_args (Any): optional positional args for the get request - (blank for Store and Container) - get_kwargs (Any): optional keyword args for the get request - (blank for Store and Container) - """ - super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) - - if not issubclass(get_location.__class__, SIM.Container | SIM.Store): - raise SimulationError( - "'put_location' must be a subclass of Container" - f" or Store, not {get_location.__class__}" - ) - - self.get_location = get_location - self.get_args = get_args - self.get_kwargs = get_kwargs - self.__is_store = issubclass(get_location.__class__, SIM.Store) - - def calculate_time_to_complete(self) -> float: - """Calculate time elapsed until the event is triggered. - - Returns: - float: Estimated time until the event triggers. - - """ - return self.rehearsal_time_to_complete - - def as_event(self) -> ContainerGet | StoreGet: - """Convert get to a ``simpy`` Event. - - Returns: - ContainerGet | StoreGet - """ - # TODO: optional checking for container types for feasibility - self._request_event = self.get_location.get( - *self.get_args, - **self.get_kwargs, - ) - return self._request_event - - def get_value(self) -> tyAny: - """Get the value returned when the request is complete. - - Returns: - Any: The amount or item requested. - """ - if self.__is_store: - if self.rehearsing and self.done_rehearsing: - return PLANNING_FACTOR_OBJECT - if self._request_event is not None and self._request_event.value is not None: - return self._request_event.value - else: - raise SimulationError("Requested item from an unfinished Get request.") - else: - raise SimulationError( - "'get_value' is not supported for Containers. Check is_" - "complete and use the amount you requested." - ) - - def rehearse(self) -> tuple[float, tyAny]: - """Mock the event to test if it is feasible. - - Note: - The function does not fully test the conditions to satisfy the - get request, but this method can be called as part of a more - complex rehearse run. - - Returns: - float: The time it took to do the request - Any: The value of the request. - """ - time_advance, _ = super().rehearse() - event_response = None - if self.__is_store: - event_response = PLANNING_FACTOR_OBJECT - return time_advance, event_response - - -class ResourceHold(BaseRequestEvent): - """Wrap the ``simpy`` request resource event. - - This manages getting and giving back all in one object. - - Example: - >>> resource = simpy.Resource(env, capacity=1) - >>> hold = ResourceHold(resource) - >>> # yield on the hold to get it - >>> yield hold - >>> # now that you have it, do things.. - >>> # give it back - >>> yield hold - >>> ... - """ - - def __init__( - self, - resource: SIM.Resource, - *resource_args: tyAny, - rehearsal_time_to_complete: float = 0.0, - **resource_kwargs: tyAny, - ) -> None: - """Create an event to use twice to get and give back a resource. - - Args: - resource (SIM.Resource): The simpy resource object. - rehearsal_time_to_complete (float, optional): Expected time to wait to - get the resource. Defaults to 0.0. - *resource_args (Any): positional arguments to the resource - **resource_kwargs (Any): keyword arguments to the resource - """ - super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) - - self.resource = resource - self.resource_args = resource_args - self.resource_kwargs = resource_kwargs - self._stage = "request" - self._request: Request | Release | None = None - - def calculate_time_to_complete(self) -> float: - """Time to complete, based on waiting for getting or giving back. - - Returns: - float: Time - """ - if self._stage == "request": - # assume the stage will switch on the next call - self._stage = "release" - return self.rehearsal_time_to_complete - elif self._stage == "release": - return 0.0 - raise UpstageError(f"Resource request stage is wrong: {self._stage}") - - def as_event(self) -> Request | Release: - """Create the simpy event for the right state of Resource usage. - - Returns: - Request | Release: The simpy event. - """ - if self._stage == "request": - self._request = self.resource.request(*self.resource_args, **self.resource_kwargs) - - self._request_event = self._request - self._stage = "release" - return self._request_event - elif self._stage == "release": - if not self._request or not self._request.processed: - raise SimulationError( - "Resource release requested when the " - "resource hasn't been given. Did you cancel?" - ) - assert isinstance(self._request, Request) - self._request_event = self.resource.release(self._request) - self._stage = "completed" - return self._request_event - raise UpstageError(f"Bad stage for Resource Hold: {self._stage}") - - -class FilterGet(Get): - """A Get for a FilterStore.""" - - def __init__( - self, - get_location: SIM.FilterStore, - filter: Callable[[tyAny], bool], - rehearsal_time_to_complete: float = 0.0, - ) -> None: - """Create a Get request on a FilterStore. - - The filter function returns a boolean (in/out of consideration). - - Args: - get_location (SIM.Store | SIM.Container): The place for the Get request - filter (Callable[[Any], bool]): The function that filters items in the store - rehearsal_time_to_complete (float, optional): _description_. Defaults to 0.0. - """ - super().__init__( - get_location=get_location, - rehearsal_time_to_complete=rehearsal_time_to_complete, - filter=filter, - ) - - -class All(MultiEvent): - """An event that requires all events to succeed before succeeding.""" - - @staticmethod - def aggregation_function(times: list[float]) -> float: - """Aggregate event times for rehearsal. - - Args: - times (list[float]): List of rehearsing times. - - Returns: - float: Aggregated (maximum) time. - """ - return max(times) - - @staticmethod - def simpy_equivalent(env: SIM.Environment, events: list[SIM.Event]) -> SIM.Event: - """Return the SimPy version of the UPSTAGE All event. - - Args: - env (SIM.Environment): SimPy Environment. - events (list[SIM.Event]): List of events. - - Returns: - SIM.Event: A simpy AllOf event. - """ - return SIM.AllOf(env, events) - - -class Event(BaseEvent): - """An UPSTAGE version of the standard SimPy Event. - - Returns a planning factor object on rehearsal for user testing against in rehearsals, in case. - - When the event is succeeded, a payload can be added through kwargs. - - This Event assumes that it might be long-lasting, and will auto-reset when yielded on. - """ - - def __init__( - self, - rehearsal_time_to_complete: float = 0.0, - auto_reset: bool = True, - ) -> None: - """Create an event. - - Args: - rehearsal_time_to_complete (float, optional): Expected time to complete. - Defaults to 0.0. - auto_reset (bool, optional): Whether to auto-reset on yield. Defaults to True. - """ - super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) - # The usage is sometimes that events might succeed before being - # yielded on - self._payload: dict[str, Any] = {} - self._auto_reset = auto_reset - assert isinstance(self.env, SIM.Environment) - self._event = SIM.Event(self.env) - - def calculate_time_to_complete(self) -> float: - """Return the time to complete. - - Returns: - float: Time to complete estimate. - """ - return self.rehearsal_time_to_complete - - def as_event(self) -> SIM.Event: - """Get the Event as a simpy type. - - This resets the event if allowed. - - Returns: - SIM.Event - """ - if self.is_complete(): - if self._auto_reset: - self.reset() - else: - raise UpstageError("Event not allowed to reset on yield.") - return self._event - - def succeed(self, **kwargs: tyAny) -> None: - """Succeed the event and store any payload. - - Args: - **kwargs (Any): key:values to store as payload. - """ - if self.is_complete(): - raise SimulationError("Event has already completed") - self._payload = kwargs - self._event.succeed() - - def is_complete(self) -> bool: - """Is the event done? - - Returns: - bool - """ - return self._event.processed - - def get_payload(self) -> dict[str, tyAny]: - """Get any payload from the call to succeed(). - - Returns: - dict[str, Any]: The payload left by the succeed() caller. - """ - return self._payload - - def reset(self) -> None: - """Reset the event to allow it to be held again.""" - assert isinstance(self.env, SIM.Environment) - self._event = SIM.Event(self.env) - - def cancel(self) -> None: - """Cancel the event. - - Cancelling doesn't mean much, since it's still going to be yielded on. - """ - try: - self._event.defused = True - self._event.succeed() - except RuntimeError as exc: - exc.add_note(f"Runtime error when cancelling '{self}'") - raise exc - - def rehearse(self) -> tuple[float, tyAny]: - """Run the event in 'trial' mode without changing the real environment. - - Returns: - tuple[float, Any]: The time to complete and the event's response. - """ - time_advance, _ = super().rehearse() - return time_advance, PLANNING_FACTOR_OBJECT +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""Classes for UPSTAGE events that feed to simpy.""" + +from collections.abc import Callable +from typing import Any as tyAny +from warnings import warn + +import simpy as SIM +from simpy.resources.container import ContainerGet, ContainerPut +from simpy.resources.resource import Release, Request +from simpy.resources.store import StoreGet, StorePut + +from .base import SimulationError, UpstageBase, UpstageError +from .constants import PLANNING_FACTOR_OBJECT + +__all__ = ( + "All", + "Any", + "BaseEvent", + "Event", + "Get", + "FilterGet", + "MultiEvent", + "Put", + "ResourceHold", + "Wait", +) + +SIM_REQ_EVTS = ContainerGet | ContainerPut | StoreGet | StorePut | Request | Release + + +class BaseEvent(UpstageBase): + """Base class for framework events.""" + + def __init__(self, *, rehearsal_time_to_complete: float = 0.0): + """Create a base event with a notion of rehearsal time. + + Args: + rehearsal_time_to_complete (float, optional): Time to simulate passing + on rehearsal. Defaults to 0.0. + """ + super().__init__() + self._simpy_event: SIM.Event | None = None + self._rehearsing: bool = False + self._done_rehearsing: bool = False + + self.created_at: float = self.now + self.rehearsal_time_to_complete = rehearsal_time_to_complete + + @property + def now(self) -> float: + """Current sim time. + + Returns: + float: sim time + """ + return self.env.now + + def calculate_time_to_complete(self) -> float: + """Calculate the time elapsed until the event is triggered. + + Returns: + float: The time until the event triggers. + """ + return self.rehearsal_time_to_complete + + def as_event(self) -> SIM.Event: + """Convert UPSTAGE event to a simpy Event. + + Returns: + SIM.Event: The upstage event as a simpy event. + """ + raise NotImplementedError( + "Events must specify how to convert to :class:`simpy.events.Event`" + ) + + def is_complete(self) -> bool: + """Is the event complete? + + Returns: + bool: If it's complete or not. + """ + if self._rehearsing: + return self._done_rehearsing + if self._simpy_event is None: + raise UpstageError("Event has no simpy equivalent made.") + return self._simpy_event.processed + + def cancel(self) -> None: + """Cancel an event.""" + raise NotImplementedError("Implement custom event cancelling") + + @property + def rehearsing(self) -> bool: + """If the event is rehearsing. + + Returns: + bool + """ + return self._rehearsing + + @property + def done_rehearsing(self) -> bool: + """If the event is done rehearsing. + + Returns: + bool + """ + return self._done_rehearsing + + def _start_rehearsal(self) -> None: + """Set the event to testing mode.""" + self._rehearsing = True + self._done_rehearsing = False + + def _finish_rehearsal(self, complete: bool) -> None: + """Finish rehearsing the event. + + Args: + complete (bool): Indicates if the event was successful during the test. + """ + if not self._rehearsing: + raise SimulationError( + "Trying to finish testing event but event testing was not started`" + ) + self._done_rehearsing = complete + + def rehearse(self) -> tuple[float, tyAny | None]: + """Run the event in 'rehearsal' mode without changing the real environment. + + This is used by the task rehearsal functions. + + Returns: + tuple[float, Any | None]: The time to complete and the event's response. + """ + self._start_rehearsal() + time_advance = self.calculate_time_to_complete() + self._finish_rehearsal(complete=True) + + event_response = None + + return time_advance, event_response + + +class Wait(BaseEvent): + """Wait a specified or random uniformly distributed amount of time. + + Return a timeout. If time is a list of length 2, choose a random time + between the interval given. + + Rehearsal time is given by the maximum time of the interval, if given. + + Parameters + ---------- + timeout : int, float, list, tuple + Amount of time to wait. If it is a list or a tuple of length 2, a + random uniform value between the two values will be used. + + """ + + def __init__( + self, + timeout: float | int, + 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. + + Args: + timeout (float | int): Time to wait. + 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?") + self._time_to_complete = timeout + self.timeout = timeout + if self._time_to_complete < 0: + raise SimulationError(f"Negative timeout in Wait: {self._time_to_complete}") + rehearse = timeout if rehearsal_time_to_complete is None else rehearsal_time_to_complete + super().__init__(rehearsal_time_to_complete=rehearse) + + @classmethod + def from_random_uniform( + cls, + low: float | int, + high: float | int, + rehearsal_time_to_complete: float | int | None = None, + ) -> "Wait": + """Create a wait from a random uniform time. + + Args: + low (float): Lower bounds of random draw + high (float): Upper bounds of random draw + rehearsal_time_to_complete (float | int, optional): The rehearsal time + to complete. Defaults to None - meaning the random value drawn. + + Returns: + Wait: The timeout event + """ + rng = UpstageBase().stage.random + timeout = rng.uniform(low, high) + return cls(timeout, rehearsal_time_to_complete) + + def as_event(self) -> SIM.Timeout: + """Cast Wait event as a simpy Timeout event. + + Returns: + SIM.Timeout + """ + assert isinstance(self.env, SIM.Environment) + self._simpy_event = self.env.timeout(self._time_to_complete) + return self._simpy_event + + def cancel(self) -> None: + """Cancel the timeout. + + There's no real meaning to cancelling a timeout. It sits in simpy's queue either way. + """ + assert self._simpy_event is not None + try: + self._simpy_event.defused = True + except RuntimeError as exc: + warn(f"Runtime error when cancelling '{self}', Error: {exc}!") + + +class BaseRequestEvent(BaseEvent): + """Base class for Request Events. + + Requests are things like Get and Put that wait in a queue. + """ + + def __init__(self, rehearsal_time_to_complete: float = 0.0) -> None: + """Create a request event. + + Args: + rehearsal_time_to_complete (float, optional): Estimated time to complete. + Defaults to 0.0. + """ + super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) + self._request_event: SIM_REQ_EVTS | None = None + + def cancel(self) -> None: + """Cancel the Request.""" + if self._request_event is None: + return + if not self.is_complete(): + self._request_event.cancel() + # TODO: Do we put it back? + + def is_complete(self) -> bool: + """Test if the request is finished. + + Returns: + bool + """ + if self.rehearsing: + if self.done_rehearsing is None: + raise SimulationError( + f"Event '{self}' rehearsal started, but completion was" + "not set as incomplete, i.e., to `False`!" + ) + return self.done_rehearsing + assert self._request_event is not None + return self._request_event.processed + + +class Put(BaseRequestEvent): + """Wrap the ``simpy`` Put event. + + This is an event that puts an object into a ``simpy`` store or puts + an amount into a container. + + """ + + def __init__( + self, + put_location: SIM.Container | SIM.Store, + put_object: float | int | tyAny, + rehearsal_time_to_complete: float = 0.0, + ) -> None: + """Create a Put request for a store or container. + + Args: + put_location (SIM.Container | SIM.Store): Any container, store, or subclass. + put_object (float | int | Any): The amount (float | int) or object (Any) to put. + rehearsal_time_to_complete (float, optional): Estimated time for the put to finish. + Defaults to 0.0. + """ + super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) + + if not issubclass(put_location.__class__, SIM.Container | SIM.Store): + raise SimulationError( + f"put_location must be a subclass of Container " + f"or Store, not {put_location.__class__}" + ) + + self.put_location = put_location + self.put_object = put_object + + def as_event(self) -> ContainerPut | StorePut: + """Convert event to a ``simpy`` Event. + + Returns: + --------- + :obj:`simpy.events.Event` + Put request as a simpy event. + + """ + self._request_event = self.put_location.put(self.put_object) + return self._request_event + + +class MultiEvent(BaseEvent): + """A base class for evaluating multiple events. + + Note: + Subclasses of MultiEvent must define these methods: + * aggregation_function: Callable[[list[float]], float] + * simpy_equivalent: simpy.Event + + For an example, refer to :class:`~Any` and :class:`~All`. + """ + + def __init__(self, *events: BaseEvent | SIM.Process) -> None: + """Create a multi-event based on a list of events. + + Args: + *events (BaseEvent): The events that comprise the multi-event. + """ + super().__init__() + + for event in events: + if not issubclass(event.__class__, BaseEvent): + warn( + f"Event '{event}' is not an upstage Event. " + f"All events in a MultiEvent must be an " + f"instance of upstage BaseEvent if you are going " + f"to rehearse the task that contains this MultiEvent.", + UserWarning, + ) + self.events = events + self._simpy_event = None + + @staticmethod + def aggregation_function(times: list[float]) -> float: + """Aggregate event times to one single time. + + Args: + times (list[float]): Event rehearsal times + + Returns: + float: The aggregated time + """ + raise NotImplementedError("Implement in subclass") + + @staticmethod + def simpy_equivalent(env: SIM.Environment, events: list[SIM.Event]) -> SIM.Event: + """Return the simpy equivalent event. + + Args: + env (SIM.Environment): The SimPy environment. + events (list[BaseEvent]): Events to turn into multi-event. + + Returns: + SIM.Event: The aggregate event. + """ + raise NotImplementedError("Implement in subclass") + + def _make_event(self, event: BaseEvent | SIM.Process) -> SIM.Event: + # handle a process in the MultiEvent for non-rehearsal uses + if isinstance(event, SIM.Process): + return event + return event.as_event() + + def as_event(self) -> SIM.Event: + """Convert the UPSTAGE event to simpy. + + Returns: + SIM.Event: typically an Any or All + """ + sub_events = [self._make_event(event) for event in self.events] + assert isinstance(self.env, SIM.Environment) + self._simpy_event = self.simpy_equivalent(self.env, sub_events) + return self._simpy_event + + def cancel(self) -> None: + """Cancel the multi event and propagate it to the sub-events.""" + if self._simpy_event is None: + raise UpstageError("Can't cancel a nonexistent event.") + self._simpy_event.defused = True + self._simpy_event.fail(Exception("defused")) + for event in self.events: + if isinstance(event, BaseEvent): + event.cancel() + + def calculate_time_to_complete( + self, + ) -> float: + """Compute time required to complete the multi-event. + + Args: + return_sub_events (bool, Optional): Whether to return all times or not. + Defaults to False. + """ + event_times = { + event: event.calculate_time_to_complete() + for event in self.events + if isinstance(event, BaseEvent) + } + + time_to_complete = self.aggregation_function(list(event_times.values())) + + return time_to_complete + + def calc_time_to_complete_with_sub(self) -> tuple[float, dict[BaseEvent, float]]: + """Compute time required for MultiEvent and get sub-event times. + + Returns: + tuple[float, dict[BaseEvent, float]]: Aggregate and individual times. + """ + event_times = { + event: event.calculate_time_to_complete() + for event in self.events + if isinstance(event, BaseEvent) + } + time_to_complete = self.aggregation_function(list(event_times.values())) + + return time_to_complete, event_times + + def _start_rehearsal(self) -> None: + """Start rehearsing all the sub-events.""" + super()._start_rehearsal() + for event in self.events: + if not hasattr(event, "_start_rehearsal"): + raise SimulationError( + f"Event '{event}' is not an upstage Event. " + f"All events in a MultiEvent must be an " + f"instance of upstage BaseEvent if you are going" + f"to rehearse the task that contains this MultiEvent." + ) + event._start_rehearsal() + + def rehearse(self) -> tuple[float, tyAny]: + """Run the event in 'trial' mode without changing the real environment. + + Returns: + tuple[float, Any]: The time to complete and the event's response. + + Note: + This is used by the task rehearsal functions. + """ + self._start_rehearsal() + + event_response = None + time_to_finish, event_times = self.calc_time_to_complete_with_sub() + + for event, event_end_time in event_times.items(): + event._finish_rehearsal(complete=event_end_time <= time_to_finish) + + self._finish_rehearsal(complete=True) + + return time_to_finish, event_response + + +class Any(MultiEvent): + """An event that requires one event to succeed before succeeding.""" + + @staticmethod + def aggregation_function(times: list[float]) -> float: + """Aggregation function for rehearsal time. + + Args: + times (list[float]): List of rehearsal times + + Returns: + float: Aggregated time (the minimum) + """ + return min(times) + + @staticmethod + def simpy_equivalent(env: SIM.Environment, events: list[SIM.Event]) -> SIM.Event: + """Return the SimPy version of the UPSTAGE Any event. + + Args: + env (SIM.Environment): SimPy Environment. + events (list[SIM.Event]): List of events. + + Returns: + SIM.Event: A simpy AnyOf event. + """ + return SIM.AnyOf(env, events) + + +class Get(BaseRequestEvent): + """Wrap the ``simpy`` Get event. + + Event that gets an object from a ``simpy`` store or gets an amount from a + container. + """ + + def __init__( + self, + get_location: SIM.Store | SIM.Container, + *get_args: tyAny, + rehearsal_time_to_complete: float = 0.0, + **get_kwargs: tyAny, + ) -> None: + """Create a Get request on a store, container, or subclass of those. + + Args: + get_location (SIM.Store | SIM.Container): The place for the Get request + rehearsal_time_to_complete (float, optional): _description_. Defaults to 0.0. + get_args (Any): optional positional args for the get request + (blank for Store and Container) + get_kwargs (Any): optional keyword args for the get request + (blank for Store and Container) + """ + super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) + + if not issubclass(get_location.__class__, SIM.Container | SIM.Store): + raise SimulationError( + "'put_location' must be a subclass of Container" + f" or Store, not {get_location.__class__}" + ) + + self.get_location = get_location + self.get_args = get_args + self.get_kwargs = get_kwargs + self.__is_store = issubclass(get_location.__class__, SIM.Store) + + def calculate_time_to_complete(self) -> float: + """Calculate time elapsed until the event is triggered. + + Returns: + float: Estimated time until the event triggers. + + """ + return self.rehearsal_time_to_complete + + def as_event(self) -> ContainerGet | StoreGet: + """Convert get to a ``simpy`` Event. + + Returns: + ContainerGet | StoreGet + """ + # TODO: optional checking for container types for feasibility + self._request_event = self.get_location.get( + *self.get_args, + **self.get_kwargs, + ) + return self._request_event + + def get_value(self) -> tyAny: + """Get the value returned when the request is complete. + + Returns: + Any: The amount or item requested. + """ + if self.__is_store: + if self.rehearsing and self.done_rehearsing: + return PLANNING_FACTOR_OBJECT + if self._request_event is not None and self._request_event.value is not None: + return self._request_event.value + else: + raise SimulationError("Requested item from an unfinished Get request.") + else: + raise SimulationError( + "'get_value' is not supported for Containers. Check is_" + "complete and use the amount you requested." + ) + + def rehearse(self) -> tuple[float, tyAny]: + """Mock the event to test if it is feasible. + + Note: + The function does not fully test the conditions to satisfy the + get request, but this method can be called as part of a more + complex rehearse run. + + Returns: + float: The time it took to do the request + Any: The value of the request. + """ + time_advance, _ = super().rehearse() + event_response = None + if self.__is_store: + event_response = PLANNING_FACTOR_OBJECT + return time_advance, event_response + + +class ResourceHold(BaseRequestEvent): + """Wrap the ``simpy`` request resource event. + + This manages getting and giving back all in one object. + + Example: + >>> resource = simpy.Resource(env, capacity=1) + >>> hold = ResourceHold(resource) + >>> # yield on the hold to get it + >>> yield hold + >>> # now that you have it, do things.. + >>> # give it back + >>> yield hold + >>> ... + """ + + def __init__( + self, + resource: SIM.Resource, + *resource_args: tyAny, + rehearsal_time_to_complete: float = 0.0, + **resource_kwargs: tyAny, + ) -> None: + """Create an event to use twice to get and give back a resource. + + Args: + resource (SIM.Resource): The simpy resource object. + rehearsal_time_to_complete (float, optional): Expected time to wait to + get the resource. Defaults to 0.0. + *resource_args (Any): positional arguments to the resource + **resource_kwargs (Any): keyword arguments to the resource + """ + super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) + + self.resource = resource + self.resource_args = resource_args + self.resource_kwargs = resource_kwargs + self._stage = "request" + self._request: Request | Release | None = None + + def calculate_time_to_complete(self) -> float: + """Time to complete, based on waiting for getting or giving back. + + Returns: + float: Time + """ + if self._stage == "request": + # assume the stage will switch on the next call + self._stage = "release" + return self.rehearsal_time_to_complete + elif self._stage == "release": + return 0.0 + raise UpstageError(f"Resource request stage is wrong: {self._stage}") + + def as_event(self) -> Request | Release: + """Create the simpy event for the right state of Resource usage. + + Returns: + Request | Release: The simpy event. + """ + if self._stage == "request": + self._request = self.resource.request(*self.resource_args, **self.resource_kwargs) + + self._request_event = self._request + self._stage = "release" + return self._request_event + elif self._stage == "release": + if not self._request or not self._request.processed: + raise SimulationError( + "Resource release requested when the " + "resource hasn't been given. Did you cancel?" + ) + assert isinstance(self._request, Request) + self._request_event = self.resource.release(self._request) + self._stage = "completed" + return self._request_event + raise UpstageError(f"Bad stage for Resource Hold: {self._stage}") + + +class FilterGet(Get): + """A Get for a FilterStore.""" + + def __init__( + self, + get_location: SIM.FilterStore, + filter: Callable[[tyAny], bool], + rehearsal_time_to_complete: float = 0.0, + ) -> None: + """Create a Get request on a FilterStore. + + The filter function returns a boolean (in/out of consideration). + + Args: + get_location (SIM.Store | SIM.Container): The place for the Get request + filter (Callable[[Any], bool]): The function that filters items in the store + rehearsal_time_to_complete (float, optional): _description_. Defaults to 0.0. + """ + super().__init__( + get_location=get_location, + rehearsal_time_to_complete=rehearsal_time_to_complete, + filter=filter, + ) + + +class All(MultiEvent): + """An event that requires all events to succeed before succeeding.""" + + @staticmethod + def aggregation_function(times: list[float]) -> float: + """Aggregate event times for rehearsal. + + Args: + times (list[float]): List of rehearsing times. + + Returns: + float: Aggregated (maximum) time. + """ + return max(times) + + @staticmethod + def simpy_equivalent(env: SIM.Environment, events: list[SIM.Event]) -> SIM.Event: + """Return the SimPy version of the UPSTAGE All event. + + Args: + env (SIM.Environment): SimPy Environment. + events (list[SIM.Event]): List of events. + + Returns: + SIM.Event: A simpy AllOf event. + """ + return SIM.AllOf(env, events) + + +class Event(BaseEvent): + """An UPSTAGE version of the standard SimPy Event. + + Returns a planning factor object on rehearsal for user testing against in rehearsals, in case. + + When the event is succeeded, a payload can be added through kwargs. + + This Event assumes that it might be long-lasting, and will auto-reset when yielded on. + """ + + def __init__( + self, + rehearsal_time_to_complete: float = 0.0, + auto_reset: bool = True, + ) -> None: + """Create an event. + + Args: + rehearsal_time_to_complete (float, optional): Expected time to complete. + Defaults to 0.0. + auto_reset (bool, optional): Whether to auto-reset on yield. Defaults to True. + """ + super().__init__(rehearsal_time_to_complete=rehearsal_time_to_complete) + # The usage is sometimes that events might succeed before being + # yielded on + self._payload: dict[str, Any] = {} + self._auto_reset = auto_reset + assert isinstance(self.env, SIM.Environment) + self._event = SIM.Event(self.env) + + def calculate_time_to_complete(self) -> float: + """Return the time to complete. + + Returns: + float: Time to complete estimate. + """ + return self.rehearsal_time_to_complete + + def as_event(self) -> SIM.Event: + """Get the Event as a simpy type. + + This resets the event if allowed. + + Returns: + SIM.Event + """ + if self.is_complete(): + if self._auto_reset: + self.reset() + else: + raise UpstageError("Event not allowed to reset on yield.") + return self._event + + def succeed(self, **kwargs: tyAny) -> None: + """Succeed the event and store any payload. + + Args: + **kwargs (Any): key:values to store as payload. + """ + if self.is_complete(): + raise SimulationError("Event has already completed") + self._payload = kwargs + self._event.succeed() + + def is_complete(self) -> bool: + """Is the event done? + + Returns: + bool + """ + return self._event.processed + + def get_payload(self) -> dict[str, tyAny]: + """Get any payload from the call to succeed(). + + Returns: + dict[str, Any]: The payload left by the succeed() caller. + """ + return self._payload + + def reset(self) -> None: + """Reset the event to allow it to be held again.""" + assert isinstance(self.env, SIM.Environment) + self._event = SIM.Event(self.env) + + def cancel(self) -> None: + """Cancel the event. + + Cancelling doesn't mean much, since it's still going to be yielded on. + """ + try: + self._event.defused = True + self._event.succeed() + except RuntimeError as exc: + exc.add_note(f"Runtime error when cancelling '{self}'") + raise exc + + def rehearse(self) -> tuple[float, tyAny]: + """Run the event in 'trial' mode without changing the real environment. + + Returns: + tuple[float, Any]: The time to complete and the event's response. + """ + time_advance, _ = super().rehearse() + return time_advance, PLANNING_FACTOR_OBJECT diff --git a/src/upstage/geography/__init__.py b/src/upstage_des/geography/__init__.py similarity index 100% rename from src/upstage/geography/__init__.py rename to src/upstage_des/geography/__init__.py diff --git a/src/upstage/geography/conversions.py b/src/upstage_des/geography/conversions.py similarity index 100% rename from src/upstage/geography/conversions.py rename to src/upstage_des/geography/conversions.py diff --git a/src/upstage/geography/geo_types.py b/src/upstage_des/geography/geo_types.py similarity index 100% rename from src/upstage/geography/geo_types.py rename to src/upstage_des/geography/geo_types.py diff --git a/src/upstage/geography/intersections.py b/src/upstage_des/geography/intersections.py similarity index 98% rename from src/upstage/geography/intersections.py rename to src/upstage_des/geography/intersections.py index 9df9758..037570d 100644 --- a/src/upstage/geography/intersections.py +++ b/src/upstage_des/geography/intersections.py @@ -5,8 +5,8 @@ """Functions for finding intersections in geodetics.""" -from upstage.math_utils import _vector_norm, _vector_subtract -from upstage.units import unit_convert +from upstage_des.math_utils import _vector_norm, _vector_subtract +from upstage_des.units import unit_convert from .geo_types import LAT_LON_ALT, POSITION, POSITIONS, CrossingCondition from .spherical import Spherical diff --git a/src/upstage/geography/spherical.py b/src/upstage_des/geography/spherical.py similarity index 99% rename from src/upstage/geography/spherical.py rename to src/upstage_des/geography/spherical.py index c2e412a..268581f 100644 --- a/src/upstage/geography/spherical.py +++ b/src/upstage_des/geography/spherical.py @@ -6,8 +6,8 @@ from math import acos, asin, atan, atan2, cos, degrees, radians, sin, sqrt -from upstage.math_utils import _vector_dot -from upstage.units import unit_convert +from upstage_des.math_utils import _vector_dot +from upstage_des.units import unit_convert from .conversions import SphericalConversions, spherical_radius from .geo_types import GEO_POINT, LAT_LON, POSITION, POSITIONS, _convert_geo diff --git a/src/upstage/geography/wgs84.py b/src/upstage_des/geography/wgs84.py similarity index 99% rename from src/upstage/geography/wgs84.py rename to src/upstage_des/geography/wgs84.py index 2c2857a..f37e6e9 100644 --- a/src/upstage/geography/wgs84.py +++ b/src/upstage_des/geography/wgs84.py @@ -6,7 +6,7 @@ from math import atan, atan2, cos, degrees, radians, sin, sqrt, tan -from upstage.units import unit_convert +from upstage_des.units import unit_convert from .conversions import WGS84_A, WGS84_B, WGS84_F, WGS84Conversions from .geo_types import GEO_POINT, LAT_LON, POSITIONS, _convert_geo diff --git a/src/upstage/math_utils.py b/src/upstage_des/math_utils.py similarity index 96% rename from src/upstage/math_utils.py rename to src/upstage_des/math_utils.py index a3f3425..126a8e3 100644 --- a/src/upstage/math_utils.py +++ b/src/upstage_des/math_utils.py @@ -1,111 +1,111 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""This module contains math utility functions to avoid numpy.""" - -from math import sqrt - -VECTOR = list[float] | tuple[float, ...] - - -def _vector_subtract(vector_a: VECTOR, vector_b: VECTOR) -> list[float]: - """Subtract equal-sized vectors. - - Args: - vector_a (list[float]): Left vector - vector_b (list[float]): Right vector - - Returns: - list[float]: Subtracted vector - """ - if not (len(vector_a) == len(vector_b)): - raise ValueError("Vectors are not the same size") - - ret = [a - b for a, b in zip(vector_a, vector_b)] - return ret - - -def _vector_add(vector_a: VECTOR, vector_b: VECTOR) -> list[float]: - """Add equal-sized vectors. - - Args: - vector_a (list[float]): Left vector - vector_b (list[float]): Right vector - - Returns: - list[float]: Added vector - """ - if not (len(vector_a) == len(vector_b)): - raise ValueError("Vectors are not the same size") - - ret = [a + b for a, b in zip(vector_a, vector_b)] - return ret - - -def _vector_dot(vector_a: VECTOR, vector_b: VECTOR) -> float: - """Inner product of two vectors. - - Args: - vector_a (VECTOR): Left vector - vector_b (VECTOR): Right vector - - Returns: - float: inner product - """ - return sum(a * b for a, b in zip(vector_a, vector_b)) - - -def _vector_norm(arr: VECTOR) -> float: - """Norm of a vector. - - Args: - arr (VECTOR): vector - - Returns: - float: norm - """ - s = sum(a**2 for a in arr) - return sqrt(s) - - -def _roots(a: float, b: float, c: float) -> list[float]: - """Calculate the roots of a quadratic. - - The form is ax^2 + bx + c = 0. - - Args: - a (float): Coefficient on the square term - b (float): Coefficient on the base term - c (float): Constant - - Returns: - list[float]: The two roots, empty if not real. - """ - discriminant = b**2 - 4 * a * c - if discriminant < 0: - return [] - root1 = (-b + sqrt(discriminant)) / (2 * a) - root2 = (-b - sqrt(discriminant)) / (2 * a) - return [root1, root2] - - -def _col_mat_mul(col: VECTOR, mat: list[list[float]]) -> list[float]: - """Do a matrix multiplication of a column against a matrix. - - Does col @ mat - - Args: - col (VECTOR): The left vector - mat (list[list[float]]): The matrix - - Returns: - list[float]: The multiplied result - """ - if len(col) != len(mat): - raise ValueError("Number of values in col must equal number of rows in mat") - - result = [sum(x * y for x, y in zip(col, c)) for c in zip(*mat)] - - return result +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""This module contains math utility functions to avoid numpy.""" + +from math import sqrt + +VECTOR = list[float] | tuple[float, ...] + + +def _vector_subtract(vector_a: VECTOR, vector_b: VECTOR) -> list[float]: + """Subtract equal-sized vectors. + + Args: + vector_a (list[float]): Left vector + vector_b (list[float]): Right vector + + Returns: + list[float]: Subtracted vector + """ + if not (len(vector_a) == len(vector_b)): + raise ValueError("Vectors are not the same size") + + ret = [a - b for a, b in zip(vector_a, vector_b)] + return ret + + +def _vector_add(vector_a: VECTOR, vector_b: VECTOR) -> list[float]: + """Add equal-sized vectors. + + Args: + vector_a (list[float]): Left vector + vector_b (list[float]): Right vector + + Returns: + list[float]: Added vector + """ + if not (len(vector_a) == len(vector_b)): + raise ValueError("Vectors are not the same size") + + ret = [a + b for a, b in zip(vector_a, vector_b)] + return ret + + +def _vector_dot(vector_a: VECTOR, vector_b: VECTOR) -> float: + """Inner product of two vectors. + + Args: + vector_a (VECTOR): Left vector + vector_b (VECTOR): Right vector + + Returns: + float: inner product + """ + return sum(a * b for a, b in zip(vector_a, vector_b)) + + +def _vector_norm(arr: VECTOR) -> float: + """Norm of a vector. + + Args: + arr (VECTOR): vector + + Returns: + float: norm + """ + s = sum(a**2 for a in arr) + return sqrt(s) + + +def _roots(a: float, b: float, c: float) -> list[float]: + """Calculate the roots of a quadratic. + + The form is ax^2 + bx + c = 0. + + Args: + a (float): Coefficient on the square term + b (float): Coefficient on the base term + c (float): Constant + + Returns: + list[float]: The two roots, empty if not real. + """ + discriminant = b**2 - 4 * a * c + if discriminant < 0: + return [] + root1 = (-b + sqrt(discriminant)) / (2 * a) + root2 = (-b - sqrt(discriminant)) / (2 * a) + return [root1, root2] + + +def _col_mat_mul(col: VECTOR, mat: list[list[float]]) -> list[float]: + """Do a matrix multiplication of a column against a matrix. + + Does col @ mat + + Args: + col (VECTOR): The left vector + mat (list[list[float]]): The matrix + + Returns: + list[float]: The multiplied result + """ + if len(col) != len(mat): + raise ValueError("Number of values in col must equal number of rows in mat") + + result = [sum(x * y for x, y in zip(col, c)) for c in zip(*mat)] + + return result diff --git a/src/upstage/motion/__init__.py b/src/upstage_des/motion/__init__.py similarity index 100% rename from src/upstage/motion/__init__.py rename to src/upstage_des/motion/__init__.py diff --git a/src/upstage/motion/cartesian_model.py b/src/upstage_des/motion/cartesian_model.py similarity index 97% rename from src/upstage/motion/cartesian_model.py rename to src/upstage_des/motion/cartesian_model.py index bc6d973..ff11e89 100644 --- a/src/upstage/motion/cartesian_model.py +++ b/src/upstage_des/motion/cartesian_model.py @@ -6,9 +6,9 @@ from typing import TypeVar -from upstage.base import MotionAndDetectionError -from upstage.data_types import CartesianLocation -from upstage.math_utils import ( +from upstage_des.base import MotionAndDetectionError +from upstage_des.data_types import CartesianLocation +from upstage_des.math_utils import ( _col_mat_mul, _roots, _vector_add, diff --git a/src/upstage/motion/geodetic_model.py b/src/upstage_des/motion/geodetic_model.py similarity index 97% rename from src/upstage/motion/geodetic_model.py rename to src/upstage_des/motion/geodetic_model.py index 0de9c42..ae228a0 100644 --- a/src/upstage/motion/geodetic_model.py +++ b/src/upstage_des/motion/geodetic_model.py @@ -6,14 +6,14 @@ from math import sqrt -from upstage.data_types import GeodeticLocation -from upstage.geography import ( +from upstage_des.data_types import GeodeticLocation +from upstage_des.geography import ( INTERSECTION_LOCATION_CALLABLE, CrossingCondition, Spherical, ) -from upstage.motion.great_circle_calcs import get_dist_rad, get_great_circle_points -from upstage.units import unit_convert +from upstage_des.motion.great_circle_calcs import get_dist_rad, get_great_circle_points +from upstage_des.units import unit_convert def _to_tuple(loc: GeodeticLocation) -> tuple[float, float, float]: diff --git a/src/upstage/motion/great_circle_calcs.py b/src/upstage_des/motion/great_circle_calcs.py similarity index 95% rename from src/upstage/motion/great_circle_calcs.py rename to src/upstage_des/motion/great_circle_calcs.py index 8d80b02..d576096 100644 --- a/src/upstage/motion/great_circle_calcs.py +++ b/src/upstage_des/motion/great_circle_calcs.py @@ -1,144 +1,144 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""Great circle calculations. - -These equations were largely adapted from https://edwilliams.org/avform147.htm, -although most of them can be verified from a number of open resources around -the web. The overall algorithms have been slightly modified to support UPSTAGE. -""" - -from functools import lru_cache -from math import acos, asin, atan2, cos, pi, sin, sqrt -from typing import cast - -from upstage.data_types import GeodeticLocation - - -@lru_cache -def get_dist_rad(point1: GeodeticLocation, point2: GeodeticLocation) -> float: - """Get the distance (in radians) between point1 and point2. - - :param point1: the starting point - :param point2: the ending point - """ - point1 = point1.to_radians() - point2 = point2.to_radians() - ans = 2.0 * asin( - sqrt( - (sin((point1.lat - point2.lat) / 2.0)) ** 2 - + cos(point1.lat) * cos(point2.lat) * (sin((point1.lon - point2.lon) / 2.0)) ** 2 - ) - ) - return cast(float, ans) - - -@lru_cache -def get_course_rad(point1: GeodeticLocation, point2: GeodeticLocation) -> float: - """Get the course (in radians) between point1 and point2. - - :param point1: the starting point - :param point2: the ending point - """ - point1 = point1.to_radians() - point2 = point2.to_radians() - tcl: float - - d = get_dist_rad(point1, point2) - - if sin(point2.lon - point1.lon) < 0: - tcl = acos((sin(point2.lat) - sin(point1.lat) * cos(d)) / (sin(d) * cos(point1.lat))) - else: - tcl = 2.0 * pi - acos( - (sin(point2.lat) - sin(point1.lat) * cos(d)) / (sin(d) * cos(point1.lat)) - ) - - return tcl - - -@lru_cache -def get_pos_from_points_and_distance( - point1: GeodeticLocation, point2: GeodeticLocation, dist: float -) -> tuple[float, float]: - """Get a position (lat, lon) given a starting position, ending position, and distance. - - :param point1: GeodeticLocation of start of great circle - :param point2: GeodeticLocation of end of great circle - :param dist: (float) distance along great circle to find third point - - returns [lat, lon] - """ - point1 = point1.to_radians() - point2 = point2.to_radians() - tc = get_course_rad(point1, point2) # course from point 1 to 2 - - lat: float = asin(sin(point1.lat) * cos(dist) + cos(point1.lat) * sin(dist) * cos(tc)) - - dlon = atan2( - sin(tc) * sin(dist) * cos(point1.lat), - cos(dist) - sin(point1.lat) * sin(lat), - ) - - lon: float = ((point1.lon - dlon + pi) % (2.0 * pi)) - pi - - return (lat, lon) - - -@lru_cache -def get_great_circle_points( - point_a: GeodeticLocation, - point_b: GeodeticLocation, - point_d: GeodeticLocation, - dist: float, -) -> tuple[list[tuple[float, float]], list[float]] | None: - """Let points A and B define a great circle route and D be a third point. - - Find the points on the great circle through A and B that lie a distance d from D, if they exist. - - :param point_a: GeodeticLocation of start of great circle - :param point_b: GeodeticLocation of end of great circle - :param point_d: GeodeticLocation, third point of interest (the center of sphere) - :param dist: (float) distance from third to point to find intersection on great circle (radians) - """ - point_a = point_a.to_radians() - point_b = point_b.to_radians() - point_d = point_d.to_radians() - course_ad = get_course_rad(point_a, point_d) - course_ab = get_course_rad(point_a, point_b) - - a = course_ad - course_ab - b = get_dist_rad(point_a, point_d) - - r = (cos(b) ** 2 + sin(b) ** 2 * cos(a) ** 2) ** ( - 1 / 2 - ) # arccos(r) is the cross track distance - - atd = atan2(sin(b) * cos(a), cos(b)) # the along track distance - - dist_ab = get_dist_rad(point_a, point_b) - - if cos(dist) ** 2 > r**2: - # no points exist - dp = None - else: - # two points exist - dp = acos(cos(dist) / r) - - if dp: - d1 = atd - dp - d2 = atd + dp - - # make sure we can get to first crossing - # if second cross is negative, both points are outside the D/dist radius - if dist_ab < d1 or d2 < 0: - return None - - p1 = get_pos_from_points_and_distance(point_a, point_b, d1) - p2 = get_pos_from_points_and_distance(point_a, point_b, d2) - - else: - return None - - return [p1, p2], [d1, d2] +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""Great circle calculations. + +These equations were largely adapted from https://edwilliams.org/avform147.htm, +although most of them can be verified from a number of open resources around +the web. The overall algorithms have been slightly modified to support UPSTAGE. +""" + +from functools import lru_cache +from math import acos, asin, atan2, cos, pi, sin, sqrt +from typing import cast + +from upstage_des.data_types import GeodeticLocation + + +@lru_cache +def get_dist_rad(point1: GeodeticLocation, point2: GeodeticLocation) -> float: + """Get the distance (in radians) between point1 and point2. + + :param point1: the starting point + :param point2: the ending point + """ + point1 = point1.to_radians() + point2 = point2.to_radians() + ans = 2.0 * asin( + sqrt( + (sin((point1.lat - point2.lat) / 2.0)) ** 2 + + cos(point1.lat) * cos(point2.lat) * (sin((point1.lon - point2.lon) / 2.0)) ** 2 + ) + ) + return cast(float, ans) + + +@lru_cache +def get_course_rad(point1: GeodeticLocation, point2: GeodeticLocation) -> float: + """Get the course (in radians) between point1 and point2. + + :param point1: the starting point + :param point2: the ending point + """ + point1 = point1.to_radians() + point2 = point2.to_radians() + tcl: float + + d = get_dist_rad(point1, point2) + + if sin(point2.lon - point1.lon) < 0: + tcl = acos((sin(point2.lat) - sin(point1.lat) * cos(d)) / (sin(d) * cos(point1.lat))) + else: + tcl = 2.0 * pi - acos( + (sin(point2.lat) - sin(point1.lat) * cos(d)) / (sin(d) * cos(point1.lat)) + ) + + return tcl + + +@lru_cache +def get_pos_from_points_and_distance( + point1: GeodeticLocation, point2: GeodeticLocation, dist: float +) -> tuple[float, float]: + """Get a position (lat, lon) given a starting position, ending position, and distance. + + :param point1: GeodeticLocation of start of great circle + :param point2: GeodeticLocation of end of great circle + :param dist: (float) distance along great circle to find third point + + returns [lat, lon] + """ + point1 = point1.to_radians() + point2 = point2.to_radians() + tc = get_course_rad(point1, point2) # course from point 1 to 2 + + lat: float = asin(sin(point1.lat) * cos(dist) + cos(point1.lat) * sin(dist) * cos(tc)) + + dlon = atan2( + sin(tc) * sin(dist) * cos(point1.lat), + cos(dist) - sin(point1.lat) * sin(lat), + ) + + lon: float = ((point1.lon - dlon + pi) % (2.0 * pi)) - pi + + return (lat, lon) + + +@lru_cache +def get_great_circle_points( + point_a: GeodeticLocation, + point_b: GeodeticLocation, + point_d: GeodeticLocation, + dist: float, +) -> tuple[list[tuple[float, float]], list[float]] | None: + """Let points A and B define a great circle route and D be a third point. + + Find the points on the great circle through A and B that lie a distance d from D, if they exist. + + :param point_a: GeodeticLocation of start of great circle + :param point_b: GeodeticLocation of end of great circle + :param point_d: GeodeticLocation, third point of interest (the center of sphere) + :param dist: (float) distance from third to point to find intersection on great circle (radians) + """ + point_a = point_a.to_radians() + point_b = point_b.to_radians() + point_d = point_d.to_radians() + course_ad = get_course_rad(point_a, point_d) + course_ab = get_course_rad(point_a, point_b) + + a = course_ad - course_ab + b = get_dist_rad(point_a, point_d) + + r = (cos(b) ** 2 + sin(b) ** 2 * cos(a) ** 2) ** ( + 1 / 2 + ) # arccos(r) is the cross track distance + + atd = atan2(sin(b) * cos(a), cos(b)) # the along track distance + + dist_ab = get_dist_rad(point_a, point_b) + + if cos(dist) ** 2 > r**2: + # no points exist + dp = None + else: + # two points exist + dp = acos(cos(dist) / r) + + if dp: + d1 = atd - dp + d2 = atd + dp + + # make sure we can get to first crossing + # if second cross is negative, both points are outside the D/dist radius + if dist_ab < d1 or d2 < 0: + return None + + p1 = get_pos_from_points_and_distance(point_a, point_b, d1) + p2 = get_pos_from_points_and_distance(point_a, point_b, d2) + + else: + return None + + return [p1, p2], [d1, d2] diff --git a/src/upstage/motion/motion.py b/src/upstage_des/motion/motion.py similarity index 96% rename from src/upstage/motion/motion.py rename to src/upstage_des/motion/motion.py index 50b2406..6428f5e 100644 --- a/src/upstage/motion/motion.py +++ b/src/upstage_des/motion/motion.py @@ -1,493 +1,493 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# 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 queueing motion manager for sensor/mover intersections.""" - -from collections.abc import Callable, Generator -from typing import Any, Protocol, TypeVar -from warnings import warn - -from simpy import Event as SimpyEvent -from simpy import Interrupt, Process - -from upstage.actor import Actor -from upstage.base import ( - MotionAndDetectionError, - SimulationError, - UpstageBase, -) -from upstage.data_types import CartesianLocation, GeodeticLocation -from upstage.states import CartesianLocationChangingState, GeodeticLocationChangingState - -VALID = [ - ("ENTER", "END_INSIDE"), - ("ENTER", "EXIT"), - ("START_INSIDE", "END_INSIDE"), - ("START_INSIDE", "EXIT"), -] - -LOC_TYPES = CartesianLocation | GeodeticLocation -LOC_LIST = list[CartesianLocation] | list[GeodeticLocation] - -LOC_INPUT = TypeVar("LOC_INPUT", "GeodeticLocation", "CartesianLocation") - -INTERSECTION_TIMING_CALLABLE = Callable[ - [ - LOC_INPUT, - LOC_INPUT, - float, - LOC_INPUT, - float, - ], - tuple[ - list[LOC_INPUT], - list[float], - list[str], - float, - ], -] - - -class SensorType(Protocol): - """Protocol class for sensor typing.""" - - def entity_exited_range( - self, - entity: Any, - ) -> None: - """Entity exit range and does something.""" - - def entity_entered_range( - self, - entity: Any, - ) -> None: - """Entity enters range and does something.""" - - -class SensorMotionManager(UpstageBase): - """Schedules the interaction of moving and detectable entities against non-moving 'sensors'. - - Movable objects must be Actors with: - 1. GeodeticLocationChangingState OR CartesianLocationChangingState - 2. DetectabilityState - - Sensor objects MUST implement these two methods: - 1. `entity_entered_range(mover)` - 2. `entity_exited_range(mover)` - - The first is called when a mover enters the sensor's visiblity. - The second is called when a mover leaves the visibility or becomes undetectable. - - The motion manager will learn about sensor objects with: - - sensor_motion_manager.add_sensor(sensor_object, location, radius) - - Where location is a location object found in upstage.data_types and radius - is a distance in the units defined in upstage.STAGE. - - """ - - def __init__( - self, intersection_model: INTERSECTION_TIMING_CALLABLE, debug: bool = False - ) -> None: - """Create a sensor motion manager for queueing intersection events. - - Args: - intersection_model (INTERSECTION_TIMING_CALLABLE): The odel to calculate - intersections. - debug (bool, optional): Allow debug logging to _debug_log. Defaults to False. - """ - super().__init__() - self._sensors: dict[SensorType, tuple[str, str]] = {} - self._movers: dict[Actor, tuple[float, LOC_LIST, float]] = {} - self._events: dict[Actor, list[tuple[SensorType, Process]]] = {} - self._in_view: dict[Actor, set[SensorType]] = {} - self._debug: bool = debug - self._debug_data: dict[Actor, list[Any]] = {} - self._debug_log: list[Any] = [] - self.intersection = intersection_model - - def _test_detect(self, mover: Actor) -> str | None: - detect_state = mover._get_detection_state() - return detect_state - - def _stop_mover(self, mover: Actor, from_not_detectable: bool = False) -> None: - """Stop a mover. - - Args: - mover (Actor): The moving actor. - from_not_detectable (bool, optional): Is this was called from detectability state. - Defaults to False. - """ - detect_state = self._test_detect(mover) - if detect_state is None: - return None - - detectable: bool = getattr(mover, detect_state) - if mover not in self._movers and not detectable: - return None - - # Call this when a mover stops its motion - if mover not in self._movers and not from_not_detectable: - raise MotionAndDetectionError(f"Mover {mover} wasn't moving yet") - elif mover not in self._movers: - return None - # It's possible for a mover to have no events when it stops - # since it has no intersections. But we need the mover to exist - # in case a new sensor pops up - if mover in self._events: - for _, proc in self._events.get(mover, []): - if proc.is_alive: - proc.interrupt() - del self._events[mover] - - # clear the mover references - del self._movers[mover] - return None - - def _mover_not_detectable(self, mover: Actor) -> None: - """Called via DetectabilityState when a mover becomes undectable. - - Could be called for any reason; use this feature to alert sensors that - a mover should no longer be considered by that sensor. - - Args: - mover (Actor): The mover. - """ - if mover in self._in_view: - for sensor in self._in_view[mover]: - # This will cause some old data to stick around, but that's - # instead of making new events to clear it out and then having - # to end those clearing events if this happens - sensor.entity_exited_range(mover) - del self._in_view[mover] - # It is unsure if the user will stop the motion via a task first - # or change detectability first - self._stop_mover(mover, from_not_detectable=True) - - def _mover_became_detectable(self, mover: Actor) -> None: - """Called via DetectabilityState when a mover becomes detectable. - - The actor calls this in its movement states. - - Args: - mover (Actor): The mover. - """ - # Before a mover is 'restarted' when becoming detectable, - # we have to know if it's still moving - move_states = ( - GeodeticLocationChangingState, - CartesianLocationChangingState, - ) - # find if there is a location changing state that is active - locations = [ - name - for name in mover._active_states - if isinstance(mover._state_defs[name], move_states) - ] - if locations: - msg = ( - "Setting DetectabilityState to True while " - "locations states are active won't affect the" - "SensorMotionManager." - ) - warn(msg, UserWarning) - - # TODO: remove sensor or 'not active'? - - def _process_mover_sensor_pair( - self, mover: Actor, sensor: SensorType - ) -> list[tuple[tuple[str, float, LOC_TYPES], tuple[str, float, LOC_TYPES]]]: - """Find the intersections (if any) b/w mover and sensor. - - Args: - mover (Actor): The mover - sensor (SensorType): The sensor - - Returns: - list[tuple[str, float]]: What the movement events are are and their times - """ - # Get pairs of "Inside/entering - Leaving/staying" to send - # to the probability model and scheduler - speed, waypoints, start_time = self._movers[mover] - location_name, radius_name = self._sensors[sensor] - location: LOC_TYPES = getattr(sensor, location_name) - radius: float = getattr(sensor, radius_name) - - # Since waypoints connect, don't keep 'finish_in' unless - # it's the last point in the series - elapsed_time: float = 0.0 - inter_data: list[tuple[str, float, LOC_TYPES]] = [] - for i in range(len(waypoints) - 1): - start, finish = waypoints[i : i + 2] - # These times are relative to the start of the path - intersections, times, types, path_time = self.intersection( - start, - finish, - speed, - location, - radius, - ) - - for ( - inter, - t, - typ, - ) in zip(intersections, times, types): - if inter_data and inter_data[-1][0] == "END_INSIDE": - # drop that one since we are continuing on from that point - # and ignore this current one since it's inside already - if typ != "START_INSIDE": - raise SimulationError("START_INSIDE must follow END_INSIDE") - - inter_data.pop() - continue - inter_data.append((typ, start_time + elapsed_time + t, inter)) - elapsed_time += path_time - - # pair off the in/out - if len(inter_data) % 2 != 0: - raise SimulationError(f"Intersections should pair in/out or in/in, found: {inter_data}") - pairs = [(inter_data[i], inter_data[i + 1]) for i in range(0, len(inter_data), 2)] - if not all((a[0], b[0]) in VALID for a, b in pairs): - raise SimulationError(f"Bad pairing of intersections: {pairs}") - return pairs - - def _add_to_view(self, mover: Actor, sensor: SensorType) -> bool: - """Add a mover to a sensor's view. - - Args: - mover (Actor): Mover - sensor (Actor): Sensor - - Returns: - bool: If it was already in view. - """ - if mover not in self._in_view: - self._in_view[mover] = set() - was_in = sensor in self._in_view[mover] - self._in_view[mover].add(sensor) - return was_in - - def _remove_from_view(self, mover: Actor, sensor: SensorType) -> None: - """Remove a mover from a sensor's view. - - Args: - mover (Actor): Mover - sensor (Actor): Sensor - """ - if mover not in self._in_view: - raise MotionAndDetectionError(f"{mover} isn't in view of anything to remove.") - if sensor not in self._in_view[mover]: - raise MotionAndDetectionError(f"{mover} isn't in view of {sensor} to allow clearing.") - self._in_view[mover].remove(sensor) - - def _end_notify(self, mover: Actor, sensor: SensorType, event: str) -> None: - """End notification and give a reason. - - Args: - mover (Actor): Mover - sensor (Actor): Sensor - event (str): Reason - """ - if self._debug: - msg = { - "time": self.env.now, - "event": f"Detection of a mover cancelled {event}", - "mover": mover, - "sensor": sensor, - } - self._debug_log.append(msg) - - def _notify( - self, - mover: Actor, - sensor: SensorType, - first_time: float, - second_time: float, - first_kind: str, - second_kind: str, - ) -> Generator[SimpyEvent, Any, None]: - """Notify a sensor about a mover. - - Handles entry to exit in one go, making interrupts easier. - - Args: - mover (Actor): Mover - sensor (Actor): Sensor - first_time (float): Time of the first notification - second_time (float): Time of the second - first_kind (str): Kind of the first - second_kind (str): Kind of the second - """ - # times are absolute on input to this method - notify_time_from_now = first_time - self.env.now - - if first_kind == "START_INSIDE" or notify_time_from_now <= 0: - was_in = self._add_to_view(mover, sensor) - if not was_in: - sensor.entity_entered_range(mover) - else: - assert first_kind == "ENTER" - try: - yield self.env.timeout(notify_time_from_now) - except Interrupt: - self._end_notify(mover, sensor, "before entry") - return None - - sensor.entity_entered_range(mover) - self._add_to_view(mover, sensor) - - if second_kind != "EXIT": - return None - - end_time_from_now = second_time - self.env.now - if end_time_from_now <= 0: - raise MotionAndDetectionError("Detection end time is less than detection start") - - try: - yield self.env.timeout(end_time_from_now) - except Interrupt: - self._end_notify(mover, sensor, "before exit") - return None - - sensor.entity_exited_range(mover) - self._remove_from_view(mover, sensor) - return None - - def _schedule( - self, - mover: Actor, - sensor: SensorType, - events: tuple[tuple[str, float, LOC_TYPES], tuple[str, float, LOC_TYPES]], - ) -> None: - """Schedule events based on entry/exit into sensor range. - - Args: - mover (Actor): The mover - sensor (SensorType): The sensor - events (list[tuple[str, float]]): Crossing events (ENTER and EXIT) - """ - if not events: - return - first_kind, first_time, first_loc = events[0] - second_kind: str = "" - second_time: float = first_time - second_loc: LOC_TYPES | None = None - if len(events) > 1: - second_kind, second_time, second_loc = events[1] - - # If both times are in the past, then the segment has already occurred - # and we can skip it. - if first_time <= self.env.now and second_time < self.env.now: - return - - if self._debug: - self._debug_data[mover].append( - ( - sensor, - [first_kind, second_kind], - [first_time, second_time], - [first_loc, second_loc], - ) - ) - msg = { - "time": self.env.now, - "event": "Scheduling sensor detecting mover", - "mover": mover, - "sensor": sensor, - } - self._debug_log.append(msg) - - proc = self.env.process( - self._notify(mover, sensor, first_time, second_time, first_kind, second_kind) - ) - if mover not in self._events: - self._events[mover] = [ - (sensor, proc), - ] - else: - self._events[mover].append((sensor, proc)) - - def _find_intersections( - self, - mover_list: list[Actor] | None = None, - sensor_list: list[SensorType] | None = None, - ) -> None: - """Find all paired intersections and schedule them. - - Optionally, use a reduced list of either movers or sensors. - - Args: - mover_list (list[Actor] | None, optional): Movers to consider. - Defaults to None (all movers). - sensor_list (list[SensorType] | None, optional): Sensors to consider. - Defaults to None (all sensors). - """ - movers = list(self._movers.keys()) if mover_list is None else mover_list - sensors = list(self._sensors.keys()) if sensor_list is None else sensor_list - for m in movers: - for s in sensors: - inter_pairs = self._process_mover_sensor_pair(m, s) - for pair in inter_pairs: - self._schedule(m, s, pair) - - def _start_mover(self, mover: Actor, speed: float, waypoints: LOC_LIST) -> None: - """Start a mover's motion and find intersections with sensors. - - Args: - mover (Actor): The mover - speed (float): Speed (in model units) - waypoints (LOC_LIST): Waypoint of travel. - """ - detect_state = self._test_detect(mover) - if detect_state is None: - return - - detectable = getattr(mover, detect_state) - # Since this class examines self._movers when mover stops, we need to put in - # some data about that so we get the right errors if new motion starts - # when this motion hasn't ended. - if not detectable: - self._movers[mover] = (0.0, [], 0.0) - return None - - if mover in self._movers: - raise MotionAndDetectionError( - f"Mover: {mover} is already known to be on a path. Did you forget to stop it?" - ) - - if self._debug and mover not in self._debug_data: - self._debug_data[mover] = [] - # TODO: Waypoints need to start with the movers current location - self._movers[mover] = (speed, waypoints, self.env.now) - self._find_intersections(mover_list=[mover], sensor_list=None) - return None - - def add_sensor( - self, - sensor: SensorType, - location_attr_name: str = "location", - radius_attr_name: str = "radius", - ) -> None: - """Add a sensor to the motion manager. - - Args: - sensor (SensorType): The sensor object - location_attr_name (str, optional): Name of the location attribute. - Defaults to "location". - radius_attr_name (str, optional): Name of the radius attribute. Defaults to "radius". - """ - # test the sensor for earlier errors about improperly-defined methods - required_methods = ["entity_entered_range", "entity_exited_range"] - for req in required_methods: - if not hasattr(sensor, req): - raise NotImplementedError(f"Sensor {sensor} does not have '{req}' method!") - for attr in [location_attr_name, radius_attr_name]: - _attr = getattr(sensor, attr, None) - if _attr is None: - raise SimulationError(f"Sensor {sensor} has no attribute: {attr}") - - self._sensors[sensor] = (location_attr_name, radius_attr_name) - self._find_intersections(mover_list=None, sensor_list=[sensor]) +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# 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 queueing motion manager for sensor/mover intersections.""" + +from collections.abc import Callable, Generator +from typing import Any, Protocol, TypeVar +from warnings import warn + +from simpy import Event as SimpyEvent +from simpy import Interrupt, Process + +from upstage_des.actor import Actor +from upstage_des.base import ( + MotionAndDetectionError, + SimulationError, + UpstageBase, +) +from upstage_des.data_types import CartesianLocation, GeodeticLocation +from upstage_des.states import CartesianLocationChangingState, GeodeticLocationChangingState + +VALID = [ + ("ENTER", "END_INSIDE"), + ("ENTER", "EXIT"), + ("START_INSIDE", "END_INSIDE"), + ("START_INSIDE", "EXIT"), +] + +LOC_TYPES = CartesianLocation | GeodeticLocation +LOC_LIST = list[CartesianLocation] | list[GeodeticLocation] + +LOC_INPUT = TypeVar("LOC_INPUT", "GeodeticLocation", "CartesianLocation") + +INTERSECTION_TIMING_CALLABLE = Callable[ + [ + LOC_INPUT, + LOC_INPUT, + float, + LOC_INPUT, + float, + ], + tuple[ + list[LOC_INPUT], + list[float], + list[str], + float, + ], +] + + +class SensorType(Protocol): + """Protocol class for sensor typing.""" + + def entity_exited_range( + self, + entity: Any, + ) -> None: + """Entity exit range and does something.""" + + def entity_entered_range( + self, + entity: Any, + ) -> None: + """Entity enters range and does something.""" + + +class SensorMotionManager(UpstageBase): + """Schedules the interaction of moving and detectable entities against non-moving 'sensors'. + + Movable objects must be Actors with: + 1. GeodeticLocationChangingState OR CartesianLocationChangingState + 2. DetectabilityState + + Sensor objects MUST implement these two methods: + 1. `entity_entered_range(mover)` + 2. `entity_exited_range(mover)` + + The first is called when a mover enters the sensor's visiblity. + The second is called when a mover leaves the visibility or becomes undetectable. + + The motion manager will learn about sensor objects with: + + sensor_motion_manager.add_sensor(sensor_object, location, radius) + + Where location is a location object found in upstage.data_types and radius + is a distance in the units defined in upstage.STAGE. + + """ + + def __init__( + self, intersection_model: INTERSECTION_TIMING_CALLABLE, debug: bool = False + ) -> None: + """Create a sensor motion manager for queueing intersection events. + + Args: + intersection_model (INTERSECTION_TIMING_CALLABLE): The odel to calculate + intersections. + debug (bool, optional): Allow debug logging to _debug_log. Defaults to False. + """ + super().__init__() + self._sensors: dict[SensorType, tuple[str, str]] = {} + self._movers: dict[Actor, tuple[float, LOC_LIST, float]] = {} + self._events: dict[Actor, list[tuple[SensorType, Process]]] = {} + self._in_view: dict[Actor, set[SensorType]] = {} + self._debug: bool = debug + self._debug_data: dict[Actor, list[Any]] = {} + self._debug_log: list[Any] = [] + self.intersection = intersection_model + + def _test_detect(self, mover: Actor) -> str | None: + detect_state = mover._get_detection_state() + return detect_state + + def _stop_mover(self, mover: Actor, from_not_detectable: bool = False) -> None: + """Stop a mover. + + Args: + mover (Actor): The moving actor. + from_not_detectable (bool, optional): Is this was called from detectability state. + Defaults to False. + """ + detect_state = self._test_detect(mover) + if detect_state is None: + return None + + detectable: bool = getattr(mover, detect_state) + if mover not in self._movers and not detectable: + return None + + # Call this when a mover stops its motion + if mover not in self._movers and not from_not_detectable: + raise MotionAndDetectionError(f"Mover {mover} wasn't moving yet") + elif mover not in self._movers: + return None + # It's possible for a mover to have no events when it stops + # since it has no intersections. But we need the mover to exist + # in case a new sensor pops up + if mover in self._events: + for _, proc in self._events.get(mover, []): + if proc.is_alive: + proc.interrupt() + del self._events[mover] + + # clear the mover references + del self._movers[mover] + return None + + def _mover_not_detectable(self, mover: Actor) -> None: + """Called via DetectabilityState when a mover becomes undectable. + + Could be called for any reason; use this feature to alert sensors that + a mover should no longer be considered by that sensor. + + Args: + mover (Actor): The mover. + """ + if mover in self._in_view: + for sensor in self._in_view[mover]: + # This will cause some old data to stick around, but that's + # instead of making new events to clear it out and then having + # to end those clearing events if this happens + sensor.entity_exited_range(mover) + del self._in_view[mover] + # It is unsure if the user will stop the motion via a task first + # or change detectability first + self._stop_mover(mover, from_not_detectable=True) + + def _mover_became_detectable(self, mover: Actor) -> None: + """Called via DetectabilityState when a mover becomes detectable. + + The actor calls this in its movement states. + + Args: + mover (Actor): The mover. + """ + # Before a mover is 'restarted' when becoming detectable, + # we have to know if it's still moving + move_states = ( + GeodeticLocationChangingState, + CartesianLocationChangingState, + ) + # find if there is a location changing state that is active + locations = [ + name + for name in mover._active_states + if isinstance(mover._state_defs[name], move_states) + ] + if locations: + msg = ( + "Setting DetectabilityState to True while " + "locations states are active won't affect the" + "SensorMotionManager." + ) + warn(msg, UserWarning) + + # TODO: remove sensor or 'not active'? + + def _process_mover_sensor_pair( + self, mover: Actor, sensor: SensorType + ) -> list[tuple[tuple[str, float, LOC_TYPES], tuple[str, float, LOC_TYPES]]]: + """Find the intersections (if any) b/w mover and sensor. + + Args: + mover (Actor): The mover + sensor (SensorType): The sensor + + Returns: + list[tuple[str, float]]: What the movement events are are and their times + """ + # Get pairs of "Inside/entering - Leaving/staying" to send + # to the probability model and scheduler + speed, waypoints, start_time = self._movers[mover] + location_name, radius_name = self._sensors[sensor] + location: LOC_TYPES = getattr(sensor, location_name) + radius: float = getattr(sensor, radius_name) + + # Since waypoints connect, don't keep 'finish_in' unless + # it's the last point in the series + elapsed_time: float = 0.0 + inter_data: list[tuple[str, float, LOC_TYPES]] = [] + for i in range(len(waypoints) - 1): + start, finish = waypoints[i : i + 2] + # These times are relative to the start of the path + intersections, times, types, path_time = self.intersection( + start, + finish, + speed, + location, + radius, + ) + + for ( + inter, + t, + typ, + ) in zip(intersections, times, types): + if inter_data and inter_data[-1][0] == "END_INSIDE": + # drop that one since we are continuing on from that point + # and ignore this current one since it's inside already + if typ != "START_INSIDE": + raise SimulationError("START_INSIDE must follow END_INSIDE") + + inter_data.pop() + continue + inter_data.append((typ, start_time + elapsed_time + t, inter)) + elapsed_time += path_time + + # pair off the in/out + if len(inter_data) % 2 != 0: + raise SimulationError(f"Intersections should pair in/out or in/in, found: {inter_data}") + pairs = [(inter_data[i], inter_data[i + 1]) for i in range(0, len(inter_data), 2)] + if not all((a[0], b[0]) in VALID for a, b in pairs): + raise SimulationError(f"Bad pairing of intersections: {pairs}") + return pairs + + def _add_to_view(self, mover: Actor, sensor: SensorType) -> bool: + """Add a mover to a sensor's view. + + Args: + mover (Actor): Mover + sensor (Actor): Sensor + + Returns: + bool: If it was already in view. + """ + if mover not in self._in_view: + self._in_view[mover] = set() + was_in = sensor in self._in_view[mover] + self._in_view[mover].add(sensor) + return was_in + + def _remove_from_view(self, mover: Actor, sensor: SensorType) -> None: + """Remove a mover from a sensor's view. + + Args: + mover (Actor): Mover + sensor (Actor): Sensor + """ + if mover not in self._in_view: + raise MotionAndDetectionError(f"{mover} isn't in view of anything to remove.") + if sensor not in self._in_view[mover]: + raise MotionAndDetectionError(f"{mover} isn't in view of {sensor} to allow clearing.") + self._in_view[mover].remove(sensor) + + def _end_notify(self, mover: Actor, sensor: SensorType, event: str) -> None: + """End notification and give a reason. + + Args: + mover (Actor): Mover + sensor (Actor): Sensor + event (str): Reason + """ + if self._debug: + msg = { + "time": self.env.now, + "event": f"Detection of a mover cancelled {event}", + "mover": mover, + "sensor": sensor, + } + self._debug_log.append(msg) + + def _notify( + self, + mover: Actor, + sensor: SensorType, + first_time: float, + second_time: float, + first_kind: str, + second_kind: str, + ) -> Generator[SimpyEvent, Any, None]: + """Notify a sensor about a mover. + + Handles entry to exit in one go, making interrupts easier. + + Args: + mover (Actor): Mover + sensor (Actor): Sensor + first_time (float): Time of the first notification + second_time (float): Time of the second + first_kind (str): Kind of the first + second_kind (str): Kind of the second + """ + # times are absolute on input to this method + notify_time_from_now = first_time - self.env.now + + if first_kind == "START_INSIDE" or notify_time_from_now <= 0: + was_in = self._add_to_view(mover, sensor) + if not was_in: + sensor.entity_entered_range(mover) + else: + assert first_kind == "ENTER" + try: + yield self.env.timeout(notify_time_from_now) + except Interrupt: + self._end_notify(mover, sensor, "before entry") + return None + + sensor.entity_entered_range(mover) + self._add_to_view(mover, sensor) + + if second_kind != "EXIT": + return None + + end_time_from_now = second_time - self.env.now + if end_time_from_now <= 0: + raise MotionAndDetectionError("Detection end time is less than detection start") + + try: + yield self.env.timeout(end_time_from_now) + except Interrupt: + self._end_notify(mover, sensor, "before exit") + return None + + sensor.entity_exited_range(mover) + self._remove_from_view(mover, sensor) + return None + + def _schedule( + self, + mover: Actor, + sensor: SensorType, + events: tuple[tuple[str, float, LOC_TYPES], tuple[str, float, LOC_TYPES]], + ) -> None: + """Schedule events based on entry/exit into sensor range. + + Args: + mover (Actor): The mover + sensor (SensorType): The sensor + events (list[tuple[str, float]]): Crossing events (ENTER and EXIT) + """ + if not events: + return + first_kind, first_time, first_loc = events[0] + second_kind: str = "" + second_time: float = first_time + second_loc: LOC_TYPES | None = None + if len(events) > 1: + second_kind, second_time, second_loc = events[1] + + # If both times are in the past, then the segment has already occurred + # and we can skip it. + if first_time <= self.env.now and second_time < self.env.now: + return + + if self._debug: + self._debug_data[mover].append( + ( + sensor, + [first_kind, second_kind], + [first_time, second_time], + [first_loc, second_loc], + ) + ) + msg = { + "time": self.env.now, + "event": "Scheduling sensor detecting mover", + "mover": mover, + "sensor": sensor, + } + self._debug_log.append(msg) + + proc = self.env.process( + self._notify(mover, sensor, first_time, second_time, first_kind, second_kind) + ) + if mover not in self._events: + self._events[mover] = [ + (sensor, proc), + ] + else: + self._events[mover].append((sensor, proc)) + + def _find_intersections( + self, + mover_list: list[Actor] | None = None, + sensor_list: list[SensorType] | None = None, + ) -> None: + """Find all paired intersections and schedule them. + + Optionally, use a reduced list of either movers or sensors. + + Args: + mover_list (list[Actor] | None, optional): Movers to consider. + Defaults to None (all movers). + sensor_list (list[SensorType] | None, optional): Sensors to consider. + Defaults to None (all sensors). + """ + movers = list(self._movers.keys()) if mover_list is None else mover_list + sensors = list(self._sensors.keys()) if sensor_list is None else sensor_list + for m in movers: + for s in sensors: + inter_pairs = self._process_mover_sensor_pair(m, s) + for pair in inter_pairs: + self._schedule(m, s, pair) + + def _start_mover(self, mover: Actor, speed: float, waypoints: LOC_LIST) -> None: + """Start a mover's motion and find intersections with sensors. + + Args: + mover (Actor): The mover + speed (float): Speed (in model units) + waypoints (LOC_LIST): Waypoint of travel. + """ + detect_state = self._test_detect(mover) + if detect_state is None: + return + + detectable = getattr(mover, detect_state) + # Since this class examines self._movers when mover stops, we need to put in + # some data about that so we get the right errors if new motion starts + # when this motion hasn't ended. + if not detectable: + self._movers[mover] = (0.0, [], 0.0) + return None + + if mover in self._movers: + raise MotionAndDetectionError( + f"Mover: {mover} is already known to be on a path. Did you forget to stop it?" + ) + + if self._debug and mover not in self._debug_data: + self._debug_data[mover] = [] + # TODO: Waypoints need to start with the movers current location + self._movers[mover] = (speed, waypoints, self.env.now) + self._find_intersections(mover_list=[mover], sensor_list=None) + return None + + def add_sensor( + self, + sensor: SensorType, + location_attr_name: str = "location", + radius_attr_name: str = "radius", + ) -> None: + """Add a sensor to the motion manager. + + Args: + sensor (SensorType): The sensor object + location_attr_name (str, optional): Name of the location attribute. + Defaults to "location". + radius_attr_name (str, optional): Name of the radius attribute. Defaults to "radius". + """ + # test the sensor for earlier errors about improperly-defined methods + required_methods = ["entity_entered_range", "entity_exited_range"] + for req in required_methods: + if not hasattr(sensor, req): + raise NotImplementedError(f"Sensor {sensor} does not have '{req}' method!") + for attr in [location_attr_name, radius_attr_name]: + _attr = getattr(sensor, attr, None) + if _attr is None: + raise SimulationError(f"Sensor {sensor} has no attribute: {attr}") + + self._sensors[sensor] = (location_attr_name, radius_attr_name) + self._find_intersections(mover_list=None, sensor_list=[sensor]) diff --git a/src/upstage/motion/stepped_motion.py b/src/upstage_des/motion/stepped_motion.py similarity index 95% rename from src/upstage/motion/stepped_motion.py rename to src/upstage_des/motion/stepped_motion.py index 2933e5b..3126b9f 100644 --- a/src/upstage/motion/stepped_motion.py +++ b/src/upstage_des/motion/stepped_motion.py @@ -1,320 +1,320 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# 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 motion manager that does time-stepping.""" - -from collections.abc import Callable, Generator -from typing import Any, cast - -from simpy import Event as SimpyEvent - -from upstage.actor import Actor -from upstage.base import SimulationError, UpstageBase -from upstage.motion.motion import LOC_TYPES, SensorType -from upstage.states import CartesianLocationChangingState, GeodeticLocationChangingState -from upstage.task import process - - -class SteppedMotionManager(UpstageBase): - """Tests relative distances of objects with a location property. - - Reports to "sensor" objects when something enters or exits a range. - - Use this manager when the sensing entities are not static. If they are - static, use `SensorMotionManager`. - - Detectable objects and sensor objects must have an attribute that is a GeodeticLocationState - OR CartesianLocationState - - Detectable objects, if they aren't Actors, could implement _get_detection_state() -> bool:` - to allow this class to ignore them sometimes. The default way is to use a `DetectabilityState` - on the actor. - - Sensor objects MUST implement these two methods: - 1. `entity_entered_range(object)` - 2. `entity_exited_range(object)` - - The first is called when an entity enters the sensor's visiblity. - The second is called when an entity leaves the visibility or becomes undetectable. - - The sensor object CAN implement a method called `detection_checker`. - That method takes the location of an object to detect and returns True/False. - - The motion manager will learn about sensor objects with: - - sensor_motion_manager.add_sensor(sensor_object, radius) - - Where radius is a distance in the units defined in upstage.STAGE. - - Simple usage: - >>> manager = SteppedMotionManager(timestep=0.1) - >>> UP.STAGE.motion_manager = manager - >>> ... - >>> manager.add_sensor(binoculars, 'vision_radius') - >>> manager.add_detectable(bird, 'location') - - # TODO: Unify sensor and movable - # TODO: Having only moving things be detectable/using `_start_mover` - is easy, but this class lets us do static detection easier, so we may - have to go about it differently. - # TODO: Data structures for efficient distances - """ - - def __init__(self, timestep: float, max_empty_events: int = 3, debug: bool = False) -> None: - """Create the Stepped motion manager. - - Args: - timestep (float): Timestep to do all pairs distance checks. - max_empty_events (int, optional): How many timesteps where no events causes a shutdown. - Defaults to 3. - debug (bool, optional): Record data or not. Defaults to False. - """ - super().__init__() - self._sensors: dict[SensorType, tuple[Callable[[], float], Callable[[], LOC_TYPES]]] = {} - self._detectables: dict[Actor, Callable[[], LOC_TYPES]] = {} - self._in_view: set[tuple[SensorType, Actor]] = set() - self._timestep = timestep - self._max_empty_events = max_empty_events - self._debug = debug - self._debug_log: list[Any] = [] - self._is_running = False - - def _do_log(self, msg: Any) -> None: - """Write to a log list. - - Args: - msg (Any): Anything to append. - """ - if self._debug: - self._debug_log.append(msg) - - def _update_awareness(self, sensor: SensorType, object: Actor, visible: bool) -> None: - """Modify sensor/object awareness. - - Args: - sensor (SensorType): Sensor - object (Actor): The sensed - visible (bool): If the sensed is visible. - """ - if visible: - if (sensor, object) not in self._in_view: - self._in_view.add((sensor, object)) - sensor.entity_entered_range(object) - else: - if (sensor, object) in self._in_view: - self._in_view.remove((sensor, object)) - sensor.entity_exited_range(object) - - def _test_detect(self, detectable: Actor) -> bool: - """Is an actor detectable? - - Args: - detectable (Actor): The detectable - - Returns: - bool: If it can be detected - """ - if not hasattr(detectable, "_get_detection_state"): - return True - detect_state = detectable._get_detection_state() - if detect_state is None: - return True - visibility: bool = getattr(detectable, detect_state) - return visibility - - @staticmethod - def _detect_dist(loc1: LOC_TYPES, radius: float, loc2: LOC_TYPES, sensor: SensorType) -> bool: - """Run a detectability check, including sensor custom function. - - Args: - loc1 (LOC_TYPES): Sensor location - radius (float): Sensor radius - loc2 (LOC_TYPES): Target location - sensor (SensorType): Sensor object - - Returns: - bool: If it's detectable - """ - if hasattr(sensor, "detection_checker"): - visible = sensor.detection_checker(loc2) - else: - dist = loc1.straight_line_distance(loc2) - visible = dist <= radius - return cast(bool, visible) - - def _run_detectable( - self, - sensor_req: list[SensorType] | None = None, - detectable_req: list[Actor] | None = None, - ) -> None: - """All pairs distance checking. - - Args: - sensor_req (list[SensorType] | None, optional): Sensors. Defaults to None. - detectable_req (list[Actor] | None, optional): Detectables. Defaults to None. - """ - sensor_req = list(self._sensors) if sensor_req is None else sensor_req - sensor_radii = [self._sensors[s][0]() for s in sensor_req] - sensor_locs = [self._sensors[s][1]() for s in sensor_req] - - detectable_req = list(self._detectables) if detectable_req is None else detectable_req - detectable_req = [d for d in detectable_req if self._test_detect(d)] - detect_locs = [self._detectables[d]() for d in detectable_req] - - for sensor, radius, loc in zip(sensor_req, sensor_radii, sensor_locs): - for detectable, d_loc in zip(detectable_req, detect_locs): - if detectable is sensor: - continue - visible = self._detect_dist(loc, radius, d_loc, sensor) - self._do_log((self.env.now, sensor, loc, detectable, d_loc)) - self._update_awareness(sensor, detectable, visible) - - def _only_event_test(self) -> bool: - """Determine if there are no events in the queue.""" - if len(self.env._queue) == 0: - return True - return False - - @process - def run(self) -> Generator[SimpyEvent, None, None]: - """Run the main stepped motion loop. - - Yields: - Generator[SimpyEvent, None, None]: _description_ - """ - if self._is_running: - # If run() is called later than a task is queued, - # then this may be true already. - return - self._is_running = True - n_empty = 0 - while True: - timeout = self.env.timeout(self._timestep) - yield timeout - self._run_detectable() - # prevents infinite simulations - if self._only_event_test(): - n_empty += 1 - if n_empty >= self._max_empty_events: - return - else: - n_empty = 0 - - @process - def run_particular(self, rate: float, detectable: Actor) -> Generator[SimpyEvent, None, None]: - """Run detections against a single target at a faster rate. - - Args: - rate (float): Time rate to do detection checks. - detectable (Actor): The actor to be detected by the known sensors. - """ - while True: - yield self.env.timeout(rate) - self._run_detectable(sensor_req=None, detectable_req=[detectable]) - - def add_sensor( - self, - sensor: SensorType, - radius_attr_name: str = "radius", - location_attr_name: str = "location", - ) -> None: - """Add a sensor the motion manager. - - Args: - sensor (SensorType): The sensing object - radius_attr_name (str): Radius attribute name. Defaults to "radius". - location_attr_name (str): Location attribute name. Defaults to "location". - """ - # test the sensor for earlier errors about improperly-defined methods - required_methods = ["entity_entered_range", "entity_exited_range"] - required_attrs = [radius_attr_name, location_attr_name] - for req in required_methods: - if not hasattr(sensor, req): - raise NotImplementedError(f"Sensor {sensor} does not have '{req}' method!") - for attr in required_attrs: - if not hasattr(sensor, attr): - raise SimulationError(f"Sensor {sensor} doesn't have attribute {attr}") - - def get_radius() -> float: - return cast(float, getattr(sensor, radius_attr_name)) - - def get_location() -> LOC_TYPES: - return cast(LOC_TYPES, getattr(sensor, location_attr_name)) - - self._sensors[sensor] = (get_radius, get_location) - - def add_detectable( - self, - detectable: Actor, - location_attr_name: str = "location", - new_rate: float | None = None, - ) -> None: - """Add an object that is detectable to the manager. - - The object must have an attribute that performs distance calculations. - See the class docstring for more. - - Args: - detectable (Actor): An object that has a location attribute - location_attr_name (str): Name of the location attribute. Defaults to "location". - new_rate (float | None): Optional new rate for a detectable - (if it needs faster, most likely) - """ - if not hasattr(detectable, location_attr_name): - raise SimulationError( - f"Detectable {detectable} doesn't have attribute {location_attr_name}" - ) - try: - self._test_detect(detectable) - except Exception: - raise SimulationError(f"Detectable {detectable} needs a detectable state.") - - def get_location() -> LOC_TYPES: - return cast(LOC_TYPES, getattr(detectable, location_attr_name)) - - self._detectables[detectable] = get_location - - def _mover_not_detectable(self, detectable: Actor) -> None: - """Called via DetectabilityState state when an object becomes undetectable. - - Could be called for any reason; use this feature to alert sensors that - an object should no longer be considered by that sensor. - """ - # TODO: key on detectable may make it faster - to_rem = set() - for sensor, detect in self._in_view: - if detect is detectable: - sensor.entity_exited_range(detectable) - to_rem.add((sensor, detect)) - self._in_view -= to_rem - - def _mover_became_detectable(self, detectable: Actor) -> None: - """Called via DetectabilityState state when an object becomes detectable. - - Could be called for any reason; use this feature to alert sensors that - an object should be considered by that sensor. - """ - self._run_detectable(detectable_req=[detectable]) - - def _start_mover(self, mover: Actor, speed: float, waypoints: list[LOC_TYPES]) -> None: - # we don't need this method, except to hook into motion states - if not self._is_running: - self.run() - if mover in self._detectables: - return - state_name_1 = mover._get_matching_state(GeodeticLocationChangingState) - state_name_2 = mover._get_matching_state(CartesianLocationChangingState) - - use_state = state_name_1 or state_name_2 - if use_state is None: - raise SimulationError(f"Mover {mover} doesn't have a Location state") - - self.add_detectable(mover, use_state) - - def _stop_mover(self, mover: Actor) -> None: - # we don't need this method, except to hook into motion states - # It may be useful for ending detections, but the user should - # handle that themselves - if mover in self._detectables: - del self._detectables[mover] +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# 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 motion manager that does time-stepping.""" + +from collections.abc import Callable, Generator +from typing import Any, cast + +from simpy import Event as SimpyEvent + +from upstage_des.actor import Actor +from upstage_des.base import SimulationError, UpstageBase +from upstage_des.motion.motion import LOC_TYPES, SensorType +from upstage_des.states import CartesianLocationChangingState, GeodeticLocationChangingState +from upstage_des.task import process + + +class SteppedMotionManager(UpstageBase): + """Tests relative distances of objects with a location property. + + Reports to "sensor" objects when something enters or exits a range. + + Use this manager when the sensing entities are not static. If they are + static, use `SensorMotionManager`. + + Detectable objects and sensor objects must have an attribute that is a GeodeticLocationState + OR CartesianLocationState + + Detectable objects, if they aren't Actors, could implement _get_detection_state() -> bool:` + to allow this class to ignore them sometimes. The default way is to use a `DetectabilityState` + on the actor. + + Sensor objects MUST implement these two methods: + 1. `entity_entered_range(object)` + 2. `entity_exited_range(object)` + + The first is called when an entity enters the sensor's visiblity. + The second is called when an entity leaves the visibility or becomes undetectable. + + The sensor object CAN implement a method called `detection_checker`. + That method takes the location of an object to detect and returns True/False. + + The motion manager will learn about sensor objects with: + + sensor_motion_manager.add_sensor(sensor_object, radius) + + Where radius is a distance in the units defined in upstage.STAGE. + + Simple usage: + >>> manager = SteppedMotionManager(timestep=0.1) + >>> UP.STAGE.motion_manager = manager + >>> ... + >>> manager.add_sensor(binoculars, 'vision_radius') + >>> manager.add_detectable(bird, 'location') + + # TODO: Unify sensor and movable + # TODO: Having only moving things be detectable/using `_start_mover` + is easy, but this class lets us do static detection easier, so we may + have to go about it differently. + # TODO: Data structures for efficient distances + """ + + def __init__(self, timestep: float, max_empty_events: int = 3, debug: bool = False) -> None: + """Create the Stepped motion manager. + + Args: + timestep (float): Timestep to do all pairs distance checks. + max_empty_events (int, optional): How many timesteps where no events causes a shutdown. + Defaults to 3. + debug (bool, optional): Record data or not. Defaults to False. + """ + super().__init__() + self._sensors: dict[SensorType, tuple[Callable[[], float], Callable[[], LOC_TYPES]]] = {} + self._detectables: dict[Actor, Callable[[], LOC_TYPES]] = {} + self._in_view: set[tuple[SensorType, Actor]] = set() + self._timestep = timestep + self._max_empty_events = max_empty_events + self._debug = debug + self._debug_log: list[Any] = [] + self._is_running = False + + def _do_log(self, msg: Any) -> None: + """Write to a log list. + + Args: + msg (Any): Anything to append. + """ + if self._debug: + self._debug_log.append(msg) + + def _update_awareness(self, sensor: SensorType, object: Actor, visible: bool) -> None: + """Modify sensor/object awareness. + + Args: + sensor (SensorType): Sensor + object (Actor): The sensed + visible (bool): If the sensed is visible. + """ + if visible: + if (sensor, object) not in self._in_view: + self._in_view.add((sensor, object)) + sensor.entity_entered_range(object) + else: + if (sensor, object) in self._in_view: + self._in_view.remove((sensor, object)) + sensor.entity_exited_range(object) + + def _test_detect(self, detectable: Actor) -> bool: + """Is an actor detectable? + + Args: + detectable (Actor): The detectable + + Returns: + bool: If it can be detected + """ + if not hasattr(detectable, "_get_detection_state"): + return True + detect_state = detectable._get_detection_state() + if detect_state is None: + return True + visibility: bool = getattr(detectable, detect_state) + return visibility + + @staticmethod + def _detect_dist(loc1: LOC_TYPES, radius: float, loc2: LOC_TYPES, sensor: SensorType) -> bool: + """Run a detectability check, including sensor custom function. + + Args: + loc1 (LOC_TYPES): Sensor location + radius (float): Sensor radius + loc2 (LOC_TYPES): Target location + sensor (SensorType): Sensor object + + Returns: + bool: If it's detectable + """ + if hasattr(sensor, "detection_checker"): + visible = sensor.detection_checker(loc2) + else: + dist = loc1.straight_line_distance(loc2) + visible = dist <= radius + return cast(bool, visible) + + def _run_detectable( + self, + sensor_req: list[SensorType] | None = None, + detectable_req: list[Actor] | None = None, + ) -> None: + """All pairs distance checking. + + Args: + sensor_req (list[SensorType] | None, optional): Sensors. Defaults to None. + detectable_req (list[Actor] | None, optional): Detectables. Defaults to None. + """ + sensor_req = list(self._sensors) if sensor_req is None else sensor_req + sensor_radii = [self._sensors[s][0]() for s in sensor_req] + sensor_locs = [self._sensors[s][1]() for s in sensor_req] + + detectable_req = list(self._detectables) if detectable_req is None else detectable_req + detectable_req = [d for d in detectable_req if self._test_detect(d)] + detect_locs = [self._detectables[d]() for d in detectable_req] + + for sensor, radius, loc in zip(sensor_req, sensor_radii, sensor_locs): + for detectable, d_loc in zip(detectable_req, detect_locs): + if detectable is sensor: + continue + visible = self._detect_dist(loc, radius, d_loc, sensor) + self._do_log((self.env.now, sensor, loc, detectable, d_loc)) + self._update_awareness(sensor, detectable, visible) + + def _only_event_test(self) -> bool: + """Determine if there are no events in the queue.""" + if len(self.env._queue) == 0: + return True + return False + + @process + def run(self) -> Generator[SimpyEvent, None, None]: + """Run the main stepped motion loop. + + Yields: + Generator[SimpyEvent, None, None]: _description_ + """ + if self._is_running: + # If run() is called later than a task is queued, + # then this may be true already. + return + self._is_running = True + n_empty = 0 + while True: + timeout = self.env.timeout(self._timestep) + yield timeout + self._run_detectable() + # prevents infinite simulations + if self._only_event_test(): + n_empty += 1 + if n_empty >= self._max_empty_events: + return + else: + n_empty = 0 + + @process + def run_particular(self, rate: float, detectable: Actor) -> Generator[SimpyEvent, None, None]: + """Run detections against a single target at a faster rate. + + Args: + rate (float): Time rate to do detection checks. + detectable (Actor): The actor to be detected by the known sensors. + """ + while True: + yield self.env.timeout(rate) + self._run_detectable(sensor_req=None, detectable_req=[detectable]) + + def add_sensor( + self, + sensor: SensorType, + radius_attr_name: str = "radius", + location_attr_name: str = "location", + ) -> None: + """Add a sensor the motion manager. + + Args: + sensor (SensorType): The sensing object + radius_attr_name (str): Radius attribute name. Defaults to "radius". + location_attr_name (str): Location attribute name. Defaults to "location". + """ + # test the sensor for earlier errors about improperly-defined methods + required_methods = ["entity_entered_range", "entity_exited_range"] + required_attrs = [radius_attr_name, location_attr_name] + for req in required_methods: + if not hasattr(sensor, req): + raise NotImplementedError(f"Sensor {sensor} does not have '{req}' method!") + for attr in required_attrs: + if not hasattr(sensor, attr): + raise SimulationError(f"Sensor {sensor} doesn't have attribute {attr}") + + def get_radius() -> float: + return cast(float, getattr(sensor, radius_attr_name)) + + def get_location() -> LOC_TYPES: + return cast(LOC_TYPES, getattr(sensor, location_attr_name)) + + self._sensors[sensor] = (get_radius, get_location) + + def add_detectable( + self, + detectable: Actor, + location_attr_name: str = "location", + new_rate: float | None = None, + ) -> None: + """Add an object that is detectable to the manager. + + The object must have an attribute that performs distance calculations. + See the class docstring for more. + + Args: + detectable (Actor): An object that has a location attribute + location_attr_name (str): Name of the location attribute. Defaults to "location". + new_rate (float | None): Optional new rate for a detectable + (if it needs faster, most likely) + """ + if not hasattr(detectable, location_attr_name): + raise SimulationError( + f"Detectable {detectable} doesn't have attribute {location_attr_name}" + ) + try: + self._test_detect(detectable) + except Exception: + raise SimulationError(f"Detectable {detectable} needs a detectable state.") + + def get_location() -> LOC_TYPES: + return cast(LOC_TYPES, getattr(detectable, location_attr_name)) + + self._detectables[detectable] = get_location + + def _mover_not_detectable(self, detectable: Actor) -> None: + """Called via DetectabilityState state when an object becomes undetectable. + + Could be called for any reason; use this feature to alert sensors that + an object should no longer be considered by that sensor. + """ + # TODO: key on detectable may make it faster + to_rem = set() + for sensor, detect in self._in_view: + if detect is detectable: + sensor.entity_exited_range(detectable) + to_rem.add((sensor, detect)) + self._in_view -= to_rem + + def _mover_became_detectable(self, detectable: Actor) -> None: + """Called via DetectabilityState state when an object becomes detectable. + + Could be called for any reason; use this feature to alert sensors that + an object should be considered by that sensor. + """ + self._run_detectable(detectable_req=[detectable]) + + def _start_mover(self, mover: Actor, speed: float, waypoints: list[LOC_TYPES]) -> None: + # we don't need this method, except to hook into motion states + if not self._is_running: + self.run() + if mover in self._detectables: + return + state_name_1 = mover._get_matching_state(GeodeticLocationChangingState) + state_name_2 = mover._get_matching_state(CartesianLocationChangingState) + + use_state = state_name_1 or state_name_2 + if use_state is None: + raise SimulationError(f"Mover {mover} doesn't have a Location state") + + self.add_detectable(mover, use_state) + + def _stop_mover(self, mover: Actor) -> None: + # we don't need this method, except to hook into motion states + # It may be useful for ending detections, but the user should + # handle that themselves + if mover in self._detectables: + del self._detectables[mover] diff --git a/src/upstage/nucleus.py b/src/upstage_des/nucleus.py similarity index 96% rename from src/upstage/nucleus.py rename to src/upstage_des/nucleus.py index de6ecff..884bb40 100644 --- a/src/upstage/nucleus.py +++ b/src/upstage_des/nucleus.py @@ -7,9 +7,9 @@ from collections import defaultdict from typing import Any -from upstage.actor import Actor -from upstage.base import UpstageError -from upstage.task_network import TaskNetwork +from upstage_des.actor import Actor +from upstage_des.base import UpstageError +from upstage_des.task_network import TaskNetwork class NucleusInterrupt: diff --git a/src/upstage/py.typed b/src/upstage_des/py.typed similarity index 100% rename from src/upstage/py.typed rename to src/upstage_des/py.typed diff --git a/src/upstage/resources/__init__.py b/src/upstage_des/resources/__init__.py similarity index 100% rename from src/upstage/resources/__init__.py rename to src/upstage_des/resources/__init__.py diff --git a/src/upstage/resources/container.py b/src/upstage_des/resources/container.py similarity index 96% rename from src/upstage/resources/container.py rename to src/upstage_des/resources/container.py index b031ab8..ff3276b 100644 --- a/src/upstage/resources/container.py +++ b/src/upstage_des/resources/container.py @@ -1,408 +1,408 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# 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 ContinuousContainer.""" - -from collections.abc import Callable, Generator -from typing import TYPE_CHECKING, Any - -from simpy import Environment, Event, Interrupt, Process -from simpy.core import BoundClass - -__all__ = ( - "ContinuousContainer", - "ContainerEmptyError", - "ContainerError", - "ContainerFullError", -) - -EMPTY_STATUS: str = "empty" -FULL_STATUS: str = "full" - - -class ContainerError(Exception): - """The container is in an invalid state.""" - - @property - def cause(self) -> Any: - """Get the exception's cause. - - Returns: - Any: The cause. - """ - return self.args[0] - - -class ContainerFullError(ContainerError): - """The container has reach or exceeded its capacity.""" - - pass - - -class ContainerEmptyError(ContainerError): - """The container is empty or has a negative level.""" - - pass - - -class _ContinuousEvent(Event): - def _run(self, runtime: float) -> Generator[Event, None, None]: - self.container.add_user(self) - do_remove = True - try: - evt = self.env.timeout(runtime) - yield evt - except Interrupt as interruption: - if interruption.cause == self.stop_cause: - # The container will handle this for us - do_remove = False - for callback in self.custom_callbacks: - callback() - elif interruption.cause != "stop": - raise Interrupt(interruption) - if do_remove: - self.container.remove_user(self) - - def __init__( - self, - container: "ContinuousContainer", - rate: float, - time: float, - stop_cause: str | None, - custom_callbacks: list[Callable[[], None]] | None = None, - ) -> None: - super().__init__(container.env) - self.container = container - self.rate = rate - self.custom_callbacks = custom_callbacks or [] - self.stop_cause = stop_cause - time = float("inf") if time is None else time - self.process = self.env.process(self._run(time)) - - def cancel(self) -> None: - """Cancel this request. - - This method has to be called if the put request must be aborted, for - example if a process needs to handle an exception like an - :class:`~simpy.events.Interrupt`. - - If the put request was created in a :keyword:`with` statement, this - method is called automatically. - - """ - if self.process.is_alive: - self.process.interrupt("stop") - - -class ContinuousPut(_ContinuousEvent): - """An event that puts *rate* per unit time into the *container*. - - Raise a :exc:`ValueError` if ``rate <= 0``. - """ - - def __init__( - self, - container: "ContinuousContainer", - rate: float, - time: float, - custom_callbacks: list[Callable[[], None]] | None = None, - ) -> None: - """Create a put event that is continuous. - - Args: - container (ContinuousContainer): Container to add to. - rate (float): Rate to add at. - time (float): Time to run the event. - custom_callbacks (list[Callable[[], None]] | None, optional): Callbacks - for completion. Defaults to None. - """ - if rate <= 0: - raise ValueError( - "Rates must be greater than zero. Put means 'positive'." - ) # pragma: no cover - super().__init__(container, rate, time, FULL_STATUS, custom_callbacks) - - -class ContinuousGet(_ContinuousEvent): - """An event that gets *amount* from the *container*.""" - - def __init__( - self, - container: "ContinuousContainer", - rate: float, - time: float, - custom_callbacks: list[Callable[[], None]] | None = None, - ) -> None: - """Create a get event that is continuous. - - Args: - container (ContinuousContainer): Container to take from. - rate (float): Rate to remote at. - time (float): Time to run the event. - custom_callbacks (list[Callable[[], None]] | None, optional): Callbacks - for completion. Defaults to None. - """ - if rate <= 0: - raise ValueError( - "Rates must be greater than zero. Get means 'negative'." - ) # pragma: no cover - super().__init__(container, -rate, time, EMPTY_STATUS, custom_callbacks) - - -class ContinuousContainer: - """A container that accepts continuous gets and puts.""" - - def __init__( - self, - env: Environment, - capacity: int | float, - init: int | float = 0.0, - error_empty: bool = True, - error_full: bool = True, - ): - """Create a container that allows continuous gets and puts. - - Args: - env (Environment): SimPy Environment. - capacity (int | float): Capacity of the container - 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. - """ - self._capacity = capacity - if init < 0 or capacity < 0: - raise ValueError("Initial and capacity cannot be negative.") # pragma: no cover - self._level = init - self.error_empty = error_empty - self.error_full = error_full - - self._rate: float = 0.0 - self._env: Environment = env - self._last: float = self._env.now - self._active_users: list[_ContinuousEvent] = [] - self._checking: Process | None = None - - if TYPE_CHECKING: - - def put( - self, - rate: float, - time: float, - custom_callbacks: list[Callable[[], None]] | None = None, - ) -> ContinuousPut: - """Request to put *item* into the store.""" - return ContinuousPut(self, rate, time, custom_callbacks) - - def get( - self, rate: float, time: float, custom_callbacks: list[Callable[[], None]] | None = None - ) -> ContinuousGet: - """Request to get an *item* out of the store.""" - return ContinuousGet(self, rate, time, custom_callbacks) - - else: - put = BoundClass(ContinuousPut) - get = BoundClass(ContinuousGet) - - def time_until_level(self, level: float, rate: float = 0.0) -> float: - """Calculate the time until the containers reaches a value. - - Args: - level (float): The value to reach. - rate (float, optional): Additional rate. Defaults to 0.0. - - Returns: - float: The time to reach the level. - """ - rate += self._rate - - if self.level == level: - return 0.0 # pragma: no cover - elif rate == 0: - return float("inf") # pragma: no cover - time = (level - self._level) / rate - return time if time > 0 else float("inf") - - def time_until_done(self, rate: float = 0.0) -> float: - """Calculate the time until the container is full or empty. - - Args: - rate (float, optional): Additional rate. Defaults to 0.0. - - Returns: - float: Time until the container reaches a limit. - """ - rate += self._rate - if rate > 0: - return (self.capacity - self.level) / rate - elif rate < 0: - return -self.level / rate - else: - return float("inf") # pragma: no cover - - @property - def env(self) -> Environment: - """Get the environment of the container. - - Returns: - Environment: The SimPy environment. - """ - return self._env - - @property - def rate(self) -> float: - """Get the current net rate. - - Returns: - float: The net rate. - """ - return self._rate - - @property - def capacity(self) -> float: - """Get the capacity of the container. - - Returns: - float: The capacity. - """ - return self._capacity - - @property - def _active_puts(self) -> list[ContinuousPut]: - puts = [] - for x in self._active_users: - if x.rate > 0: - assert isinstance(x, ContinuousPut) - puts.append(x) - return puts - - @property - def _active_gets(self) -> list[ContinuousGet]: - gets = [] - for x in self._active_users: - if x.rate < 0: - assert isinstance(x, ContinuousGet) - gets.append(x) - return gets - - def _set_level(self) -> float: - """Set the level of the container based on the active gets/puts. - - Returns: - float: The current level. - """ - now = self._env.now - if now > self._last: - self._level += self._rate * (now - self._last) - self._last = now - return self._level - - def _check_empty(self) -> tuple[list[ContinuousGet], float]: - level = self._level - to_rem: list[ContinuousGet] = [] - rate = 0.0 - if level > 0: - return to_rem, rate - for get in tuple(self._active_gets): - get.process.interrupt(EMPTY_STATUS) - to_rem.append(get) - rate += -get.rate - if level == 0 and self.error_empty: - raise ContainerEmptyError("Container is empty!") - elif level < 0.0: - raise ContainerError(f"Container level is less than 0 ({level:.3f})!") - return to_rem, rate - - def _check_full(self) -> tuple[list[ContinuousPut], float]: - level = self._level - to_rem: list[ContinuousPut] = [] - rate: float = 0.0 - if level < self.capacity: - return to_rem, rate - for put in tuple(self._active_puts): - put.process.interrupt(FULL_STATUS) - to_rem.append(put) - rate += -put.rate - if level == self.capacity and self.error_full: - raise ContainerFullError("Container is full!") - elif level > self.capacity: - msg = "Container level exceeds capacity by {:.3f}!" - raise ContainerError(msg.format(level - self._capacity)) - return to_rem, rate - - def _check(self) -> Generator[Event, None, None]: - if not self._rate or self._rate == 0: - return # pragma: no coverd - - level = self.level - - check_wait = ((self._capacity if self._rate > 0 else 0.0) - level) / self._rate - - try: - yield self._env.timeout(check_wait) - except Interrupt as interruption: - if interruption.cause != "updated": - raise # pragma: no cover - finally: - to_rem: list[ContinuousGet] | list[ContinuousPut] = [] - rate: float - self._set_level() - if self._rate < 0: - to_rem, rate = self._check_empty() - elif self._rate > 0: - to_rem, rate = self._check_full() - if to_rem: - for req in to_rem: - self._active_users.remove(req) - self._add_rate(rate, interrupt=False) - - def _add_rate(self, rate_change: float | int, interrupt: bool = True) -> None: - """Add a new rate to the existing rate.""" - if self._checking is not None and interrupt: - self._checking.interrupt("updated") - - self._set_level() - self._rate += rate_change - - if self._rate == 0.0: - self._checking = None - else: - self._checking = self._env.process(self._check()) - - def add_user(self, user: _ContinuousEvent) -> None: - """Add a user to the container. - - Args: - user (_ContinuousEvent): The user event - """ - self._active_users.append(user) - self._add_rate(user.rate) - - def remove_user(self, user: _ContinuousEvent) -> None: - """Remove a user of the container. - - Args: - user (_ContinuousEvent): The user event. - """ - to_remove_rate = -1 * user.rate - self._active_users.remove(user) - self._add_rate(to_remove_rate) - - def _set_new_rate(self, rate: float | int) -> None: - """Set a new rate. - - Args: - rate (float | int): The new total rate. - """ - curr_rate = self.rate - diff = rate - curr_rate - self._add_rate(diff) - - @property - def level(self) -> float: - """Get the level of the container. - - Returns: - float: The current amount remaining. - """ - return self._level + self._rate * (self._env.now - self._last) +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# 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 ContinuousContainer.""" + +from collections.abc import Callable, Generator +from typing import TYPE_CHECKING, Any + +from simpy import Environment, Event, Interrupt, Process +from simpy.core import BoundClass + +__all__ = ( + "ContinuousContainer", + "ContainerEmptyError", + "ContainerError", + "ContainerFullError", +) + +EMPTY_STATUS: str = "empty" +FULL_STATUS: str = "full" + + +class ContainerError(Exception): + """The container is in an invalid state.""" + + @property + def cause(self) -> Any: + """Get the exception's cause. + + Returns: + Any: The cause. + """ + return self.args[0] + + +class ContainerFullError(ContainerError): + """The container has reach or exceeded its capacity.""" + + pass + + +class ContainerEmptyError(ContainerError): + """The container is empty or has a negative level.""" + + pass + + +class _ContinuousEvent(Event): + def _run(self, runtime: float) -> Generator[Event, None, None]: + self.container.add_user(self) + do_remove = True + try: + evt = self.env.timeout(runtime) + yield evt + except Interrupt as interruption: + if interruption.cause == self.stop_cause: + # The container will handle this for us + do_remove = False + for callback in self.custom_callbacks: + callback() + elif interruption.cause != "stop": + raise Interrupt(interruption) + if do_remove: + self.container.remove_user(self) + + def __init__( + self, + container: "ContinuousContainer", + rate: float, + time: float, + stop_cause: str | None, + custom_callbacks: list[Callable[[], None]] | None = None, + ) -> None: + super().__init__(container.env) + self.container = container + self.rate = rate + self.custom_callbacks = custom_callbacks or [] + self.stop_cause = stop_cause + time = float("inf") if time is None else time + self.process = self.env.process(self._run(time)) + + def cancel(self) -> None: + """Cancel this request. + + This method has to be called if the put request must be aborted, for + example if a process needs to handle an exception like an + :class:`~simpy.events.Interrupt`. + + If the put request was created in a :keyword:`with` statement, this + method is called automatically. + + """ + if self.process.is_alive: + self.process.interrupt("stop") + + +class ContinuousPut(_ContinuousEvent): + """An event that puts *rate* per unit time into the *container*. + + Raise a :exc:`ValueError` if ``rate <= 0``. + """ + + def __init__( + self, + container: "ContinuousContainer", + rate: float, + time: float, + custom_callbacks: list[Callable[[], None]] | None = None, + ) -> None: + """Create a put event that is continuous. + + Args: + container (ContinuousContainer): Container to add to. + rate (float): Rate to add at. + time (float): Time to run the event. + custom_callbacks (list[Callable[[], None]] | None, optional): Callbacks + for completion. Defaults to None. + """ + if rate <= 0: + raise ValueError( + "Rates must be greater than zero. Put means 'positive'." + ) # pragma: no cover + super().__init__(container, rate, time, FULL_STATUS, custom_callbacks) + + +class ContinuousGet(_ContinuousEvent): + """An event that gets *amount* from the *container*.""" + + def __init__( + self, + container: "ContinuousContainer", + rate: float, + time: float, + custom_callbacks: list[Callable[[], None]] | None = None, + ) -> None: + """Create a get event that is continuous. + + Args: + container (ContinuousContainer): Container to take from. + rate (float): Rate to remote at. + time (float): Time to run the event. + custom_callbacks (list[Callable[[], None]] | None, optional): Callbacks + for completion. Defaults to None. + """ + if rate <= 0: + raise ValueError( + "Rates must be greater than zero. Get means 'negative'." + ) # pragma: no cover + super().__init__(container, -rate, time, EMPTY_STATUS, custom_callbacks) + + +class ContinuousContainer: + """A container that accepts continuous gets and puts.""" + + def __init__( + self, + env: Environment, + capacity: int | float, + init: int | float = 0.0, + error_empty: bool = True, + error_full: bool = True, + ): + """Create a container that allows continuous gets and puts. + + Args: + env (Environment): SimPy Environment. + capacity (int | float): Capacity of the container + 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. + """ + self._capacity = capacity + if init < 0 or capacity < 0: + raise ValueError("Initial and capacity cannot be negative.") # pragma: no cover + self._level = init + self.error_empty = error_empty + self.error_full = error_full + + self._rate: float = 0.0 + self._env: Environment = env + self._last: float = self._env.now + self._active_users: list[_ContinuousEvent] = [] + self._checking: Process | None = None + + if TYPE_CHECKING: + + def put( + self, + rate: float, + time: float, + custom_callbacks: list[Callable[[], None]] | None = None, + ) -> ContinuousPut: + """Request to put *item* into the store.""" + return ContinuousPut(self, rate, time, custom_callbacks) + + def get( + self, rate: float, time: float, custom_callbacks: list[Callable[[], None]] | None = None + ) -> ContinuousGet: + """Request to get an *item* out of the store.""" + return ContinuousGet(self, rate, time, custom_callbacks) + + else: + put = BoundClass(ContinuousPut) + get = BoundClass(ContinuousGet) + + def time_until_level(self, level: float, rate: float = 0.0) -> float: + """Calculate the time until the containers reaches a value. + + Args: + level (float): The value to reach. + rate (float, optional): Additional rate. Defaults to 0.0. + + Returns: + float: The time to reach the level. + """ + rate += self._rate + + if self.level == level: + return 0.0 # pragma: no cover + elif rate == 0: + return float("inf") # pragma: no cover + time = (level - self._level) / rate + return time if time > 0 else float("inf") + + def time_until_done(self, rate: float = 0.0) -> float: + """Calculate the time until the container is full or empty. + + Args: + rate (float, optional): Additional rate. Defaults to 0.0. + + Returns: + float: Time until the container reaches a limit. + """ + rate += self._rate + if rate > 0: + return (self.capacity - self.level) / rate + elif rate < 0: + return -self.level / rate + else: + return float("inf") # pragma: no cover + + @property + def env(self) -> Environment: + """Get the environment of the container. + + Returns: + Environment: The SimPy environment. + """ + return self._env + + @property + def rate(self) -> float: + """Get the current net rate. + + Returns: + float: The net rate. + """ + return self._rate + + @property + def capacity(self) -> float: + """Get the capacity of the container. + + Returns: + float: The capacity. + """ + return self._capacity + + @property + def _active_puts(self) -> list[ContinuousPut]: + puts = [] + for x in self._active_users: + if x.rate > 0: + assert isinstance(x, ContinuousPut) + puts.append(x) + return puts + + @property + def _active_gets(self) -> list[ContinuousGet]: + gets = [] + for x in self._active_users: + if x.rate < 0: + assert isinstance(x, ContinuousGet) + gets.append(x) + return gets + + def _set_level(self) -> float: + """Set the level of the container based on the active gets/puts. + + Returns: + float: The current level. + """ + now = self._env.now + if now > self._last: + self._level += self._rate * (now - self._last) + self._last = now + return self._level + + def _check_empty(self) -> tuple[list[ContinuousGet], float]: + level = self._level + to_rem: list[ContinuousGet] = [] + rate = 0.0 + if level > 0: + return to_rem, rate + for get in tuple(self._active_gets): + get.process.interrupt(EMPTY_STATUS) + to_rem.append(get) + rate += -get.rate + if level == 0 and self.error_empty: + raise ContainerEmptyError("Container is empty!") + elif level < 0.0: + raise ContainerError(f"Container level is less than 0 ({level:.3f})!") + return to_rem, rate + + def _check_full(self) -> tuple[list[ContinuousPut], float]: + level = self._level + to_rem: list[ContinuousPut] = [] + rate: float = 0.0 + if level < self.capacity: + return to_rem, rate + for put in tuple(self._active_puts): + put.process.interrupt(FULL_STATUS) + to_rem.append(put) + rate += -put.rate + if level == self.capacity and self.error_full: + raise ContainerFullError("Container is full!") + elif level > self.capacity: + msg = "Container level exceeds capacity by {:.3f}!" + raise ContainerError(msg.format(level - self._capacity)) + return to_rem, rate + + def _check(self) -> Generator[Event, None, None]: + if not self._rate or self._rate == 0: + return # pragma: no coverd + + level = self.level + + check_wait = ((self._capacity if self._rate > 0 else 0.0) - level) / self._rate + + try: + yield self._env.timeout(check_wait) + except Interrupt as interruption: + if interruption.cause != "updated": + raise # pragma: no cover + finally: + to_rem: list[ContinuousGet] | list[ContinuousPut] = [] + rate: float + self._set_level() + if self._rate < 0: + to_rem, rate = self._check_empty() + elif self._rate > 0: + to_rem, rate = self._check_full() + if to_rem: + for req in to_rem: + self._active_users.remove(req) + self._add_rate(rate, interrupt=False) + + def _add_rate(self, rate_change: float | int, interrupt: bool = True) -> None: + """Add a new rate to the existing rate.""" + if self._checking is not None and interrupt: + self._checking.interrupt("updated") + + self._set_level() + self._rate += rate_change + + if self._rate == 0.0: + self._checking = None + else: + self._checking = self._env.process(self._check()) + + def add_user(self, user: _ContinuousEvent) -> None: + """Add a user to the container. + + Args: + user (_ContinuousEvent): The user event + """ + self._active_users.append(user) + self._add_rate(user.rate) + + def remove_user(self, user: _ContinuousEvent) -> None: + """Remove a user of the container. + + Args: + user (_ContinuousEvent): The user event. + """ + to_remove_rate = -1 * user.rate + self._active_users.remove(user) + self._add_rate(to_remove_rate) + + def _set_new_rate(self, rate: float | int) -> None: + """Set a new rate. + + Args: + rate (float | int): The new total rate. + """ + curr_rate = self.rate + diff = rate - curr_rate + self._add_rate(diff) + + @property + def level(self) -> float: + """Get the level of the container. + + Returns: + float: The current amount remaining. + """ + return self._level + self._rate * (self._env.now - self._last) diff --git a/src/upstage/resources/monitoring.py b/src/upstage_des/resources/monitoring.py similarity index 100% rename from src/upstage/resources/monitoring.py rename to src/upstage_des/resources/monitoring.py diff --git a/src/upstage/resources/reserve.py b/src/upstage_des/resources/reserve.py similarity index 96% rename from src/upstage/resources/reserve.py rename to src/upstage_des/resources/reserve.py index ca339fb..bc91b42 100644 --- a/src/upstage/resources/reserve.py +++ b/src/upstage_des/resources/reserve.py @@ -1,129 +1,129 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# 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.""" - -from collections.abc import Generator -from typing import Any - -from simpy import Environment, Event - -from ..task import process - -__all__ = ("ReserveStore",) - - -class ReserveStore: - """A store that allows requests to be scheduled in advance. - - This is not a true store (you can't yield on a reserve slot!). - """ - - def __init__( - self, - env: Environment, - init: float = 0.0, - capacity: float = float("inf"), - ) -> None: - """Create a store-like object that allows reservations. - - Note that this store 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. - - Args: - env (Environment): The SimPy Environment - init (float, optional): Initial amount available. Defaults to 0.0. - capacity (float, optional): Total capacity. Defaults to float("inf"). - """ - self.capacity = capacity - self._env = env - self._level = init - self._real_level = init - self._queued: dict[Any, tuple[float, float]] = {} - - @property - def remaining(self) -> float: - """Return the amount remaining in the store. - - Returns: - float: Amount remaining - """ - return self._level - - @property - def available(self) -> float: - """Return the amount remaining in the store. - - Returns: - float: Amount remaining. - """ - return self.remaining - - @property - def queued(self) -> list[Any]: - """Get the queued requesters. - - Returns: - list[Any]: List of requesters. - """ - return list(self._queued.keys()) - - @process - def _expire_request(self, requester: Any, time: float) -> Generator[Event, None, None]: - """Expire the request after an expiration period or at a specific time. - - :param request: the Request namedtuple object - :param expiration: the expiration Event object - - :type request: - :type expiration: - - """ - yield self._env.timeout(time) - self.cancel_request(requester) - - def reserve(self, requester: Any, quantity: float, expiration: float | None = None) -> bool: - """Reserve a quantity of storage.""" - if self.available < quantity: - return False - elif requester not in self._queued: - self._level -= quantity - self._queued[requester] = (quantity, self._env.now) - if expiration is not None: - self._expire_request(requester, expiration) - return True - else: - return False - - def cancel_request(self, requester: Any) -> bool: - """Have a request cancelled.""" - if requester not in self._queued: - return False - else: - request = [x for x in self._queued if x is requester] - if not request: - raise ValueError("Requester not available to cancel") - self._level += self._queued[requester][0] - self._queued.pop(request[0]) - return True - - def take(self, requester: Any) -> float: - """If in queue, allow requester take the actual quantity.""" - if requester not in self._queued: - raise ValueError("Requester is not in queue, cannot take.") - else: - amt, _ = self._queued.pop(requester) - self._real_level -= amt - return amt - - def put(self, amount: float, capacity_increase: bool = False) -> None: - """Put some quantity back in.""" - new = self._level + amount - if new > self.capacity and not capacity_increase: - raise ValueError("Adding too much.") - else: - self._level = new - self._real_level += amount +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# 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.""" + +from collections.abc import Generator +from typing import Any + +from simpy import Environment, Event + +from ..task import process + +__all__ = ("ReserveStore",) + + +class ReserveStore: + """A store that allows requests to be scheduled in advance. + + This is not a true store (you can't yield on a reserve slot!). + """ + + def __init__( + self, + env: Environment, + init: float = 0.0, + capacity: float = float("inf"), + ) -> None: + """Create a store-like object that allows reservations. + + Note that this store 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. + + Args: + env (Environment): The SimPy Environment + init (float, optional): Initial amount available. Defaults to 0.0. + capacity (float, optional): Total capacity. Defaults to float("inf"). + """ + self.capacity = capacity + self._env = env + self._level = init + self._real_level = init + self._queued: dict[Any, tuple[float, float]] = {} + + @property + def remaining(self) -> float: + """Return the amount remaining in the store. + + Returns: + float: Amount remaining + """ + return self._level + + @property + def available(self) -> float: + """Return the amount remaining in the store. + + Returns: + float: Amount remaining. + """ + return self.remaining + + @property + def queued(self) -> list[Any]: + """Get the queued requesters. + + Returns: + list[Any]: List of requesters. + """ + return list(self._queued.keys()) + + @process + def _expire_request(self, requester: Any, time: float) -> Generator[Event, None, None]: + """Expire the request after an expiration period or at a specific time. + + :param request: the Request namedtuple object + :param expiration: the expiration Event object + + :type request: + :type expiration: + + """ + yield self._env.timeout(time) + self.cancel_request(requester) + + def reserve(self, requester: Any, quantity: float, expiration: float | None = None) -> bool: + """Reserve a quantity of storage.""" + if self.available < quantity: + return False + elif requester not in self._queued: + self._level -= quantity + self._queued[requester] = (quantity, self._env.now) + if expiration is not None: + self._expire_request(requester, expiration) + return True + else: + return False + + def cancel_request(self, requester: Any) -> bool: + """Have a request cancelled.""" + if requester not in self._queued: + return False + else: + request = [x for x in self._queued if x is requester] + if not request: + raise ValueError("Requester not available to cancel") + self._level += self._queued[requester][0] + self._queued.pop(request[0]) + return True + + def take(self, requester: Any) -> float: + """If in queue, allow requester take the actual quantity.""" + if requester not in self._queued: + raise ValueError("Requester is not in queue, cannot take.") + else: + amt, _ = self._queued.pop(requester) + self._real_level -= amt + return amt + + def put(self, amount: float, capacity_increase: bool = False) -> None: + """Put some quantity back in.""" + new = self._level + amount + if new > self.capacity and not capacity_increase: + raise ValueError("Adding too much.") + else: + self._level = new + self._real_level += amount diff --git a/src/upstage/resources/sorted.py b/src/upstage_des/resources/sorted.py similarity index 100% rename from src/upstage/resources/sorted.py rename to src/upstage_des/resources/sorted.py diff --git a/src/upstage/state_sharing.py b/src/upstage_des/state_sharing.py similarity index 96% rename from src/upstage/state_sharing.py rename to src/upstage_des/state_sharing.py index 4a7430b..359c52f 100644 --- a/src/upstage/state_sharing.py +++ b/src/upstage_des/state_sharing.py @@ -4,10 +4,10 @@ # See the LICENSE file in the project root for complete license terms and disclaimers. """States that enable sharing between tasks.""" -from upstage.actor import Actor -from upstage.base import UpstageError -from upstage.states import ActiveState -from upstage.task import Task +from upstage_des.actor import Actor +from upstage_des.base import UpstageError +from upstage_des.states import ActiveState +from upstage_des.task import Task class SharedLinearChangingState(ActiveState[float]): diff --git a/src/upstage/states.py b/src/upstage_des/states.py similarity index 96% rename from src/upstage/states.py rename to src/upstage_des/states.py index 53b934e..1c13797 100644 --- a/src/upstage/states.py +++ b/src/upstage_des/states.py @@ -1,1001 +1,1001 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""A state defines the conditions of an actor over time.""" - -from collections.abc import Callable -from copy import deepcopy -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast - -from simpy import Container, Store - -from upstage.base import SimulationError, UpstageError -from upstage.data_types import CartesianLocation, GeodeticLocation -from upstage.math_utils import _vector_add, _vector_subtract -from upstage.resources.monitoring import SelfMonitoringStore -from upstage.task import Task - -if TYPE_CHECKING: - from upstage.actor import Actor - -__all__ = ( - "ActiveState", - "State", - "LinearChangingState", - "CartesianLocationChangingState", - "GeodeticLocationChangingState", - "ResourceState", - "DetectabilityState", - "CommunicationStore", -) - -CALLBACK_FUNC = Callable[["Actor", Any], None] -ST = TypeVar("ST") - - -class State(Generic[ST]): - """The particular condition that something is in at a specific time. - - The states are implemented as - `Descriptors `_ - which are associated to :class:`upstage.actor.Actor`. - - Note: - The classes that use this descriptor must contain an ``env`` attribute. - - States are aware - - """ - - def __init__( - self, - *, - default: ST | None = None, - frozen: bool = False, - valid_types: type | tuple[type, ...] | None = None, - recording: bool = False, - default_factory: Callable[[], ST] | None = None, - ) -> None: - """Create a state descriptor for an Actor. - - The default can be set either with the value or the factory. Use the factory if - the default needs to be a list, dict, or similar type of object. The default - is used if both are present (not the factory). - - Setting frozen to True will throw an error if the value of the state is changed. - - 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. - - 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. - default_factory (Callable[[], type] | None, optional): Default from function. - Defaults to None. - """ - if default is None and default_factory is not None: - default = default_factory() - - self._default = default - self._frozen = frozen - self._recording = recording - self._recording_callbacks: dict[Any, CALLBACK_FUNC] = {} - - self._types: tuple[type, ...] - - if isinstance(valid_types, type): - self._types = (valid_types,) - elif valid_types is None: - self._types = tuple() - else: - self._types = valid_types - self.IGNORE_LOCK: bool = False - - def _do_record(self, instance: "Actor", value: ST) -> None: - """Record the value of the state. - - Args: - instance (Actor): The actor holding the state - value (ST): State value - """ - 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, 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]: - instance._state_histories[self.name].append(to_append) - - def _do_callback(self, instance: "Actor", value: ST) -> None: - """Run callbacks for the state change. - - Args: - instance (Actor): The actor holding the state - value (Any): The value of the state - """ - for _, callback in self._recording_callbacks.items(): - callback(instance, value) - - def _broadcast_change(self, instance: "Actor", name: str, value: ST) -> None: - """Send state change values to nucleus. - - Args: - instance (Actor): The actor holding the state - name (str): The state's name - value (Any): The state's value - """ - # broadcast changes to the instance - if instance._state_listener is not None: - instance._state_listener.send_change(name, value) - - # NOTE: A dictionary as a descriptor doesn't work well, - # 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. - - Args: - instance (Actor): The actor holding the state - value (Any): The state's value - """ - if self._frozen: - old_value = instance.__dict__.get(self.name, None) - if old_value is not None: - raise SimulationError( - f"State '{self}' on '{instance}' has already been frozen " - f"to value of {old_value}. It cannot be changed once set!" - ) - - if self._types and not isinstance(value, self._types): - raise TypeError(f"{value} is of type {type(value)} not of type {self._types}") - - instance.__dict__[self.name] = value - - if self._recording: - self._do_record(instance, value) - self._do_callback(instance, value) - - self._broadcast_change(instance, self.name, value) - - def __get__(self, instance: "Actor", objtype: type | None = None) -> ST: - if instance is None: - # instance attribute accessed on class, return self - return self # pragma: no cover - if self.name in instance._mimic_states: - actor, name = instance._mimic_states[self.name] - value = getattr(actor, name) - self.__set__(instance, value) - if self.name not in instance.__dict__: - # Just set the value to the default - # Mutable types will be tricky here, so deepcopy them - instance.__dict__[self.name] = deepcopy(self._default) - v = instance.__dict__[self.name] - return cast(ST, v) - - def __set_name__(self, owner: "Actor", name: str) -> None: - self.name = name - - def has_default(self) -> bool: - """Check if a default exists. - - Returns: - bool - """ - return self._default is not None - - def _add_callback(self, source: Any, callback: CALLBACK_FUNC) -> None: - """Add a recording callback. - - Args: - source (Any): A key for the callback - callback (Callable[[Actor, Any], None]): A function to call - """ - self._recording_callbacks[source] = callback - - def _remove_callback(self, source: Any) -> None: - """Remove a callback. - - Args: - source (Any): The callback's key - """ - del self._recording_callbacks[source] - - @property - def is_recording(self) -> bool: - """Check if the state is recording. - - Returns: - bool - """ - return self._recording - - -class DetectabilityState(State[bool]): - """A state whose purpose is to indicate True or False. - - For consideration in the motion manager's <>LocationChangingState checks. - """ - - def __init__(self, *, default: bool = False, recording: bool = False) -> None: - """Create the detectability state. - - Args: - default (bool, optional): If the state starts on/off. Defaults to False. - recording (bool, optional): If the state records. Defaults to False. - """ - super().__init__( - default=default, - frozen=False, - valid_types=(bool,), - recording=recording, - ) - - def __set__(self, instance: "Actor", value: bool) -> None: - """Set the detectability. - - Args: - instance (Actor): The actor - value (bool): The value to set - """ - super().__set__(instance, value) - if hasattr(instance.stage, "motion_manager"): - mgr = instance.stage.motion_manager - if not value: - mgr._mover_not_detectable(instance) - else: - mgr._mover_became_detectable(instance) - - -class ActiveState(State, Generic[ST]): - """Base class for states that change over time according to some rules. - - This class must be subclasses with an implemented `active` method. - - """ - - def _active(self, instance: "Actor") -> Any: - """Determine if the instance has an active state. - - Note: - The instance must have two methods: ``get_active_state_data`` and - ``_set_active_state_data``. - - Note: - When you call ``activate_state`` from an actor, that is where - you define the activity data. It is up to the Actor's subclass to - make sure the activity data meet its needs. - - The first entry in the active data is always the time. - Alternatively, you can call ``self.get_activity_data`` for some - more data. - - """ - raise NotImplementedError("Method active not implemented.") - - def __get__(self, instance: "Actor", owner: type | None = None) -> ST: - if instance is None: - # instance attribute accessed on class, return self - return self # pragma: no cover - if self.name not in instance.__dict__: - # Just set the value to the default - # Mutable types will be tricky here, so deepcopy them - instance.__dict__[self.name] = deepcopy(self._default) # pragma: no cover - if self.name in instance._mimic_states: - actor, name = instance._mimic_states[self.name] - value = getattr(actor, name) - self.__set__(instance, value) - return cast(ST, value) - # test if this instance is active or not - res = self._active(instance) - # comes back as None (not active), or if it can be obtained from dict - if res is None: - res = instance.__dict__[self.name] - return cast(ST, res) - - def get_activity_data(self, instance: "Actor") -> dict[str, Any]: - """Get the data useful for updating active states. - - Returns: - dict[str, Any]: A dictionary with the state's pertinent data. Includes the actor's - environment current time (``'now'``) and the value of the actor's - state (``'state'``). - - """ - res = instance.get_active_state_data(self.name, without_update=True) - res["now"] = instance.env.now - res["value"] = instance.__dict__[self.name] - return res - - def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: - """Optional method to override that is called when a state is deactivated. - - Useful for motion states to deactivate their motion from - the motion manager. - - Defaults to any deactivation removing active state data. - """ - # Returns if the state should be ignored - # A False means the state is completely deactivated - return False - - -class LinearChangingState(ActiveState[float]): - """A state whose value changes linearly over time. - - When activating: - - >>> class Lin(Actor): - >>> x = LinearChangingState() - >>> - >>> def task(self, actor: Lin): - >>> actor.activate_state( - >>> name="x", - >>> task=self, - >>> rate=3.2, - >>> ) - """ - - def _active(self, instance: "Actor") -> float | None: - """Return a value to set based on time or some other criteria.""" - data = self.get_activity_data(instance) - now: float = data["now"] - current: float = data["value"] - started: float | None = data.get("started_at", None) - if started is None: - # it's not currently active - return None - # The user needs to know what their active data looks like. - # Alternatively, it could be defined in the state or the actor. - rate: float = data["rate"] - if now < started: - raise SimulationError( - f"Cannot set state '{self.name}' start time after now. " - f"This probably happened because the active state was " - f"set incorrectly." - ) - value = (now - started) * rate - return_value = current + value - self.__set__(instance, return_value) - instance._set_active_state_data( - state_name=self.name, - started_at=now, - rate=rate, - ) - return return_value - - -class CartesianLocationChangingState(ActiveState[CartesianLocation]): - """A state that contains the location in 3-dimensional Cartesian space. - - Movement is along straight lines in that space. - - For activating: - >>> actor.activate_state( - >>> state=, - >>> task=self, # usually - >>> speed=, - >>> waypoints=[ - >>> List of CartesianLocation - >>> ] - >>> ) - """ - - def __init__(self, *, recording: bool = False): - """Set a Location changing state. - - Defaults are disabled due to immutability of location objects. - (We could copy it, but it seems like better practice to force inputting it at runtime.) - - Args: - recording (bool, optional): Whether to record. Defaults to False. - """ - super().__init__( - default=None, - frozen=False, - default_factory=None, - valid_types=(CartesianLocation,), - recording=recording, - ) - - def _setup(self, instance: "Actor") -> None: - """Initialize data about a path. - - Args: - instance (Actor): The actor - """ - data = self.get_activity_data(instance) - current: CartesianLocation = data["value"] - speed: float = data["speed"] - waypoints: list[CartesianLocation] = data["waypoints"] - # get the times, distances, and bearings from the waypoints - times: list[float] = [] - distances: list[float] = [] - starts: list[CartesianLocation] = [] - vectors: list[list[float]] = [] - for wypt in waypoints: - dist = wypt - current - time = dist / speed - times.append(time) - distances.append(dist) - starts.append(current.copy()) - vectors.append(_vector_subtract(wypt._as_array(), current._as_array())) - current = wypt - - path_data = { - "times": times, - "distances": distances, - "starts": starts, - "vectors": vectors, - } - instance._set_active_state_data( - self.name, - started_at=data["now"], - origin=data["value"], - speed=speed, - waypoints=waypoints, - path_data=path_data, - ) - # if there is a motion manager, notify it - if hasattr(instance.stage, "motion_manager"): - if not getattr(instance, "_is_rehearsing", False): - instance.stage.motion_manager._start_mover( - instance, - speed, - [data["value"]] + waypoints, - ) - - def _get_index(self, path_data: dict[str, Any], time_elapsed: float) -> tuple[int, float]: - """Find out how far along waypoints the state is. - - Args: - path_data (dict[str, Any]): Data about the movement path - time_elapsed (float): Time spent moving - - Returns: - int: index in waypoints - float: time spent on path - """ - sum_t = 0.0 - t: float - for i, t in enumerate(path_data["times"]): - sum_t += t - if time_elapsed <= (sum_t + 1e-12): - return i, sum_t - t - raise SimulationError( - "CartesianLocation active state exceeded travel time: " - f"elapsed: {time_elapsed}, maximum: {sum_t}" - ) - - def _get_remaining_waypoints(self, instance: "Actor") -> list[CartesianLocation]: - """Convenience for getting waypoints left. - - Args: - instance (Actor): The owning actor. - - Returns: - list[CartesianLocation]: The waypoints left - """ - data = self.get_activity_data(instance) - current_time: float = data["now"] - path_start_time: float = data["started_at"] - elapsed = current_time - path_start_time - idx, _ = self._get_index(data["path_data"], elapsed) - return list(data["waypoints"][idx:]) - - def _active(self, instance: "Actor") -> CartesianLocation | None: - """Get the current value while active. - - Args: - instance (Actor): The owning actor - - Returns: - CartesianLocation | None: The current value - """ - data = self.get_activity_data(instance) - path_start_time: float | None = data.get("started_at", None) - if path_start_time is None: - # it's not active - return None - - path_data: dict[str, Any] | None = data.get("path_data", None) - if path_data is None: - self._setup(instance) - data = self.get_activity_data(instance) - - path_data: dict[str, Any] = data["path_data"] - current_time: float = data["now"] - elapsed = current_time - path_start_time - if elapsed < 0: - # Can probably only happen if active state is set incorrectly - raise SimulationError(f"Cannot set state '{self.name}' start time in the future!") - elif elapsed == 0: - return_value: CartesianLocation = data["value"] # pragma: no cover - else: - # Get the location along the waypoint path - wypt_index, wypt_start = self._get_index(path_data, elapsed) - time_along = elapsed - wypt_start - path_time: float = path_data["times"][wypt_index] - path_start: CartesianLocation = path_data["starts"][wypt_index] - path_vector: list[float] = path_data["vectors"][wypt_index] - time_frac = time_along / path_time - direction_amount = [time_frac * v for v in path_vector] - new_point = _vector_add(path_start._as_array(), direction_amount) - - # make the right kind of location object - new_location = CartesianLocation( - x=new_point[0], - y=new_point[1], - z=new_point[2], - ) - return_value = new_location - - self.__set__(instance, return_value) - - # No new data needs to be added - # Only the current time is needed once we run _setup() - data["value"] = return_value - instance._set_active_state_data( - state_name=self.name, - **data, - ) - - return return_value - - def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: - """Deactivate the motion. - - Args: - instance (Actor): The owning actor - task (Task): The task calling the deactivation. - - Returns: - bool: _description_ - """ - if hasattr(instance.stage, "motion_manager"): - if not getattr(instance, "_is_rehearsing", False): - instance.stage.motion_manager._stop_mover(instance) - return super().deactivate(instance, task) - - -class GeodeticLocationChangingState(ActiveState[GeodeticLocation]): - """A state that contains a location around an ellipsoid that follows great-circle paths. - - Requires a distance model class that implements: - 1. distance_and_bearing - 2. point_from_bearing_dist - and outputs objects with .lat and .lon attributes - - - For activating: - - >>> actor.activate_state( - >>> state=, - >>> task=self, # usually - >>> speed=, - >>> waypoints=[ - >>> List of CartesianLocation - >>> ] - >>> ) - """ - - def __init__(self, *, recording: bool = False) -> None: - """Create the location changing state. - - Defaults are disabled due to immutability of location objects. - (We could copy it, but it seems like better practice to force inputting it at runtime.) - - Args: - recording (bool, optional): If the location is recorded. Defaults to False. - """ - super().__init__( - default=None, - frozen=False, - valid_types=(GeodeticLocation,), - recording=recording, - ) - - def _setup(self, instance: "Actor") -> None: - """Initialize data about a path.""" - STAGE = instance.stage - data = self.get_activity_data(instance) - current: GeodeticLocation = data["value"] - speed: float = data["speed"] - waypoints: list[GeodeticLocation] = data["waypoints"] - # get the times, distances, and bearings from the waypoints - times: list[float] = [] - distances: list[float] = [] - bearings: list[float] = [] - starts: list[GeodeticLocation] = [] - for wypt in waypoints: - dist, bear = STAGE.stage_model.distance_and_bearing( - (current.lat, current.lon), - (wypt.lat, wypt.lon), - units=STAGE.distance_units, - ) - time = dist / speed - times.append(time) - distances.append(dist) - bearings.append(bear) - starts.append(current.copy()) - current = wypt - - path_data = { - "times": times, - "distances": distances, - "bearings": bearings, - "starts": starts, - } - instance._set_active_state_data( - self.name, - started_at=data["now"], - origin=data["value"], - speed=speed, - waypoints=waypoints, - path_data=path_data, - ) - - # if there is a motion manager, notify it - if hasattr(STAGE, "motion_manager"): - if not getattr(instance, "_is_rehearsing", False): - STAGE.motion_manager._start_mover( - instance, - speed, - [data["value"]] + waypoints, - ) - - def _get_index(self, path_data: dict[str, Any], time_elapsed: float) -> tuple[int, float]: - """Get the index of the waypoint the path is on. - - Args: - path_data (dict[str, Any]): Data about the motion - time_elapsed (float): Time spent on motion - - Returns: - int: Index of the waypoint - float: time elapsed - """ - sum_t = 0.0 - t: float - for i, t in enumerate(path_data["times"]): - sum_t += t - if time_elapsed <= (sum_t + 1e-4): # near one second allowed - return i, sum_t - t - raise SimulationError( - f"GeodeticLocation active state exceeded travel time: Elapsed: {time_elapsed}, " - "Actual: {sum_t}" - ) - - def _get_remaining_waypoints(self, instance: "Actor") -> list[GeodeticLocation]: - """Get waypoints left in travel. - - Args: - instance (Actor): The owning actor. - - Returns: - list[GeodeticLocation]: Waypoint remaining - """ - data = self.get_activity_data(instance) - current_time: float = data["now"] - path_start_time: float = data["started_at"] - elapsed = current_time - path_start_time - idx, _ = self._get_index(data["path_data"], elapsed) - wypts: list[GeodeticLocation] = data["waypoints"] - return wypts[idx:] - - def _active(self, instance: "Actor") -> GeodeticLocation | None: - """Get the value of the location while in motion. - - Args: - instance (Actor): The owning actor. - - Returns: - GeodeticLocation | None: Location while in motion. None if still. - """ - STAGE = instance.stage - data = self.get_activity_data(instance) - path_start_time: float | None = data.get("started_at", None) - if path_start_time is None: - # it's not active - return None - - path_data: dict[str, Any] | None = data.get("path_data", None) - if path_data is None: - self._setup(instance) - data = self.get_activity_data(instance) - - path_data: dict[str, Any] = data["path_data"] - - current_time: float = data["now"] - path_start_time: float = data["started_at"] - elapsed = current_time - path_start_time - - if elapsed < 0: - # Can probably only happen if active state is set incorrectly - raise SimulationError(f"Cannot set state '{self.name}' start time in the future!") - elif elapsed == 0: - return_value: GeodeticLocation = data["value"] # pragma: no cover - else: - # Get the location along the waypoint path - wypt_index, wypt_start = self._get_index(path_data, elapsed) - time_along = elapsed - wypt_start - path_time: float = path_data["times"][wypt_index] - path_dist: float = path_data["distances"][wypt_index] - path_bearing: float = path_data["bearings"][wypt_index] - path_start: GeodeticLocation = path_data["starts"][wypt_index] - moved_distance = (time_along / path_time) * path_dist - new_point = STAGE.stage_model.point_from_bearing_dist( - (path_start.lat, path_start.lon), - path_bearing, - moved_distance, - STAGE.distance_units, - ) - # update the altitude - waypoint: GeodeticLocation = data["waypoints"][wypt_index] - alt_shift = waypoint.alt - path_start.alt - alt_shift *= time_along / path_time - new_alt = path_start.alt + alt_shift - # make the right kind of location object - lat, lon = new_point[0], new_point[1] - new_location = GeodeticLocation( - lat, - lon, - new_alt, - ) - return_value = new_location - - self.__set__(instance, return_value) - - # No new data needs to be added - # Only the current time is needed once we run _setup() - instance._set_active_state_data( - state_name=self.name, - **data, - ) - - return return_value - - def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: - """Deactivate the state. - - Args: - instance (Actor): The owning actor - task (Task): The task doing the deactivating - - Returns: - bool: If the state is all done - """ - STAGE = instance.stage - if hasattr(STAGE, "motion_manager"): - if not getattr(instance, "_is_rehearsing", False): - STAGE.motion_manager._stop_mover(instance) - return super().deactivate(instance, task) - - -T = TypeVar("T", bound=Store | Container) - - -class ResourceState(State, Generic[T]): - """A State class for States that are meant to be Stores or Containers. - - This should enable easier initialization of Actors with stores/containers or - similar objects as states. - - No input is needed for the state if you define a default resource class in - the class definition and do not wish to modify the default inputs of that - class. - - The input an Actor needs to receive for a ResourceState is a dictionary of: - * 'kind': (optional if you provided a default) - * 'capacity': (optional, works on stores and containers) - * 'init': (optional, works on containers) - * key:value for any other input expected as a keyword argument by the resource class - - Note that the resource class given must accept the environment as the first - positional argument. This is to maintain compatibility with simpy. - - Example: - >>> class Warehouse(Actor): - >>> shelf = ResourceState[Store](default=Store) - >>> bucket = ResourceState[Container]( - >>> default=Container, - >>> valid_types=(Container, SelfMonitoringContainer), - >>> ) - >>> - >>> wh = Warehouse( - >>> name='Depot', - >>> shelf={'capacity': 10}, - >>> bucket={'kind': SelfMonitoringContainer, 'init': 30}, - >>> ) - """ - - def __init__( - self, - *, - default: Any | None = None, - valid_types: type | tuple[type, ...] | None = None, - ) -> None: - """Create a resource State decorator. - - Args: - default (Any | None, optional): Default store/container class. Defaults to None. - valid_types (type | tuple[type, ...] | None, optional): Valid store/container - classes. Defaults to None. - """ - if isinstance(valid_types, type): - valid_types = (valid_types,) - - if valid_types: - for v in valid_types: - if not isinstance(v, type) or not issubclass(v, Store | Container): - raise UpstageError(f"Bad valid type for {self}: {v}") - else: - valid_types = (Store, Container) - - if default is not None and ( - not isinstance(default, type) or not issubclass(default, Store | Container) - ): - raise UpstageError(f"Bad default type for {self}: {default}") - - super().__init__( - default=default, - frozen=False, - recording=False, - valid_types=valid_types, - ) - self._been_set: set[Actor] = set() - - def __set__(self, instance: "Actor", value: dict | Any) -> None: - """Set the state value. - - Args: - instance (Actor): The actor instance - value (dict | Any): Either a dictionary of resource data OR an actual resource - """ - if instance in self._been_set: - raise UpstageError( - f"State '{self}' on '{instance}' has already been created " - "It cannot be changed once set!" - ) - - if not isinstance(value, dict): - # we've been passed an actual resource, so save it and leave - if not isinstance(value, self._types): - raise UpstageError(f"Resource object: '{value}' is not an expected type.") - instance.__dict__[self.name] = value - self._been_set.add(instance) - return - - resource_type = value.get("kind", self._default) - if resource_type is None: - raise UpstageError(f"No resource type (Store, e.g.) specified for {instance}") - - if self._types and not issubclass(resource_type, self._types): - raise UpstageError( - f"{resource_type} is of type {type(resource_type)} not of type {self._types}" - ) - - env = getattr(instance, "env", None) - if env is None: - raise UpstageError( - f"Actor {instance} does not have an `env` attribute for state {self.name}" - ) - kwargs = {k: v for k, v in value.items() if k != "kind"} - try: - resource_obj = resource_type(env, **kwargs) - except TypeError as e: - raise UpstageError( - f"Bad argument input to resource state {self.name}" - f" resource class {resource_type} :{e}" - ) - except Exception as e: - raise UpstageError(f"Exception in ResourceState init: {e}") - - instance.__dict__[self.name] = resource_obj - self._been_set.add(instance) - # remember what we did for cloning - instance.__dict__["_memory_for_" + self.name] = kwargs.copy() - - self._broadcast_change(instance, self.name, value) - - def _set_default(self, instance: "Actor") -> None: - self.__set__(instance, {}) - - def __get__(self, instance: "Actor", owner: type | None = None) -> T: - if instance is None: - # instance attribute accessed on class, return self - return self # pragma: no cover - if self.name not in instance.__dict__: - self._set_default(instance) - obj = instance.__dict__[self.name] - if not issubclass(type(obj), Store | Container): - raise UpstageError("Bad type of ResourceStatee") - return cast(T, obj) - - def _make_clone(self, instance: "Actor", copy: T) -> T: - """Method to support cloning a store or container. - - Args: - instance (Actor): The owning actor - copy (T): The store or container to copy - - Returns: - T: The copied store or container - """ - base_class = type(copy) - memory: dict[str, Any] = instance.__dict__[f"_memory_for_{self.name}"] - new = base_class(instance.env, **memory) # type: ignore [arg-type] - if isinstance(copy, Store) and isinstance(new, Store): - new.items = list(copy.items) - if isinstance(copy, Container) and isinstance(new, Container): - # This is a particularity of simpy containers - new._level = float(copy.level) - return cast(T, new) - - -class CommunicationStore(ResourceState[Store]): - """A State class for communications inputs. - - Used for automated finding of communication inputs on Actors by the CommsTransfer code. - - Follows the same rules for defaults as `ResourceState`, except this - defaults to a SelfMonitoringStore without any user input. - - Only resources inheriting from simpy.Store will work for this state. - Capacities are assumed infinite. - - The input an Actor needs to receive for a CommunicationStore is a dictionary of: - >>> { - >>> 'kind': (optional) - >>> 'mode': (required) - >>> } - - Example: - >>> class Worker(Actor): - >>> walkie = CommunicationStore(mode="UHF") - >>> intercom = CommunicationStore(mode="loudspeaker") - >>> - >>> worker = Worker( - >>> name='Billy', - >>> walkie={'kind': SelfMonitoringStore}, - >>> ) - - """ - - def __init__( - self, - *, - mode: str, - default: type | None = None, - valid_types: type | tuple[type, ...] | None = None, - ): - """Create a comms store. - - Args: - mode (str): A mode to describe the comms channel. - default (type | None, optional): Store class by default. - Defaults to None. - valid_types (type | tuple[type, ...] | None, optional): Valid store classes. - Defaults to None. - """ - if default is None: - default = SelfMonitoringStore - if valid_types is None: - valid_types = (Store, SelfMonitoringStore) - elif isinstance(valid_types, type): - valid_types = (valid_types,) - for v in valid_types: - if not issubclass(v, Store): - raise SimulationError("CommunicationStore must use a Store subclass") - super().__init__(default=default, valid_types=valid_types) - self._mode = mode +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""A state defines the conditions of an actor over time.""" + +from collections.abc import Callable +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast + +from simpy import Container, Store + +from upstage_des.base import SimulationError, UpstageError +from upstage_des.data_types import CartesianLocation, GeodeticLocation +from upstage_des.math_utils import _vector_add, _vector_subtract +from upstage_des.resources.monitoring import SelfMonitoringStore +from upstage_des.task import Task + +if TYPE_CHECKING: + from upstage_des.actor import Actor + +__all__ = ( + "ActiveState", + "State", + "LinearChangingState", + "CartesianLocationChangingState", + "GeodeticLocationChangingState", + "ResourceState", + "DetectabilityState", + "CommunicationStore", +) + +CALLBACK_FUNC = Callable[["Actor", Any], None] +ST = TypeVar("ST") + + +class State(Generic[ST]): + """The particular condition that something is in at a specific time. + + The states are implemented as + `Descriptors `_ + which are associated to :class:`upstage.actor.Actor`. + + Note: + The classes that use this descriptor must contain an ``env`` attribute. + + States are aware + + """ + + def __init__( + self, + *, + default: ST | None = None, + frozen: bool = False, + valid_types: type | tuple[type, ...] | None = None, + recording: bool = False, + default_factory: Callable[[], ST] | None = None, + ) -> None: + """Create a state descriptor for an Actor. + + The default can be set either with the value or the factory. Use the factory if + the default needs to be a list, dict, or similar type of object. The default + is used if both are present (not the factory). + + Setting frozen to True will throw an error if the value of the state is changed. + + 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. + + 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. + default_factory (Callable[[], type] | None, optional): Default from function. + Defaults to None. + """ + if default is None and default_factory is not None: + default = default_factory() + + self._default = default + self._frozen = frozen + self._recording = recording + self._recording_callbacks: dict[Any, CALLBACK_FUNC] = {} + + self._types: tuple[type, ...] + + if isinstance(valid_types, type): + self._types = (valid_types,) + elif valid_types is None: + self._types = tuple() + else: + self._types = valid_types + self.IGNORE_LOCK: bool = False + + def _do_record(self, instance: "Actor", value: ST) -> None: + """Record the value of the state. + + Args: + instance (Actor): The actor holding the state + value (ST): State value + """ + 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, 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]: + instance._state_histories[self.name].append(to_append) + + def _do_callback(self, instance: "Actor", value: ST) -> None: + """Run callbacks for the state change. + + Args: + instance (Actor): The actor holding the state + value (Any): The value of the state + """ + for _, callback in self._recording_callbacks.items(): + callback(instance, value) + + def _broadcast_change(self, instance: "Actor", name: str, value: ST) -> None: + """Send state change values to nucleus. + + Args: + instance (Actor): The actor holding the state + name (str): The state's name + value (Any): The state's value + """ + # broadcast changes to the instance + if instance._state_listener is not None: + instance._state_listener.send_change(name, value) + + # NOTE: A dictionary as a descriptor doesn't work well, + # 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. + + Args: + instance (Actor): The actor holding the state + value (Any): The state's value + """ + if self._frozen: + old_value = instance.__dict__.get(self.name, None) + if old_value is not None: + raise SimulationError( + f"State '{self}' on '{instance}' has already been frozen " + f"to value of {old_value}. It cannot be changed once set!" + ) + + if self._types and not isinstance(value, self._types): + raise TypeError(f"{value} is of type {type(value)} not of type {self._types}") + + instance.__dict__[self.name] = value + + if self._recording: + self._do_record(instance, value) + self._do_callback(instance, value) + + self._broadcast_change(instance, self.name, value) + + def __get__(self, instance: "Actor", objtype: type | None = None) -> ST: + if instance is None: + # instance attribute accessed on class, return self + return self # pragma: no cover + if self.name in instance._mimic_states: + actor, name = instance._mimic_states[self.name] + value = getattr(actor, name) + self.__set__(instance, value) + if self.name not in instance.__dict__: + # Just set the value to the default + # Mutable types will be tricky here, so deepcopy them + instance.__dict__[self.name] = deepcopy(self._default) + v = instance.__dict__[self.name] + return cast(ST, v) + + def __set_name__(self, owner: "Actor", name: str) -> None: + self.name = name + + def has_default(self) -> bool: + """Check if a default exists. + + Returns: + bool + """ + return self._default is not None + + def _add_callback(self, source: Any, callback: CALLBACK_FUNC) -> None: + """Add a recording callback. + + Args: + source (Any): A key for the callback + callback (Callable[[Actor, Any], None]): A function to call + """ + self._recording_callbacks[source] = callback + + def _remove_callback(self, source: Any) -> None: + """Remove a callback. + + Args: + source (Any): The callback's key + """ + del self._recording_callbacks[source] + + @property + def is_recording(self) -> bool: + """Check if the state is recording. + + Returns: + bool + """ + return self._recording + + +class DetectabilityState(State[bool]): + """A state whose purpose is to indicate True or False. + + For consideration in the motion manager's <>LocationChangingState checks. + """ + + def __init__(self, *, default: bool = False, recording: bool = False) -> None: + """Create the detectability state. + + Args: + default (bool, optional): If the state starts on/off. Defaults to False. + recording (bool, optional): If the state records. Defaults to False. + """ + super().__init__( + default=default, + frozen=False, + valid_types=(bool,), + recording=recording, + ) + + def __set__(self, instance: "Actor", value: bool) -> None: + """Set the detectability. + + Args: + instance (Actor): The actor + value (bool): The value to set + """ + super().__set__(instance, value) + if hasattr(instance.stage, "motion_manager"): + mgr = instance.stage.motion_manager + if not value: + mgr._mover_not_detectable(instance) + else: + mgr._mover_became_detectable(instance) + + +class ActiveState(State, Generic[ST]): + """Base class for states that change over time according to some rules. + + This class must be subclasses with an implemented `active` method. + + """ + + def _active(self, instance: "Actor") -> Any: + """Determine if the instance has an active state. + + Note: + The instance must have two methods: ``get_active_state_data`` and + ``_set_active_state_data``. + + Note: + When you call ``activate_state`` from an actor, that is where + you define the activity data. It is up to the Actor's subclass to + make sure the activity data meet its needs. + + The first entry in the active data is always the time. + Alternatively, you can call ``self.get_activity_data`` for some + more data. + + """ + raise NotImplementedError("Method active not implemented.") + + def __get__(self, instance: "Actor", owner: type | None = None) -> ST: + if instance is None: + # instance attribute accessed on class, return self + return self # pragma: no cover + if self.name not in instance.__dict__: + # Just set the value to the default + # Mutable types will be tricky here, so deepcopy them + instance.__dict__[self.name] = deepcopy(self._default) # pragma: no cover + if self.name in instance._mimic_states: + actor, name = instance._mimic_states[self.name] + value = getattr(actor, name) + self.__set__(instance, value) + return cast(ST, value) + # test if this instance is active or not + res = self._active(instance) + # comes back as None (not active), or if it can be obtained from dict + if res is None: + res = instance.__dict__[self.name] + return cast(ST, res) + + def get_activity_data(self, instance: "Actor") -> dict[str, Any]: + """Get the data useful for updating active states. + + Returns: + dict[str, Any]: A dictionary with the state's pertinent data. Includes the actor's + environment current time (``'now'``) and the value of the actor's + state (``'state'``). + + """ + res = instance.get_active_state_data(self.name, without_update=True) + res["now"] = instance.env.now + res["value"] = instance.__dict__[self.name] + return res + + def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: + """Optional method to override that is called when a state is deactivated. + + Useful for motion states to deactivate their motion from + the motion manager. + + Defaults to any deactivation removing active state data. + """ + # Returns if the state should be ignored + # A False means the state is completely deactivated + return False + + +class LinearChangingState(ActiveState[float]): + """A state whose value changes linearly over time. + + When activating: + + >>> class Lin(Actor): + >>> x = LinearChangingState() + >>> + >>> def task(self, actor: Lin): + >>> actor.activate_state( + >>> name="x", + >>> task=self, + >>> rate=3.2, + >>> ) + """ + + def _active(self, instance: "Actor") -> float | None: + """Return a value to set based on time or some other criteria.""" + data = self.get_activity_data(instance) + now: float = data["now"] + current: float = data["value"] + started: float | None = data.get("started_at", None) + if started is None: + # it's not currently active + return None + # The user needs to know what their active data looks like. + # Alternatively, it could be defined in the state or the actor. + rate: float = data["rate"] + if now < started: + raise SimulationError( + f"Cannot set state '{self.name}' start time after now. " + f"This probably happened because the active state was " + f"set incorrectly." + ) + value = (now - started) * rate + return_value = current + value + self.__set__(instance, return_value) + instance._set_active_state_data( + state_name=self.name, + started_at=now, + rate=rate, + ) + return return_value + + +class CartesianLocationChangingState(ActiveState[CartesianLocation]): + """A state that contains the location in 3-dimensional Cartesian space. + + Movement is along straight lines in that space. + + For activating: + >>> actor.activate_state( + >>> state=, + >>> task=self, # usually + >>> speed=, + >>> waypoints=[ + >>> List of CartesianLocation + >>> ] + >>> ) + """ + + def __init__(self, *, recording: bool = False): + """Set a Location changing state. + + Defaults are disabled due to immutability of location objects. + (We could copy it, but it seems like better practice to force inputting it at runtime.) + + Args: + recording (bool, optional): Whether to record. Defaults to False. + """ + super().__init__( + default=None, + frozen=False, + default_factory=None, + valid_types=(CartesianLocation,), + recording=recording, + ) + + def _setup(self, instance: "Actor") -> None: + """Initialize data about a path. + + Args: + instance (Actor): The actor + """ + data = self.get_activity_data(instance) + current: CartesianLocation = data["value"] + speed: float = data["speed"] + waypoints: list[CartesianLocation] = data["waypoints"] + # get the times, distances, and bearings from the waypoints + times: list[float] = [] + distances: list[float] = [] + starts: list[CartesianLocation] = [] + vectors: list[list[float]] = [] + for wypt in waypoints: + dist = wypt - current + time = dist / speed + times.append(time) + distances.append(dist) + starts.append(current.copy()) + vectors.append(_vector_subtract(wypt._as_array(), current._as_array())) + current = wypt + + path_data = { + "times": times, + "distances": distances, + "starts": starts, + "vectors": vectors, + } + instance._set_active_state_data( + self.name, + started_at=data["now"], + origin=data["value"], + speed=speed, + waypoints=waypoints, + path_data=path_data, + ) + # if there is a motion manager, notify it + if hasattr(instance.stage, "motion_manager"): + if not getattr(instance, "_is_rehearsing", False): + instance.stage.motion_manager._start_mover( + instance, + speed, + [data["value"]] + waypoints, + ) + + def _get_index(self, path_data: dict[str, Any], time_elapsed: float) -> tuple[int, float]: + """Find out how far along waypoints the state is. + + Args: + path_data (dict[str, Any]): Data about the movement path + time_elapsed (float): Time spent moving + + Returns: + int: index in waypoints + float: time spent on path + """ + sum_t = 0.0 + t: float + for i, t in enumerate(path_data["times"]): + sum_t += t + if time_elapsed <= (sum_t + 1e-12): + return i, sum_t - t + raise SimulationError( + "CartesianLocation active state exceeded travel time: " + f"elapsed: {time_elapsed}, maximum: {sum_t}" + ) + + def _get_remaining_waypoints(self, instance: "Actor") -> list[CartesianLocation]: + """Convenience for getting waypoints left. + + Args: + instance (Actor): The owning actor. + + Returns: + list[CartesianLocation]: The waypoints left + """ + data = self.get_activity_data(instance) + current_time: float = data["now"] + path_start_time: float = data["started_at"] + elapsed = current_time - path_start_time + idx, _ = self._get_index(data["path_data"], elapsed) + return list(data["waypoints"][idx:]) + + def _active(self, instance: "Actor") -> CartesianLocation | None: + """Get the current value while active. + + Args: + instance (Actor): The owning actor + + Returns: + CartesianLocation | None: The current value + """ + data = self.get_activity_data(instance) + path_start_time: float | None = data.get("started_at", None) + if path_start_time is None: + # it's not active + return None + + path_data: dict[str, Any] | None = data.get("path_data", None) + if path_data is None: + self._setup(instance) + data = self.get_activity_data(instance) + + path_data: dict[str, Any] = data["path_data"] + current_time: float = data["now"] + elapsed = current_time - path_start_time + if elapsed < 0: + # Can probably only happen if active state is set incorrectly + raise SimulationError(f"Cannot set state '{self.name}' start time in the future!") + elif elapsed == 0: + return_value: CartesianLocation = data["value"] # pragma: no cover + else: + # Get the location along the waypoint path + wypt_index, wypt_start = self._get_index(path_data, elapsed) + time_along = elapsed - wypt_start + path_time: float = path_data["times"][wypt_index] + path_start: CartesianLocation = path_data["starts"][wypt_index] + path_vector: list[float] = path_data["vectors"][wypt_index] + time_frac = time_along / path_time + direction_amount = [time_frac * v for v in path_vector] + new_point = _vector_add(path_start._as_array(), direction_amount) + + # make the right kind of location object + new_location = CartesianLocation( + x=new_point[0], + y=new_point[1], + z=new_point[2], + ) + return_value = new_location + + self.__set__(instance, return_value) + + # No new data needs to be added + # Only the current time is needed once we run _setup() + data["value"] = return_value + instance._set_active_state_data( + state_name=self.name, + **data, + ) + + return return_value + + def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: + """Deactivate the motion. + + Args: + instance (Actor): The owning actor + task (Task): The task calling the deactivation. + + Returns: + bool: _description_ + """ + if hasattr(instance.stage, "motion_manager"): + if not getattr(instance, "_is_rehearsing", False): + instance.stage.motion_manager._stop_mover(instance) + return super().deactivate(instance, task) + + +class GeodeticLocationChangingState(ActiveState[GeodeticLocation]): + """A state that contains a location around an ellipsoid that follows great-circle paths. + + Requires a distance model class that implements: + 1. distance_and_bearing + 2. point_from_bearing_dist + and outputs objects with .lat and .lon attributes + + + For activating: + + >>> actor.activate_state( + >>> state=, + >>> task=self, # usually + >>> speed=, + >>> waypoints=[ + >>> List of CartesianLocation + >>> ] + >>> ) + """ + + def __init__(self, *, recording: bool = False) -> None: + """Create the location changing state. + + Defaults are disabled due to immutability of location objects. + (We could copy it, but it seems like better practice to force inputting it at runtime.) + + Args: + recording (bool, optional): If the location is recorded. Defaults to False. + """ + super().__init__( + default=None, + frozen=False, + valid_types=(GeodeticLocation,), + recording=recording, + ) + + def _setup(self, instance: "Actor") -> None: + """Initialize data about a path.""" + STAGE = instance.stage + data = self.get_activity_data(instance) + current: GeodeticLocation = data["value"] + speed: float = data["speed"] + waypoints: list[GeodeticLocation] = data["waypoints"] + # get the times, distances, and bearings from the waypoints + times: list[float] = [] + distances: list[float] = [] + bearings: list[float] = [] + starts: list[GeodeticLocation] = [] + for wypt in waypoints: + dist, bear = STAGE.stage_model.distance_and_bearing( + (current.lat, current.lon), + (wypt.lat, wypt.lon), + units=STAGE.distance_units, + ) + time = dist / speed + times.append(time) + distances.append(dist) + bearings.append(bear) + starts.append(current.copy()) + current = wypt + + path_data = { + "times": times, + "distances": distances, + "bearings": bearings, + "starts": starts, + } + instance._set_active_state_data( + self.name, + started_at=data["now"], + origin=data["value"], + speed=speed, + waypoints=waypoints, + path_data=path_data, + ) + + # if there is a motion manager, notify it + if hasattr(STAGE, "motion_manager"): + if not getattr(instance, "_is_rehearsing", False): + STAGE.motion_manager._start_mover( + instance, + speed, + [data["value"]] + waypoints, + ) + + def _get_index(self, path_data: dict[str, Any], time_elapsed: float) -> tuple[int, float]: + """Get the index of the waypoint the path is on. + + Args: + path_data (dict[str, Any]): Data about the motion + time_elapsed (float): Time spent on motion + + Returns: + int: Index of the waypoint + float: time elapsed + """ + sum_t = 0.0 + t: float + for i, t in enumerate(path_data["times"]): + sum_t += t + if time_elapsed <= (sum_t + 1e-4): # near one second allowed + return i, sum_t - t + raise SimulationError( + f"GeodeticLocation active state exceeded travel time: Elapsed: {time_elapsed}, " + "Actual: {sum_t}" + ) + + def _get_remaining_waypoints(self, instance: "Actor") -> list[GeodeticLocation]: + """Get waypoints left in travel. + + Args: + instance (Actor): The owning actor. + + Returns: + list[GeodeticLocation]: Waypoint remaining + """ + data = self.get_activity_data(instance) + current_time: float = data["now"] + path_start_time: float = data["started_at"] + elapsed = current_time - path_start_time + idx, _ = self._get_index(data["path_data"], elapsed) + wypts: list[GeodeticLocation] = data["waypoints"] + return wypts[idx:] + + def _active(self, instance: "Actor") -> GeodeticLocation | None: + """Get the value of the location while in motion. + + Args: + instance (Actor): The owning actor. + + Returns: + GeodeticLocation | None: Location while in motion. None if still. + """ + STAGE = instance.stage + data = self.get_activity_data(instance) + path_start_time: float | None = data.get("started_at", None) + if path_start_time is None: + # it's not active + return None + + path_data: dict[str, Any] | None = data.get("path_data", None) + if path_data is None: + self._setup(instance) + data = self.get_activity_data(instance) + + path_data: dict[str, Any] = data["path_data"] + + current_time: float = data["now"] + path_start_time: float = data["started_at"] + elapsed = current_time - path_start_time + + if elapsed < 0: + # Can probably only happen if active state is set incorrectly + raise SimulationError(f"Cannot set state '{self.name}' start time in the future!") + elif elapsed == 0: + return_value: GeodeticLocation = data["value"] # pragma: no cover + else: + # Get the location along the waypoint path + wypt_index, wypt_start = self._get_index(path_data, elapsed) + time_along = elapsed - wypt_start + path_time: float = path_data["times"][wypt_index] + path_dist: float = path_data["distances"][wypt_index] + path_bearing: float = path_data["bearings"][wypt_index] + path_start: GeodeticLocation = path_data["starts"][wypt_index] + moved_distance = (time_along / path_time) * path_dist + new_point = STAGE.stage_model.point_from_bearing_dist( + (path_start.lat, path_start.lon), + path_bearing, + moved_distance, + STAGE.distance_units, + ) + # update the altitude + waypoint: GeodeticLocation = data["waypoints"][wypt_index] + alt_shift = waypoint.alt - path_start.alt + alt_shift *= time_along / path_time + new_alt = path_start.alt + alt_shift + # make the right kind of location object + lat, lon = new_point[0], new_point[1] + new_location = GeodeticLocation( + lat, + lon, + new_alt, + ) + return_value = new_location + + self.__set__(instance, return_value) + + # No new data needs to be added + # Only the current time is needed once we run _setup() + instance._set_active_state_data( + state_name=self.name, + **data, + ) + + return return_value + + def deactivate(self, instance: "Actor", task: Task | None = None) -> bool: + """Deactivate the state. + + Args: + instance (Actor): The owning actor + task (Task): The task doing the deactivating + + Returns: + bool: If the state is all done + """ + STAGE = instance.stage + if hasattr(STAGE, "motion_manager"): + if not getattr(instance, "_is_rehearsing", False): + STAGE.motion_manager._stop_mover(instance) + return super().deactivate(instance, task) + + +T = TypeVar("T", bound=Store | Container) + + +class ResourceState(State, Generic[T]): + """A State class for States that are meant to be Stores or Containers. + + This should enable easier initialization of Actors with stores/containers or + similar objects as states. + + No input is needed for the state if you define a default resource class in + the class definition and do not wish to modify the default inputs of that + class. + + The input an Actor needs to receive for a ResourceState is a dictionary of: + * 'kind': (optional if you provided a default) + * 'capacity': (optional, works on stores and containers) + * 'init': (optional, works on containers) + * key:value for any other input expected as a keyword argument by the resource class + + Note that the resource class given must accept the environment as the first + positional argument. This is to maintain compatibility with simpy. + + Example: + >>> class Warehouse(Actor): + >>> shelf = ResourceState[Store](default=Store) + >>> bucket = ResourceState[Container]( + >>> default=Container, + >>> valid_types=(Container, SelfMonitoringContainer), + >>> ) + >>> + >>> wh = Warehouse( + >>> name='Depot', + >>> shelf={'capacity': 10}, + >>> bucket={'kind': SelfMonitoringContainer, 'init': 30}, + >>> ) + """ + + def __init__( + self, + *, + default: Any | None = None, + valid_types: type | tuple[type, ...] | None = None, + ) -> None: + """Create a resource State decorator. + + Args: + default (Any | None, optional): Default store/container class. Defaults to None. + valid_types (type | tuple[type, ...] | None, optional): Valid store/container + classes. Defaults to None. + """ + if isinstance(valid_types, type): + valid_types = (valid_types,) + + if valid_types: + for v in valid_types: + if not isinstance(v, type) or not issubclass(v, Store | Container): + raise UpstageError(f"Bad valid type for {self}: {v}") + else: + valid_types = (Store, Container) + + if default is not None and ( + not isinstance(default, type) or not issubclass(default, Store | Container) + ): + raise UpstageError(f"Bad default type for {self}: {default}") + + super().__init__( + default=default, + frozen=False, + recording=False, + valid_types=valid_types, + ) + self._been_set: set[Actor] = set() + + def __set__(self, instance: "Actor", value: dict | Any) -> None: + """Set the state value. + + Args: + instance (Actor): The actor instance + value (dict | Any): Either a dictionary of resource data OR an actual resource + """ + if instance in self._been_set: + raise UpstageError( + f"State '{self}' on '{instance}' has already been created " + "It cannot be changed once set!" + ) + + if not isinstance(value, dict): + # we've been passed an actual resource, so save it and leave + if not isinstance(value, self._types): + raise UpstageError(f"Resource object: '{value}' is not an expected type.") + instance.__dict__[self.name] = value + self._been_set.add(instance) + return + + resource_type = value.get("kind", self._default) + if resource_type is None: + raise UpstageError(f"No resource type (Store, e.g.) specified for {instance}") + + if self._types and not issubclass(resource_type, self._types): + raise UpstageError( + f"{resource_type} is of type {type(resource_type)} not of type {self._types}" + ) + + env = getattr(instance, "env", None) + if env is None: + raise UpstageError( + f"Actor {instance} does not have an `env` attribute for state {self.name}" + ) + kwargs = {k: v for k, v in value.items() if k != "kind"} + try: + resource_obj = resource_type(env, **kwargs) + except TypeError as e: + raise UpstageError( + f"Bad argument input to resource state {self.name}" + f" resource class {resource_type} :{e}" + ) + except Exception as e: + raise UpstageError(f"Exception in ResourceState init: {e}") + + instance.__dict__[self.name] = resource_obj + self._been_set.add(instance) + # remember what we did for cloning + instance.__dict__["_memory_for_" + self.name] = kwargs.copy() + + self._broadcast_change(instance, self.name, value) + + def _set_default(self, instance: "Actor") -> None: + self.__set__(instance, {}) + + def __get__(self, instance: "Actor", owner: type | None = None) -> T: + if instance is None: + # instance attribute accessed on class, return self + return self # pragma: no cover + if self.name not in instance.__dict__: + self._set_default(instance) + obj = instance.__dict__[self.name] + if not issubclass(type(obj), Store | Container): + raise UpstageError("Bad type of ResourceStatee") + return cast(T, obj) + + def _make_clone(self, instance: "Actor", copy: T) -> T: + """Method to support cloning a store or container. + + Args: + instance (Actor): The owning actor + copy (T): The store or container to copy + + Returns: + T: The copied store or container + """ + base_class = type(copy) + memory: dict[str, Any] = instance.__dict__[f"_memory_for_{self.name}"] + new = base_class(instance.env, **memory) # type: ignore [arg-type] + if isinstance(copy, Store) and isinstance(new, Store): + new.items = list(copy.items) + if isinstance(copy, Container) and isinstance(new, Container): + # This is a particularity of simpy containers + new._level = float(copy.level) + return cast(T, new) + + +class CommunicationStore(ResourceState[Store]): + """A State class for communications inputs. + + Used for automated finding of communication inputs on Actors by the CommsTransfer code. + + Follows the same rules for defaults as `ResourceState`, except this + defaults to a SelfMonitoringStore without any user input. + + Only resources inheriting from simpy.Store will work for this state. + Capacities are assumed infinite. + + The input an Actor needs to receive for a CommunicationStore is a dictionary of: + >>> { + >>> 'kind': (optional) + >>> 'mode': (required) + >>> } + + Example: + >>> class Worker(Actor): + >>> walkie = CommunicationStore(mode="UHF") + >>> intercom = CommunicationStore(mode="loudspeaker") + >>> + >>> worker = Worker( + >>> name='Billy', + >>> walkie={'kind': SelfMonitoringStore}, + >>> ) + + """ + + def __init__( + self, + *, + mode: str, + default: type | None = None, + valid_types: type | tuple[type, ...] | None = None, + ): + """Create a comms store. + + Args: + mode (str): A mode to describe the comms channel. + default (type | None, optional): Store class by default. + Defaults to None. + valid_types (type | tuple[type, ...] | None, optional): Valid store classes. + Defaults to None. + """ + if default is None: + default = SelfMonitoringStore + if valid_types is None: + valid_types = (Store, SelfMonitoringStore) + elif isinstance(valid_types, type): + valid_types = (valid_types,) + for v in valid_types: + if not issubclass(v, Store): + raise SimulationError("CommunicationStore must use a Store subclass") + super().__init__(default=default, valid_types=valid_types) + self._mode = mode diff --git a/src/upstage/task.py b/src/upstage_des/task.py similarity index 97% rename from src/upstage/task.py rename to src/upstage_des/task.py index 64c0793..93683e2 100644 --- a/src/upstage/task.py +++ b/src/upstage_des/task.py @@ -1,585 +1,585 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""Tasks constitute the actions that Actors can perform.""" - -from collections.abc import Callable, Generator -from enum import IntFlag -from functools import wraps -from typing import TYPE_CHECKING, Any, TypeVar -from warnings import warn - -from simpy import Environment as SimpyEnv -from simpy import Event as SimpyEvent -from simpy import Interrupt, Process - -if TYPE_CHECKING: - from .actor import Actor - from .task_network import TaskNetwork - -from .base import ENV_CONTEXT_VAR, MockEnvironment, SettableEnv, SimulationError -from .constants import PLANNING_FACTOR_OBJECT -from .events import BaseEvent, Event - -TASK_TYPE = Generator[BaseEvent | Process, Any, None] - - -__all__ = ("DecisionTask", "Task", "process", "TerminalTask", "TASK_TYPE", "InterruptStates") - - -NOT_IMPLEMENTED_MSG = "User must define the actions performed when executing this task" - - -class InterruptStates(IntFlag): - """Class that describes how to behave after an interrupt.""" - - END = 0 - IGNORE = 1 - RESTART = 2 - - -def process( - func: Callable[..., Generator[SimpyEvent, Any, None]], -) -> Callable[..., Process]: - """Decorate a ``simpy`` process to schedule it as a callable. - - Allows users to decorate a generator, and when they want to schedule them - as a ``simpy`` process, they can simply call it, e.g., instead of calling: - - Usage: - - >>> from upstage.api import process, Wait - ... - >>> @process - >>> def generator(wait_period=1.0, msg="Finished Waiting"): - >>> # A simple process that periodically prints a statement - >>> while True: - >>> yield Wait(wait_period).as_event() - >>> print(msg) - ... - >>> @process - >>> def another_process(): - >>> # Some other process that calls the first one - >>> generator() - - Args: - func (Callable[..., Generator[BaseEvent, None, None]]): The process function that is a - generator of simpy events. - - Returns: - Process: The generator as a ``simpy`` process. - - Note: - The value of this decorator is that it reduces the chance of a user - forgetting to call the generator as a process, which tends to produce - behaviors that are difficult to troubleshoot because the code will - build and can run, but the simulation will not work schedule the - process defined by the generator. - - """ - - @wraps(func) - def wrapped_generator(*args: Any, **kwargs: Any) -> Process: - """Wrap the generator with a function that calls it as a process.""" - try: - environment = ENV_CONTEXT_VAR.get() - except LookupError: - raise SimulationError("No environment found on process call") - return environment.process(func(*args, **kwargs)) - - return wrapped_generator - - -EVT = TypeVar("EVT", bound=BaseEvent) -REH_ACTOR = TypeVar("REH_ACTOR", bound="Actor") - - -class Task(SettableEnv): - """A Task is an action that can be performed by an Actor.""" - - INTERRUPT = InterruptStates - - def __init__(self) -> None: - """Create a task instance.""" - super().__init__() - self._proc: TASK_TYPE | None = None - self._network_name: str | None = None - self._network_ref: TaskNetwork | None = None - self._marker: str | None = None - self._marked_time: float | None = None - self._interrupt_action: InterruptStates = InterruptStates.END - self._rehearsing: bool = False - - def task(self, *, actor: Any) -> TASK_TYPE: - """Define the process this task follows.""" - raise NotImplementedError(NOT_IMPLEMENTED_MSG) - - def on_interrupt(self, *, actor: Any, cause: Any) -> InterruptStates: - """Define any actions to take on the actor if this task is interrupted. - - Note: - Custom Tasks can overwrite this method so they can handle being - interrupted with a custom procedure. By default, interrupt ends the - task. - - Args: - actor (Actor): the actor using the task - cause (Any): Optional data for the interrupt - """ - actor.log(f"Interrupted while performing {self}. Reasons: {cause}") - return self._interrupt_action - - def set_marker( - self, marker: str, interrupt_action: InterruptStates = InterruptStates.END - ) -> None: - """Set a marker to help with inspection of interrupts. - - The interrupt_action is set for when no `on_interrupt` is implemented. - - Args: - marker (str): String for the marker. - interrupt_action (InterruptStates, optional): Action to take on interrupt. - Defaults to InterruptStates.END. - """ - self._marker = marker - self._marked_time = self.env.now - self._interrupt_action = interrupt_action - - def get_marker(self) -> str | None: - """Get the current marker. - - Returns: - str | None: Marker (or None if cleared) - """ - return self._marker - - def get_marker_time(self) -> float | None: - """The time the current marker was set. - - Returns: - float | None: Marker set time (or None if cleared) - """ - return self._marked_time - - def clear_marker(self) -> None: - """Clear the marker and set that an interrupt ends the task.""" - self._marker = None - self._marked_time = None - self._interrupt_action = InterruptStates.END - - def _set_network_ref(self, network: "TaskNetwork") -> None: - """Set the reference to the task network object. - - Args: - network (TaskNetwork): The network - """ - if self._network_ref is not None: - raise SimulationError( - "Setting task network reference on task that already has a network" - ) - self._network_ref = network - - def _set_network_name(self, network_name: str) -> None: - """Set the name of the network this task is in. - - Args: - network_name (str): Network name - """ - if self._network_name is not None: - raise SimulationError("Setting task network name on task that already has a network") - self._network_name = network_name - - def clear_actor_task_queue(self, actor: "Actor") -> None: - """Clear out the task queue on the network. - - Args: - actor (Actor): The actor whose queue will be cleared - """ - assert self._network_name is not None - actor.clear_task_queue(self._network_name) - - def set_actor_task_queue(self, actor: "Actor", task_list: list[str]) -> None: - """Set the task queue on the actor. - - This assumes an empty queue. - - Args: - actor (Actor): The actor to modify the task queue of - task_list (list[str]): The list of task names to queue. - """ - assert self._network_name is not None - actor.set_task_queue(self._network_name, task_list) - - def get_actor_task_queue(self, actor: "Actor") -> list[str]: - """Get the task queue on the actor. - - Args: - actor (Actor): The actor to modify the task queue of - """ - assert self._network_name is not None - return actor.get_task_queue(self._network_name) - - def get_actor_next_task(self, actor: "Actor") -> str | None: - """Get the next queued task. - - Args: - actor (Actor): The actor to get the next task from - - Returns: - str | None: The next task name (or None if no task) - """ - assert self._network_name is not None - return actor.get_next_task(self._network_name) - - def set_actor_knowledge( - self, - actor: "Actor", - name: str, - value: Any, - overwrite: bool = False, - ) -> None: - """Set knowledge on the actor. - - Convenience method for passing in the name of task for actor logging. - - Args: - actor (Actor): The actor to set knowledge on. - name (str): Name of the knowledge - value (Any): Value of the knowledge - overwrite (bool, optional): Allow overwrite or not. Defaults to False. - """ - cname = self.__class__.__qualname__ - actor.set_knowledge(name, value, overwrite=overwrite, caller=cname) - - def clear_actor_knowledge(self, actor: "Actor", name: str) -> None: - """Clear knowledge from an actor. - - Convenience method for passing in the name of task for actor logging. - - Args: - actor (Actor): The actor to clear knowledge from - name (str): The name of the knowledge - """ - cname = self.__class__.__qualname__ - actor.clear_knowledge(name, caller=cname) - - @staticmethod - def get_actor_knowledge(actor: "Actor", name: str, must_exist: bool = False) -> Any: - """Get knowledge from the actor. - - Args: - actor (Actor): The actor to get knowledge from. - name (str): Name of the knowledge - must_exist (bool, optional): Raise errors if the knowledge doesn't exist. - Defaults to False. - - Returns: - Any: The knowledge value, which could be None - """ - return actor.get_knowledge(name, must_exist) - - def _clone_actor(self, actor: REH_ACTOR, knowledge: dict[str, Any] | None) -> REH_ACTOR: - """Create a clone of the actor. - - Args: - actor (Actor): The actor to clone - knowledge (dict[str, Any] | None): Additional knowledge to add. - - Returns: - Actor: Cloned actor - """ - mocked_env = MockEnvironment.mock(self.env) - self.env = mocked_env - understudy = actor.clone( - new_env=mocked_env, - knowledge=knowledge, - ) - return understudy - - def rehearse( - self, - *, - actor: REH_ACTOR, - knowledge: dict[str, Any] | None = None, - cloned_actor: bool = False, - **kwargs: Any, - ) -> REH_ACTOR: - """Rehearse the task to evaluate its feasibility. - - Args: - actor (Actor): The actor to rehearse in the task - knowledge (dict[str, Any], optional): Knowledge to add to the actor. Defaults to None. - cloned_actor (bool, optional): If the actor is a clone or not. Defaults to False. - kwargs (Any): Optional args to send to the task. - - Returns: - Actor: The cloned actor with a state reflecting the task flow. - """ - knowledge = {} if knowledge is None else knowledge - _old_env = self.env - understudy = actor - if not cloned_actor: - understudy = self._clone_actor(actor, knowledge) - if not isinstance(understudy.env, MockEnvironment): - raise SimulationError("Bad actor cloning.") - self.env = understudy.env - mocked_env: MockEnvironment = understudy.env - - self._rehearsing = True - generator = self.task(actor=understudy, **kwargs) - returned_item = None - while True: - try: - if returned_item is None: - next_event = next(generator) - else: - next_event = generator.send(returned_item) - returned_item = None - if not issubclass(next_event.__class__, BaseEvent): - raise SimulationError( - f"Task {self} event {next_event} must be a subclass of BaseEvent!" - ) - time_advance, returned_item = next_event.rehearse() - mocked_env.now += time_advance - - except StopIteration: - # warn(f"Stopping rehearsal of task '{self.__class__.__name__}' " - # f"for actor '{actor}'! [Rehearsal duration: " - # f"{self.env.now - _old_env.now:.3g}]") - break - - self.env = _old_env - self._rehearsing = False - return understudy - - def _handle_interruption( - self, actor: "Actor", interrupt: Interrupt, next_event: BaseEvent | Process - ) -> tuple[bool, bool]: - """Clean up after an interrupt and perform interrupt checks/actions. - - Args: - actor (Actor): _description_ - interrupt (Interrupt): _description_ - next_event (BaseEvent): _description_ - - Returns: - bool: If the task should be stopped - bool: If the task should be restarted - """ - # test the interrupt behavior: - stop_run = False - restart = False - _interrupt_action = self.on_interrupt( - actor=actor, - cause=interrupt.cause, - ) - if _interrupt_action is None: - 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.deactivate_all_states(task=self) - actor.deactivate_all_mimic_states(task=self) - if isinstance(next_event, BaseEvent): - next_event.cancel() - elif isinstance(next_event, Process): - next_event.interrupt(cause="Interrupt from task") - else: - raise SimulationError(f"Bad event passed: {next_event}") - stop_run = True - if _interrupt_action is InterruptStates.RESTART: - restart = True - elif _interrupt_action is InterruptStates.IGNORE: - # go back to waiting on the event - stop_run = False - else: - raise SimulationError(f"Wrong interrupt action value: {_interrupt_action}") - if restart and not stop_run: - raise SimulationError("Restarting a task, but it isn't stopping") - return stop_run, restart - - @process - def run(self, *, actor: "Actor") -> Generator[SimpyEvent | Process, Any, None]: - """Execute the task. - - Args: - actor (Actor): The actor using the task - - Returns: - Generator[SimpyEvent, Any, None] - """ - generator = self.task(actor=actor) - self._proc = generator - return_item = None - stop_run = False - restart = False - come_back_to = False - event_to_yield: Process | SimpyEvent - while not stop_run: - try: - while True: - try: - if not come_back_to: - if return_item is None: - next_event = next(generator) - else: - next_event = generator.send(return_item) - # Allows processes to be yielded on inside an event - # This is dangerous - if isinstance(next_event, Process): - warn( - f"Yielding a simpy.Process from {self}. " - f"This is dangerous, take care. ", - UserWarning, - ) - event_to_yield = next_event - elif isinstance(next_event, BaseEvent): - event_to_yield = next_event.as_event() - else: - raise SimulationError( - f"Unexpected yielded event type: {next_event}" - ) - else: - come_back_to = False - return_item = yield event_to_yield - # TODO: test if the return_item is for a multi-event - # that way we can return it as a more useful object - except AttributeError as exc: - if "as_event" in exc.args[0]: - raise SimulationError("Task is yielding objects without `as_event`") - else: - raise exc - except StopIteration: - stop_run = True - break - - except Interrupt as interrupt: - stop_run, restart = self._handle_interruption( - actor, - interrupt, - next_event, - ) - if not stop_run: - come_back_to = True - if restart: - generator = self.task(actor=actor) - self._proc = generator - return_item = None - stop_run = False - restart = False - - -class DecisionTask(Task): - """A task used for decision processes.""" - - def task(self, *, actor: Any) -> TASK_TYPE: - """Define the process this task follows.""" - raise SimulationError("No need to call `task` on a DecisionTask") - - def rehearse_decision(self, *, actor: Any) -> None: - """Define the process this task follows.""" - raise NotImplementedError(NOT_IMPLEMENTED_MSG) - - def make_decision(self, *, actor: Any) -> None: - """Define the process this task follows.""" - raise NotImplementedError(NOT_IMPLEMENTED_MSG) - - def rehearse( - self, - *, - actor: REH_ACTOR, - knowledge: dict[str, Any] | None = None, - cloned_actor: bool = False, - **kwargs: Any, - ) -> REH_ACTOR: - """Rehearse the task to evaluate its feasibility. - - Args: - actor (Actor): The actor to rehearse with - knowledge (Optional[dict[str, Any]], optional): Knowledge to add. Defaults to None. - cloned_actor (bool, optional): If the actor is a clone or not. Defaults to False. - kwargs (Any): Kwargs for rehearsal. Kept for consistency to the base class. - - Returns: - Actor: Cloned actor after rehearsing this task. - """ - knowledge = {} if knowledge is None else knowledge - _old_env = self.env - understudy = actor - if not cloned_actor: - understudy = self._clone_actor(actor, knowledge) - self.env = understudy.env - - self._rehearsing = True - - self.rehearse_decision(actor=understudy) - self.env = _old_env - self._rehearsing = False - return understudy - - @process - def run(self, *, actor: "Actor") -> Generator[SimpyEvent, None, None]: - """Run the decision task. - - Args: - actor (Actor): The actor making decisions - - Yields: - Generator[SimpyEvent, None, None]: Generator for SimPy event queue. - """ - self.make_decision(actor=actor) - assert isinstance(self.env, SimpyEnv) - yield self.env.timeout(0.0) - - -class TerminalTask(Task): - """A rehearsal-safe task that cannot exit, i.e., it is terminal. - - Note: - The user can re-implement the `log_message` method to return a custom - message that will be appended to the actor's log through its `log` - method. - """ - - _time_to_complete: float = 1e24 - - def log_message(self, *, actor: "Actor") -> str: - """A message to save to a log when this task is reached. - - Args: - actor (Actor): The actor using this task. - - Returns: - str: A log message - """ - return f"Entering terminal task: {self} on network {self._network_name}" - - def on_interrupt(self, *, actor: "Actor", cause: Any) -> InterruptStates: - """Special case interrupt for terminal task. - - Args: - actor (Actor): The actor - cause (Any): Additional data sent to the interrupt. - """ - raise SimulationError( - f"Cannot interrupt a terminal task {self} on {actor}. Kwargs sent: {cause}" - ) - return InterruptStates.END - - def task(self, *, actor: "Actor") -> TASK_TYPE: - """The terminal task. - - It's just a long wait. - - Args: - actor (Actor): The actor - """ - log_message = self.log_message(actor=actor) - actor.log(log_message) - the_long_event = Event(rehearsal_time_to_complete=self._time_to_complete) - res = yield the_long_event - if res is not PLANNING_FACTOR_OBJECT: - raise SimulationError(f"A terminal task completed on {actor}") +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""Tasks constitute the actions that Actors can perform.""" + +from collections.abc import Callable, Generator +from enum import IntFlag +from functools import wraps +from typing import TYPE_CHECKING, Any, TypeVar +from warnings import warn + +from simpy import Environment as SimpyEnv +from simpy import Event as SimpyEvent +from simpy import Interrupt, Process + +if TYPE_CHECKING: + from .actor import Actor + from .task_network import TaskNetwork + +from .base import ENV_CONTEXT_VAR, MockEnvironment, SettableEnv, SimulationError +from .constants import PLANNING_FACTOR_OBJECT +from .events import BaseEvent, Event + +TASK_TYPE = Generator[BaseEvent | Process, Any, None] + + +__all__ = ("DecisionTask", "Task", "process", "TerminalTask", "TASK_TYPE", "InterruptStates") + + +NOT_IMPLEMENTED_MSG = "User must define the actions performed when executing this task" + + +class InterruptStates(IntFlag): + """Class that describes how to behave after an interrupt.""" + + END = 0 + IGNORE = 1 + RESTART = 2 + + +def process( + func: Callable[..., Generator[SimpyEvent, Any, None]], +) -> Callable[..., Process]: + """Decorate a ``simpy`` process to schedule it as a callable. + + Allows users to decorate a generator, and when they want to schedule them + as a ``simpy`` process, they can simply call it, e.g., instead of calling: + + Usage: + + >>> from upstage.api import process, Wait + ... + >>> @process + >>> def generator(wait_period=1.0, msg="Finished Waiting"): + >>> # A simple process that periodically prints a statement + >>> while True: + >>> yield Wait(wait_period).as_event() + >>> print(msg) + ... + >>> @process + >>> def another_process(): + >>> # Some other process that calls the first one + >>> generator() + + Args: + func (Callable[..., Generator[BaseEvent, None, None]]): The process function that is a + generator of simpy events. + + Returns: + Process: The generator as a ``simpy`` process. + + Note: + The value of this decorator is that it reduces the chance of a user + forgetting to call the generator as a process, which tends to produce + behaviors that are difficult to troubleshoot because the code will + build and can run, but the simulation will not work schedule the + process defined by the generator. + + """ + + @wraps(func) + def wrapped_generator(*args: Any, **kwargs: Any) -> Process: + """Wrap the generator with a function that calls it as a process.""" + try: + environment = ENV_CONTEXT_VAR.get() + except LookupError: + raise SimulationError("No environment found on process call") + return environment.process(func(*args, **kwargs)) + + return wrapped_generator + + +EVT = TypeVar("EVT", bound=BaseEvent) +REH_ACTOR = TypeVar("REH_ACTOR", bound="Actor") + + +class Task(SettableEnv): + """A Task is an action that can be performed by an Actor.""" + + INTERRUPT = InterruptStates + + def __init__(self) -> None: + """Create a task instance.""" + super().__init__() + self._proc: TASK_TYPE | None = None + self._network_name: str | None = None + self._network_ref: TaskNetwork | None = None + self._marker: str | None = None + self._marked_time: float | None = None + self._interrupt_action: InterruptStates = InterruptStates.END + self._rehearsing: bool = False + + def task(self, *, actor: Any) -> TASK_TYPE: + """Define the process this task follows.""" + raise NotImplementedError(NOT_IMPLEMENTED_MSG) + + def on_interrupt(self, *, actor: Any, cause: Any) -> InterruptStates: + """Define any actions to take on the actor if this task is interrupted. + + Note: + Custom Tasks can overwrite this method so they can handle being + interrupted with a custom procedure. By default, interrupt ends the + task. + + Args: + actor (Actor): the actor using the task + cause (Any): Optional data for the interrupt + """ + actor.log(f"Interrupted while performing {self}. Reasons: {cause}") + return self._interrupt_action + + def set_marker( + self, marker: str, interrupt_action: InterruptStates = InterruptStates.END + ) -> None: + """Set a marker to help with inspection of interrupts. + + The interrupt_action is set for when no `on_interrupt` is implemented. + + Args: + marker (str): String for the marker. + interrupt_action (InterruptStates, optional): Action to take on interrupt. + Defaults to InterruptStates.END. + """ + self._marker = marker + self._marked_time = self.env.now + self._interrupt_action = interrupt_action + + def get_marker(self) -> str | None: + """Get the current marker. + + Returns: + str | None: Marker (or None if cleared) + """ + return self._marker + + def get_marker_time(self) -> float | None: + """The time the current marker was set. + + Returns: + float | None: Marker set time (or None if cleared) + """ + return self._marked_time + + def clear_marker(self) -> None: + """Clear the marker and set that an interrupt ends the task.""" + self._marker = None + self._marked_time = None + self._interrupt_action = InterruptStates.END + + def _set_network_ref(self, network: "TaskNetwork") -> None: + """Set the reference to the task network object. + + Args: + network (TaskNetwork): The network + """ + if self._network_ref is not None: + raise SimulationError( + "Setting task network reference on task that already has a network" + ) + self._network_ref = network + + def _set_network_name(self, network_name: str) -> None: + """Set the name of the network this task is in. + + Args: + network_name (str): Network name + """ + if self._network_name is not None: + raise SimulationError("Setting task network name on task that already has a network") + self._network_name = network_name + + def clear_actor_task_queue(self, actor: "Actor") -> None: + """Clear out the task queue on the network. + + Args: + actor (Actor): The actor whose queue will be cleared + """ + assert self._network_name is not None + actor.clear_task_queue(self._network_name) + + def set_actor_task_queue(self, actor: "Actor", task_list: list[str]) -> None: + """Set the task queue on the actor. + + This assumes an empty queue. + + Args: + actor (Actor): The actor to modify the task queue of + task_list (list[str]): The list of task names to queue. + """ + assert self._network_name is not None + actor.set_task_queue(self._network_name, task_list) + + def get_actor_task_queue(self, actor: "Actor") -> list[str]: + """Get the task queue on the actor. + + Args: + actor (Actor): The actor to modify the task queue of + """ + assert self._network_name is not None + return actor.get_task_queue(self._network_name) + + def get_actor_next_task(self, actor: "Actor") -> str | None: + """Get the next queued task. + + Args: + actor (Actor): The actor to get the next task from + + Returns: + str | None: The next task name (or None if no task) + """ + assert self._network_name is not None + return actor.get_next_task(self._network_name) + + def set_actor_knowledge( + self, + actor: "Actor", + name: str, + value: Any, + overwrite: bool = False, + ) -> None: + """Set knowledge on the actor. + + Convenience method for passing in the name of task for actor logging. + + Args: + actor (Actor): The actor to set knowledge on. + name (str): Name of the knowledge + value (Any): Value of the knowledge + overwrite (bool, optional): Allow overwrite or not. Defaults to False. + """ + cname = self.__class__.__qualname__ + actor.set_knowledge(name, value, overwrite=overwrite, caller=cname) + + def clear_actor_knowledge(self, actor: "Actor", name: str) -> None: + """Clear knowledge from an actor. + + Convenience method for passing in the name of task for actor logging. + + Args: + actor (Actor): The actor to clear knowledge from + name (str): The name of the knowledge + """ + cname = self.__class__.__qualname__ + actor.clear_knowledge(name, caller=cname) + + @staticmethod + def get_actor_knowledge(actor: "Actor", name: str, must_exist: bool = False) -> Any: + """Get knowledge from the actor. + + Args: + actor (Actor): The actor to get knowledge from. + name (str): Name of the knowledge + must_exist (bool, optional): Raise errors if the knowledge doesn't exist. + Defaults to False. + + Returns: + Any: The knowledge value, which could be None + """ + return actor.get_knowledge(name, must_exist) + + def _clone_actor(self, actor: REH_ACTOR, knowledge: dict[str, Any] | None) -> REH_ACTOR: + """Create a clone of the actor. + + Args: + actor (Actor): The actor to clone + knowledge (dict[str, Any] | None): Additional knowledge to add. + + Returns: + Actor: Cloned actor + """ + mocked_env = MockEnvironment.mock(self.env) + self.env = mocked_env + understudy = actor.clone( + new_env=mocked_env, + knowledge=knowledge, + ) + return understudy + + def rehearse( + self, + *, + actor: REH_ACTOR, + knowledge: dict[str, Any] | None = None, + cloned_actor: bool = False, + **kwargs: Any, + ) -> REH_ACTOR: + """Rehearse the task to evaluate its feasibility. + + Args: + actor (Actor): The actor to rehearse in the task + knowledge (dict[str, Any], optional): Knowledge to add to the actor. Defaults to None. + cloned_actor (bool, optional): If the actor is a clone or not. Defaults to False. + kwargs (Any): Optional args to send to the task. + + Returns: + Actor: The cloned actor with a state reflecting the task flow. + """ + knowledge = {} if knowledge is None else knowledge + _old_env = self.env + understudy = actor + if not cloned_actor: + understudy = self._clone_actor(actor, knowledge) + if not isinstance(understudy.env, MockEnvironment): + raise SimulationError("Bad actor cloning.") + self.env = understudy.env + mocked_env: MockEnvironment = understudy.env + + self._rehearsing = True + generator = self.task(actor=understudy, **kwargs) + returned_item = None + while True: + try: + if returned_item is None: + next_event = next(generator) + else: + next_event = generator.send(returned_item) + returned_item = None + if not issubclass(next_event.__class__, BaseEvent): + raise SimulationError( + f"Task {self} event {next_event} must be a subclass of BaseEvent!" + ) + time_advance, returned_item = next_event.rehearse() + mocked_env.now += time_advance + + except StopIteration: + # warn(f"Stopping rehearsal of task '{self.__class__.__name__}' " + # f"for actor '{actor}'! [Rehearsal duration: " + # f"{self.env.now - _old_env.now:.3g}]") + break + + self.env = _old_env + self._rehearsing = False + return understudy + + def _handle_interruption( + self, actor: "Actor", interrupt: Interrupt, next_event: BaseEvent | Process + ) -> tuple[bool, bool]: + """Clean up after an interrupt and perform interrupt checks/actions. + + Args: + actor (Actor): _description_ + interrupt (Interrupt): _description_ + next_event (BaseEvent): _description_ + + Returns: + bool: If the task should be stopped + bool: If the task should be restarted + """ + # test the interrupt behavior: + stop_run = False + restart = False + _interrupt_action = self.on_interrupt( + actor=actor, + cause=interrupt.cause, + ) + if _interrupt_action is None: + 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.deactivate_all_states(task=self) + actor.deactivate_all_mimic_states(task=self) + if isinstance(next_event, BaseEvent): + next_event.cancel() + elif isinstance(next_event, Process): + next_event.interrupt(cause="Interrupt from task") + else: + raise SimulationError(f"Bad event passed: {next_event}") + stop_run = True + if _interrupt_action is InterruptStates.RESTART: + restart = True + elif _interrupt_action is InterruptStates.IGNORE: + # go back to waiting on the event + stop_run = False + else: + raise SimulationError(f"Wrong interrupt action value: {_interrupt_action}") + if restart and not stop_run: + raise SimulationError("Restarting a task, but it isn't stopping") + return stop_run, restart + + @process + def run(self, *, actor: "Actor") -> Generator[SimpyEvent | Process, Any, None]: + """Execute the task. + + Args: + actor (Actor): The actor using the task + + Returns: + Generator[SimpyEvent, Any, None] + """ + generator = self.task(actor=actor) + self._proc = generator + return_item = None + stop_run = False + restart = False + come_back_to = False + event_to_yield: Process | SimpyEvent + while not stop_run: + try: + while True: + try: + if not come_back_to: + if return_item is None: + next_event = next(generator) + else: + next_event = generator.send(return_item) + # Allows processes to be yielded on inside an event + # This is dangerous + if isinstance(next_event, Process): + warn( + f"Yielding a simpy.Process from {self}. " + f"This is dangerous, take care. ", + UserWarning, + ) + event_to_yield = next_event + elif isinstance(next_event, BaseEvent): + event_to_yield = next_event.as_event() + else: + raise SimulationError( + f"Unexpected yielded event type: {next_event}" + ) + else: + come_back_to = False + return_item = yield event_to_yield + # TODO: test if the return_item is for a multi-event + # that way we can return it as a more useful object + except AttributeError as exc: + if "as_event" in exc.args[0]: + raise SimulationError("Task is yielding objects without `as_event`") + else: + raise exc + except StopIteration: + stop_run = True + break + + except Interrupt as interrupt: + stop_run, restart = self._handle_interruption( + actor, + interrupt, + next_event, + ) + if not stop_run: + come_back_to = True + if restart: + generator = self.task(actor=actor) + self._proc = generator + return_item = None + stop_run = False + restart = False + + +class DecisionTask(Task): + """A task used for decision processes.""" + + def task(self, *, actor: Any) -> TASK_TYPE: + """Define the process this task follows.""" + raise SimulationError("No need to call `task` on a DecisionTask") + + def rehearse_decision(self, *, actor: Any) -> None: + """Define the process this task follows.""" + raise NotImplementedError(NOT_IMPLEMENTED_MSG) + + def make_decision(self, *, actor: Any) -> None: + """Define the process this task follows.""" + raise NotImplementedError(NOT_IMPLEMENTED_MSG) + + def rehearse( + self, + *, + actor: REH_ACTOR, + knowledge: dict[str, Any] | None = None, + cloned_actor: bool = False, + **kwargs: Any, + ) -> REH_ACTOR: + """Rehearse the task to evaluate its feasibility. + + Args: + actor (Actor): The actor to rehearse with + knowledge (Optional[dict[str, Any]], optional): Knowledge to add. Defaults to None. + cloned_actor (bool, optional): If the actor is a clone or not. Defaults to False. + kwargs (Any): Kwargs for rehearsal. Kept for consistency to the base class. + + Returns: + Actor: Cloned actor after rehearsing this task. + """ + knowledge = {} if knowledge is None else knowledge + _old_env = self.env + understudy = actor + if not cloned_actor: + understudy = self._clone_actor(actor, knowledge) + self.env = understudy.env + + self._rehearsing = True + + self.rehearse_decision(actor=understudy) + self.env = _old_env + self._rehearsing = False + return understudy + + @process + def run(self, *, actor: "Actor") -> Generator[SimpyEvent, None, None]: + """Run the decision task. + + Args: + actor (Actor): The actor making decisions + + Yields: + Generator[SimpyEvent, None, None]: Generator for SimPy event queue. + """ + self.make_decision(actor=actor) + assert isinstance(self.env, SimpyEnv) + yield self.env.timeout(0.0) + + +class TerminalTask(Task): + """A rehearsal-safe task that cannot exit, i.e., it is terminal. + + Note: + The user can re-implement the `log_message` method to return a custom + message that will be appended to the actor's log through its `log` + method. + """ + + _time_to_complete: float = 1e24 + + def log_message(self, *, actor: "Actor") -> str: + """A message to save to a log when this task is reached. + + Args: + actor (Actor): The actor using this task. + + Returns: + str: A log message + """ + return f"Entering terminal task: {self} on network {self._network_name}" + + def on_interrupt(self, *, actor: "Actor", cause: Any) -> InterruptStates: + """Special case interrupt for terminal task. + + Args: + actor (Actor): The actor + cause (Any): Additional data sent to the interrupt. + """ + raise SimulationError( + f"Cannot interrupt a terminal task {self} on {actor}. Kwargs sent: {cause}" + ) + return InterruptStates.END + + def task(self, *, actor: "Actor") -> TASK_TYPE: + """The terminal task. + + It's just a long wait. + + Args: + actor (Actor): The actor + """ + log_message = self.log_message(actor=actor) + actor.log(log_message) + the_long_event = Event(rehearsal_time_to_complete=self._time_to_complete) + res = yield the_long_event + if res is not PLANNING_FACTOR_OBJECT: + raise SimulationError(f"A terminal task completed on {actor}") diff --git a/src/upstage/task_network.py b/src/upstage_des/task_network.py similarity index 96% rename from src/upstage/task_network.py rename to src/upstage_des/task_network.py index 271bd97..3392d9b 100644 --- a/src/upstage/task_network.py +++ b/src/upstage_des/task_network.py @@ -1,321 +1,321 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""The task network class, and factory classes.""" - -from collections.abc import Generator, Mapping, Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, TypeVar - -if TYPE_CHECKING: - from upstage.actor import Actor - -from simpy import Process - -from upstage.base import SimulationError -from upstage.task import Task, TerminalTask, process - -REH_ACTOR = TypeVar("REH_ACTOR", bound="Actor") - - -@dataclass -class TaskLinks: - """Type hinting for task link dictionaries.""" - - default: str | None - allowed: Sequence[str] - - -class TaskNetwork: - """A means to represent, execute, and rehearse interdependent tasks.""" - - def __init__( - self, - name: str, - task_classes: Mapping[str, type[Task]], - task_links: Mapping[str, TaskLinks], - ) -> None: - """Create a task network. - - Task links are defined as: - {task_name: TaskLinks(default= task_name | None, allowed= list[task_names]} - where each task has a default next task (or None), and tasks that could follow it. - - Args: - name (str): Network name - task_classes (Mapping[str, Task]): Task names to Task object mapping. - task_links (Mapping[str, TaskLinks]): Task links. - """ - self.name = name - self.task_classes = task_classes - self.task_links = task_links - self._current_task_name: str | None = None - self._current_task_inst: Task | None = None - self._current_task_proc: Process | None = None - - def is_feasible(self, curr: str, new: str) -> bool: - """Determine if a task can follow another one. - - Args: - curr (str): Current task name - new (str): Potential next task name - - Returns: - bool: If the new task can follow the current. - """ - value = self.task_links[curr].allowed - return new in value - - def _next_task_name( - self, curr_task_name: str, actor: "Actor", clear_queue: bool = False - ) -> str: - """Get the next task name. - - Returns: - str: Task name - """ - task_from_queue = actor.get_next_task(self.name) - default_next_task = self.task_links[curr_task_name].default - if task_from_queue is None: - if default_next_task is None: - raise SimulationError( # pramga: no cover - f"No default task set for after {curr_task_name} on {actor}." - ) - next_name = default_next_task - else: - next_name = task_from_queue - # once we have the name, pop it from the queue - if clear_queue: - actor._clear_task(self.name) - return next_name - - @process - def loop( - self, *, actor: "Actor", init_task_name: str | None = None - ) -> Generator[Process, None, None]: - """Start a task network running its loop. - - If no initial task name is given, it will default to following the queue. - - Args: - actor (Actor): The actor to run the loop on. - init_task_name (Optional[str], optional): Optional task to start running. - Defaults to None. - """ - next_name = actor.get_next_task(self.name) - if next_name is None: - if init_task_name is None: - raise SimulationError( - f"Actor {actor} wasn't supplied an initial task" - ) # pramga: no cover - next_name = init_task_name - - self._current_task_name = next_name - - while True: - task_name = self._current_task_name - assert isinstance(task_name, str) - actor.log(f"Outer: starting {task_name}") - actor._begin_next_task(self.name, task_name) - task_cls = self.task_classes[task_name] - task_instance: Task = task_cls() - self._current_task_inst = task_instance - self._current_task_inst._set_network_name(self.name) - self._current_task_inst._set_network_ref(self) - self._current_task_proc = self._current_task_inst.run(actor=actor) - - yield self._current_task_proc - - next_name = self._next_task_name(task_name, actor) - self._current_task_name = next_name - - def rehearse_network( - self, - *, - actor: REH_ACTOR, - task_name_list: list[str], - knowledge: dict[str, Any] | None = None, - end_task: str | None = None, - ) -> REH_ACTOR: - """Rehearse a path through the task network. - - Args: - actor (Actor): The actor to perform the task rehearsal withs - task_name_list (list[str]): The tasks to be performed in order - knowledge (dict[str, Any], optional): Knowledge to give to the cloned/rehearsing actor - end_task (str, optional): A task name to end on - - Returns: - Actor: A copy of the original actor with state changes associated with the network. - """ - _old_name = self._current_task_name - _old_inst = self._current_task_inst - _old_proc = self._current_task_proc - knowledge = {} if knowledge is None else knowledge - num_tasks = len(task_name_list) - # pre-clone the actor to get a hold of the new environment - new_actor = actor.clone(knowledge=knowledge) - task_idx = 0 - while True: - if task_idx < num_tasks: - task_name = task_name_list[task_idx] - elif end_task is None: - break - else: - # Grab the default or one from the queue, clearing the queue to prevent loops - task_name = self._next_task_name(task_name, new_actor, clear_queue=True) - if end_task is not None and end_task == task_name: - break # pragma: no cover - self._current_task_name = task_name - self._current_task_inst = self.task_classes[task_name]() - self._current_task_inst._set_network_name(self.name) - new_actor = self._current_task_inst.rehearse( - actor=new_actor, - cloned_actor=True, - ) - # The next name should be feasible - if task_idx < num_tasks - 1: - follow_on = task_name_list[task_idx + 1] - if not self.is_feasible(task_name, follow_on): - raise SimulationError( # pragma: no cover - f"Task {follow_on} not allowed after '{task_name}' in network" - ) - task_idx += 1 - # reset the internal parameters - self._current_task_name = _old_name - self._current_task_inst = _old_inst - self._current_task_proc = _old_proc - return new_actor - - def __repr__(self) -> str: - return f"Task network: {self.name}" - - -class TaskNetworkFactory: - """A factory for creating task network instances.""" - - def __init__( - self, - name: str, - task_classes: Mapping[str, type[Task]], - task_links: Mapping[str, TaskLinks], - ) -> None: - """Create a factory for making instances of a task network. - - Task links are defined as: - {task_name: TaskLinks(default= task_name | None, allowed= list[task_names]} - where each task has a default next task (or None), and tasks that could follow it. - - Args: - name (str): The network name - task_classes (dict[str, Task]): Network task classes - task_links (dict[str, dict[str, str | list[str] | None]]): Network links. - """ - self.name = name - self.task_classes = task_classes - self.task_links = task_links - - @classmethod - def from_single_looping(cls, name: str, task_class: type[Task]) -> "TaskNetworkFactory": - """Create a network factory from a single task that loops. - - Args: - name (str): Network name - task_class (Task): The single task to loop - - Returns: - TaskNetworkFactory: The factory for the single looping network. - """ - taskname = task_class.__name__ - task_classes = {taskname: task_class} - task_links: dict[str, TaskLinks] = { - taskname: TaskLinks(default=taskname, allowed=[taskname]) - } - return TaskNetworkFactory(name, task_classes, task_links) - - @classmethod - def from_single_terminating(cls, name: str, task_class: type[Task]) -> "TaskNetworkFactory": - """Create a network factory from a single task that terminates. - - Args: - name (str): Network name - task_class (Task): The single task to terminate after - - Returns: - TaskNetworkFactory: The factory for the single terminating network. - """ - taskname = task_class.__name__ - end_name = f"{taskname}_FINAL" - task_classes = {taskname: task_class, end_name: TerminalTask} - task_links: dict[str, TaskLinks] = { - taskname: TaskLinks(default=end_name, allowed=[end_name]) - } - return TaskNetworkFactory(name, task_classes, task_links) - - @classmethod - def from_ordered_terminating( - cls, name: str, task_classes: list[type[Task]] - ) -> "TaskNetworkFactory": - """Create a network factory from a list of tasks that terminates. - - Args: - name (str): Network name - task_classes (list[Task]): The tasks to run in order. - - Returns: - TaskNetworkFactory: The factory for the ordered network. - """ - task_class = {} - task_links: dict[str, TaskLinks] = {} - for i, tc in enumerate(task_classes): - the_name = tc.__name__ - task_class[the_name] = tc - try: - nxt = task_classes[i + 1] - nxt_name = nxt.__name__ - except IndexError: - nxt = TerminalTask - nxt_name = f"{name}_TERMINATING" - task_class[nxt_name] = nxt - task_links[the_name] = TaskLinks(default=nxt_name, allowed=[nxt_name]) - return TaskNetworkFactory(name, task_class, task_links) - - @classmethod - def from_ordered_loop(cls, name: str, task_classes: list[type[Task]]) -> "TaskNetworkFactory": - """Create a network factory from a list of tasks that loops. - - Args: - name (str): Network name - task_classes (list[Task]): The tasks to run in order. - - Returns: - TaskNetworkFactory: The factory for the ordered network. - """ - task_class = {} - task_links: dict[str, TaskLinks] = {} - for i, tc in enumerate(task_classes): - the_name = tc.__name__ - task_class[the_name] = tc - try: - nxt = task_classes[i + 1] - except IndexError: - nxt = task_classes[0] - nxt_name = nxt.__name__ - task_links[the_name] = TaskLinks(default=nxt_name, allowed=[nxt_name]) - return TaskNetworkFactory(name, task_class, task_links) - - def make_network(self, other_name: str | None = None) -> TaskNetwork: - """Create an instance of the task network. - - By default, this uses the name defined on instantiation. - - Args: - other_name (str, optional): Another name for the network. Defaults to None. - - Returns: - TaskNetwork - """ - use_name = other_name if other_name is not None else self.name - return TaskNetwork(use_name, self.task_classes, self.task_links) +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""The task network class, and factory classes.""" + +from collections.abc import Generator, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypeVar + +if TYPE_CHECKING: + from upstage_des.actor import Actor + +from simpy import Process + +from upstage_des.base import SimulationError +from upstage_des.task import Task, TerminalTask, process + +REH_ACTOR = TypeVar("REH_ACTOR", bound="Actor") + + +@dataclass +class TaskLinks: + """Type hinting for task link dictionaries.""" + + default: str | None + allowed: Sequence[str] + + +class TaskNetwork: + """A means to represent, execute, and rehearse interdependent tasks.""" + + def __init__( + self, + name: str, + task_classes: Mapping[str, type[Task]], + task_links: Mapping[str, TaskLinks], + ) -> None: + """Create a task network. + + Task links are defined as: + {task_name: TaskLinks(default= task_name | None, allowed= list[task_names]} + where each task has a default next task (or None), and tasks that could follow it. + + Args: + name (str): Network name + task_classes (Mapping[str, Task]): Task names to Task object mapping. + task_links (Mapping[str, TaskLinks]): Task links. + """ + self.name = name + self.task_classes = task_classes + self.task_links = task_links + self._current_task_name: str | None = None + self._current_task_inst: Task | None = None + self._current_task_proc: Process | None = None + + def is_feasible(self, curr: str, new: str) -> bool: + """Determine if a task can follow another one. + + Args: + curr (str): Current task name + new (str): Potential next task name + + Returns: + bool: If the new task can follow the current. + """ + value = self.task_links[curr].allowed + return new in value + + def _next_task_name( + self, curr_task_name: str, actor: "Actor", clear_queue: bool = False + ) -> str: + """Get the next task name. + + Returns: + str: Task name + """ + task_from_queue = actor.get_next_task(self.name) + default_next_task = self.task_links[curr_task_name].default + if task_from_queue is None: + if default_next_task is None: + raise SimulationError( # pramga: no cover + f"No default task set for after {curr_task_name} on {actor}." + ) + next_name = default_next_task + else: + next_name = task_from_queue + # once we have the name, pop it from the queue + if clear_queue: + actor._clear_task(self.name) + return next_name + + @process + def loop( + self, *, actor: "Actor", init_task_name: str | None = None + ) -> Generator[Process, None, None]: + """Start a task network running its loop. + + If no initial task name is given, it will default to following the queue. + + Args: + actor (Actor): The actor to run the loop on. + init_task_name (Optional[str], optional): Optional task to start running. + Defaults to None. + """ + next_name = actor.get_next_task(self.name) + if next_name is None: + if init_task_name is None: + raise SimulationError( + f"Actor {actor} wasn't supplied an initial task" + ) # pramga: no cover + next_name = init_task_name + + self._current_task_name = next_name + + while True: + task_name = self._current_task_name + assert isinstance(task_name, str) + actor.log(f"Outer: starting {task_name}") + actor._begin_next_task(self.name, task_name) + task_cls = self.task_classes[task_name] + task_instance: Task = task_cls() + self._current_task_inst = task_instance + self._current_task_inst._set_network_name(self.name) + self._current_task_inst._set_network_ref(self) + self._current_task_proc = self._current_task_inst.run(actor=actor) + + yield self._current_task_proc + + next_name = self._next_task_name(task_name, actor) + self._current_task_name = next_name + + def rehearse_network( + self, + *, + actor: REH_ACTOR, + task_name_list: list[str], + knowledge: dict[str, Any] | None = None, + end_task: str | None = None, + ) -> REH_ACTOR: + """Rehearse a path through the task network. + + Args: + actor (Actor): The actor to perform the task rehearsal withs + task_name_list (list[str]): The tasks to be performed in order + knowledge (dict[str, Any], optional): Knowledge to give to the cloned/rehearsing actor + end_task (str, optional): A task name to end on + + Returns: + Actor: A copy of the original actor with state changes associated with the network. + """ + _old_name = self._current_task_name + _old_inst = self._current_task_inst + _old_proc = self._current_task_proc + knowledge = {} if knowledge is None else knowledge + num_tasks = len(task_name_list) + # pre-clone the actor to get a hold of the new environment + new_actor = actor.clone(knowledge=knowledge) + task_idx = 0 + while True: + if task_idx < num_tasks: + task_name = task_name_list[task_idx] + elif end_task is None: + break + else: + # Grab the default or one from the queue, clearing the queue to prevent loops + task_name = self._next_task_name(task_name, new_actor, clear_queue=True) + if end_task is not None and end_task == task_name: + break # pragma: no cover + self._current_task_name = task_name + self._current_task_inst = self.task_classes[task_name]() + self._current_task_inst._set_network_name(self.name) + new_actor = self._current_task_inst.rehearse( + actor=new_actor, + cloned_actor=True, + ) + # The next name should be feasible + if task_idx < num_tasks - 1: + follow_on = task_name_list[task_idx + 1] + if not self.is_feasible(task_name, follow_on): + raise SimulationError( # pragma: no cover + f"Task {follow_on} not allowed after '{task_name}' in network" + ) + task_idx += 1 + # reset the internal parameters + self._current_task_name = _old_name + self._current_task_inst = _old_inst + self._current_task_proc = _old_proc + return new_actor + + def __repr__(self) -> str: + return f"Task network: {self.name}" + + +class TaskNetworkFactory: + """A factory for creating task network instances.""" + + def __init__( + self, + name: str, + task_classes: Mapping[str, type[Task]], + task_links: Mapping[str, TaskLinks], + ) -> None: + """Create a factory for making instances of a task network. + + Task links are defined as: + {task_name: TaskLinks(default= task_name | None, allowed= list[task_names]} + where each task has a default next task (or None), and tasks that could follow it. + + Args: + name (str): The network name + task_classes (dict[str, Task]): Network task classes + task_links (dict[str, dict[str, str | list[str] | None]]): Network links. + """ + self.name = name + self.task_classes = task_classes + self.task_links = task_links + + @classmethod + def from_single_looping(cls, name: str, task_class: type[Task]) -> "TaskNetworkFactory": + """Create a network factory from a single task that loops. + + Args: + name (str): Network name + task_class (Task): The single task to loop + + Returns: + TaskNetworkFactory: The factory for the single looping network. + """ + taskname = task_class.__name__ + task_classes = {taskname: task_class} + task_links: dict[str, TaskLinks] = { + taskname: TaskLinks(default=taskname, allowed=[taskname]) + } + return TaskNetworkFactory(name, task_classes, task_links) + + @classmethod + def from_single_terminating(cls, name: str, task_class: type[Task]) -> "TaskNetworkFactory": + """Create a network factory from a single task that terminates. + + Args: + name (str): Network name + task_class (Task): The single task to terminate after + + Returns: + TaskNetworkFactory: The factory for the single terminating network. + """ + taskname = task_class.__name__ + end_name = f"{taskname}_FINAL" + task_classes = {taskname: task_class, end_name: TerminalTask} + task_links: dict[str, TaskLinks] = { + taskname: TaskLinks(default=end_name, allowed=[end_name]) + } + return TaskNetworkFactory(name, task_classes, task_links) + + @classmethod + def from_ordered_terminating( + cls, name: str, task_classes: list[type[Task]] + ) -> "TaskNetworkFactory": + """Create a network factory from a list of tasks that terminates. + + Args: + name (str): Network name + task_classes (list[Task]): The tasks to run in order. + + Returns: + TaskNetworkFactory: The factory for the ordered network. + """ + task_class = {} + task_links: dict[str, TaskLinks] = {} + for i, tc in enumerate(task_classes): + the_name = tc.__name__ + task_class[the_name] = tc + try: + nxt = task_classes[i + 1] + nxt_name = nxt.__name__ + except IndexError: + nxt = TerminalTask + nxt_name = f"{name}_TERMINATING" + task_class[nxt_name] = nxt + task_links[the_name] = TaskLinks(default=nxt_name, allowed=[nxt_name]) + return TaskNetworkFactory(name, task_class, task_links) + + @classmethod + def from_ordered_loop(cls, name: str, task_classes: list[type[Task]]) -> "TaskNetworkFactory": + """Create a network factory from a list of tasks that loops. + + Args: + name (str): Network name + task_classes (list[Task]): The tasks to run in order. + + Returns: + TaskNetworkFactory: The factory for the ordered network. + """ + task_class = {} + task_links: dict[str, TaskLinks] = {} + for i, tc in enumerate(task_classes): + the_name = tc.__name__ + task_class[the_name] = tc + try: + nxt = task_classes[i + 1] + except IndexError: + nxt = task_classes[0] + nxt_name = nxt.__name__ + task_links[the_name] = TaskLinks(default=nxt_name, allowed=[nxt_name]) + return TaskNetworkFactory(name, task_class, task_links) + + def make_network(self, other_name: str | None = None) -> TaskNetwork: + """Create an instance of the task network. + + By default, this uses the name defined on instantiation. + + Args: + other_name (str, optional): Another name for the network. Defaults to None. + + Returns: + TaskNetwork + """ + use_name = other_name if other_name is not None else self.name + return TaskNetwork(use_name, self.task_classes, self.task_links) diff --git a/src/upstage/test/__init__.py b/src/upstage_des/test/__init__.py similarity index 100% rename from src/upstage/test/__init__.py rename to src/upstage_des/test/__init__.py diff --git a/src/upstage/test/conftest.py b/src/upstage_des/test/conftest.py similarity index 98% rename from src/upstage/test/conftest.py rename to src/upstage_des/test/conftest.py index d5e726b..ddcc2d9 100644 --- a/src/upstage/test/conftest.py +++ b/src/upstage_des/test/conftest.py @@ -8,7 +8,7 @@ import pytest -import upstage.api as UP +import upstage_des.api as UP @pytest.fixture diff --git a/src/upstage/test/test_actor.py b/src/upstage_des/test/test_actor.py similarity index 97% rename from src/upstage/test/test_actor.py rename to src/upstage_des/test/test_actor.py index fed39a5..9b76434 100644 --- a/src/upstage/test/test_actor.py +++ b/src/upstage_des/test/test_actor.py @@ -8,10 +8,10 @@ import pytest -import upstage.api as UP -from upstage.actor import Actor -from upstage.base import EnvironmentContext, SimulationError -from upstage.states import State +import upstage_des.api as UP +from upstage_des.actor import Actor +from upstage_des.base import EnvironmentContext, SimulationError +from upstage_des.states import State def test_actor_creation() -> None: diff --git a/src/upstage/test/test_api.py b/src/upstage_des/test/test_api.py similarity index 95% rename from src/upstage/test/test_api.py rename to src/upstage_des/test/test_api.py index 398a9b3..6702342 100644 --- a/src/upstage/test/test_api.py +++ b/src/upstage_des/test/test_api.py @@ -1,82 +1,82 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -from upstage import api - - -def test_api() -> None: - api_items = dir(api) - - items_to_test = ( - "UpstageError", - "SimulationError", - "RulesError", - "Actor", - "PLANNING_FACTOR_OBJECT", - "UpstageBase", - "NamedUpstageEntity", - "EnvironmentContext", - "add_stage_variable", - "get_stage_variable", - "get_stage", - "All", - "Any", - "Event", - "Get", - "FilterGet", - "SortedFilterGet", - "Put", - "ResourceHold", - "Wait", - "ContainerEmptyError", - "ContainerError", - "ContainerFullError", - "ContinuousContainer", - "SelfMonitoringContainer", - "SelfMonitoringContinuousContainer", - "SelfMonitoringFilterStore", - "SelfMonitoringSortedFilterStore", - "SelfMonitoringReserveStore", - "SelfMonitoringStore", - "ReserveStore", - "SortedFilterStore", - "Location", - "CartesianLocation", - "GeodeticLocation", - "CartesianLocationData", - "GeodeticLocationData", - "LinearChangingState", - "CartesianLocationChangingState", - "State", - "GeodeticLocationChangingState", - "DetectabilityState", - "ResourceState", - "DecisionTask", - "Task", - "process", - "InterruptStates", - "TerminalTask", - "TaskNetwork", - "TaskNetworkFactory", - "TaskLinks", - "CommsManager", - "Message", - "MessageContent", - "MotionAndDetectionError", - "SensorMotionManager", - "SteppedMotionManager", - "TaskNetworkNucleus", - "NucleusInterrupt", - "SharedLinearChangingState", - "CommunicationStore", - "unit_convert", - ) - - for item in items_to_test: - assert item in api_items - - for item in api_items: - if not item.startswith("_"): - assert item in items_to_test +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +from upstage_des import api + + +def test_api() -> None: + api_items = dir(api) + + items_to_test = ( + "UpstageError", + "SimulationError", + "RulesError", + "Actor", + "PLANNING_FACTOR_OBJECT", + "UpstageBase", + "NamedUpstageEntity", + "EnvironmentContext", + "add_stage_variable", + "get_stage_variable", + "get_stage", + "All", + "Any", + "Event", + "Get", + "FilterGet", + "SortedFilterGet", + "Put", + "ResourceHold", + "Wait", + "ContainerEmptyError", + "ContainerError", + "ContainerFullError", + "ContinuousContainer", + "SelfMonitoringContainer", + "SelfMonitoringContinuousContainer", + "SelfMonitoringFilterStore", + "SelfMonitoringSortedFilterStore", + "SelfMonitoringReserveStore", + "SelfMonitoringStore", + "ReserveStore", + "SortedFilterStore", + "Location", + "CartesianLocation", + "GeodeticLocation", + "CartesianLocationData", + "GeodeticLocationData", + "LinearChangingState", + "CartesianLocationChangingState", + "State", + "GeodeticLocationChangingState", + "DetectabilityState", + "ResourceState", + "DecisionTask", + "Task", + "process", + "InterruptStates", + "TerminalTask", + "TaskNetwork", + "TaskNetworkFactory", + "TaskLinks", + "CommsManager", + "Message", + "MessageContent", + "MotionAndDetectionError", + "SensorMotionManager", + "SteppedMotionManager", + "TaskNetworkNucleus", + "NucleusInterrupt", + "SharedLinearChangingState", + "CommunicationStore", + "unit_convert", + ) + + for item in items_to_test: + assert item in api_items + + for item in api_items: + if not item.startswith("_"): + assert item in items_to_test diff --git a/src/upstage/test/test_base.py b/src/upstage_des/test/test_base.py similarity index 99% rename from src/upstage/test/test_base.py rename to src/upstage_des/test/test_base.py index 6904b90..cbad807 100644 --- a/src/upstage/test/test_base.py +++ b/src/upstage_des/test/test_base.py @@ -11,7 +11,7 @@ import pytest import simpy as SIM -from upstage.base import ( +from upstage_des.base import ( STAGE_CONTEXT_VAR, EnvironmentContext, NamedUpstageEntity, diff --git a/src/upstage/test/test_comms.py b/src/upstage_des/test/test_comms.py similarity index 96% rename from src/upstage/test/test_comms.py rename to src/upstage_des/test/test_comms.py index ffb31fc..da1049d 100644 --- a/src/upstage/test/test_comms.py +++ b/src/upstage_des/test/test_comms.py @@ -7,8 +7,8 @@ from simpy import Store -import upstage.api as UP -from upstage.api import ( +import upstage_des.api as UP +from upstage_des.api import ( Actor, CommsManager, EnvironmentContext, @@ -20,8 +20,8 @@ Task, Wait, ) -from upstage.communications.processes import generate_comms_wait -from upstage.type_help import SIMPY_GEN, TASK_GEN +from upstage_des.communications.processes import generate_comms_wait +from upstage_des.type_help import SIMPY_GEN, TASK_GEN class ReceiveSend(Actor): diff --git a/src/upstage/test/test_container.py b/src/upstage_des/test/test_container.py similarity index 97% rename from src/upstage/test/test_container.py rename to src/upstage_des/test/test_container.py index ae0626e..ab43871 100644 --- a/src/upstage/test/test_container.py +++ b/src/upstage_des/test/test_container.py @@ -7,17 +7,17 @@ import pytest -from upstage.base import EnvironmentContext -from upstage.resources.container import ( +from upstage_des.base import EnvironmentContext +from upstage_des.resources.container import ( ContainerEmptyError, ContainerError, ContinuousContainer, ) -from upstage.resources.monitoring import ( +from upstage_des.resources.monitoring import ( SelfMonitoringContainer, SelfMonitoringContinuousContainer, ) -from upstage.type_help import SIMPY_GEN +from upstage_des.type_help import SIMPY_GEN CONTAINER_CAPACITY = 100 INITIAL_LEVEL = 50 diff --git a/src/upstage/test/test_data_types.py b/src/upstage_des/test/test_data_types.py similarity index 96% rename from src/upstage/test/test_data_types.py rename to src/upstage_des/test/test_data_types.py index 652eb97..92e0b22 100644 --- a/src/upstage/test/test_data_types.py +++ b/src/upstage_des/test/test_data_types.py @@ -8,8 +8,8 @@ import pytest -import upstage.api as UP -from upstage.geography import Spherical, get_intersection_locations +import upstage_des.api as UP +from upstage_des.geography import Spherical, get_intersection_locations STAGE_SETUP = dict( altitude_units="ft", diff --git a/src/upstage/test/test_docs_examples/__init__.py b/src/upstage_des/test/test_docs_examples/__init__.py similarity index 100% rename from src/upstage/test/test_docs_examples/__init__.py rename to src/upstage_des/test/test_docs_examples/__init__.py diff --git a/src/upstage/test/test_docs_examples/test_cashier.py b/src/upstage_des/test/test_docs_examples/test_cashier.py similarity index 96% rename from src/upstage/test/test_docs_examples/test_cashier.py rename to src/upstage_des/test/test_docs_examples/test_cashier.py index 597c952..b430656 100644 --- a/src/upstage/test/test_docs_examples/test_cashier.py +++ b/src/upstage_des/test/test_docs_examples/test_cashier.py @@ -1,229 +1,229 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -from collections.abc import Generator - -import simpy as SIM - -import upstage.api as UP -from upstage.type_help import TASK_GEN - - -class Cashier(UP.Actor): - scan_speed = UP.State[float]( - valid_types=(float,), - frozen=True, - ) - time_until_break = UP.State[float]( - default=120.0, - valid_types=(float,), - frozen=True, - ) - breaks_until_done = UP.State[int](default=2, valid_types=int) - breaks_taken = UP.State[int](default=0, valid_types=int, recording=True) - items_scanned = UP.State[int]( - default=0, - valid_types=(int,), - recording=True, - ) - time_scanning = UP.LinearChangingState( - default=0.0, - valid_types=(float,), - ) - - -class CheckoutLane(UP.Actor): - customer_queue = UP.ResourceState[UP.SelfMonitoringStore]( - default=UP.SelfMonitoringStore, - ) - - -class StoreBoss(UP.UpstageBase): - def __init__(self, lanes: list[CheckoutLane]) -> None: - self.lanes = lanes - self._lane_map: dict[CheckoutLane, Cashier] = {} - - def get_lane(self, cashier: Cashier) -> CheckoutLane: - possible = [lane for lane in self.lanes if lane not in self._lane_map] - lane = self.stage.random.choice(possible) - self._lane_map[lane] = cashier - return lane - - -class GoToWork(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Go to work""" - yield UP.Wait(15.0) - - -class TalkToBoss(UP.DecisionTask): - def make_decision(self, *, actor: Cashier) -> None: - """Zero-time task to get information.""" - boss: StoreBoss = self.stage.boss - lane = boss.get_lane(actor) - self.set_actor_knowledge(actor, "checkout_lane", lane, overwrite=False) - actor.breaks_taken = 0 - self.set_actor_knowledge(actor, "start_time", self.env.now) - - -class WaitInLane(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Wait until break time, or a customer.""" - lane: CheckoutLane = self.get_actor_knowledge( - actor, - "checkout_lane", - must_exist=True, - ) - customer_arrival = UP.Get(lane.customer_queue) - - start_time = self.get_actor_knowledge( - actor, - "start_time", - must_exist=True, - ) - break_start = start_time + actor.time_until_break - wait_until_break = break_start - self.env.now - if wait_until_break < 0: - self.set_actor_task_queue(actor, ["Break"]) - return - - break_event = UP.Wait(wait_until_break) - - yield UP.Any(customer_arrival, break_event) - - if customer_arrival.is_complete(): - customer: int = customer_arrival.get_value() - self.set_actor_knowledge(actor, "customer", customer, overwrite=True) - else: - customer_arrival.cancel() - self.set_actor_task_queue(actor, ["Break"]) - - -class DoCheckout(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Do the checkout""" - items: int = self.get_actor_knowledge( - actor, - "customer", - must_exist=True, - ) - per_item_time = actor.scan_speed / items - actor.activate_linear_state( - state="time_scanning", - rate=1.0, - task=self, - ) - for _ in range(items): - yield UP.Wait(per_item_time) - actor.items_scanned += 1 - actor.deactivate_all_states(task=self) - # assume 2 minutes to take payment - yield UP.Wait(2.0) - - -class Break(UP.DecisionTask): - def make_decision(self, *, actor: Cashier) -> None: - """Decide what kind of break we are taking.""" - actor.breaks_taken += 1 - if actor.breaks_taken == actor.breaks_until_done: - self.set_actor_task_queue(actor, ["NightBreak"]) - elif actor.breaks_taken > actor.breaks_until_done: - raise UP.SimulationError("Too many breaks taken") - else: - self.set_actor_task_queue(actor, ["ShortBreak"]) - - -class ShortBreak(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Take a short break.""" - yield UP.Wait(15.0) - self.set_actor_knowledge(actor, "start_time", self.env.now, overwrite=True) - - -class NightBreak(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Go home and rest.""" - self.clear_actor_knowledge(actor, "checkout_lane") - yield UP.Wait(60 * 12.0) - - -task_classes = { - "GoToWork": GoToWork, - "TalkToBoss": TalkToBoss, - "WaitInLane": WaitInLane, - "DoCheckout": DoCheckout, - "Break": Break, - "ShortBreak": ShortBreak, - "NightBreak": NightBreak, -} - -task_links = { - "GoToWork": UP.TaskLinks(default="TalkToBoss", allowed=["TalkToBoss"]), - "TalkToBoss": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), - "WaitInLane": UP.TaskLinks(default="DoCheckout", allowed=["DoCheckout", "Break"]), - "DoCheckout": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), - "Break": UP.TaskLinks(default="ShortBreak", allowed=["ShortBreak", "NightBreak"]), - "ShortBreak": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), - "NightBreak": UP.TaskLinks(default="GoToWork", allowed=["GoToWork"]), -} - - -cashier_task_network = UP.TaskNetworkFactory( - name="CashierJob", - task_classes=task_classes, - task_links=task_links, -) - - -def customer_spawner( - env: SIM.Environment, - lanes: list[CheckoutLane], -) -> Generator[SIM.Event, None, None]: - # sneaky way to get access to stage - stage = lanes[0].stage - while True: - hrs = env.now / 60 - time_of_day = hrs // 24 - if time_of_day <= 8 or time_of_day >= 15.5: - time_until_open = (24 - time_of_day) + 8 - yield env.timeout(time_until_open) - - lane_pick = stage.random.choice(lanes) - number_pick = stage.random.randint(3, 17) - yield lane_pick.customer_queue.put(number_pick) - yield UP.Wait.from_random_uniform(5.0, 30.0).as_event() - - -def test_cashier_example() -> None: - with UP.EnvironmentContext(initial_time=8 * 60) as env: - UP.add_stage_variable("time_unit", "min") - cashier = Cashier( - name="Bob", - scan_speed=1.0, - time_until_break=120.0, - breaks_until_done=4, - debug_log=True, - ) - lane_1 = CheckoutLane(name="Lane 1") - lane_2 = CheckoutLane(name="Lane 2") - boss = StoreBoss(lanes=[lane_1, lane_2]) - - UP.add_stage_variable("boss", boss) - - net = cashier_task_network.make_network() - cashier.add_task_network(net) - cashier.start_network_loop(net.name, "GoToWork") - - customer_proc = customer_spawner(env, [lane_1, lane_2]) - _ = env.process(customer_proc) - - env.run(until=20 * 60) - - for line in cashier.get_log(): - print(line) - - -if __name__ == "__main__": - test_cashier_example() +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +from collections.abc import Generator + +import simpy as SIM + +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN + + +class Cashier(UP.Actor): + scan_speed = UP.State[float]( + valid_types=(float,), + frozen=True, + ) + time_until_break = UP.State[float]( + default=120.0, + valid_types=(float,), + frozen=True, + ) + breaks_until_done = UP.State[int](default=2, valid_types=int) + breaks_taken = UP.State[int](default=0, valid_types=int, recording=True) + items_scanned = UP.State[int]( + default=0, + valid_types=(int,), + recording=True, + ) + time_scanning = UP.LinearChangingState( + default=0.0, + valid_types=(float,), + ) + + +class CheckoutLane(UP.Actor): + customer_queue = UP.ResourceState[UP.SelfMonitoringStore]( + default=UP.SelfMonitoringStore, + ) + + +class StoreBoss(UP.UpstageBase): + def __init__(self, lanes: list[CheckoutLane]) -> None: + self.lanes = lanes + self._lane_map: dict[CheckoutLane, Cashier] = {} + + def get_lane(self, cashier: Cashier) -> CheckoutLane: + possible = [lane for lane in self.lanes if lane not in self._lane_map] + lane = self.stage.random.choice(possible) + self._lane_map[lane] = cashier + return lane + + +class GoToWork(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Go to work""" + yield UP.Wait(15.0) + + +class TalkToBoss(UP.DecisionTask): + def make_decision(self, *, actor: Cashier) -> None: + """Zero-time task to get information.""" + boss: StoreBoss = self.stage.boss + lane = boss.get_lane(actor) + self.set_actor_knowledge(actor, "checkout_lane", lane, overwrite=False) + actor.breaks_taken = 0 + self.set_actor_knowledge(actor, "start_time", self.env.now) + + +class WaitInLane(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Wait until break time, or a customer.""" + lane: CheckoutLane = self.get_actor_knowledge( + actor, + "checkout_lane", + must_exist=True, + ) + customer_arrival = UP.Get(lane.customer_queue) + + start_time = self.get_actor_knowledge( + actor, + "start_time", + must_exist=True, + ) + break_start = start_time + actor.time_until_break + wait_until_break = break_start - self.env.now + if wait_until_break < 0: + self.set_actor_task_queue(actor, ["Break"]) + return + + break_event = UP.Wait(wait_until_break) + + yield UP.Any(customer_arrival, break_event) + + if customer_arrival.is_complete(): + customer: int = customer_arrival.get_value() + self.set_actor_knowledge(actor, "customer", customer, overwrite=True) + else: + customer_arrival.cancel() + self.set_actor_task_queue(actor, ["Break"]) + + +class DoCheckout(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Do the checkout""" + items: int = self.get_actor_knowledge( + actor, + "customer", + must_exist=True, + ) + per_item_time = actor.scan_speed / items + actor.activate_linear_state( + state="time_scanning", + rate=1.0, + task=self, + ) + for _ in range(items): + yield UP.Wait(per_item_time) + actor.items_scanned += 1 + actor.deactivate_all_states(task=self) + # assume 2 minutes to take payment + yield UP.Wait(2.0) + + +class Break(UP.DecisionTask): + def make_decision(self, *, actor: Cashier) -> None: + """Decide what kind of break we are taking.""" + actor.breaks_taken += 1 + if actor.breaks_taken == actor.breaks_until_done: + self.set_actor_task_queue(actor, ["NightBreak"]) + elif actor.breaks_taken > actor.breaks_until_done: + raise UP.SimulationError("Too many breaks taken") + else: + self.set_actor_task_queue(actor, ["ShortBreak"]) + + +class ShortBreak(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Take a short break.""" + yield UP.Wait(15.0) + self.set_actor_knowledge(actor, "start_time", self.env.now, overwrite=True) + + +class NightBreak(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Go home and rest.""" + self.clear_actor_knowledge(actor, "checkout_lane") + yield UP.Wait(60 * 12.0) + + +task_classes = { + "GoToWork": GoToWork, + "TalkToBoss": TalkToBoss, + "WaitInLane": WaitInLane, + "DoCheckout": DoCheckout, + "Break": Break, + "ShortBreak": ShortBreak, + "NightBreak": NightBreak, +} + +task_links = { + "GoToWork": UP.TaskLinks(default="TalkToBoss", allowed=["TalkToBoss"]), + "TalkToBoss": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), + "WaitInLane": UP.TaskLinks(default="DoCheckout", allowed=["DoCheckout", "Break"]), + "DoCheckout": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), + "Break": UP.TaskLinks(default="ShortBreak", allowed=["ShortBreak", "NightBreak"]), + "ShortBreak": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), + "NightBreak": UP.TaskLinks(default="GoToWork", allowed=["GoToWork"]), +} + + +cashier_task_network = UP.TaskNetworkFactory( + name="CashierJob", + task_classes=task_classes, + task_links=task_links, +) + + +def customer_spawner( + env: SIM.Environment, + lanes: list[CheckoutLane], +) -> Generator[SIM.Event, None, None]: + # sneaky way to get access to stage + stage = lanes[0].stage + while True: + hrs = env.now / 60 + time_of_day = hrs // 24 + if time_of_day <= 8 or time_of_day >= 15.5: + time_until_open = (24 - time_of_day) + 8 + yield env.timeout(time_until_open) + + lane_pick = stage.random.choice(lanes) + number_pick = stage.random.randint(3, 17) + yield lane_pick.customer_queue.put(number_pick) + yield UP.Wait.from_random_uniform(5.0, 30.0).as_event() + + +def test_cashier_example() -> None: + with UP.EnvironmentContext(initial_time=8 * 60) as env: + UP.add_stage_variable("time_unit", "min") + cashier = Cashier( + name="Bob", + scan_speed=1.0, + time_until_break=120.0, + breaks_until_done=4, + debug_log=True, + ) + lane_1 = CheckoutLane(name="Lane 1") + lane_2 = CheckoutLane(name="Lane 2") + boss = StoreBoss(lanes=[lane_1, lane_2]) + + UP.add_stage_variable("boss", boss) + + net = cashier_task_network.make_network() + cashier.add_task_network(net) + cashier.start_network_loop(net.name, "GoToWork") + + customer_proc = customer_spawner(env, [lane_1, lane_2]) + _ = env.process(customer_proc) + + env.run(until=20 * 60) + + for line in cashier.get_log(): + print(line) + + +if __name__ == "__main__": + test_cashier_example() diff --git a/src/upstage/test/test_docs_examples/test_cashier_complex.py b/src/upstage_des/test/test_docs_examples/test_cashier_complex.py similarity index 96% rename from src/upstage/test/test_docs_examples/test_cashier_complex.py rename to src/upstage_des/test/test_docs_examples/test_cashier_complex.py index 72f9b33..8a8bdcd 100644 --- a/src/upstage/test/test_docs_examples/test_cashier_complex.py +++ b/src/upstage_des/test/test_docs_examples/test_cashier_complex.py @@ -1,321 +1,321 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for license terms. - -from collections.abc import Generator -from typing import Any - -import simpy as SIM - -import upstage.api as UP -from upstage.task import InterruptStates -from upstage.type_help import SIMPY_GEN, TASK_GEN - - -class Cashier(UP.Actor): - scan_speed = UP.State[float]( - valid_types=(float,), - frozen=True, - ) - time_until_break = UP.State[float]( - default=120.0, - valid_types=(float,), - frozen=True, - ) - breaks_until_done = UP.State[int](default=2, valid_types=int) - breaks_taken = UP.State[int](default=0, valid_types=int, recording=True) - items_scanned = UP.State[int]( - default=0, - valid_types=(int,), - recording=True, - ) - time_scanning = UP.LinearChangingState( - default=0.0, - valid_types=(float,), - ) - messages = UP.ResourceState[UP.SelfMonitoringStore]( - default=UP.SelfMonitoringStore, - ) - - def time_left_to_break(self) -> float: - elapsed = self.env.now - float(self.get_knowledge("start_time", must_exist=True)) - return self.time_until_break - elapsed - - -class CheckoutLane(UP.Actor): - customer_queue = UP.ResourceState[UP.SelfMonitoringStore]( - default=UP.SelfMonitoringStore, - ) - - -class StoreBoss(UP.UpstageBase): - def __init__(self, lanes: list[CheckoutLane]) -> None: - self.lanes = lanes - self._lane_map: dict[CheckoutLane, Cashier] = {} - - def get_lane(self, cashier: Cashier) -> CheckoutLane: - possible = [lane for lane in self.lanes if lane not in self._lane_map] - lane = self.stage.random.choice(possible) - self._lane_map[lane] = cashier - return lane - - def clear_lane(self, cashier: Cashier) -> None: - to_del = [name for name, cash in self._lane_map.items() if cash is cashier] - for name in to_del: - del self._lane_map[name] - - -class CashierBreakTimer(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - yield UP.Wait(actor.time_until_break) - actor.interrupt_network("CashierJob", cause=dict(reason="BREAK TIME")) - - -class InterruptibleTask(UP.Task): - def on_interrupt(self, *, actor: Cashier, cause: dict[str, Any]) -> InterruptStates: - # We will only interrupt with a dictionary of data - assert isinstance(cause, dict) - job_list: list[str] - - if cause["reason"] == "BREAK TIME": - job_list = ["Break"] - elif cause["reason"] == "NEW JOB": - job_list = cause["job_list"] - else: - raise UP.SimulationError("Unexpected interrupt cause") - - # determine time until break - time_left = actor.time_left_to_break() - # if there are only five minutes left, take the break and queue the task. - if time_left <= 5.0 and "Break" not in job_list: - job_list = ["Break"] + job_list - - # Ignore the interrupt, unless we've marked it to know otherwise - marker = self.get_marker() or "none" - if marker == "on break": - if "Break" in job_list: - job_list.remove("Break") - - self.clear_actor_task_queue(actor) - self.set_actor_task_queue(actor, job_list) - if marker == "cancellable": - return self.INTERRUPT.END - return self.INTERRUPT.IGNORE - - -class GoToWork(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Go to work""" - yield UP.Wait(15.0) - - -class TalkToBoss(UP.DecisionTask): - def make_decision(self, *, actor: Cashier) -> None: - """Zero-time task to get information.""" - boss: StoreBoss = self.stage.boss - lane = boss.get_lane(actor) - self.set_actor_knowledge(actor, "checkout_lane", lane, overwrite=False) - actor.breaks_taken = 0 - self.set_actor_knowledge(actor, "start_time", self.env.now, overwrite=True) - # Convenient spot to run the timer. - CashierBreakTimer().run(actor=actor) - - -class WaitInLane(InterruptibleTask): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Wait until break time, or a customer.""" - lane: CheckoutLane = self.get_actor_knowledge( - actor, - "checkout_lane", - must_exist=True, - ) - customer_arrival = UP.Get(lane.customer_queue) - - self.set_marker(marker="cancellable") - yield customer_arrival - - customer: int = customer_arrival.get_value() - self.set_actor_knowledge(actor, "customer", customer, overwrite=True) - - -class DoCheckout(InterruptibleTask): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Do the checkout""" - items: int = self.get_actor_knowledge( - actor, - "customer", - must_exist=True, - ) - per_item_time = actor.scan_speed / items - actor.activate_linear_state( - state="time_scanning", - rate=1.0, - task=self, - ) - for _ in range(items): - yield UP.Wait(per_item_time) - actor.items_scanned += 1 - actor.deactivate_all_states(task=self) - # assume 2 minutes to take payment - yield UP.Wait(2.0) - - -class Break(UP.DecisionTask): - def make_decision(self, *, actor: Cashier) -> None: - """Decide what kind of break we are taking.""" - actor.breaks_taken += 1 - - # we might have jobs queued - queue = self.get_actor_task_queue(actor) or [] - if "Break" in queue: - raise UP.SimulationError("Odd task network state") - self.clear_actor_task_queue(actor) - - if actor.breaks_taken == actor.breaks_until_done: - self.set_actor_task_queue(actor, ["NightBreak"]) - elif actor.breaks_taken > actor.breaks_until_done: - raise UP.SimulationError("Too many breaks taken") - else: - self.set_actor_task_queue(actor, ["ShortBreak"] + queue) - - -class ShortBreak(InterruptibleTask): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Take a short break.""" - self.set_marker("on break") - yield UP.Wait(15.0) - self.set_actor_knowledge(actor, "start_time", self.env.now, overwrite=True) - CashierBreakTimer().run(actor=actor) - - -class NightBreak(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Go home and rest.""" - self.clear_actor_knowledge(actor, "checkout_lane") - self.stage.boss.clear_lane(actor) - yield UP.Wait(60 * 12.0) - - -class Restock(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - """Restock.""" - yield UP.Wait(10.0) - - -task_classes = { - "GoToWork": GoToWork, - "TalkToBoss": TalkToBoss, - "WaitInLane": WaitInLane, - "DoCheckout": DoCheckout, - "Break": Break, - "ShortBreak": ShortBreak, - "NightBreak": NightBreak, - "Restock": Restock, -} - -task_links = { - "GoToWork": UP.TaskLinks(default="TalkToBoss", allowed=["TalkToBoss"]), - "TalkToBoss": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), - "WaitInLane": UP.TaskLinks(default="DoCheckout", allowed=["DoCheckout", "Break"]), - "DoCheckout": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane", "Break"]), - "Break": UP.TaskLinks(default="ShortBreak", allowed=["ShortBreak", "NightBreak"]), - "ShortBreak": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), - "NightBreak": UP.TaskLinks(default="GoToWork", allowed=["GoToWork"]), - "Restock": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane", "Break"]), -} - -cashier_task_network = UP.TaskNetworkFactory( - name="CashierJob", - task_classes=task_classes, - task_links=task_links, -) - - -class CashierMessages(UP.Task): - def task(self, *, actor: Cashier) -> TASK_GEN: - getter = UP.Get(actor.messages) - yield getter - tasks_needed: list[str] | str = getter.get_value() - tasks_needed = [tasks_needed] if isinstance(tasks_needed, str) else tasks_needed - actor.interrupt_network("CashierJob", cause=dict(reason="NEW JOB", job_list=tasks_needed)) - - -cashier_message_net = UP.TaskNetworkFactory.from_single_looping("Messages", CashierMessages) - - -def customer_spawner( - env: SIM.Environment, - lanes: list[CheckoutLane], -) -> Generator[SIM.Event, None, None]: - # sneaky way to get access to stage - stage = lanes[0].stage - while True: - hrs = env.now / 60 - time_of_day = hrs // 24 - if time_of_day <= 8 or time_of_day >= 15.5: - time_until_open = (24 - time_of_day) + 8 - yield env.timeout(time_until_open) - - lane_pick = stage.random.choice(lanes) - number_pick = stage.random.randint(3, 17) - yield lane_pick.customer_queue.put(number_pick) - yield UP.Wait.from_random_uniform(5.0, 30.0).as_event() - - -def manager_process(boss: StoreBoss, cashiers: list[Cashier]) -> SIMPY_GEN: - while True: - # Use the random uniform feature, but convert the UPSTAGE event to simpy - # because this is a simpy only process - yield UP.Wait.from_random_uniform(30.0, 90.0).as_event() - possible = [ - cash - for cash in cashiers - if getattr(cash.get_running_task("CashierJob"), "name", "") != "NightBreak" - ] - if not possible: - return - cash = boss.stage.random.choice(possible) - yield cash.messages.put(["Restock"]) - - -def test_cashier_example() -> None: - with UP.EnvironmentContext(initial_time=8 * 60) as env: - UP.add_stage_variable("time_unit", "min") - cashier = Cashier( - name="Bob", - scan_speed=1.0, - time_until_break=120.0, - breaks_until_done=4, - debug_log=True, - ) - lane_1 = CheckoutLane(name="Lane 1") - lane_2 = CheckoutLane(name="Lane 2") - boss = StoreBoss(lanes=[lane_1, lane_2]) - - UP.add_stage_variable("boss", boss) - - net = cashier_task_network.make_network() - cashier.add_task_network(net) - cashier.start_network_loop(net.name, "GoToWork") - - net = cashier_message_net.make_network() - cashier.add_task_network(net) - cashier.start_network_loop(net.name, "CashierMessages") - - customer_proc = customer_spawner(env, [lane_1, lane_2]) - _ = env.process(customer_proc) - - _ = env.process(manager_process(boss, [cashier])) - - env.run(until=20 * 60) - - for line in cashier.get_log(): - if "Interrupt" in line: - print(line) - - print(cashier.items_scanned) - - -if __name__ == "__main__": - test_cashier_example() +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for license terms. + +from collections.abc import Generator +from typing import Any + +import simpy as SIM + +import upstage_des.api as UP +from upstage_des.task import InterruptStates +from upstage_des.type_help import SIMPY_GEN, TASK_GEN + + +class Cashier(UP.Actor): + scan_speed = UP.State[float]( + valid_types=(float,), + frozen=True, + ) + time_until_break = UP.State[float]( + default=120.0, + valid_types=(float,), + frozen=True, + ) + breaks_until_done = UP.State[int](default=2, valid_types=int) + breaks_taken = UP.State[int](default=0, valid_types=int, recording=True) + items_scanned = UP.State[int]( + default=0, + valid_types=(int,), + recording=True, + ) + time_scanning = UP.LinearChangingState( + default=0.0, + valid_types=(float,), + ) + messages = UP.ResourceState[UP.SelfMonitoringStore]( + default=UP.SelfMonitoringStore, + ) + + def time_left_to_break(self) -> float: + elapsed = self.env.now - float(self.get_knowledge("start_time", must_exist=True)) + return self.time_until_break - elapsed + + +class CheckoutLane(UP.Actor): + customer_queue = UP.ResourceState[UP.SelfMonitoringStore]( + default=UP.SelfMonitoringStore, + ) + + +class StoreBoss(UP.UpstageBase): + def __init__(self, lanes: list[CheckoutLane]) -> None: + self.lanes = lanes + self._lane_map: dict[CheckoutLane, Cashier] = {} + + def get_lane(self, cashier: Cashier) -> CheckoutLane: + possible = [lane for lane in self.lanes if lane not in self._lane_map] + lane = self.stage.random.choice(possible) + self._lane_map[lane] = cashier + return lane + + def clear_lane(self, cashier: Cashier) -> None: + to_del = [name for name, cash in self._lane_map.items() if cash is cashier] + for name in to_del: + del self._lane_map[name] + + +class CashierBreakTimer(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + yield UP.Wait(actor.time_until_break) + actor.interrupt_network("CashierJob", cause=dict(reason="BREAK TIME")) + + +class InterruptibleTask(UP.Task): + def on_interrupt(self, *, actor: Cashier, cause: dict[str, Any]) -> InterruptStates: + # We will only interrupt with a dictionary of data + assert isinstance(cause, dict) + job_list: list[str] + + if cause["reason"] == "BREAK TIME": + job_list = ["Break"] + elif cause["reason"] == "NEW JOB": + job_list = cause["job_list"] + else: + raise UP.SimulationError("Unexpected interrupt cause") + + # determine time until break + time_left = actor.time_left_to_break() + # if there are only five minutes left, take the break and queue the task. + if time_left <= 5.0 and "Break" not in job_list: + job_list = ["Break"] + job_list + + # Ignore the interrupt, unless we've marked it to know otherwise + marker = self.get_marker() or "none" + if marker == "on break": + if "Break" in job_list: + job_list.remove("Break") + + self.clear_actor_task_queue(actor) + self.set_actor_task_queue(actor, job_list) + if marker == "cancellable": + return self.INTERRUPT.END + return self.INTERRUPT.IGNORE + + +class GoToWork(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Go to work""" + yield UP.Wait(15.0) + + +class TalkToBoss(UP.DecisionTask): + def make_decision(self, *, actor: Cashier) -> None: + """Zero-time task to get information.""" + boss: StoreBoss = self.stage.boss + lane = boss.get_lane(actor) + self.set_actor_knowledge(actor, "checkout_lane", lane, overwrite=False) + actor.breaks_taken = 0 + self.set_actor_knowledge(actor, "start_time", self.env.now, overwrite=True) + # Convenient spot to run the timer. + CashierBreakTimer().run(actor=actor) + + +class WaitInLane(InterruptibleTask): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Wait until break time, or a customer.""" + lane: CheckoutLane = self.get_actor_knowledge( + actor, + "checkout_lane", + must_exist=True, + ) + customer_arrival = UP.Get(lane.customer_queue) + + self.set_marker(marker="cancellable") + yield customer_arrival + + customer: int = customer_arrival.get_value() + self.set_actor_knowledge(actor, "customer", customer, overwrite=True) + + +class DoCheckout(InterruptibleTask): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Do the checkout""" + items: int = self.get_actor_knowledge( + actor, + "customer", + must_exist=True, + ) + per_item_time = actor.scan_speed / items + actor.activate_linear_state( + state="time_scanning", + rate=1.0, + task=self, + ) + for _ in range(items): + yield UP.Wait(per_item_time) + actor.items_scanned += 1 + actor.deactivate_all_states(task=self) + # assume 2 minutes to take payment + yield UP.Wait(2.0) + + +class Break(UP.DecisionTask): + def make_decision(self, *, actor: Cashier) -> None: + """Decide what kind of break we are taking.""" + actor.breaks_taken += 1 + + # we might have jobs queued + queue = self.get_actor_task_queue(actor) or [] + if "Break" in queue: + raise UP.SimulationError("Odd task network state") + self.clear_actor_task_queue(actor) + + if actor.breaks_taken == actor.breaks_until_done: + self.set_actor_task_queue(actor, ["NightBreak"]) + elif actor.breaks_taken > actor.breaks_until_done: + raise UP.SimulationError("Too many breaks taken") + else: + self.set_actor_task_queue(actor, ["ShortBreak"] + queue) + + +class ShortBreak(InterruptibleTask): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Take a short break.""" + self.set_marker("on break") + yield UP.Wait(15.0) + self.set_actor_knowledge(actor, "start_time", self.env.now, overwrite=True) + CashierBreakTimer().run(actor=actor) + + +class NightBreak(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Go home and rest.""" + self.clear_actor_knowledge(actor, "checkout_lane") + self.stage.boss.clear_lane(actor) + yield UP.Wait(60 * 12.0) + + +class Restock(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + """Restock.""" + yield UP.Wait(10.0) + + +task_classes = { + "GoToWork": GoToWork, + "TalkToBoss": TalkToBoss, + "WaitInLane": WaitInLane, + "DoCheckout": DoCheckout, + "Break": Break, + "ShortBreak": ShortBreak, + "NightBreak": NightBreak, + "Restock": Restock, +} + +task_links = { + "GoToWork": UP.TaskLinks(default="TalkToBoss", allowed=["TalkToBoss"]), + "TalkToBoss": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), + "WaitInLane": UP.TaskLinks(default="DoCheckout", allowed=["DoCheckout", "Break"]), + "DoCheckout": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane", "Break"]), + "Break": UP.TaskLinks(default="ShortBreak", allowed=["ShortBreak", "NightBreak"]), + "ShortBreak": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane"]), + "NightBreak": UP.TaskLinks(default="GoToWork", allowed=["GoToWork"]), + "Restock": UP.TaskLinks(default="WaitInLane", allowed=["WaitInLane", "Break"]), +} + +cashier_task_network = UP.TaskNetworkFactory( + name="CashierJob", + task_classes=task_classes, + task_links=task_links, +) + + +class CashierMessages(UP.Task): + def task(self, *, actor: Cashier) -> TASK_GEN: + getter = UP.Get(actor.messages) + yield getter + tasks_needed: list[str] | str = getter.get_value() + tasks_needed = [tasks_needed] if isinstance(tasks_needed, str) else tasks_needed + actor.interrupt_network("CashierJob", cause=dict(reason="NEW JOB", job_list=tasks_needed)) + + +cashier_message_net = UP.TaskNetworkFactory.from_single_looping("Messages", CashierMessages) + + +def customer_spawner( + env: SIM.Environment, + lanes: list[CheckoutLane], +) -> Generator[SIM.Event, None, None]: + # sneaky way to get access to stage + stage = lanes[0].stage + while True: + hrs = env.now / 60 + time_of_day = hrs // 24 + if time_of_day <= 8 or time_of_day >= 15.5: + time_until_open = (24 - time_of_day) + 8 + yield env.timeout(time_until_open) + + lane_pick = stage.random.choice(lanes) + number_pick = stage.random.randint(3, 17) + yield lane_pick.customer_queue.put(number_pick) + yield UP.Wait.from_random_uniform(5.0, 30.0).as_event() + + +def manager_process(boss: StoreBoss, cashiers: list[Cashier]) -> SIMPY_GEN: + while True: + # Use the random uniform feature, but convert the UPSTAGE event to simpy + # because this is a simpy only process + yield UP.Wait.from_random_uniform(30.0, 90.0).as_event() + possible = [ + cash + for cash in cashiers + if getattr(cash.get_running_task("CashierJob"), "name", "") != "NightBreak" + ] + if not possible: + return + cash = boss.stage.random.choice(possible) + yield cash.messages.put(["Restock"]) + + +def test_cashier_example() -> None: + with UP.EnvironmentContext(initial_time=8 * 60) as env: + UP.add_stage_variable("time_unit", "min") + cashier = Cashier( + name="Bob", + scan_speed=1.0, + time_until_break=120.0, + breaks_until_done=4, + debug_log=True, + ) + lane_1 = CheckoutLane(name="Lane 1") + lane_2 = CheckoutLane(name="Lane 2") + boss = StoreBoss(lanes=[lane_1, lane_2]) + + UP.add_stage_variable("boss", boss) + + net = cashier_task_network.make_network() + cashier.add_task_network(net) + cashier.start_network_loop(net.name, "GoToWork") + + net = cashier_message_net.make_network() + cashier.add_task_network(net) + cashier.start_network_loop(net.name, "CashierMessages") + + customer_proc = customer_spawner(env, [lane_1, lane_2]) + _ = env.process(customer_proc) + + _ = env.process(manager_process(boss, [cashier])) + + env.run(until=20 * 60) + + for line in cashier.get_log(): + if "Interrupt" in line: + print(line) + + print(cashier.items_scanned) + + +if __name__ == "__main__": + test_cashier_example() diff --git a/src/upstage/test/test_docs_examples/test_nucleus_sharing.py b/src/upstage_des/test/test_docs_examples/test_nucleus_sharing.py similarity index 98% rename from src/upstage/test/test_docs_examples/test_nucleus_sharing.py rename to src/upstage_des/test/test_docs_examples/test_nucleus_sharing.py index 2882692..ccd7f9e 100644 --- a/src/upstage/test/test_docs_examples/test_nucleus_sharing.py +++ b/src/upstage_des/test/test_docs_examples/test_nucleus_sharing.py @@ -5,8 +5,8 @@ import simpy as SIM -import upstage.api as UP -from upstage.type_help import SIMPY_GEN, TASK_GEN +import upstage_des.api as UP +from upstage_des.type_help import SIMPY_GEN, TASK_GEN class CPU(UP.Actor): diff --git a/src/upstage/test/test_docs_examples/test_rehearsing_example.py b/src/upstage_des/test/test_docs_examples/test_rehearsing_example.py similarity index 97% rename from src/upstage/test/test_docs_examples/test_rehearsing_example.py rename to src/upstage_des/test/test_docs_examples/test_rehearsing_example.py index 1f0554f..023abbe 100644 --- a/src/upstage/test/test_docs_examples/test_rehearsing_example.py +++ b/src/upstage_des/test/test_docs_examples/test_rehearsing_example.py @@ -5,9 +5,9 @@ import pytest -import upstage.api as UP -from upstage.type_help import TASK_GEN -from upstage.utils import waypoint_time_and_dist +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN +from upstage_des.utils import waypoint_time_and_dist class Plane(UP.Actor): diff --git a/src/upstage/test/test_event.py b/src/upstage_des/test/test_event.py similarity index 96% rename from src/upstage/test/test_event.py rename to src/upstage_des/test/test_event.py index 799e8fd..fde0c50 100644 --- a/src/upstage/test/test_event.py +++ b/src/upstage_des/test/test_event.py @@ -1,466 +1,466 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -import pytest -import simpy as SIM -from simpy.resources import base -from simpy.resources.container import ContainerGet, ContainerPut -from simpy.resources.store import StoreGet, StorePut - -from upstage.api import Actor, EnvironmentContext, SimulationError, State, Task -from upstage.events import ( - All, - Any, - BaseEvent, - BaseRequestEvent, - Event, - Get, - Put, - ResourceHold, - Wait, -) -from upstage.type_help import SIMPY_GEN, TASK_GEN - - -def test_base_event() -> None: - init_time = 1.23 - with EnvironmentContext(initial_time=init_time) as env: - base = BaseEvent() - assert base.created_at == init_time, "Problem in environment time being stored in event" - assert base.env is env, "Problem in environment being stored in event" - - with pytest.raises(NotImplementedError): - base.as_event() - - -def test_wait_event() -> None: - init_time = 1.23 - with EnvironmentContext(initial_time=init_time) as env: - timeout = 1 - - wait = Wait(timeout=timeout) - assert wait.created_at == init_time, "Problem in environment time being stored in event" - assert wait.env is env, "Problem in environment being stored in event" - assert wait.timeout == timeout - - ret = wait.as_event() - assert isinstance(ret, SIM.Timeout), "Wait doesn't return a simpy timeout" - assert ret._delay == timeout, "Incorrect timeout time" - - with EnvironmentContext(initial_time=init_time) as env: - timeout_2 = [1, 3] - wait = Wait.from_random_uniform(*timeout_2) - 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" - - with pytest.raises(SimulationError): - Wait(timeout={1, 2}) # type: ignore [arg-type] - - with pytest.raises(SimulationError): - Wait(timeout="1") # type: ignore [arg-type] - - with pytest.raises(SimulationError): - Wait(timeout=[1]) # type: ignore [arg-type] - - with pytest.raises(SimulationError): - Wait(timeout=[1, 2, 3]) # type: ignore [arg-type] - - -def test_base_request_event() -> None: - init_time = 1.23 - with EnvironmentContext(initial_time=init_time) as env: - base = BaseRequestEvent() - assert base.created_at == init_time, "Problem in environment time being stored in event" - assert base.env is env, "Problem in environment being stored in event" - - base.cancel() - - -def test_put_event_with_stores() -> None: - with EnvironmentContext() as env: - store = SIM.Store(env, capacity=1) - put_object = ("A Test Object", 1.0) - put_event = Put(store, put_object) - - assert put_event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" - returned_object = put_event.as_event() - assert issubclass( - returned_object.__class__, base.Put - ), "Event returned is not simpy put event" - env.run() - assert isinstance(returned_object, StorePut) - assert returned_object.item is put_object, "Wrong object put" - assert put_object in store.items - - put_object = ("A Second Test Object", 2.0) - put_event = Put(store, put_object) - event = put_event.as_event() - env.run() - assert not event.triggered, "Event shouldn't have completed" - assert event in store.put_queue, "Event is not waiting" - put_event.cancel() - env.run() - assert not event.triggered, "Event shouldn't have completed" - assert event not in store.put_queue, "Event is still in the store's queue" - - -def test_put_event_with_containers() -> None: - with EnvironmentContext() as env: - container = SIM.Container(env, capacity=1) - put_arg = 1.0 - put_event = Put(container, put_arg) - - assert put_event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" - returned_object = put_event.as_event() - assert issubclass( - returned_object.__class__, base.Put - ), "Event returned is not simpy put event" - env.run() - assert isinstance(returned_object, ContainerPut) - assert returned_object.amount == put_arg, "Wrong amount put" - assert container.level == put_arg - - put_arg = 2 - put_event = Put(container, put_arg) - event = put_event.as_event() - env.run() - assert not event.triggered, "Event shouldn't have completed" - assert event in container.put_queue, "Event is not waiting" - put_event.cancel() - env.run() - assert not event.triggered, "Event shouldn't have completed" - assert event not in container.put_queue, "Event is still in the store's queue" - - -def test_get_event_with_stores() -> None: - with EnvironmentContext() as env: - store = SIM.Store(env, capacity=1) - put_object = ("A Test Object", 1.0) - store.put(put_object) - env.run() - - event = Get(store) - assert event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" - returned_object = event.as_event() - assert issubclass( - returned_object.__class__, base.Get - ), "Event returned is not simpy put event" - - env.run() - assert isinstance(event._request_event, StoreGet) - item = event._request_event.value - assert item is put_object, "Returned item is not the original item" - item2 = event.get_value() - assert item is item2, "Same object from both methods" - - event = Get(store) - returned_object = event.as_event() - env.run() - assert returned_object in store.get_queue, "Event not in queue" - event.cancel() - assert returned_object not in store.get_queue, "Event is still in queue" - - -def test_get_event_with_containers() -> None: - with EnvironmentContext() as env: - container = SIM.Container(env, capacity=1) - put_arg = 1.0 - container.put(put_arg) - env.run() - - get_arg = 1.0 - event = Get(container, get_arg) - assert event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" - returned_object = event.as_event() - assert issubclass( - returned_object.__class__, base.Get - ), "Event returned is not simpy put event" - - env.run() - assert isinstance(event._request_event, ContainerGet) - amount = event._request_event.amount - assert amount == get_arg, "Returned item is not the original item" - with pytest.raises( - SimulationError, - match="'get_value' is not supported for Containers. " - "Check is_complete and use the amount you " - "requested", - ): - event.get_value() - - event = Get(container, get_arg) - returned_object = event.as_event() - env.run() - assert returned_object in container.get_queue, "Event not in queue" - event.cancel() - assert returned_object not in container.get_queue, "Event is still in queue" - - -def test_resource_events() -> None: - with EnvironmentContext() as env: - a_resource = SIM.Resource(env, capacity=1) - - request_object = ResourceHold(a_resource) - assert request_object._stage == "request", "Request object in wrong state" - request_object.as_event() - env.run() - - assert request_object._stage == "release", "Request object in wrong state" - assert a_resource.users[0] is request_object._request, "The user is the request object" - - new_request = ResourceHold(a_resource) - assert new_request._stage == "request", "Request object in wrong state" - new_request.as_event() - env.run() - - assert new_request._stage == "release", "Request object in wrong state" - assert new_request._request is not None - assert not new_request._request.processed, "Request went through when it shouldn't" - - # put the old one back - request_object.as_event() - env.run() - assert new_request._request is not None - assert new_request._request.processed, "Follow-on request didn't go through" - - newest_request = ResourceHold(a_resource) - assert newest_request._stage == "request", "Request object in wrong state" - newest_request.as_event() - env.run() - - # cancel it - assert newest_request._stage == "release", "Request object in wrong state" - assert newest_request._request is not None - assert not newest_request._request.processed, "Request went through when it shouldn't" - - assert ( - newest_request._request in a_resource.put_queue - ), "Resource isn't waiting to be gathered" - with pytest.raises(SimulationError, match="Resource release requested.*?"): - newest_request.as_event() - - newest_request.cancel() - env.run() - assert ( - newest_request._request not in a_resource.put_queue - ), "Resource hasn't left the wait queue" - - -def test_multi_event() -> None: - with EnvironmentContext() as env: - event1 = Wait(1.0) - event2 = Wait(1.5) - event = All(event1, event2) - assert event.calculate_time_to_complete() == 1.5 - - with EnvironmentContext() as env: - with pytest.warns(UserWarning): - event1 = Wait(1.0) - event3 = SIM.Timeout(env, 1.5) - All(event1, event3) # type: ignore [arg-type] - - -def test_and_event() -> None: - with EnvironmentContext() as env: - - def run(env: SIM.Environment, data: dict[str, float]) -> SIMPY_GEN: - event1 = Wait(1.0) - event2 = Wait(1.5) - - event = All(event1, event2) - yield event.as_event() - data["time"] = env.now - - data: dict[str, float] = {} - env.process(run(env, data)) - env.run() - assert data["time"] == 1.5 - - -def test_or_event() -> None: - with EnvironmentContext() as env: - data = { - "time": 0.0, - } - - def run(env: SIM.Environment) -> SIMPY_GEN: - event1 = Wait(1.0) - event2 = Wait(1.5) - - event = Any(event1, event2) - yield event.as_event() - data["time"] = env.now - - env.process(run(env)) - env.run() - # SimPy still runs the simulation long enough to finish the timeout - assert data["time"] == 1.0 - - -def test_composite() -> None: - with EnvironmentContext() as env: - data = { - "time": 0.0, - "result": 0, - } - - def run(env: SIM.Environment) -> SIMPY_GEN: - event1 = Wait(1.0) - event2 = Wait(1.5) - - event3 = Wait(2.1) - event4 = Wait(0.9) - - event_a = Any(event1, event2) - event_b = All(event3, event4, event_a) - result = yield event_b.as_event() - data["time"] = env.now - data["result"] = len(result.events) - - env.process(run(env)) - env.run() - assert data["time"] == 2.1 - assert data["result"] == 4 - - -def test_process_in_multi() -> None: - with EnvironmentContext() as env: - - def a_process() -> SIMPY_GEN: - yield env.timeout(2) - - class Thing(Actor): - result = State[dict]() - events = State[list]() - - class TheTask(Task): - def task(self, *, actor: Thing) -> TASK_GEN: - wait = Wait(3.0) - proc = env.process(a_process()) - res = yield Any(wait, proc) - actor.events = [wait, proc] - actor.result = res - - t = Thing(name="Thing", result=None, events=None) - task = TheTask() - task.run(actor=t) - with pytest.warns(UserWarning): - env.run() - assert t.events[-1] in t.result - assert t.events[0] not in t.result - - -def test_rehearse_process_in_multi() -> None: - with EnvironmentContext() as env: - - def a_process() -> SIMPY_GEN: - yield env.timeout(2) - - class Thing(Actor): ... - - class TheTask(Task): - def task(self, *, actor: Thing) -> TASK_GEN: - wait = Wait(3.0) - proc = env.process(a_process()) - yield Any(wait, proc) - - t = Thing(name="Thing") - task = TheTask() - with pytest.raises(SimulationError, match="All events in a MultiEvent"): - with pytest.warns(UserWarning): - task.rehearse(actor=t) - - -def run_one(env: SIM.Environment, event: Event) -> SIMPY_GEN: - yield env.timeout(1.0) - event.succeed(data="here") - - -def run_two(env: SIM.Environment, event: Event, data: dict[str, float]) -> SIMPY_GEN: - yield event._event - data["time_two"] = env.now - - -def run_three(env: SIM.Environment, event: Event, data: dict[str, float]) -> SIMPY_GEN: - yield event._event - data["time_three"] = env.now - - -def run_four(env: SIM.Environment, event: Event) -> SIMPY_GEN: - yield env.timeout(1.1) - event.succeed() - - -def run_four_alt(env: SIM.Environment, event: Event) -> SIMPY_GEN: - yield env.timeout(1.1) - event.succeed() - - -def run_five(env: SIM.Environment, event: Event, data: dict[str, float]) -> SIMPY_GEN: - # Timeout until after the event suceeded, but before its reset - yield env.timeout(1.05) - yield event.as_event() - data["time_five"] = env.now - - -def test_basic_usage() -> None: - with EnvironmentContext() as env: - event = Event() - assert event._event is not None - assert isinstance(event._event, SIM.Event) - assert event._event is event.as_event() - assert event.is_complete() is False - - env.process(run_one(env, event)) - data: dict[str, float] = {} - env.process(run_two(env, event, data)) - env.run() - assert data["time_two"] == 1.0 - assert event.is_complete() - payload = event.get_payload() - assert payload == {"data": "here"} - - with pytest.raises(SimulationError): - event.succeed() - - assert event.calculate_time_to_complete() == 0.0 - - last_event = event._event - event.reset() - assert last_event is not event._event - - -def test_conflicts() -> None: - data: dict[str, float] = {} - with EnvironmentContext() as env: - event = Event() - env.process(run_one(env, event)) - env.process(run_two(env, event, data)) - env.process(run_three(env, event, data)) - env.run() - assert data["time_two"] == data["time_three"] - - data: dict[str, float] = {} - with EnvironmentContext() as env: - with pytest.raises(SimulationError): - event = Event() - env.process(run_one(env, event)) - env.process(run_two(env, event, data)) - env.process(run_three(env, event, data)) - env.process(run_four(env, event)) - env.run() - - data: dict[str, float] = {} - with EnvironmentContext() as env: - event = Event() - env.process(run_one(env, event)) - env.process(run_two(env, event, data)) - env.process(run_three(env, event, data)) - env.process(run_four_alt(env, event)) - env.process(run_five(env, event, data)) - env.run() - assert data["time_two"] == data["time_three"] - assert data["time_five"] == 1.1 +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +import pytest +import simpy as SIM +from simpy.resources import base +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.events import ( + All, + Any, + BaseEvent, + BaseRequestEvent, + Event, + Get, + Put, + ResourceHold, + Wait, +) +from upstage_des.type_help import SIMPY_GEN, TASK_GEN + + +def test_base_event() -> None: + init_time = 1.23 + with EnvironmentContext(initial_time=init_time) as env: + base = BaseEvent() + assert base.created_at == init_time, "Problem in environment time being stored in event" + assert base.env is env, "Problem in environment being stored in event" + + with pytest.raises(NotImplementedError): + base.as_event() + + +def test_wait_event() -> None: + init_time = 1.23 + with EnvironmentContext(initial_time=init_time) as env: + timeout = 1 + + wait = Wait(timeout=timeout) + assert wait.created_at == init_time, "Problem in environment time being stored in event" + assert wait.env is env, "Problem in environment being stored in event" + assert wait.timeout == timeout + + ret = wait.as_event() + assert isinstance(ret, SIM.Timeout), "Wait doesn't return a simpy timeout" + assert ret._delay == timeout, "Incorrect timeout time" + + with EnvironmentContext(initial_time=init_time) as env: + timeout_2 = [1, 3] + wait = Wait.from_random_uniform(*timeout_2) + 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" + + with pytest.raises(SimulationError): + Wait(timeout={1, 2}) # type: ignore [arg-type] + + with pytest.raises(SimulationError): + Wait(timeout="1") # type: ignore [arg-type] + + with pytest.raises(SimulationError): + Wait(timeout=[1]) # type: ignore [arg-type] + + with pytest.raises(SimulationError): + Wait(timeout=[1, 2, 3]) # type: ignore [arg-type] + + +def test_base_request_event() -> None: + init_time = 1.23 + with EnvironmentContext(initial_time=init_time) as env: + base = BaseRequestEvent() + assert base.created_at == init_time, "Problem in environment time being stored in event" + assert base.env is env, "Problem in environment being stored in event" + + base.cancel() + + +def test_put_event_with_stores() -> None: + with EnvironmentContext() as env: + store = SIM.Store(env, capacity=1) + put_object = ("A Test Object", 1.0) + put_event = Put(store, put_object) + + assert put_event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" + returned_object = put_event.as_event() + assert issubclass( + returned_object.__class__, base.Put + ), "Event returned is not simpy put event" + env.run() + assert isinstance(returned_object, StorePut) + assert returned_object.item is put_object, "Wrong object put" + assert put_object in store.items + + put_object = ("A Second Test Object", 2.0) + put_event = Put(store, put_object) + event = put_event.as_event() + env.run() + assert not event.triggered, "Event shouldn't have completed" + assert event in store.put_queue, "Event is not waiting" + put_event.cancel() + env.run() + assert not event.triggered, "Event shouldn't have completed" + assert event not in store.put_queue, "Event is still in the store's queue" + + +def test_put_event_with_containers() -> None: + with EnvironmentContext() as env: + container = SIM.Container(env, capacity=1) + put_arg = 1.0 + put_event = Put(container, put_arg) + + assert put_event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" + returned_object = put_event.as_event() + assert issubclass( + returned_object.__class__, base.Put + ), "Event returned is not simpy put event" + env.run() + assert isinstance(returned_object, ContainerPut) + assert returned_object.amount == put_arg, "Wrong amount put" + assert container.level == put_arg + + put_arg = 2 + put_event = Put(container, put_arg) + event = put_event.as_event() + env.run() + assert not event.triggered, "Event shouldn't have completed" + assert event in container.put_queue, "Event is not waiting" + put_event.cancel() + env.run() + assert not event.triggered, "Event shouldn't have completed" + assert event not in container.put_queue, "Event is still in the store's queue" + + +def test_get_event_with_stores() -> None: + with EnvironmentContext() as env: + store = SIM.Store(env, capacity=1) + put_object = ("A Test Object", 1.0) + store.put(put_object) + env.run() + + event = Get(store) + assert event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" + returned_object = event.as_event() + assert issubclass( + returned_object.__class__, base.Get + ), "Event returned is not simpy put event" + + env.run() + assert isinstance(event._request_event, StoreGet) + item = event._request_event.value + assert item is put_object, "Returned item is not the original item" + item2 = event.get_value() + assert item is item2, "Same object from both methods" + + event = Get(store) + returned_object = event.as_event() + env.run() + assert returned_object in store.get_queue, "Event not in queue" + event.cancel() + assert returned_object not in store.get_queue, "Event is still in queue" + + +def test_get_event_with_containers() -> None: + with EnvironmentContext() as env: + container = SIM.Container(env, capacity=1) + put_arg = 1.0 + container.put(put_arg) + env.run() + + get_arg = 1.0 + event = Get(container, get_arg) + assert event.calculate_time_to_complete() == 0.0, "Incorrect time to complete" + returned_object = event.as_event() + assert issubclass( + returned_object.__class__, base.Get + ), "Event returned is not simpy put event" + + env.run() + assert isinstance(event._request_event, ContainerGet) + amount = event._request_event.amount + assert amount == get_arg, "Returned item is not the original item" + with pytest.raises( + SimulationError, + match="'get_value' is not supported for Containers. " + "Check is_complete and use the amount you " + "requested", + ): + event.get_value() + + event = Get(container, get_arg) + returned_object = event.as_event() + env.run() + assert returned_object in container.get_queue, "Event not in queue" + event.cancel() + assert returned_object not in container.get_queue, "Event is still in queue" + + +def test_resource_events() -> None: + with EnvironmentContext() as env: + a_resource = SIM.Resource(env, capacity=1) + + request_object = ResourceHold(a_resource) + assert request_object._stage == "request", "Request object in wrong state" + request_object.as_event() + env.run() + + assert request_object._stage == "release", "Request object in wrong state" + assert a_resource.users[0] is request_object._request, "The user is the request object" + + new_request = ResourceHold(a_resource) + assert new_request._stage == "request", "Request object in wrong state" + new_request.as_event() + env.run() + + assert new_request._stage == "release", "Request object in wrong state" + assert new_request._request is not None + assert not new_request._request.processed, "Request went through when it shouldn't" + + # put the old one back + request_object.as_event() + env.run() + assert new_request._request is not None + assert new_request._request.processed, "Follow-on request didn't go through" + + newest_request = ResourceHold(a_resource) + assert newest_request._stage == "request", "Request object in wrong state" + newest_request.as_event() + env.run() + + # cancel it + assert newest_request._stage == "release", "Request object in wrong state" + assert newest_request._request is not None + assert not newest_request._request.processed, "Request went through when it shouldn't" + + assert ( + newest_request._request in a_resource.put_queue + ), "Resource isn't waiting to be gathered" + with pytest.raises(SimulationError, match="Resource release requested.*?"): + newest_request.as_event() + + newest_request.cancel() + env.run() + assert ( + newest_request._request not in a_resource.put_queue + ), "Resource hasn't left the wait queue" + + +def test_multi_event() -> None: + with EnvironmentContext() as env: + event1 = Wait(1.0) + event2 = Wait(1.5) + event = All(event1, event2) + assert event.calculate_time_to_complete() == 1.5 + + with EnvironmentContext() as env: + with pytest.warns(UserWarning): + event1 = Wait(1.0) + event3 = SIM.Timeout(env, 1.5) + All(event1, event3) # type: ignore [arg-type] + + +def test_and_event() -> None: + with EnvironmentContext() as env: + + def run(env: SIM.Environment, data: dict[str, float]) -> SIMPY_GEN: + event1 = Wait(1.0) + event2 = Wait(1.5) + + event = All(event1, event2) + yield event.as_event() + data["time"] = env.now + + data: dict[str, float] = {} + env.process(run(env, data)) + env.run() + assert data["time"] == 1.5 + + +def test_or_event() -> None: + with EnvironmentContext() as env: + data = { + "time": 0.0, + } + + def run(env: SIM.Environment) -> SIMPY_GEN: + event1 = Wait(1.0) + event2 = Wait(1.5) + + event = Any(event1, event2) + yield event.as_event() + data["time"] = env.now + + env.process(run(env)) + env.run() + # SimPy still runs the simulation long enough to finish the timeout + assert data["time"] == 1.0 + + +def test_composite() -> None: + with EnvironmentContext() as env: + data = { + "time": 0.0, + "result": 0, + } + + def run(env: SIM.Environment) -> SIMPY_GEN: + event1 = Wait(1.0) + event2 = Wait(1.5) + + event3 = Wait(2.1) + event4 = Wait(0.9) + + event_a = Any(event1, event2) + event_b = All(event3, event4, event_a) + result = yield event_b.as_event() + data["time"] = env.now + data["result"] = len(result.events) + + env.process(run(env)) + env.run() + assert data["time"] == 2.1 + assert data["result"] == 4 + + +def test_process_in_multi() -> None: + with EnvironmentContext() as env: + + def a_process() -> SIMPY_GEN: + yield env.timeout(2) + + class Thing(Actor): + result = State[dict]() + events = State[list]() + + class TheTask(Task): + def task(self, *, actor: Thing) -> TASK_GEN: + wait = Wait(3.0) + proc = env.process(a_process()) + res = yield Any(wait, proc) + actor.events = [wait, proc] + actor.result = res + + t = Thing(name="Thing", result=None, events=None) + task = TheTask() + task.run(actor=t) + with pytest.warns(UserWarning): + env.run() + assert t.events[-1] in t.result + assert t.events[0] not in t.result + + +def test_rehearse_process_in_multi() -> None: + with EnvironmentContext() as env: + + def a_process() -> SIMPY_GEN: + yield env.timeout(2) + + class Thing(Actor): ... + + class TheTask(Task): + def task(self, *, actor: Thing) -> TASK_GEN: + wait = Wait(3.0) + proc = env.process(a_process()) + yield Any(wait, proc) + + t = Thing(name="Thing") + task = TheTask() + with pytest.raises(SimulationError, match="All events in a MultiEvent"): + with pytest.warns(UserWarning): + task.rehearse(actor=t) + + +def run_one(env: SIM.Environment, event: Event) -> SIMPY_GEN: + yield env.timeout(1.0) + event.succeed(data="here") + + +def run_two(env: SIM.Environment, event: Event, data: dict[str, float]) -> SIMPY_GEN: + yield event._event + data["time_two"] = env.now + + +def run_three(env: SIM.Environment, event: Event, data: dict[str, float]) -> SIMPY_GEN: + yield event._event + data["time_three"] = env.now + + +def run_four(env: SIM.Environment, event: Event) -> SIMPY_GEN: + yield env.timeout(1.1) + event.succeed() + + +def run_four_alt(env: SIM.Environment, event: Event) -> SIMPY_GEN: + yield env.timeout(1.1) + event.succeed() + + +def run_five(env: SIM.Environment, event: Event, data: dict[str, float]) -> SIMPY_GEN: + # Timeout until after the event suceeded, but before its reset + yield env.timeout(1.05) + yield event.as_event() + data["time_five"] = env.now + + +def test_basic_usage() -> None: + with EnvironmentContext() as env: + event = Event() + assert event._event is not None + assert isinstance(event._event, SIM.Event) + assert event._event is event.as_event() + assert event.is_complete() is False + + env.process(run_one(env, event)) + data: dict[str, float] = {} + env.process(run_two(env, event, data)) + env.run() + assert data["time_two"] == 1.0 + assert event.is_complete() + payload = event.get_payload() + assert payload == {"data": "here"} + + with pytest.raises(SimulationError): + event.succeed() + + assert event.calculate_time_to_complete() == 0.0 + + last_event = event._event + event.reset() + assert last_event is not event._event + + +def test_conflicts() -> None: + data: dict[str, float] = {} + with EnvironmentContext() as env: + event = Event() + env.process(run_one(env, event)) + env.process(run_two(env, event, data)) + env.process(run_three(env, event, data)) + env.run() + assert data["time_two"] == data["time_three"] + + data: dict[str, float] = {} + with EnvironmentContext() as env: + with pytest.raises(SimulationError): + event = Event() + env.process(run_one(env, event)) + env.process(run_two(env, event, data)) + env.process(run_three(env, event, data)) + env.process(run_four(env, event)) + env.run() + + data: dict[str, float] = {} + with EnvironmentContext() as env: + event = Event() + env.process(run_one(env, event)) + env.process(run_two(env, event, data)) + env.process(run_three(env, event, data)) + env.process(run_four_alt(env, event)) + env.process(run_five(env, event, data)) + env.run() + assert data["time_two"] == data["time_three"] + assert data["time_five"] == 1.1 diff --git a/src/upstage/test/test_geography/__init__.py b/src/upstage_des/test/test_geography/__init__.py similarity index 100% rename from src/upstage/test/test_geography/__init__.py rename to src/upstage_des/test/test_geography/__init__.py diff --git a/src/upstage/test/test_geography/conftest.py b/src/upstage_des/test/test_geography/conftest.py similarity index 100% rename from src/upstage/test/test_geography/conftest.py rename to src/upstage_des/test/test_geography/conftest.py diff --git a/src/upstage/test/test_geography/test_conversions.py b/src/upstage_des/test/test_geography/test_conversions.py similarity index 84% rename from src/upstage/test/test_geography/test_conversions.py rename to src/upstage_des/test/test_geography/test_conversions.py index cacf80a..913b63e 100644 --- a/src/upstage/test/test_geography/test_conversions.py +++ b/src/upstage_des/test/test_geography/test_conversions.py @@ -5,8 +5,8 @@ import pytest -from upstage.geography import conversions, spherical, wgs84 -from upstage.geography.conversions import BaseConversions +from upstage_des.geography import conversions, spherical, wgs84 +from upstage_des.geography.conversions import BaseConversions SC = conversions.SphericalConversions WSGC = conversions.WGS84Conversions diff --git a/src/upstage/test/test_geography/test_intersections.py b/src/upstage_des/test/test_geography/test_intersections.py similarity index 94% rename from src/upstage/test/test_geography/test_intersections.py rename to src/upstage_des/test/test_geography/test_intersections.py index 8351ab0..448e84a 100644 --- a/src/upstage/test/test_geography/test_intersections.py +++ b/src/upstage_des/test/test_geography/test_intersections.py @@ -5,8 +5,8 @@ import pytest -from upstage.geography import WGS84, Spherical, get_intersection_locations -from upstage.motion.cartesian_model import ray_intersection +from upstage_des.geography import WGS84, Spherical, get_intersection_locations +from upstage_des.motion.cartesian_model import ray_intersection from .conftest import POS diff --git a/src/upstage/test/test_geography/test_spherical.py b/src/upstage_des/test/test_geography/test_spherical.py similarity index 97% rename from src/upstage/test/test_geography/test_spherical.py rename to src/upstage_des/test/test_geography/test_spherical.py index 88b1c48..5bd2616 100644 --- a/src/upstage/test/test_geography/test_spherical.py +++ b/src/upstage_des/test/test_geography/test_spherical.py @@ -5,7 +5,7 @@ import pytest -from upstage.geography import Spherical +from upstage_des.geography import Spherical def test_distance(atl: tuple[float, float], nas: tuple[float, float]) -> None: diff --git a/src/upstage/test/test_geography/test_wsg84.py b/src/upstage_des/test/test_geography/test_wsg84.py similarity index 98% rename from src/upstage/test/test_geography/test_wsg84.py rename to src/upstage_des/test/test_geography/test_wsg84.py index b7e004c..460d62e 100644 --- a/src/upstage/test/test_geography/test_wsg84.py +++ b/src/upstage_des/test/test_geography/test_wsg84.py @@ -5,7 +5,7 @@ import pytest -from upstage.geography import WGS84 +from upstage_des.geography import WGS84 def test_distance_and_bearing(atl: tuple[float, float], nas: tuple[float, float]) -> None: diff --git a/src/upstage/test/test_great_circle_calcs.py b/src/upstage_des/test/test_great_circle_calcs.py similarity index 97% rename from src/upstage/test/test_great_circle_calcs.py rename to src/upstage_des/test/test_great_circle_calcs.py index 0230b67..5c440e1 100644 --- a/src/upstage/test/test_great_circle_calcs.py +++ b/src/upstage_des/test/test_great_circle_calcs.py @@ -7,8 +7,8 @@ import pytest -import upstage.api as UP -from upstage.motion.great_circle_calcs import ( +import upstage_des.api as UP +from upstage_des.motion.great_circle_calcs import ( get_course_rad, get_dist_rad, get_great_circle_points, diff --git a/src/upstage/test/test_integration.py b/src/upstage_des/test/test_integration.py similarity index 96% rename from src/upstage/test/test_integration.py rename to src/upstage_des/test/test_integration.py index 8603c3f..c62d42d 100644 --- a/src/upstage/test/test_integration.py +++ b/src/upstage_des/test/test_integration.py @@ -6,13 +6,13 @@ import simpy as SIM from simpy import Environment, Process -from upstage.actor import Actor -from upstage.base import EnvironmentContext, MockEnvironment -from upstage.constants import PLANNING_FACTOR_OBJECT -from upstage.events import Any, Get, Put, ResourceHold, Wait -from upstage.states import LinearChangingState, State -from upstage.task import Task -from upstage.type_help import SIMPY_GEN, TASK_GEN +from upstage_des.actor import Actor +from upstage_des.base import EnvironmentContext, MockEnvironment +from upstage_des.constants import PLANNING_FACTOR_OBJECT +from upstage_des.events import Any, Get, Put, ResourceHold, Wait +from upstage_des.states import LinearChangingState, State +from upstage_des.task import Task +from upstage_des.type_help import SIMPY_GEN, TASK_GEN class ActorForTest(Actor): diff --git a/src/upstage/test/test_locations.py b/src/upstage_des/test/test_locations.py similarity index 94% rename from src/upstage/test/test_locations.py rename to src/upstage_des/test/test_locations.py index 23f5125..051abbd 100644 --- a/src/upstage/test/test_locations.py +++ b/src/upstage_des/test/test_locations.py @@ -5,11 +5,11 @@ import pytest -from upstage.actor import Actor -from upstage.api import EnvironmentContext, SimulationError, add_stage_variable -from upstage.data_types import GeodeticLocation -from upstage.geography import Spherical -from upstage.states import GeodeticLocationChangingState +from upstage_des.actor import Actor +from upstage_des.api import EnvironmentContext, SimulationError, add_stage_variable +from upstage_des.data_types import GeodeticLocation +from upstage_des.geography import Spherical +from upstage_des.states import GeodeticLocationChangingState # example lat lon alts ATLANTA = [33.7490, -84.3880, 1050] diff --git a/src/upstage/test/test_motion.py b/src/upstage_des/test/test_motion.py similarity index 96% rename from src/upstage/test/test_motion.py rename to src/upstage_des/test/test_motion.py index dbd75b9..e39fc3d 100644 --- a/src/upstage/test/test_motion.py +++ b/src/upstage_des/test/test_motion.py @@ -1,867 +1,867 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -from typing import Any, Generic, TypeVar, cast - -import pytest -import simpy as SIM - -import upstage.api as UP -from upstage.geography import Spherical, get_intersection_locations -from upstage.motion.cartesian_model import cartesian_linear_intersection as cli -from upstage.motion.geodetic_model import analytical_intersection as agi -from upstage.motion.geodetic_model import subdivide_intersection as gi -from upstage.type_help import TASK_GEN - -LOC = TypeVar("LOC", bound=UP.CartesianLocation | UP.GeodeticLocation) - - -class DummySensor(Generic[LOC]): - """A simple sensor for testing purposes.""" - - def __init__(self, env: SIM.Environment, location: LOC, radius: float = 1.0) -> None: - self.env = env - self.data: list[tuple[Any, float, str]] = [] - self._location = location - self._radius = radius - - def entity_entered_range(self, mover: Any) -> None: - self.data.append((mover, self.env.now, "detect")) - if hasattr(mover, "loc"): - # call the location to record it - mover.loc - - def entity_exited_range(self, mover: Any) -> None: - self.data.append((mover, self.env.now, "end detect")) - if hasattr(mover, "loc"): - # call the location to record it - mover.loc - - @property - def location(self) -> LOC: - return self._location - - @property - def radius(self) -> float: - return self._radius - - -class BadSensor: - """An incomplete sensor for testing purposes.""" - - def __init__(self, env: SIM.Environment, location: tuple[float, ...], radius: float) -> None: - self.env = env - self._location = UP.CartesianLocation(*location) - self._radius = radius - - @property - def location(self) -> UP.CartesianLocation: - return self._location - - @property - def radius(self) -> float: - return self._radius - - -class DummyMover: - """A simple mover for testing purposes.""" - - def __init__(self, env: SIM.Environment) -> None: - self.env = env - self.detect = True - - def _get_detection_state(self) -> str: - return "detect" - - -class RealMover(UP.Actor): - """A more realistic mover that moves in Cartesian Space.""" - - loc = UP.CartesianLocationChangingState(recording=True) - speed = UP.State[float]() - detect = UP.DetectabilityState() - - -class RealGeodeticMover(UP.Actor): - """A more realistic mover that moves in Geodetic Space.""" - - loc = UP.GeodeticLocationChangingState(recording=True) - speed = UP.State[float]() - detect = UP.DetectabilityState() - - -# This is not best practices, but it works for testing -class DoMove(UP.Task): - """A task for movers to move.""" - - waypoints: list[UP.GeodeticLocation] | list[UP.CartesianLocation] = [] - - def task(self, *, actor: RealGeodeticMover | RealMover) -> TASK_GEN: - dist = 0.0 - wayps = [actor.loc] + list(self.waypoints) - for i in range(len(wayps) - 1): - dist += wayps[i + 1] - wayps[i] - time = dist / actor.speed - - actor.activate_location_state( - state="loc", - task=self, - speed=actor.speed, - waypoints=self.waypoints, - ) - yield UP.Wait(time) - actor.deactivate_all_states(task=self) - - def on_interrupt( - self, *, actor: RealGeodeticMover | RealMover, cause: Any - ) -> UP.InterruptStates: - if cause == "Become undetectable": - actor.detect = False - return self.INTERRUPT.IGNORE - return self.INTERRUPT.END - - -L = TypeVar("L") - - -def _create_mover_and_waypoints( - env: SIM.Environment, - mover_type: type, - location_type: type[L], - *waypoints: tuple[float, ...], -) -> tuple[UP.Actor, list[L]]: - mover = cast(UP.Actor, mover_type(env)) - waypoints = [location_type(*waypoint) for waypoint in waypoints] - return mover, waypoints - - -def test_errors() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli) - - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (0, 0, 0), - (1, 1, 0), - ) - - bad_sensor = BadSensor(env, (0.9, 0.9), 0.5) - - with pytest.raises(UP.MotionAndDetectionError): - motion._stop_mover(mover) - - motion._start_mover(mover, speed=1.0, waypoints=waypoints) - with pytest.raises(UP.MotionAndDetectionError): - motion._start_mover(mover, speed=1.0, waypoints=[[2, 2], [3, 3]]) # type: ignore [arg-type] - - with pytest.raises(NotImplementedError): - motion.add_sensor(bad_sensor, "location", "radius") # type: ignore [arg-type] - - -def test_no_interaction_cli() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli) - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (2, 0, 0), - (2, -2, 0), - ) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc) - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run(until=5.0) - - assert abs(env.now - 5.0) < 1e-12 - - assert ( - len(sensor.data) == 0 - ), f"There should be no interaction events, but found: {sensor.data}" - assert not motion._events.get(mover, []) - assert sensor not in motion._in_view.get(mover, {}) - assert motion._debug_log == [], "No log expected for no actions" - - -def test_enter_exit() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(cli) - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (2, 2, 0), - (-2, -2, 0), - ) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc) - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run() - motion._stop_mover(mover) - - assert abs(env.now - 3.828427124746188) < 1e-12 - - assert ( - len(sensor.data) == 2 - ), f"For now, motion manager only has entry event recorded, {sensor.data}" - - assert abs(sensor.data[0][1] - 1.8284271247461907) < 1e-12 - assert sensor.data[0][2] == "detect" - assert abs(sensor.data[1][1] - 3.828427124746188) < 1e-12 - assert sensor.data[1][2] == "end detect" - assert not motion._events.get(mover, []) - assert sensor not in motion._in_view.get(mover, {}) - - -def test_sensor_popup() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli) - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (2, 2, 0), - (-2, -2, 0), - ) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc) - motion._start_mover(mover, 1.0, waypoints) - env.run(until=2) - - motion.add_sensor( - sensor, - ) - env.run() - motion._stop_mover(mover) - - assert abs(env.now - 3.828427124746188) < 1e-12 - - assert ( - len(sensor.data) == 2 - ), f"For now, motion manager only has entry event recorded, {sensor.data}" - - assert abs(sensor.data[0][1] - 2) < 1e-12 - assert sensor.data[0][2] == "detect" - assert abs(sensor.data[1][1] - 3.828427124746188) < 1e-12 - assert sensor.data[1][2] == "end detect" - assert not motion._events.get(mover, []) - assert sensor not in motion._in_view.get(mover, {}) - - -def test_start_inside_exit() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli) - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (0.5, 0.5, 0), - (-2, -2, 0), - ) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc) - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run() - motion._stop_mover(mover) - - assert env.now == 1.7071067811865475 - - assert len(sensor.data) == 2, "Need entry and exit events" - - assert abs(sensor.data[0][1] - 0) < 1e-12 - assert sensor.data[0][2] == "detect" - assert abs(sensor.data[1][1] - 1.7071067811865475) < 1e-12 - assert sensor.data[1][2] == "end detect" - assert not motion._events.get(mover, []) - assert sensor not in motion._in_view.get(mover, {}) - - -def test_enter_end_inside_then_leave() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli) - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (2, 2, 0), - (-0.5, -0.5, 0), - ) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc) - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run() - motion._stop_mover(mover) - - assert env.now == 1.8284271247461907 - - assert len(sensor.data) == 1, "Ending inside means no exit event" - - assert abs(sensor.data[-1][1] - 1.8284271247461907) < 1e-12 - assert sensor in motion._in_view[mover] - assert not motion._events.get(mover, []) - - # have the mover leave and run the clock a bit in case - env.run(until=20) - assert len(sensor.data) == 1, "No new events should be added to the log" - assert abs(sensor.data[-1][1] - 1.8284271247461907) < 1e-12 - assert sensor in motion._in_view[mover] - - motion._start_mover(mover, 1.0, waypoints[::-1]) - env.run() - assert env.now == 21.707106781186546 - assert len(sensor.data) == 2, "Wrong amount of sensor data" - assert abs(sensor.data[-1][1] - 21.707106781186546) < 1e-12 - assert sensor not in motion._in_view[mover] - - -def test_start_inside_end_inside() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli) - mover, waypoints = _create_mover_and_waypoints( - env, - DummyMover, - UP.CartesianLocation, - (0.5, 0.5, 0), - (-0.5, -0.5, 0), - ) - mover = cast(UP.Actor, mover) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc) - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run() - - assert env.now == 0 - - assert len(sensor.data) == 1, f"Only need to see the start recorded: {sensor.data}" - assert sensor.data[0][2] == "detect" - assert sensor in motion._in_view[mover] - - -def test_motion_setup_cli() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover = cast(UP.Actor, DummyMover(env)) - mover_start = UP.CartesianLocation(*[8, 8, 2]) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - - motion.add_sensor(sensor, "location", "radius") - # This isn't how these things are normally called for movers, but - # since it isn't going through a task, it's ok here. - motion._start_mover(mover, 1.0, waypoints) - assert len(motion._events) == 1 - assert len(motion._events[mover]) == 3 - env.run() - assert len(sensor.data) == 6 - matches = [2.343145750507622, 18.343145750507624, 34.268912605325006] - for datum, truth in zip(sensor.data[::2], matches): - assert pytest.approx(truth) == datum[1] - - -def test_late_intersection() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=5.0) - - mover = cast(UP.Actor, DummyMover(env)) - mover_start = UP.CartesianLocation(*[0, 8, 0]) - waypoints = [ - mover_start, - UP.CartesianLocation(*[0, 6, 0]), - ] - motion.add_sensor(sensor) - # This isn't how these things are normally called for movers, but - # since it isn't going through a task, it's ok here. - motion._start_mover(mover, 1.0, waypoints) - env.run() - - -def test_motion_coordination_cli() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - # Test that if the location is a changing state, that it matches up - # when detections happen - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover_start = UP.CartesianLocation(*[8, 8, 2]) - mover = RealMover(name="A Mover", loc=mover_start, speed=1, detect=True) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - task.run(actor=mover) - - motion.add_sensor(sensor) - - env.run() - assert mover.loc == waypoints[-1] - 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 - - -def test_background_motion() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover_start = UP.CartesianLocation(*[8, 8, 2]) - mover = RealMover(name="A Mover", loc=mover_start, speed=1, detect=True) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - task.run(actor=mover) - - motion.add_sensor(sensor) - # no need to start the mover this time - env.run() - assert mover.loc == waypoints[-1] - # The motion manager needs to see the mover 3 times, and - # so does the sensor - assert len(motion._debug_data[mover]) == 3 - assert len(sensor.data) == 6 - - -def test_background_rehearse() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - - # This is the key change relative to the above - flyer_start = UP.CartesianLocation(*[8, 8, 2]) - flyer = RealMover(name="A Mover", loc=flyer_start, speed=1, detect=True) - waypoints = [ - flyer_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - - flyer_clone = task.rehearse(actor=flyer) - - env.run() - assert env.now == 0, "No time should pass for rehearsal" - assert flyer.loc == waypoints[0] - # The motion manager shouldn't see anything - assert flyer not in motion._debug_data - assert flyer_clone not in motion._debug_data - - -def test_interrupt_clean() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover_start = UP.CartesianLocation(*[8, 8, 2]) - mover = RealMover(name="A Mover", loc=mover_start, speed=1, detect=True, debug_log=True) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - proc = task.run(actor=mover) - - motion.add_sensor(sensor) - # no need to start the mover this time - env.run(until=25) - proc.interrupt(cause="Stop") - env.run() - - # The motion manager needs to see the mover 3 times, and - # the sensor will see it double that for entry/exit - assert len(motion._debug_data[mover]) == 3 - assert len(sensor.data) == 3 - # the two messages for cancelling the notifications - assert len(motion._debug_log) == 5 - assert all( - log_entry["event"] == "Scheduling sensor detecting mover" - for log_entry in motion._debug_log[:3] - ) - assert all(line["mover"] is mover for line in motion._debug_log) - # the order of these two may switch.. - msgs = [ - "Detection of a mover cancelled before exit", - "Detection of a mover cancelled before entry", - ] - events = [motion._debug_log[i]["event"] for i in [3, 4]] - assert len(set(events)) == 2, "Need two unique event descriptions" - assert all(x in msgs for x in events), "Need both types exactly" - # the mover should be stopped - assert mover not in motion._movers - - -def test_undetectable_cli() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover_start = UP.CartesianLocation(*[8, 8, 2]) - mover = RealMover(name="A Mover", loc=mover_start, speed=1, debug_log=True, detect=True) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - proc = task.run(actor=mover) - - motion.add_sensor(sensor) - # no need to start the mover this time - env.run(until=25) - - # check that the motion manager has the right data - assert mover in motion._in_view, "Mover not found in progress" - proc.interrupt(cause="Become undetectable") - env.run() - - assert sensor.data[-1] == (mover, 25, "end detect") - - -def test_redetectable() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover_start = UP.CartesianLocation(*[8, 8, 2]) - mover = RealMover(name="A Mover", loc=mover_start, speed=1, debug_log=True, detect=False) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - task.run(actor=mover) - - motion.add_sensor(sensor) - # no need to start the mover this time - env.run(until=25) - with pytest.warns(UserWarning, match="Setting DetectabilityState to True while*"): - mover.detect = True - - -def test_undetectable_after() -> None: - with UP.EnvironmentContext() as env: - UP.add_stage_variable("distance_units", "m") - motion = UP.SensorMotionManager(cli, debug=True) - UP.add_stage_variable("motion_manager", motion) - loc = UP.CartesianLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=10.0) - - mover_start = UP.CartesianLocation(*[8, 8, 2]) - mover = RealMover(name="A Mover", loc=mover_start, speed=1, debug_log=True, detect=True) - waypoints = [ - mover_start, - UP.CartesianLocation(*[-8, 8, 2]), - UP.CartesianLocation(*[-8, -8, 2]), - UP.CartesianLocation(*[8, -8, 0]), - ] - task = DoMove() - task.waypoints = waypoints[1:] - proc = task.run(actor=mover) - - motion.add_sensor(sensor) - # no need to start the mover this time - env.run(until=30) - - # check that the motion manager has the right data - assert mover in motion._in_view, "Mover not found in progress" - proc.interrupt(cause="Become undetectable") - env.run() - - assert (mover, 30, "end detect") not in sensor.data - assert len(sensor.data) == 4 - - -def test_motion_setup_gi() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(gi, debug=True) - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "m") - UP.add_stage_variable("distance_units", "nmi") - UP.add_stage_variable("intersection_model", get_intersection_locations) - loc = UP.GeodeticLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=150.0) - - geo_mover = cast(UP.Actor, DummyMover(env)) - t = 2 - geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) - waypoints = [ - geo_mover_start, - UP.GeodeticLocation(*[-t, t, 4000]), - UP.GeodeticLocation(*[-t, -t, 4000]), - UP.GeodeticLocation(*[t, -t, 0]), - ] - - motion.add_sensor(sensor) - motion._start_mover(geo_mover, 1.0, waypoints) - assert len(motion._events) == 1 - assert len(motion._events[geo_mover]) == 3 - env.run() - assert len(sensor.data) == 6 - matches = [30.59361210120766, 271.030439713508, 511.28446115022086] - for datum, truth in zip(sensor.data[::2], matches): - assert pytest.approx(truth, abs=0.001) == datum[1] - - -def test_no_interaction_gi() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(gi, debug=True) - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "ft") - UP.add_stage_variable("distance_units", "nmi") - UP.add_stage_variable("intersection_model", get_intersection_locations) - - motion = UP.SensorMotionManager(gi) - loc = UP.GeodeticLocation(*[90, 40, 0]) - sensor = DummySensor(env, loc, 1.0) - - mover = cast(UP.Actor, DummyMover(env)) - waypoints = [ - UP.GeodeticLocation(0, 10, 0), - UP.GeodeticLocation(10, 10, 0), - ] - - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run(until=5.0) - - assert abs(env.now - 5.0) < 1e-12 - - assert ( - len(sensor.data) == 0 - ), f"There should be no interaction events, but found: {sensor.data}" - - motion._stop_mover(mover) - - -def test_motion_coordination_gi() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(gi, debug=True) - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "m") - UP.add_stage_variable("distance_units", "nmi") - UP.add_stage_variable("intersection_model", get_intersection_locations) - UP.add_stage_variable("motion_manager", motion) - loc = UP.GeodeticLocation(0, 0, 0) - sensor = DummySensor(env, loc, radius=150.0) - - t = 2 - geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) - geo_mover = RealGeodeticMover(name="Mover", loc=geo_mover_start, speed=1, detect=True) - - waypoints = [ - geo_mover_start, - UP.GeodeticLocation(*[-t, t, 4000]), - UP.GeodeticLocation(*[-t, -t, 4000]), - UP.GeodeticLocation(*[t, -t, 0]), - ] - - task = DoMove() - task.waypoints = waypoints[1:] - task.run(actor=geo_mover) - - motion.add_sensor(sensor) - - env.run() - assert abs(geo_mover.loc - waypoints[-1]) <= 1e-12 - 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 - - -def test_motion_setup_agi() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(agi, debug=True) - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "m") - UP.add_stage_variable("distance_units", "nmi") - UP.add_stage_variable("intersection_model", get_intersection_locations) - UP.add_stage_variable("motion_manager", motion) - sensor = DummySensor(env, UP.GeodeticLocation(0, 0, 0), radius=150.0) - - geo_mover = cast(UP.Actor, DummyMover(env)) - t = 2 - geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) - waypoints = [ - geo_mover_start, - UP.GeodeticLocation(*[-t, t, 4000]), - UP.GeodeticLocation(*[-t, -t, 4000]), - UP.GeodeticLocation(*[t, -t, 0]), - ] - - motion.add_sensor(sensor) - motion._start_mover(geo_mover, 1.0, waypoints) - assert len(motion._events) == 1 - assert len(motion._events[geo_mover]) == 3 - env.run() - assert len(sensor.data) == 6 - matches = [30.511, 270.967, 511.207] - for datum, truth in zip(sensor.data[::2], matches): - assert pytest.approx(truth, abs=0.1) == datum[1] - - -def test_no_interaction_agi() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(agi, debug=True) - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "m") - UP.add_stage_variable("distance_units", "nmi") - UP.add_stage_variable("intersection_model", get_intersection_locations) - UP.add_stage_variable("motion_manager", motion) - - motion = UP.SensorMotionManager(agi) - sensor = DummySensor(env, UP.GeodeticLocation(0, 0, 0)) - UP.GeodeticLocation(*[0, 0, 0]) - - mover = cast(UP.Actor, DummyMover(env)) - waypoints = [ - UP.GeodeticLocation(0, 10, 0), - UP.GeodeticLocation(10, 10, 0), - ] - - motion.add_sensor( - sensor, - ) - motion._start_mover(mover, 1.0, waypoints) - env.run(until=5.0) - - assert abs(env.now - 5.0) < 1e-12 - - assert ( - len(sensor.data) == 0 - ), f"There should be no interaction events, but found: {sensor.data}" - - motion._stop_mover(mover) - - -def test_motion_coordination_agi() -> None: - with UP.EnvironmentContext() as env: - motion = UP.SensorMotionManager(agi, debug=True) - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "m") - UP.add_stage_variable("distance_units", "nmi") - UP.add_stage_variable("intersection_model", get_intersection_locations) - UP.add_stage_variable("motion_manager", motion) - - sensor = DummySensor(env, UP.GeodeticLocation(0, 0, 0), radius=150.0) - - t = 2 - geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) - geo_mover = RealGeodeticMover(name="Mover", loc=geo_mover_start, speed=1, detect=True) - - waypoints = [ - geo_mover_start, - UP.GeodeticLocation(*[-t, t, 4000]), - UP.GeodeticLocation(*[-t, -t, 4000]), - UP.GeodeticLocation(*[t, -t, 0]), - ] - - task = DoMove() - task.waypoints = waypoints[1:] - task.run(actor=geo_mover) - - motion.add_sensor(sensor) - - env.run() - assert abs(geo_mover.loc - waypoints[-1]) <= 1e-12 - 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 - - -def test_analytical_intersection() -> None: - with UP.EnvironmentContext(): - UP.add_stage_variable("stage_model", Spherical) - UP.add_stage_variable("altitude_units", "m") - UP.add_stage_variable("distance_units", "nmi") - - start = UP.GeodeticLocation(33.67009544379275, -84.59178543542892, 5_000) - finish = UP.GeodeticLocation(33.871012616336344, -84.16331866903882, 5_000) - middle = UP.GeodeticLocation(33.7774620987044, -84.38304521590554, 4_000) - - res = agi(start, finish, 200.0, middle, 200.0) - intersections, times, types, path_time = res - assert types == ["START_INSIDE", "END_INSIDE"] +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +from typing import Any, Generic, TypeVar, cast + +import pytest +import simpy as SIM + +import upstage_des.api as UP +from upstage_des.geography import Spherical, get_intersection_locations +from upstage_des.motion.cartesian_model import cartesian_linear_intersection as cli +from upstage_des.motion.geodetic_model import analytical_intersection as agi +from upstage_des.motion.geodetic_model import subdivide_intersection as gi +from upstage_des.type_help import TASK_GEN + +LOC = TypeVar("LOC", bound=UP.CartesianLocation | UP.GeodeticLocation) + + +class DummySensor(Generic[LOC]): + """A simple sensor for testing purposes.""" + + def __init__(self, env: SIM.Environment, location: LOC, radius: float = 1.0) -> None: + self.env = env + self.data: list[tuple[Any, float, str]] = [] + self._location = location + self._radius = radius + + def entity_entered_range(self, mover: Any) -> None: + self.data.append((mover, self.env.now, "detect")) + if hasattr(mover, "loc"): + # call the location to record it + mover.loc + + def entity_exited_range(self, mover: Any) -> None: + self.data.append((mover, self.env.now, "end detect")) + if hasattr(mover, "loc"): + # call the location to record it + mover.loc + + @property + def location(self) -> LOC: + return self._location + + @property + def radius(self) -> float: + return self._radius + + +class BadSensor: + """An incomplete sensor for testing purposes.""" + + def __init__(self, env: SIM.Environment, location: tuple[float, ...], radius: float) -> None: + self.env = env + self._location = UP.CartesianLocation(*location) + self._radius = radius + + @property + def location(self) -> UP.CartesianLocation: + return self._location + + @property + def radius(self) -> float: + return self._radius + + +class DummyMover: + """A simple mover for testing purposes.""" + + def __init__(self, env: SIM.Environment) -> None: + self.env = env + self.detect = True + + def _get_detection_state(self) -> str: + return "detect" + + +class RealMover(UP.Actor): + """A more realistic mover that moves in Cartesian Space.""" + + loc = UP.CartesianLocationChangingState(recording=True) + speed = UP.State[float]() + detect = UP.DetectabilityState() + + +class RealGeodeticMover(UP.Actor): + """A more realistic mover that moves in Geodetic Space.""" + + loc = UP.GeodeticLocationChangingState(recording=True) + speed = UP.State[float]() + detect = UP.DetectabilityState() + + +# This is not best practices, but it works for testing +class DoMove(UP.Task): + """A task for movers to move.""" + + waypoints: list[UP.GeodeticLocation] | list[UP.CartesianLocation] = [] + + def task(self, *, actor: RealGeodeticMover | RealMover) -> TASK_GEN: + dist = 0.0 + wayps = [actor.loc] + list(self.waypoints) + for i in range(len(wayps) - 1): + dist += wayps[i + 1] - wayps[i] + time = dist / actor.speed + + actor.activate_location_state( + state="loc", + task=self, + speed=actor.speed, + waypoints=self.waypoints, + ) + yield UP.Wait(time) + actor.deactivate_all_states(task=self) + + def on_interrupt( + self, *, actor: RealGeodeticMover | RealMover, cause: Any + ) -> UP.InterruptStates: + if cause == "Become undetectable": + actor.detect = False + return self.INTERRUPT.IGNORE + return self.INTERRUPT.END + + +L = TypeVar("L") + + +def _create_mover_and_waypoints( + env: SIM.Environment, + mover_type: type, + location_type: type[L], + *waypoints: tuple[float, ...], +) -> tuple[UP.Actor, list[L]]: + mover = cast(UP.Actor, mover_type(env)) + waypoints = [location_type(*waypoint) for waypoint in waypoints] + return mover, waypoints + + +def test_errors() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli) + + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (0, 0, 0), + (1, 1, 0), + ) + + bad_sensor = BadSensor(env, (0.9, 0.9), 0.5) + + with pytest.raises(UP.MotionAndDetectionError): + motion._stop_mover(mover) + + motion._start_mover(mover, speed=1.0, waypoints=waypoints) + with pytest.raises(UP.MotionAndDetectionError): + motion._start_mover(mover, speed=1.0, waypoints=[[2, 2], [3, 3]]) # type: ignore [arg-type] + + with pytest.raises(NotImplementedError): + motion.add_sensor(bad_sensor, "location", "radius") # type: ignore [arg-type] + + +def test_no_interaction_cli() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli) + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (2, 0, 0), + (2, -2, 0), + ) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc) + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run(until=5.0) + + assert abs(env.now - 5.0) < 1e-12 + + assert ( + len(sensor.data) == 0 + ), f"There should be no interaction events, but found: {sensor.data}" + assert not motion._events.get(mover, []) + assert sensor not in motion._in_view.get(mover, {}) + assert motion._debug_log == [], "No log expected for no actions" + + +def test_enter_exit() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(cli) + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (2, 2, 0), + (-2, -2, 0), + ) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc) + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run() + motion._stop_mover(mover) + + assert abs(env.now - 3.828427124746188) < 1e-12 + + assert ( + len(sensor.data) == 2 + ), f"For now, motion manager only has entry event recorded, {sensor.data}" + + assert abs(sensor.data[0][1] - 1.8284271247461907) < 1e-12 + assert sensor.data[0][2] == "detect" + assert abs(sensor.data[1][1] - 3.828427124746188) < 1e-12 + assert sensor.data[1][2] == "end detect" + assert not motion._events.get(mover, []) + assert sensor not in motion._in_view.get(mover, {}) + + +def test_sensor_popup() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli) + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (2, 2, 0), + (-2, -2, 0), + ) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc) + motion._start_mover(mover, 1.0, waypoints) + env.run(until=2) + + motion.add_sensor( + sensor, + ) + env.run() + motion._stop_mover(mover) + + assert abs(env.now - 3.828427124746188) < 1e-12 + + assert ( + len(sensor.data) == 2 + ), f"For now, motion manager only has entry event recorded, {sensor.data}" + + assert abs(sensor.data[0][1] - 2) < 1e-12 + assert sensor.data[0][2] == "detect" + assert abs(sensor.data[1][1] - 3.828427124746188) < 1e-12 + assert sensor.data[1][2] == "end detect" + assert not motion._events.get(mover, []) + assert sensor not in motion._in_view.get(mover, {}) + + +def test_start_inside_exit() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli) + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (0.5, 0.5, 0), + (-2, -2, 0), + ) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc) + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run() + motion._stop_mover(mover) + + assert env.now == 1.7071067811865475 + + assert len(sensor.data) == 2, "Need entry and exit events" + + assert abs(sensor.data[0][1] - 0) < 1e-12 + assert sensor.data[0][2] == "detect" + assert abs(sensor.data[1][1] - 1.7071067811865475) < 1e-12 + assert sensor.data[1][2] == "end detect" + assert not motion._events.get(mover, []) + assert sensor not in motion._in_view.get(mover, {}) + + +def test_enter_end_inside_then_leave() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli) + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (2, 2, 0), + (-0.5, -0.5, 0), + ) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc) + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run() + motion._stop_mover(mover) + + assert env.now == 1.8284271247461907 + + assert len(sensor.data) == 1, "Ending inside means no exit event" + + assert abs(sensor.data[-1][1] - 1.8284271247461907) < 1e-12 + assert sensor in motion._in_view[mover] + assert not motion._events.get(mover, []) + + # have the mover leave and run the clock a bit in case + env.run(until=20) + assert len(sensor.data) == 1, "No new events should be added to the log" + assert abs(sensor.data[-1][1] - 1.8284271247461907) < 1e-12 + assert sensor in motion._in_view[mover] + + motion._start_mover(mover, 1.0, waypoints[::-1]) + env.run() + assert env.now == 21.707106781186546 + assert len(sensor.data) == 2, "Wrong amount of sensor data" + assert abs(sensor.data[-1][1] - 21.707106781186546) < 1e-12 + assert sensor not in motion._in_view[mover] + + +def test_start_inside_end_inside() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli) + mover, waypoints = _create_mover_and_waypoints( + env, + DummyMover, + UP.CartesianLocation, + (0.5, 0.5, 0), + (-0.5, -0.5, 0), + ) + mover = cast(UP.Actor, mover) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc) + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run() + + assert env.now == 0 + + assert len(sensor.data) == 1, f"Only need to see the start recorded: {sensor.data}" + assert sensor.data[0][2] == "detect" + assert sensor in motion._in_view[mover] + + +def test_motion_setup_cli() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover = cast(UP.Actor, DummyMover(env)) + mover_start = UP.CartesianLocation(*[8, 8, 2]) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + + motion.add_sensor(sensor, "location", "radius") + # This isn't how these things are normally called for movers, but + # since it isn't going through a task, it's ok here. + motion._start_mover(mover, 1.0, waypoints) + assert len(motion._events) == 1 + assert len(motion._events[mover]) == 3 + env.run() + assert len(sensor.data) == 6 + matches = [2.343145750507622, 18.343145750507624, 34.268912605325006] + for datum, truth in zip(sensor.data[::2], matches): + assert pytest.approx(truth) == datum[1] + + +def test_late_intersection() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=5.0) + + mover = cast(UP.Actor, DummyMover(env)) + mover_start = UP.CartesianLocation(*[0, 8, 0]) + waypoints = [ + mover_start, + UP.CartesianLocation(*[0, 6, 0]), + ] + motion.add_sensor(sensor) + # This isn't how these things are normally called for movers, but + # since it isn't going through a task, it's ok here. + motion._start_mover(mover, 1.0, waypoints) + env.run() + + +def test_motion_coordination_cli() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + # Test that if the location is a changing state, that it matches up + # when detections happen + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover_start = UP.CartesianLocation(*[8, 8, 2]) + mover = RealMover(name="A Mover", loc=mover_start, speed=1, detect=True) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + task.run(actor=mover) + + motion.add_sensor(sensor) + + env.run() + assert mover.loc == waypoints[-1] + 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 + + +def test_background_motion() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover_start = UP.CartesianLocation(*[8, 8, 2]) + mover = RealMover(name="A Mover", loc=mover_start, speed=1, detect=True) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + task.run(actor=mover) + + motion.add_sensor(sensor) + # no need to start the mover this time + env.run() + assert mover.loc == waypoints[-1] + # The motion manager needs to see the mover 3 times, and + # so does the sensor + assert len(motion._debug_data[mover]) == 3 + assert len(sensor.data) == 6 + + +def test_background_rehearse() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + + # This is the key change relative to the above + flyer_start = UP.CartesianLocation(*[8, 8, 2]) + flyer = RealMover(name="A Mover", loc=flyer_start, speed=1, detect=True) + waypoints = [ + flyer_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + + flyer_clone = task.rehearse(actor=flyer) + + env.run() + assert env.now == 0, "No time should pass for rehearsal" + assert flyer.loc == waypoints[0] + # The motion manager shouldn't see anything + assert flyer not in motion._debug_data + assert flyer_clone not in motion._debug_data + + +def test_interrupt_clean() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover_start = UP.CartesianLocation(*[8, 8, 2]) + mover = RealMover(name="A Mover", loc=mover_start, speed=1, detect=True, debug_log=True) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + proc = task.run(actor=mover) + + motion.add_sensor(sensor) + # no need to start the mover this time + env.run(until=25) + proc.interrupt(cause="Stop") + env.run() + + # The motion manager needs to see the mover 3 times, and + # the sensor will see it double that for entry/exit + assert len(motion._debug_data[mover]) == 3 + assert len(sensor.data) == 3 + # the two messages for cancelling the notifications + assert len(motion._debug_log) == 5 + assert all( + log_entry["event"] == "Scheduling sensor detecting mover" + for log_entry in motion._debug_log[:3] + ) + assert all(line["mover"] is mover for line in motion._debug_log) + # the order of these two may switch.. + msgs = [ + "Detection of a mover cancelled before exit", + "Detection of a mover cancelled before entry", + ] + events = [motion._debug_log[i]["event"] for i in [3, 4]] + assert len(set(events)) == 2, "Need two unique event descriptions" + assert all(x in msgs for x in events), "Need both types exactly" + # the mover should be stopped + assert mover not in motion._movers + + +def test_undetectable_cli() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover_start = UP.CartesianLocation(*[8, 8, 2]) + mover = RealMover(name="A Mover", loc=mover_start, speed=1, debug_log=True, detect=True) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + proc = task.run(actor=mover) + + motion.add_sensor(sensor) + # no need to start the mover this time + env.run(until=25) + + # check that the motion manager has the right data + assert mover in motion._in_view, "Mover not found in progress" + proc.interrupt(cause="Become undetectable") + env.run() + + assert sensor.data[-1] == (mover, 25, "end detect") + + +def test_redetectable() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover_start = UP.CartesianLocation(*[8, 8, 2]) + mover = RealMover(name="A Mover", loc=mover_start, speed=1, debug_log=True, detect=False) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + task.run(actor=mover) + + motion.add_sensor(sensor) + # no need to start the mover this time + env.run(until=25) + with pytest.warns(UserWarning, match="Setting DetectabilityState to True while*"): + mover.detect = True + + +def test_undetectable_after() -> None: + with UP.EnvironmentContext() as env: + UP.add_stage_variable("distance_units", "m") + motion = UP.SensorMotionManager(cli, debug=True) + UP.add_stage_variable("motion_manager", motion) + loc = UP.CartesianLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=10.0) + + mover_start = UP.CartesianLocation(*[8, 8, 2]) + mover = RealMover(name="A Mover", loc=mover_start, speed=1, debug_log=True, detect=True) + waypoints = [ + mover_start, + UP.CartesianLocation(*[-8, 8, 2]), + UP.CartesianLocation(*[-8, -8, 2]), + UP.CartesianLocation(*[8, -8, 0]), + ] + task = DoMove() + task.waypoints = waypoints[1:] + proc = task.run(actor=mover) + + motion.add_sensor(sensor) + # no need to start the mover this time + env.run(until=30) + + # check that the motion manager has the right data + assert mover in motion._in_view, "Mover not found in progress" + proc.interrupt(cause="Become undetectable") + env.run() + + assert (mover, 30, "end detect") not in sensor.data + assert len(sensor.data) == 4 + + +def test_motion_setup_gi() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(gi, debug=True) + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "m") + UP.add_stage_variable("distance_units", "nmi") + UP.add_stage_variable("intersection_model", get_intersection_locations) + loc = UP.GeodeticLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=150.0) + + geo_mover = cast(UP.Actor, DummyMover(env)) + t = 2 + geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) + waypoints = [ + geo_mover_start, + UP.GeodeticLocation(*[-t, t, 4000]), + UP.GeodeticLocation(*[-t, -t, 4000]), + UP.GeodeticLocation(*[t, -t, 0]), + ] + + motion.add_sensor(sensor) + motion._start_mover(geo_mover, 1.0, waypoints) + assert len(motion._events) == 1 + assert len(motion._events[geo_mover]) == 3 + env.run() + assert len(sensor.data) == 6 + matches = [30.59361210120766, 271.030439713508, 511.28446115022086] + for datum, truth in zip(sensor.data[::2], matches): + assert pytest.approx(truth, abs=0.001) == datum[1] + + +def test_no_interaction_gi() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(gi, debug=True) + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "ft") + UP.add_stage_variable("distance_units", "nmi") + UP.add_stage_variable("intersection_model", get_intersection_locations) + + motion = UP.SensorMotionManager(gi) + loc = UP.GeodeticLocation(*[90, 40, 0]) + sensor = DummySensor(env, loc, 1.0) + + mover = cast(UP.Actor, DummyMover(env)) + waypoints = [ + UP.GeodeticLocation(0, 10, 0), + UP.GeodeticLocation(10, 10, 0), + ] + + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run(until=5.0) + + assert abs(env.now - 5.0) < 1e-12 + + assert ( + len(sensor.data) == 0 + ), f"There should be no interaction events, but found: {sensor.data}" + + motion._stop_mover(mover) + + +def test_motion_coordination_gi() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(gi, debug=True) + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "m") + UP.add_stage_variable("distance_units", "nmi") + UP.add_stage_variable("intersection_model", get_intersection_locations) + UP.add_stage_variable("motion_manager", motion) + loc = UP.GeodeticLocation(0, 0, 0) + sensor = DummySensor(env, loc, radius=150.0) + + t = 2 + geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) + geo_mover = RealGeodeticMover(name="Mover", loc=geo_mover_start, speed=1, detect=True) + + waypoints = [ + geo_mover_start, + UP.GeodeticLocation(*[-t, t, 4000]), + UP.GeodeticLocation(*[-t, -t, 4000]), + UP.GeodeticLocation(*[t, -t, 0]), + ] + + task = DoMove() + task.waypoints = waypoints[1:] + task.run(actor=geo_mover) + + motion.add_sensor(sensor) + + env.run() + assert abs(geo_mover.loc - waypoints[-1]) <= 1e-12 + 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 + + +def test_motion_setup_agi() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(agi, debug=True) + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "m") + UP.add_stage_variable("distance_units", "nmi") + UP.add_stage_variable("intersection_model", get_intersection_locations) + UP.add_stage_variable("motion_manager", motion) + sensor = DummySensor(env, UP.GeodeticLocation(0, 0, 0), radius=150.0) + + geo_mover = cast(UP.Actor, DummyMover(env)) + t = 2 + geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) + waypoints = [ + geo_mover_start, + UP.GeodeticLocation(*[-t, t, 4000]), + UP.GeodeticLocation(*[-t, -t, 4000]), + UP.GeodeticLocation(*[t, -t, 0]), + ] + + motion.add_sensor(sensor) + motion._start_mover(geo_mover, 1.0, waypoints) + assert len(motion._events) == 1 + assert len(motion._events[geo_mover]) == 3 + env.run() + assert len(sensor.data) == 6 + matches = [30.511, 270.967, 511.207] + for datum, truth in zip(sensor.data[::2], matches): + assert pytest.approx(truth, abs=0.1) == datum[1] + + +def test_no_interaction_agi() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(agi, debug=True) + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "m") + UP.add_stage_variable("distance_units", "nmi") + UP.add_stage_variable("intersection_model", get_intersection_locations) + UP.add_stage_variable("motion_manager", motion) + + motion = UP.SensorMotionManager(agi) + sensor = DummySensor(env, UP.GeodeticLocation(0, 0, 0)) + UP.GeodeticLocation(*[0, 0, 0]) + + mover = cast(UP.Actor, DummyMover(env)) + waypoints = [ + UP.GeodeticLocation(0, 10, 0), + UP.GeodeticLocation(10, 10, 0), + ] + + motion.add_sensor( + sensor, + ) + motion._start_mover(mover, 1.0, waypoints) + env.run(until=5.0) + + assert abs(env.now - 5.0) < 1e-12 + + assert ( + len(sensor.data) == 0 + ), f"There should be no interaction events, but found: {sensor.data}" + + motion._stop_mover(mover) + + +def test_motion_coordination_agi() -> None: + with UP.EnvironmentContext() as env: + motion = UP.SensorMotionManager(agi, debug=True) + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "m") + UP.add_stage_variable("distance_units", "nmi") + UP.add_stage_variable("intersection_model", get_intersection_locations) + UP.add_stage_variable("motion_manager", motion) + + sensor = DummySensor(env, UP.GeodeticLocation(0, 0, 0), radius=150.0) + + t = 2 + geo_mover_start = UP.GeodeticLocation(*[t, t, 4000]) + geo_mover = RealGeodeticMover(name="Mover", loc=geo_mover_start, speed=1, detect=True) + + waypoints = [ + geo_mover_start, + UP.GeodeticLocation(*[-t, t, 4000]), + UP.GeodeticLocation(*[-t, -t, 4000]), + UP.GeodeticLocation(*[t, -t, 0]), + ] + + task = DoMove() + task.waypoints = waypoints[1:] + task.run(actor=geo_mover) + + motion.add_sensor(sensor) + + env.run() + assert abs(geo_mover.loc - waypoints[-1]) <= 1e-12 + 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 + + +def test_analytical_intersection() -> None: + with UP.EnvironmentContext(): + UP.add_stage_variable("stage_model", Spherical) + UP.add_stage_variable("altitude_units", "m") + UP.add_stage_variable("distance_units", "nmi") + + start = UP.GeodeticLocation(33.67009544379275, -84.59178543542892, 5_000) + finish = UP.GeodeticLocation(33.871012616336344, -84.16331866903882, 5_000) + middle = UP.GeodeticLocation(33.7774620987044, -84.38304521590554, 4_000) + + res = agi(start, finish, 200.0, middle, 200.0) + intersections, times, types, path_time = res + assert types == ["START_INSIDE", "END_INSIDE"] diff --git a/src/upstage/test/test_network_qol.py b/src/upstage_des/test/test_network_qol.py similarity index 97% rename from src/upstage/test/test_network_qol.py rename to src/upstage_des/test/test_network_qol.py index 2174312..60268f1 100644 --- a/src/upstage/test/test_network_qol.py +++ b/src/upstage_des/test/test_network_qol.py @@ -3,8 +3,8 @@ # Licensed under the BSD 3-Clause License. # See the LICENSE file in the project root for complete license terms and disclaimers. -import upstage.api as UP -from upstage.type_help import TASK_GEN +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN class Act(UP.Actor): diff --git a/src/upstage/test/test_nucleus.py b/src/upstage_des/test/test_nucleus.py similarity index 96% rename from src/upstage/test/test_nucleus.py rename to src/upstage_des/test/test_nucleus.py index f5843d6..2f36863 100644 --- a/src/upstage/test/test_nucleus.py +++ b/src/upstage_des/test/test_nucleus.py @@ -5,8 +5,8 @@ from typing import Any -import upstage.api as UP -from upstage.type_help import TASK_GEN +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN class Dummy(UP.Actor): diff --git a/src/upstage/test/test_nucleus_state_share/__init__.py b/src/upstage_des/test/test_nucleus_state_share/__init__.py similarity index 100% rename from src/upstage/test/test_nucleus_state_share/__init__.py rename to src/upstage_des/test/test_nucleus_state_share/__init__.py diff --git a/src/upstage/test/test_nucleus_state_share/flyer.py b/src/upstage_des/test/test_nucleus_state_share/flyer.py similarity index 97% rename from src/upstage/test/test_nucleus_state_share/flyer.py rename to src/upstage_des/test/test_nucleus_state_share/flyer.py index f49a4d2..4dfb1e1 100644 --- a/src/upstage/test/test_nucleus_state_share/flyer.py +++ b/src/upstage_des/test/test_nucleus_state_share/flyer.py @@ -5,8 +5,8 @@ import simpy as SIM -import upstage.api as UP -from upstage.type_help import TASK_GEN +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN from .mothership import Mothership from .mover import Mover diff --git a/src/upstage/test/test_nucleus_state_share/mothership.py b/src/upstage_des/test/test_nucleus_state_share/mothership.py similarity index 94% rename from src/upstage/test/test_nucleus_state_share/mothership.py rename to src/upstage_des/test/test_nucleus_state_share/mothership.py index 312fed1..ab260a1 100644 --- a/src/upstage/test/test_nucleus_state_share/mothership.py +++ b/src/upstage_des/test/test_nucleus_state_share/mothership.py @@ -1,75 +1,75 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -from typing import Any - -import simpy as SIM - -import upstage.api as UP -from upstage.type_help import TASK_GEN - -from .mover import Mover - - -class Mothership(Mover): - fuel_ports_in_use = UP.State[int](valid_types=(int,)) - fuel_ports_max = UP.State[int](valid_types=(int,), frozen=True) - messages = UP.ResourceState[UP.SelfMonitoringStore]( - default=UP.SelfMonitoringStore, valid_types=(UP.SelfMonitoringStore, SIM.Store) - ) - - -class DispenseFuel(UP.Task): - def task(self, *, actor: Mothership) -> TASK_GEN: - draws = self.get_actor_knowledge(actor, "fuel_users", must_exist=False) - draws = {} if draws is None else draws - total_draw = sum(draws.values()) - actor.activate_state( - state="fuel", - task=self, - rate=total_draw, - ) - # Infinite wait! - # We use Nucleus to go to the interrupt. - yield UP.Event() - - def on_interrupt(self, *, actor: Mothership, cause: Any) -> UP.InterruptStates: - # No matter what, restart - return self.INTERRUPT.RESTART - - -give_fuel_factory = UP.TaskNetworkFactory.from_single_looping("GiveFuel", DispenseFuel) - - -class CrewMember(UP.Task): - def _user_add(self, actor: Mothership, vehicle: Mover, add: float) -> int: - know = self.get_actor_knowledge(actor, "fuel_users") - know = {} if know is None else know - know[vehicle] = add - self.set_actor_knowledge(actor, "fuel_users", know, overwrite=True) - return len(know) - - def _user_remove(self, actor: Mothership, vehicle: Mover) -> int: - know = self.get_actor_knowledge(actor, "fuel_users") - know = {} if know is None else know - del know[vehicle] - self.set_actor_knowledge(actor, "fuel_users", know, overwrite=True) - return len(know) - - def task(self, *, actor: Mothership) -> TASK_GEN: - # receive a message that someone is ready, then update fuel_users - msg = yield UP.Get(actor.messages) - vehicle, draw = msg - if draw == 0: - ports = self._user_remove(actor, vehicle) - else: - ports = self._user_add(actor, vehicle, draw) - # kill some time to send a message back - yield UP.Wait(5 / 60) - yield UP.Put(vehicle.messages, "GO") - actor.fuel_ports_in_use = ports - - -crew_factory = UP.TaskNetworkFactory.from_single_looping("crew", CrewMember) +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +from typing import Any + +import simpy as SIM + +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN + +from .mover import Mover + + +class Mothership(Mover): + fuel_ports_in_use = UP.State[int](valid_types=(int,)) + fuel_ports_max = UP.State[int](valid_types=(int,), frozen=True) + messages = UP.ResourceState[UP.SelfMonitoringStore]( + default=UP.SelfMonitoringStore, valid_types=(UP.SelfMonitoringStore, SIM.Store) + ) + + +class DispenseFuel(UP.Task): + def task(self, *, actor: Mothership) -> TASK_GEN: + draws = self.get_actor_knowledge(actor, "fuel_users", must_exist=False) + draws = {} if draws is None else draws + total_draw = sum(draws.values()) + actor.activate_state( + state="fuel", + task=self, + rate=total_draw, + ) + # Infinite wait! + # We use Nucleus to go to the interrupt. + yield UP.Event() + + def on_interrupt(self, *, actor: Mothership, cause: Any) -> UP.InterruptStates: + # No matter what, restart + return self.INTERRUPT.RESTART + + +give_fuel_factory = UP.TaskNetworkFactory.from_single_looping("GiveFuel", DispenseFuel) + + +class CrewMember(UP.Task): + def _user_add(self, actor: Mothership, vehicle: Mover, add: float) -> int: + know = self.get_actor_knowledge(actor, "fuel_users") + know = {} if know is None else know + know[vehicle] = add + self.set_actor_knowledge(actor, "fuel_users", know, overwrite=True) + return len(know) + + def _user_remove(self, actor: Mothership, vehicle: Mover) -> int: + know = self.get_actor_knowledge(actor, "fuel_users") + know = {} if know is None else know + del know[vehicle] + self.set_actor_knowledge(actor, "fuel_users", know, overwrite=True) + return len(know) + + def task(self, *, actor: Mothership) -> TASK_GEN: + # receive a message that someone is ready, then update fuel_users + msg = yield UP.Get(actor.messages) + vehicle, draw = msg + if draw == 0: + ports = self._user_remove(actor, vehicle) + else: + ports = self._user_add(actor, vehicle, draw) + # kill some time to send a message back + yield UP.Wait(5 / 60) + yield UP.Put(vehicle.messages, "GO") + actor.fuel_ports_in_use = ports + + +crew_factory = UP.TaskNetworkFactory.from_single_looping("crew", CrewMember) diff --git a/src/upstage/test/test_nucleus_state_share/mover.py b/src/upstage_des/test/test_nucleus_state_share/mover.py similarity index 96% rename from src/upstage/test/test_nucleus_state_share/mover.py rename to src/upstage_des/test/test_nucleus_state_share/mover.py index 5179265..483991b 100644 --- a/src/upstage/test/test_nucleus_state_share/mover.py +++ b/src/upstage_des/test/test_nucleus_state_share/mover.py @@ -5,8 +5,8 @@ from typing import Any -import upstage.api as UP -from upstage.type_help import TASK_GEN +import upstage_des.api as UP +from upstage_des.type_help import TASK_GEN class Mover(UP.Actor): diff --git a/src/upstage/test/test_nucleus_state_share/test_refuel_example.py b/src/upstage_des/test/test_nucleus_state_share/test_refuel_example.py similarity index 99% rename from src/upstage/test/test_nucleus_state_share/test_refuel_example.py rename to src/upstage_des/test/test_nucleus_state_share/test_refuel_example.py index 8e3df08..2837c9d 100644 --- a/src/upstage/test/test_nucleus_state_share/test_refuel_example.py +++ b/src/upstage_des/test/test_nucleus_state_share/test_refuel_example.py @@ -6,8 +6,8 @@ import pytest import simpy as SIM -import upstage.api as UP -from upstage.type_help import SIMPY_GEN +import upstage_des.api as UP +from upstage_des.type_help import SIMPY_GEN from .flyer import Flyer, flyer_refuel_factory, mission_plan_net from .mothership import Mothership, crew_factory, give_fuel_factory diff --git a/src/upstage/test/test_parallel_task_network.py b/src/upstage_des/test/test_parallel_task_network.py similarity index 96% rename from src/upstage/test/test_parallel_task_network.py rename to src/upstage_des/test/test_parallel_task_network.py index ad49b87..facf517 100644 --- a/src/upstage/test/test_parallel_task_network.py +++ b/src/upstage_des/test/test_parallel_task_network.py @@ -5,9 +5,9 @@ import simpy as SIM -import upstage.api as UP -from upstage.api import Task -from upstage.type_help import SIMPY_GEN, TASK_GEN +import upstage_des.api as UP +from upstage_des.api import Task +from upstage_des.type_help import SIMPY_GEN, TASK_GEN class ParallelTest(UP.Actor): diff --git a/src/upstage/test/test_sim_wide_tracking.py b/src/upstage_des/test/test_sim_wide_tracking.py similarity index 99% rename from src/upstage/test/test_sim_wide_tracking.py rename to src/upstage_des/test/test_sim_wide_tracking.py index 22df4cd..c431443 100644 --- a/src/upstage/test/test_sim_wide_tracking.py +++ b/src/upstage_des/test/test_sim_wide_tracking.py @@ -3,7 +3,7 @@ # Licensed under the BSD 3-Clause License. # See the LICENSE file in the project root for complete license terms and disclaimers. -import upstage.api as UP +import upstage_des.api as UP class Example(UP.Actor): diff --git a/src/upstage/test/test_stage.py b/src/upstage_des/test/test_stage.py similarity index 97% rename from src/upstage/test/test_stage.py rename to src/upstage_des/test/test_stage.py index a7d35b5..ec8fd01 100644 --- a/src/upstage/test/test_stage.py +++ b/src/upstage_des/test/test_stage.py @@ -5,7 +5,7 @@ import pytest -from upstage.api import ( +from upstage_des.api import ( EnvironmentContext, UpstageBase, UpstageError, diff --git a/src/upstage/test/test_state.py b/src/upstage_des/test/test_state.py similarity index 97% rename from src/upstage/test/test_state.py rename to src/upstage_des/test/test_state.py index cb5322d..9568d43 100644 --- a/src/upstage/test/test_state.py +++ b/src/upstage_des/test/test_state.py @@ -8,12 +8,12 @@ import pytest from simpy import Container, Environment, Store -import upstage.api as UP -import upstage.resources.monitoring as monitor -from upstage.actor import Actor -from upstage.api import EnvironmentContext, SimulationError, UpstageError -from upstage.states import LinearChangingState, ResourceState, State -from upstage.type_help import SIMPY_GEN +import upstage_des.api as UP +import upstage_des.resources.monitoring as monitor +from upstage_des.actor import Actor +from upstage_des.api import EnvironmentContext, SimulationError, UpstageError +from upstage_des.states import LinearChangingState, ResourceState, State +from upstage_des.type_help import SIMPY_GEN class StateTest: diff --git a/src/upstage/test/test_state_and_task_sharing.py b/src/upstage_des/test/test_state_and_task_sharing.py similarity index 97% rename from src/upstage/test/test_state_and_task_sharing.py rename to src/upstage_des/test/test_state_and_task_sharing.py index 34edf1f..7cfc628 100644 --- a/src/upstage/test/test_state_and_task_sharing.py +++ b/src/upstage_des/test/test_state_and_task_sharing.py @@ -7,7 +7,7 @@ import pytest -from upstage.api import ( +from upstage_des.api import ( Actor, CartesianLocationChangingState, EnvironmentContext, @@ -19,9 +19,9 @@ Wait, add_stage_variable, ) -from upstage.data_types import CartesianLocation, GeodeticLocation -from upstage.geography import Spherical -from upstage.type_help import TASK_GEN +from upstage_des.data_types import CartesianLocation, GeodeticLocation +from upstage_des.geography import Spherical +from upstage_des.type_help import TASK_GEN class Mover(Actor): diff --git a/src/upstage/test/test_state_piggyback.py b/src/upstage_des/test/test_state_piggyback.py similarity index 96% rename from src/upstage/test/test_state_piggyback.py rename to src/upstage_des/test/test_state_piggyback.py index 8dff457..2874061 100644 --- a/src/upstage/test/test_state_piggyback.py +++ b/src/upstage_des/test/test_state_piggyback.py @@ -5,11 +5,11 @@ import pytest -from upstage.actor import Actor -from upstage.api import Task, UpstageError, Wait -from upstage.base import EnvironmentContext -from upstage.states import LinearChangingState, State -from upstage.type_help import TASK_GEN +from upstage_des.actor import Actor +from upstage_des.api import Task, UpstageError, Wait +from upstage_des.base import EnvironmentContext +from upstage_des.states import LinearChangingState, State +from upstage_des.type_help import TASK_GEN class Piggy(Actor): diff --git a/src/upstage/test/test_stepped_motion.py b/src/upstage_des/test/test_stepped_motion.py similarity index 97% rename from src/upstage/test/test_stepped_motion.py rename to src/upstage_des/test/test_stepped_motion.py index 76be953..43a8a75 100644 --- a/src/upstage/test/test_stepped_motion.py +++ b/src/upstage_des/test/test_stepped_motion.py @@ -7,10 +7,10 @@ import pytest -import upstage.api as UP -from upstage.motion import SteppedMotionManager -from upstage.type_help import TASK_GEN -from upstage.utils import waypoint_time_and_dist +import upstage_des.api as UP +from upstage_des.motion import SteppedMotionManager +from upstage_des.type_help import TASK_GEN +from upstage_des.utils import waypoint_time_and_dist class LocatedActor(Protocol): diff --git a/src/upstage/test/test_stores.py b/src/upstage_des/test/test_stores.py similarity index 95% rename from src/upstage/test/test_stores.py rename to src/upstage_des/test/test_stores.py index 051fa07..39af4b1 100644 --- a/src/upstage/test/test_stores.py +++ b/src/upstage_des/test/test_stores.py @@ -9,19 +9,19 @@ import pytest from simpy import Environment, Store -from upstage.actor import Actor -from upstage.base import EnvironmentContext -from upstage.events import FilterGet -from upstage.resources.monitoring import ( +from upstage_des.actor import Actor +from upstage_des.base import EnvironmentContext +from upstage_des.events import FilterGet +from upstage_des.resources.monitoring import ( SelfMonitoringContainer, SelfMonitoringFilterStore, SelfMonitoringReserveStore, SelfMonitoringSortedFilterStore, SelfMonitoringStore, ) -from upstage.resources.reserve import ReserveStore -from upstage.resources.sorted import SortedFilterGet, SortedFilterStore -from upstage.type_help import SIMPY_GEN +from upstage_des.resources.reserve import ReserveStore +from upstage_des.resources.sorted import SortedFilterGet, SortedFilterStore +from upstage_des.type_help import SIMPY_GEN MAX_RUN_TIME = 10.0 diff --git a/src/upstage/test/test_task.py b/src/upstage_des/test/test_task.py similarity index 97% rename from src/upstage/test/test_task.py rename to src/upstage_des/test/test_task.py index c14896e..9f3bb48 100644 --- a/src/upstage/test/test_task.py +++ b/src/upstage_des/test/test_task.py @@ -9,13 +9,13 @@ import pytest from simpy import Environment, Process -from upstage.actor import Actor -from upstage.api import InterruptStates, SimulationError -from upstage.base import EnvironmentContext -from upstage.events import Wait -from upstage.states import LinearChangingState, State -from upstage.task import Task, TerminalTask -from upstage.type_help import SIMPY_GEN, TASK_GEN +from upstage_des.actor import Actor +from upstage_des.api import InterruptStates, SimulationError +from upstage_des.base import EnvironmentContext +from upstage_des.events import Wait +from upstage_des.states import LinearChangingState, State +from upstage_des.task import Task, TerminalTask +from upstage_des.type_help import SIMPY_GEN, TASK_GEN class ActorForTest(Actor): diff --git a/src/upstage/test/test_task_network.py b/src/upstage_des/test/test_task_network.py similarity index 96% rename from src/upstage/test/test_task_network.py rename to src/upstage_des/test/test_task_network.py index 1fe05b3..15f5916 100644 --- a/src/upstage/test/test_task_network.py +++ b/src/upstage_des/test/test_task_network.py @@ -1,832 +1,832 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -from collections.abc import Sequence - -import pytest -from simpy import Environment -from simpy import Resource as sp_resource -from simpy import Store as sp_store - -from upstage.api import ( - Actor, - Any, - CartesianLocationChangingState, - DecisionTask, - EnvironmentContext, - Event, - Get, - InterruptStates, - LinearChangingState, - Put, - ResourceHold, - State, - Task, - TaskLinks, - TaskNetworkFactory, - Wait, - add_stage_variable, -) -from upstage.data_types import CartesianLocation, Location -from upstage.task import process -from upstage.type_help import SIMPY_GEN, TASK_GEN - - -class Base: - def __init__( - self, - env: Environment, - name: str, - x: float, - y: float, - num_runways: int = 1, - parking_max: int = 10, - ) -> None: - self.env = env - self.name = name - self.location = CartesianLocation(x=x, y=y) - self.runway = sp_resource(self.env, capacity=num_runways) - self.maintenance_queue = sp_store(self.env) - self.parking = sp_store(self.env, parking_max) - self.parking_tokens = sp_store(self.env, parking_max) - self.parking_tokens.items = [(self, self.parking_tokens, i) for i in range(parking_max)] - self.operational = True - - # yes, this is weird - self.location = CartesianLocation(x, y) - - self.parking_max = parking_max - self.curr_parked: int = 0 - self.claimed_parking: int = 0 - self._parking_claims: list[Any] = [] - self._parked: list[Any] = [] - - def claim_parking(self, plane: Any) -> None: - self._parking_claims.append(plane) - self.claimed_parking += 1 - - def has_parking(self, plane: Any) -> int: - return self.curr_parked + self.claimed_parking < self.parking_max - - @process - def run_maintenance(self) -> SIMPY_GEN: - while True: - plane = yield Get(self.maintenance_queue).as_event() - yield Wait(1.6).as_event() - mx_wait = plane.get_knowledge("mx_wait") - # alter the plane's code - plane.code = 0 - mx_wait.succeed() - - def __repr__(self) -> str: - return f"{self.name}:{super().__repr__()}" - - -class World: - """A helper class for data storage and environment analysis""" - - def __init__(self, bases: Sequence[Base]) -> None: - self.bases = bases - - def nearest_base(self, loc: CartesianLocation) -> Base: - b = min(self.bases, key=lambda x: x.location - loc) - return b - - def bases_by_location(self, loc: CartesianLocation) -> Base: - x, y = (loc.x, loc.y) - return [b for b in self.bases if b.location.x == x and b.location.y == y][0] - - -class Aircraft(Actor): - base = State[Base | None](default=None) - location = CartesianLocationChangingState(recording=True) - speed = State[float]() - landing_time = State[float]() - takeoff_time = State[float]() - code = State[int]() - fuel = LinearChangingState(recording=True) - fuel_burn = State[float]() - parking_token = State[int]() - parking_spot = State[int]() - command_data = State[Any]() - world = State[World]() - - def calculate_bingo(self, max_time: float = float("inf")) -> float: - # get the farthest base - farthest: Base = max(self.world.bases, key=lambda x: self.location - x.location) - dist = self.location - farthest.location - time = dist / self.speed - fuel_needed = self.fuel_burn * time - time_until_bingo = (self.fuel - fuel_needed) / self.fuel_burn - return min(time_until_bingo, max_time) - - -class CodeFour(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - wait = Event(rehearsal_time_to_complete=float("inf")) - yield wait - - -class GroundWait(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - wait = Event(rehearsal_time_to_complete=0.0) - yield wait - - -class GroundTakeoffWait(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - destination = self.get_actor_knowledge(actor, "destination") - if not isinstance(destination, Location): - destination = destination.location - arrival_time = self.get_actor_knowledge(actor, "arrival time") - if arrival_time is None: - wait_time = 0 - else: - distance = destination - actor.location - flight_time = distance / actor.speed - wait_time = arrival_time - flight_time - yield Wait(wait_time) - - -class Takeoff(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - base = actor.base - assert base is not None - runway_request = ResourceHold(base.runway) - yield runway_request - takeoff_time = Wait(actor.takeoff_time) - yield takeoff_time - yield runway_request - # HOW WOULD THIS WORK IN TRIAL MODE? - # what if it's a parking spot token? A token state? - parking_event = Put(base.parking_tokens, actor.parking_spot) - yield parking_event - actor.parking_spot = -1 - # set our current location as above the base - actor.base = None - - def on_interrupt(self, *, actor: Aircraft, cause: str) -> InterruptStates: - director = self.get_actor_knowledge(actor, "director") - if director is not None: - director.report_failure(actor) - return self.INTERRUPT.END - - -class Fly(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - destination = self.get_actor_knowledge(actor, "destination") - if not isinstance(destination, Location): - destination = destination.location - actor.activate_state( - state="location", - task=self, - speed=actor.speed, - waypoints=[destination], - ) - actor.activate_state( - state="fuel", - task=self, - rate=-actor.fuel_burn, - ) - - # assume that locations can do this - distance = destination - actor.location - time = distance / actor.speed - fly_wait = Wait(time) - - yield fly_wait - - actor.deactivate_all_states(task=self) - intent = self.get_actor_knowledge(actor, "intent") - next_task = self.get_actor_next_task(actor) - - # if our intent is to land, make sure next task is a landing check - if intent == "land" and next_task != "LandingCheck": - self.clear_actor_task_queue(actor) - self.set_actor_task_queue( - actor, - [ - "LandingCheck", - ], - ) - - def on_interrupt(self, *, actor: Aircraft, cause: str) -> InterruptStates: - # For testing a restart, this flying is fine, since there is only one - # final destination. - if cause == "restart": - actor.speed = 0.5 - return self.INTERRUPT.RESTART - else: - return self.INTERRUPT.END - - -class Loiter(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - # calculate bingo - time = actor.calculate_bingo() - loiter_wait = Wait(time) - actor.activate_state( - state="fuel", - task=self, - fuel_burn_rate=-actor.fuel_burn, - ) - yield loiter_wait - actor.deactivate_state( - state="fuel", - task=self, - ) - - -class Mission(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - # tell the mission folks you are here - # they'll give you an event to watch to leave - commander = self.get_actor_knowledge(actor, "commander", must_exist=True) - leave_event = commander.arrival(actor) - # set up an alternate bingo leave event - time = actor.calculate_bingo() - bingo_wait = Wait(time) - - stop_mission = Any(leave_event, bingo_wait) - actor.activate_state( - state="fuel", - task=self, - fuel_burn_rate=-actor.fuel_burn, - ) - yield stop_mission - actor.deactivate_all_states(task=self) - - -class LandingLocationSelection(DecisionTask): - def rehearse_decision(self, *, actor: Aircraft) -> None: - base = actor.stage.world.bases[0] - self.set_actor_knowledge(actor, "destination", base) - self.set_actor_knowledge(actor, "intent", "land") - - def make_decision(self, *, actor: Aircraft) -> None: - # These kinds of cognitive tasks must be zero-time (no yields!) - landable_bases = [b for b in actor.stage.world.bases if b.has_parking(actor)] - base = landable_bases[0] - - self.set_actor_knowledge(actor, "destination", base) - self.set_actor_knowledge(actor, "intent", "land") - - -class LandingLocationPrep(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - base = self.get_actor_knowledge(actor, "destination") - token_event = Get(base.parking_tokens) - parking_token = yield token_event - self.set_actor_knowledge(actor, "parking_token", parking_token) - - -class LandingCheck(DecisionTask): - """Task for checking the ability to land at the base an actor is above. - - If landing is available, continue in the task network. Otherwise, reselect a base. - """ - - return_task_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - def rehearse_decision(self, *, actor: Aircraft) -> None: - return None - - def make_decision(self, *, actor: Aircraft) -> None: - # get base from the actor's destination - base = self.get_actor_knowledge(actor, "destination") - # assert that the base can be landed at - if not base.operational: - # clear the queue - self.clear_actor_task_queue(actor) - self.clear_actor_knowledge(actor, "destination") - self.clear_actor_knowledge(actor, "intent") - # set up for a task network path that gets a new place to land - self.set_actor_task_queue(actor, self.return_task_list) - else: - # check that we are landing - msg = f"Actor {actor} not landing after check" - assert self.get_actor_next_task(actor) == "Land", msg - - -class Land(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - base = self.get_actor_knowledge(actor, "destination") - self.clear_actor_knowledge(actor, "destination") - runway_request = ResourceHold(base.runway) - - self.set_marker("pre-runway") - yield runway_request - landing_time = Wait(actor.landing_time) - - self.set_marker("during landing") - yield landing_time - - self.set_marker("post-landing", self.INTERRUPT.IGNORE) - yield runway_request - self.clear_marker() - - self.set_actor_knowledge(actor, "base", base) - # just to help with a 'clear actor from all stores' need? - put_event = Put(base.parking, actor) - self.set_marker("get parking", self.INTERRUPT.IGNORE) - yield put_event - - self.set_marker("post-parking", interrupt_action=self.INTERRUPT.IGNORE) - parking_token = self.get_actor_knowledge(actor, "parking_token") - put_event = Put(base.parking_tokens, parking_token) - yield put_event - - self.clear_actor_knowledge(actor, "parking_token") - self.clear_actor_knowledge(actor, "intent") - - def on_interrupt(self, *, actor: Aircraft, cause: str) -> InterruptStates: - # if interrupted, find a new place to land - # figure out where we were in the task based on the marker - marker = self.get_marker() - self.get_marker_time() - if marker in [ - "pre-runway", - ]: - # We are done with this base - # put the parking token back - parking_token = self.get_actor_knowledge(actor, "parking_token") - if parking_token: - store = parking_token[1] - Put(store, parking_token) - self.clear_actor_knowledge(actor, "parking_token") - # set the task network to try landing again - return_task_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - self.clear_actor_task_queue(actor) - self.set_actor_task_queue(actor, return_task_list) - return self.INTERRUPT.END - elif marker in [ - "during landing", - ]: - # continue on if the cause is benign - if cause == "Code 4": - # clear the knowledge and task queue - parking_token = self.get_actor_knowledge(actor, "parking_token") - if parking_token: - store = parking_token[1] - Put(store, parking_token) - self.clear_actor_knowledge(actor, "parking_token") - self.clear_actor_task_queue(actor) - self.set_actor_task_queue(actor, ["Code4"]) - return self.INTERRUPT.END - else: - return self.INTERRUPT.IGNORE - else: - # other markers mean that the landing was safe so no - # need to do anything - raise ValueError("Shouldn't be here") - - -class MaintenanceWait(Task): - def task(self, *, actor: Aircraft) -> TASK_GEN: - base = self.get_actor_knowledge(actor, "base") - maintenance_put = Put(base.maintenance_queue, actor) - yield maintenance_put - mx_wait = Event(rehearsal_time_to_complete=2.0) - self.set_actor_knowledge(actor, "mx_wait", mx_wait) - yield mx_wait - self.clear_actor_knowledge(actor, "mx_wait") - - def on_interrupt(self, *, actor: Actor, cause: Any) -> InterruptStates: - return InterruptStates.IGNORE - - -BASE_LOCATIONS = [ - (10.023, 4.63), - (2.409, 7.279), - (0.529, 11.004), - (6.468, 17.153), - (5.802, 17.215), -] - -task_classes = { - "GroundWait": GroundWait, - "GroundTakeoffWait": GroundTakeoffWait, - "Takeoff": Takeoff, - "Fly": Fly, - "Loiter": Loiter, - "Mission": Mission, - "LandingLocationSelection": LandingLocationSelection, - "LandingLocationPrep": LandingLocationPrep, - "LandingCheck": LandingCheck, - "Land": Land, - "MaintenanceWait": MaintenanceWait, - "Code4": CodeFour, -} -_task_links = { - "GroundWait": [ - "Takeoff", - "Code4", - ], - "Takeoff": [ - "Fly", - "Loiter", - "LandingLocationSelection", - "Code4", - ], - "Loiter": [ - "Fly", - "Mission", - "LandingLocationSelection", - ], - "Fly": [ - "Loiter", - "Mission", - "LandingLocationSelection", - "LandingCheck", - "Fly", - ], - "Mission": [ - "Fly", - "Loiter", - "LandingLocationSelection", - ], - "LandingLocationSelection": [ - "Fly", - "Loiter", - "LandingLocationPrep", - ], - "LandingLocationPrep": [ - "Fly", - "Loiter", - "LandingCheck", - "LandingLocationSelection", - ], - "LandingCheck": [ - "Land", - "Loiter", - "Fly", - "LandingLocationSelection", - ], - "Land": [ - "MaintenanceWait", - "Loiter", - "Code4", - ], - "MaintenanceWait": [ - "GroundWait", - "Code4", - ], - "Code4": [], -} -# quick fix for new task network link style -task_links: dict[str, TaskLinks] = {} -for k, v in _task_links.items(): - new = TaskLinks(default=v[0] if v else None, allowed=v) - task_links[k] = new - - -def _build_test(env: Environment) -> Aircraft: - bases = [] - for i in range(len(BASE_LOCATIONS)): - x, y = BASE_LOCATIONS[i] - b = Base(env, f"Base {i}", x, y) - bases.append(b) - b.run_maintenance() - - world = World(bases) - add_stage_variable("world", world) - - p = Aircraft( - name="my plane", - base=None, - location=CartesianLocation(0, 0), - speed=12, - landing_time=5 / 60, - takeoff_time=10 / 60, - code=2, - fuel=100, - fuel_burn=15, - parking_token=None, - parking_spot=None, - command_data=None, - world=world, - debug_log=True, - ) - - return p - - -def test_plane_bingo() -> None: - with EnvironmentContext() as env: - p = _build_test(env) - bingo_hours = 5.14 - bingo_result = p.calculate_bingo() - assert pytest.approx(bingo_result, abs=0.01) == bingo_hours - - -def test_creating_network() -> None: - with EnvironmentContext(): - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - _ = task_fact.make_network() - - -def test_rehearsing_network() -> None: - with EnvironmentContext() as env: - actor = _build_test(env) - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - net = task_fact.make_network() - - # build arguments for the task list - task_name_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - actor.add_task_network(net) - # start the task network - actor.start_network_loop("plane_net", init_task_name="GroundWait") - env.run() - assert env.now == 0 - - new_actor = net.rehearse_network(actor=actor, task_name_list=task_name_list) - - # Did the new actor do what we wanted it to? - base = new_actor.get_knowledge("base") - base2 = actor.stage.world.bases[0] - # Notice that due to copying the actor, the bases aren't exactly the same - assert base.name == base2.name, "Wrong base selected" - assert len(new_actor._knowledge) == 1, "Too much knowledge left" - assert pytest.approx(new_actor.fuel, abs=0.01) == 86.199 - - # Is the original actor untouched? - assert len(actor._knowledge) == 0, "Actor should not have done anything" - - assert new_actor.code == 2, "Wrong MX code" - - assert new_actor.env.now > 2.0, "Cloned actor environment at the wrong time" - - task = actor.get_running_task("plane_net") - assert task is not None and task.name == "GroundWait" - - -def test_rehearsing_from_actor() -> None: - with EnvironmentContext() as env: - actor = _build_test(env) - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - net = task_fact.make_network() - - # build arguments for the task list - task_name_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - actor.add_task_network(net) - - new_actor = actor.rehearse_network( - "plane_net", - task_name_list, - knowledge={"dummy_know": 8675309}, - ) - - # Make sure the knowledge was set on the copy, but not the original - assert actor.get_knowledge("dummy_know") is None - assert new_actor.get_knowledge("dummy_know") == 8675309 - - # Did the new actor do what we wanted it to? - base = new_actor.get_knowledge("base") - base2 = actor.stage.world.bases[0] - # Notice that due to copying the actor, the bases aren't exactly the same - assert base.name == base2.name, "Wrong base selected" - assert len(new_actor._knowledge) == 2, "Too much knowledge left" - assert pytest.approx(new_actor.fuel, abs=0.01) == 86.199 - - # Is the original actor untouched? - assert len(actor._knowledge) == 0, "Actor should not have done anything" - assert new_actor.code == 2, "Wrong MX code" - - assert new_actor.env.now > 2.0, "Cloned actor environment at the wrong time" - - -def test_running_simple_network() -> None: - with EnvironmentContext() as env: - actor = _build_test(env) - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - net = task_fact.make_network() - - assert str(net) == "Task network: plane_net" - - # build arguments for the task list - task_name_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - # tell the actor the queue its getting - actor.add_task_network(net) - actor.set_task_queue("plane_net", task_name_list) - - # run the queue with the network - net.loop(actor=actor) - env.run() - - base = actor.get_knowledge("base") - base2 = actor.stage.world.bases[0] - assert base is base2, "Wrong base selected" - assert len(actor._knowledge) == 1, "Too much knowledge left" - assert pytest.approx(actor.fuel, abs=0.01) == 86.199 - assert actor.code == 0, "Wrong MX code" - - -def test_interrupting_network() -> None: - with EnvironmentContext() as env: - actor = _build_test(env) - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - net = task_fact.make_network() - - # build arguments for the task list - task_name_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - # tell the actor the queue its getting - actor.add_task_network(net) - actor.set_task_queue("plane_net", task_name_list) - - # create a process that interrupts the plane during different times - def interrupting_proc( - env: Environment, actor: Aircraft, interrupt_time: float - ) -> SIMPY_GEN: - yield env.timeout(interrupt_time) - # get the process - network = actor._task_networks["plane_net"] - assert network._current_task_proc is not None - network._current_task_proc.interrupt(cause="a reason") - - # run the queue with the network - net.loop(actor=actor) - env.process(interrupting_proc(env, actor, 1.0)) - env.run() - - # the plane should land still - assert actor._task_queue["plane_net"] == [], "Actor had tasks left" - assert actor.code == 0, "Actor didn't get maintained" - - -def test_interrupting_network_with_cause() -> None: - with EnvironmentContext() as env: - actor = _build_test(env) - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - net = task_fact.make_network() - - # build arguments for the task list - task_name_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - # tell the actor the queue its getting - actor.add_task_network(net) - actor.set_task_queue("plane_net", task_name_list) - - # create a process that interrupts the plane during different times - def interrupting_proc( - env: Environment, actor: Aircraft, interrupt_time: float - ) -> SIMPY_GEN: - yield env.timeout(interrupt_time) - # get the process - # network = actor._task_networks["plane_net"] - # network._current_task_proc.interrupt(cause="Code 4") - actor.interrupt_network("plane_net", cause="Code 4") - - # run the queue with the network - net.loop(actor=actor) - env.process(interrupting_proc(env, actor, 1.0)) - env.run() - - -def test_interrupting_network_with_restart() -> None: - with EnvironmentContext() as env: - actor = _build_test(env) - task_fact = TaskNetworkFactory( - "plane_net", - task_classes, - task_links, - ) - net = task_fact.make_network() - - # build arguments for the task list - task_name_list = [ - "LandingLocationSelection", - "LandingLocationPrep", - "Fly", - "LandingCheck", - "Land", - "MaintenanceWait", - ] - - # tell the actor the queue its getting - actor.add_task_network(net) - actor.set_task_queue("plane_net", task_name_list) - - # create a process that interrupts the plane during different times - def interrupting_proc( - env: Environment, actor: Aircraft, interrupt_time: float - ) -> SIMPY_GEN: - yield env.timeout(interrupt_time) - # get the process - network = actor._task_networks["plane_net"] - assert network._current_task_name is not None and network._current_task_name == "Fly" - assert network._current_task_proc is not None - network._current_task_proc.interrupt(cause="restart") - - # run the queue with the network - net.loop(actor=actor) - env.process(interrupting_proc(env, actor, 0.1)) - env.run() - # the plane should land still - assert actor._task_queue["plane_net"] == [], "Actor had tasks left" - assert actor.code == 0, "Actor didn't get maintained" - # it should take longer than the cancelled version - assert pytest.approx(env.now, abs=0.0001) == 21.464767 - - -def test_rehearsal_time() -> None: - class Thing(Actor): - the_time = LinearChangingState(recording=True) - - class ThingWait(Task): - def task(self, *, actor: Thing) -> TASK_GEN: - actor.activate_state( - state="the_time", - task=self, - rate=1.0, - ) - yield Wait.from_random_uniform(1.0, 2.0) - actor.deactivate_all_states(task=self) - - with EnvironmentContext(): - tasks = {"ThingWait": ThingWait} - task_links = {"ThingWait": TaskLinks(default="ThingWait", allowed=["ThingWait"])} - factory = TaskNetworkFactory("fact", tasks, task_links) - - thing = Thing(name="Actor", the_time=0) - thing.add_task_network(factory.make_network()) - new_thing = thing.rehearse_network("fact", ["ThingWait", "ThingWait"]) - assert new_thing.env.now == new_thing.the_time, "Bad rehearsal env time" +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +from collections.abc import Sequence + +import pytest +from simpy import Environment +from simpy import Resource as sp_resource +from simpy import Store as sp_store + +from upstage_des.api import ( + Actor, + Any, + CartesianLocationChangingState, + DecisionTask, + EnvironmentContext, + Event, + Get, + InterruptStates, + LinearChangingState, + Put, + ResourceHold, + State, + Task, + TaskLinks, + TaskNetworkFactory, + Wait, + add_stage_variable, +) +from upstage_des.data_types import CartesianLocation, Location +from upstage_des.task import process +from upstage_des.type_help import SIMPY_GEN, TASK_GEN + + +class Base: + def __init__( + self, + env: Environment, + name: str, + x: float, + y: float, + num_runways: int = 1, + parking_max: int = 10, + ) -> None: + self.env = env + self.name = name + self.location = CartesianLocation(x=x, y=y) + self.runway = sp_resource(self.env, capacity=num_runways) + self.maintenance_queue = sp_store(self.env) + self.parking = sp_store(self.env, parking_max) + self.parking_tokens = sp_store(self.env, parking_max) + self.parking_tokens.items = [(self, self.parking_tokens, i) for i in range(parking_max)] + self.operational = True + + # yes, this is weird + self.location = CartesianLocation(x, y) + + self.parking_max = parking_max + self.curr_parked: int = 0 + self.claimed_parking: int = 0 + self._parking_claims: list[Any] = [] + self._parked: list[Any] = [] + + def claim_parking(self, plane: Any) -> None: + self._parking_claims.append(plane) + self.claimed_parking += 1 + + def has_parking(self, plane: Any) -> int: + return self.curr_parked + self.claimed_parking < self.parking_max + + @process + def run_maintenance(self) -> SIMPY_GEN: + while True: + plane = yield Get(self.maintenance_queue).as_event() + yield Wait(1.6).as_event() + mx_wait = plane.get_knowledge("mx_wait") + # alter the plane's code + plane.code = 0 + mx_wait.succeed() + + def __repr__(self) -> str: + return f"{self.name}:{super().__repr__()}" + + +class World: + """A helper class for data storage and environment analysis""" + + def __init__(self, bases: Sequence[Base]) -> None: + self.bases = bases + + def nearest_base(self, loc: CartesianLocation) -> Base: + b = min(self.bases, key=lambda x: x.location - loc) + return b + + def bases_by_location(self, loc: CartesianLocation) -> Base: + x, y = (loc.x, loc.y) + return [b for b in self.bases if b.location.x == x and b.location.y == y][0] + + +class Aircraft(Actor): + base = State[Base | None](default=None) + location = CartesianLocationChangingState(recording=True) + speed = State[float]() + landing_time = State[float]() + takeoff_time = State[float]() + code = State[int]() + fuel = LinearChangingState(recording=True) + fuel_burn = State[float]() + parking_token = State[int]() + parking_spot = State[int]() + command_data = State[Any]() + world = State[World]() + + def calculate_bingo(self, max_time: float = float("inf")) -> float: + # get the farthest base + farthest: Base = max(self.world.bases, key=lambda x: self.location - x.location) + dist = self.location - farthest.location + time = dist / self.speed + fuel_needed = self.fuel_burn * time + time_until_bingo = (self.fuel - fuel_needed) / self.fuel_burn + return min(time_until_bingo, max_time) + + +class CodeFour(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + wait = Event(rehearsal_time_to_complete=float("inf")) + yield wait + + +class GroundWait(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + wait = Event(rehearsal_time_to_complete=0.0) + yield wait + + +class GroundTakeoffWait(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + destination = self.get_actor_knowledge(actor, "destination") + if not isinstance(destination, Location): + destination = destination.location + arrival_time = self.get_actor_knowledge(actor, "arrival time") + if arrival_time is None: + wait_time = 0 + else: + distance = destination - actor.location + flight_time = distance / actor.speed + wait_time = arrival_time - flight_time + yield Wait(wait_time) + + +class Takeoff(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + base = actor.base + assert base is not None + runway_request = ResourceHold(base.runway) + yield runway_request + takeoff_time = Wait(actor.takeoff_time) + yield takeoff_time + yield runway_request + # HOW WOULD THIS WORK IN TRIAL MODE? + # what if it's a parking spot token? A token state? + parking_event = Put(base.parking_tokens, actor.parking_spot) + yield parking_event + actor.parking_spot = -1 + # set our current location as above the base + actor.base = None + + def on_interrupt(self, *, actor: Aircraft, cause: str) -> InterruptStates: + director = self.get_actor_knowledge(actor, "director") + if director is not None: + director.report_failure(actor) + return self.INTERRUPT.END + + +class Fly(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + destination = self.get_actor_knowledge(actor, "destination") + if not isinstance(destination, Location): + destination = destination.location + actor.activate_state( + state="location", + task=self, + speed=actor.speed, + waypoints=[destination], + ) + actor.activate_state( + state="fuel", + task=self, + rate=-actor.fuel_burn, + ) + + # assume that locations can do this + distance = destination - actor.location + time = distance / actor.speed + fly_wait = Wait(time) + + yield fly_wait + + actor.deactivate_all_states(task=self) + intent = self.get_actor_knowledge(actor, "intent") + next_task = self.get_actor_next_task(actor) + + # if our intent is to land, make sure next task is a landing check + if intent == "land" and next_task != "LandingCheck": + self.clear_actor_task_queue(actor) + self.set_actor_task_queue( + actor, + [ + "LandingCheck", + ], + ) + + def on_interrupt(self, *, actor: Aircraft, cause: str) -> InterruptStates: + # For testing a restart, this flying is fine, since there is only one + # final destination. + if cause == "restart": + actor.speed = 0.5 + return self.INTERRUPT.RESTART + else: + return self.INTERRUPT.END + + +class Loiter(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + # calculate bingo + time = actor.calculate_bingo() + loiter_wait = Wait(time) + actor.activate_state( + state="fuel", + task=self, + fuel_burn_rate=-actor.fuel_burn, + ) + yield loiter_wait + actor.deactivate_state( + state="fuel", + task=self, + ) + + +class Mission(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + # tell the mission folks you are here + # they'll give you an event to watch to leave + commander = self.get_actor_knowledge(actor, "commander", must_exist=True) + leave_event = commander.arrival(actor) + # set up an alternate bingo leave event + time = actor.calculate_bingo() + bingo_wait = Wait(time) + + stop_mission = Any(leave_event, bingo_wait) + actor.activate_state( + state="fuel", + task=self, + fuel_burn_rate=-actor.fuel_burn, + ) + yield stop_mission + actor.deactivate_all_states(task=self) + + +class LandingLocationSelection(DecisionTask): + def rehearse_decision(self, *, actor: Aircraft) -> None: + base = actor.stage.world.bases[0] + self.set_actor_knowledge(actor, "destination", base) + self.set_actor_knowledge(actor, "intent", "land") + + def make_decision(self, *, actor: Aircraft) -> None: + # These kinds of cognitive tasks must be zero-time (no yields!) + landable_bases = [b for b in actor.stage.world.bases if b.has_parking(actor)] + base = landable_bases[0] + + self.set_actor_knowledge(actor, "destination", base) + self.set_actor_knowledge(actor, "intent", "land") + + +class LandingLocationPrep(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + base = self.get_actor_knowledge(actor, "destination") + token_event = Get(base.parking_tokens) + parking_token = yield token_event + self.set_actor_knowledge(actor, "parking_token", parking_token) + + +class LandingCheck(DecisionTask): + """Task for checking the ability to land at the base an actor is above. + + If landing is available, continue in the task network. Otherwise, reselect a base. + """ + + return_task_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + def rehearse_decision(self, *, actor: Aircraft) -> None: + return None + + def make_decision(self, *, actor: Aircraft) -> None: + # get base from the actor's destination + base = self.get_actor_knowledge(actor, "destination") + # assert that the base can be landed at + if not base.operational: + # clear the queue + self.clear_actor_task_queue(actor) + self.clear_actor_knowledge(actor, "destination") + self.clear_actor_knowledge(actor, "intent") + # set up for a task network path that gets a new place to land + self.set_actor_task_queue(actor, self.return_task_list) + else: + # check that we are landing + msg = f"Actor {actor} not landing after check" + assert self.get_actor_next_task(actor) == "Land", msg + + +class Land(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + base = self.get_actor_knowledge(actor, "destination") + self.clear_actor_knowledge(actor, "destination") + runway_request = ResourceHold(base.runway) + + self.set_marker("pre-runway") + yield runway_request + landing_time = Wait(actor.landing_time) + + self.set_marker("during landing") + yield landing_time + + self.set_marker("post-landing", self.INTERRUPT.IGNORE) + yield runway_request + self.clear_marker() + + self.set_actor_knowledge(actor, "base", base) + # just to help with a 'clear actor from all stores' need? + put_event = Put(base.parking, actor) + self.set_marker("get parking", self.INTERRUPT.IGNORE) + yield put_event + + self.set_marker("post-parking", interrupt_action=self.INTERRUPT.IGNORE) + parking_token = self.get_actor_knowledge(actor, "parking_token") + put_event = Put(base.parking_tokens, parking_token) + yield put_event + + self.clear_actor_knowledge(actor, "parking_token") + self.clear_actor_knowledge(actor, "intent") + + def on_interrupt(self, *, actor: Aircraft, cause: str) -> InterruptStates: + # if interrupted, find a new place to land + # figure out where we were in the task based on the marker + marker = self.get_marker() + self.get_marker_time() + if marker in [ + "pre-runway", + ]: + # We are done with this base + # put the parking token back + parking_token = self.get_actor_knowledge(actor, "parking_token") + if parking_token: + store = parking_token[1] + Put(store, parking_token) + self.clear_actor_knowledge(actor, "parking_token") + # set the task network to try landing again + return_task_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + self.clear_actor_task_queue(actor) + self.set_actor_task_queue(actor, return_task_list) + return self.INTERRUPT.END + elif marker in [ + "during landing", + ]: + # continue on if the cause is benign + if cause == "Code 4": + # clear the knowledge and task queue + parking_token = self.get_actor_knowledge(actor, "parking_token") + if parking_token: + store = parking_token[1] + Put(store, parking_token) + self.clear_actor_knowledge(actor, "parking_token") + self.clear_actor_task_queue(actor) + self.set_actor_task_queue(actor, ["Code4"]) + return self.INTERRUPT.END + else: + return self.INTERRUPT.IGNORE + else: + # other markers mean that the landing was safe so no + # need to do anything + raise ValueError("Shouldn't be here") + + +class MaintenanceWait(Task): + def task(self, *, actor: Aircraft) -> TASK_GEN: + base = self.get_actor_knowledge(actor, "base") + maintenance_put = Put(base.maintenance_queue, actor) + yield maintenance_put + mx_wait = Event(rehearsal_time_to_complete=2.0) + self.set_actor_knowledge(actor, "mx_wait", mx_wait) + yield mx_wait + self.clear_actor_knowledge(actor, "mx_wait") + + def on_interrupt(self, *, actor: Actor, cause: Any) -> InterruptStates: + return InterruptStates.IGNORE + + +BASE_LOCATIONS = [ + (10.023, 4.63), + (2.409, 7.279), + (0.529, 11.004), + (6.468, 17.153), + (5.802, 17.215), +] + +task_classes = { + "GroundWait": GroundWait, + "GroundTakeoffWait": GroundTakeoffWait, + "Takeoff": Takeoff, + "Fly": Fly, + "Loiter": Loiter, + "Mission": Mission, + "LandingLocationSelection": LandingLocationSelection, + "LandingLocationPrep": LandingLocationPrep, + "LandingCheck": LandingCheck, + "Land": Land, + "MaintenanceWait": MaintenanceWait, + "Code4": CodeFour, +} +_task_links = { + "GroundWait": [ + "Takeoff", + "Code4", + ], + "Takeoff": [ + "Fly", + "Loiter", + "LandingLocationSelection", + "Code4", + ], + "Loiter": [ + "Fly", + "Mission", + "LandingLocationSelection", + ], + "Fly": [ + "Loiter", + "Mission", + "LandingLocationSelection", + "LandingCheck", + "Fly", + ], + "Mission": [ + "Fly", + "Loiter", + "LandingLocationSelection", + ], + "LandingLocationSelection": [ + "Fly", + "Loiter", + "LandingLocationPrep", + ], + "LandingLocationPrep": [ + "Fly", + "Loiter", + "LandingCheck", + "LandingLocationSelection", + ], + "LandingCheck": [ + "Land", + "Loiter", + "Fly", + "LandingLocationSelection", + ], + "Land": [ + "MaintenanceWait", + "Loiter", + "Code4", + ], + "MaintenanceWait": [ + "GroundWait", + "Code4", + ], + "Code4": [], +} +# quick fix for new task network link style +task_links: dict[str, TaskLinks] = {} +for k, v in _task_links.items(): + new = TaskLinks(default=v[0] if v else None, allowed=v) + task_links[k] = new + + +def _build_test(env: Environment) -> Aircraft: + bases = [] + for i in range(len(BASE_LOCATIONS)): + x, y = BASE_LOCATIONS[i] + b = Base(env, f"Base {i}", x, y) + bases.append(b) + b.run_maintenance() + + world = World(bases) + add_stage_variable("world", world) + + p = Aircraft( + name="my plane", + base=None, + location=CartesianLocation(0, 0), + speed=12, + landing_time=5 / 60, + takeoff_time=10 / 60, + code=2, + fuel=100, + fuel_burn=15, + parking_token=None, + parking_spot=None, + command_data=None, + world=world, + debug_log=True, + ) + + return p + + +def test_plane_bingo() -> None: + with EnvironmentContext() as env: + p = _build_test(env) + bingo_hours = 5.14 + bingo_result = p.calculate_bingo() + assert pytest.approx(bingo_result, abs=0.01) == bingo_hours + + +def test_creating_network() -> None: + with EnvironmentContext(): + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + _ = task_fact.make_network() + + +def test_rehearsing_network() -> None: + with EnvironmentContext() as env: + actor = _build_test(env) + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + net = task_fact.make_network() + + # build arguments for the task list + task_name_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + actor.add_task_network(net) + # start the task network + actor.start_network_loop("plane_net", init_task_name="GroundWait") + env.run() + assert env.now == 0 + + new_actor = net.rehearse_network(actor=actor, task_name_list=task_name_list) + + # Did the new actor do what we wanted it to? + base = new_actor.get_knowledge("base") + base2 = actor.stage.world.bases[0] + # Notice that due to copying the actor, the bases aren't exactly the same + assert base.name == base2.name, "Wrong base selected" + assert len(new_actor._knowledge) == 1, "Too much knowledge left" + assert pytest.approx(new_actor.fuel, abs=0.01) == 86.199 + + # Is the original actor untouched? + assert len(actor._knowledge) == 0, "Actor should not have done anything" + + assert new_actor.code == 2, "Wrong MX code" + + assert new_actor.env.now > 2.0, "Cloned actor environment at the wrong time" + + task = actor.get_running_task("plane_net") + assert task is not None and task.name == "GroundWait" + + +def test_rehearsing_from_actor() -> None: + with EnvironmentContext() as env: + actor = _build_test(env) + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + net = task_fact.make_network() + + # build arguments for the task list + task_name_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + actor.add_task_network(net) + + new_actor = actor.rehearse_network( + "plane_net", + task_name_list, + knowledge={"dummy_know": 8675309}, + ) + + # Make sure the knowledge was set on the copy, but not the original + assert actor.get_knowledge("dummy_know") is None + assert new_actor.get_knowledge("dummy_know") == 8675309 + + # Did the new actor do what we wanted it to? + base = new_actor.get_knowledge("base") + base2 = actor.stage.world.bases[0] + # Notice that due to copying the actor, the bases aren't exactly the same + assert base.name == base2.name, "Wrong base selected" + assert len(new_actor._knowledge) == 2, "Too much knowledge left" + assert pytest.approx(new_actor.fuel, abs=0.01) == 86.199 + + # Is the original actor untouched? + assert len(actor._knowledge) == 0, "Actor should not have done anything" + assert new_actor.code == 2, "Wrong MX code" + + assert new_actor.env.now > 2.0, "Cloned actor environment at the wrong time" + + +def test_running_simple_network() -> None: + with EnvironmentContext() as env: + actor = _build_test(env) + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + net = task_fact.make_network() + + assert str(net) == "Task network: plane_net" + + # build arguments for the task list + task_name_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + # tell the actor the queue its getting + actor.add_task_network(net) + actor.set_task_queue("plane_net", task_name_list) + + # run the queue with the network + net.loop(actor=actor) + env.run() + + base = actor.get_knowledge("base") + base2 = actor.stage.world.bases[0] + assert base is base2, "Wrong base selected" + assert len(actor._knowledge) == 1, "Too much knowledge left" + assert pytest.approx(actor.fuel, abs=0.01) == 86.199 + assert actor.code == 0, "Wrong MX code" + + +def test_interrupting_network() -> None: + with EnvironmentContext() as env: + actor = _build_test(env) + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + net = task_fact.make_network() + + # build arguments for the task list + task_name_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + # tell the actor the queue its getting + actor.add_task_network(net) + actor.set_task_queue("plane_net", task_name_list) + + # create a process that interrupts the plane during different times + def interrupting_proc( + env: Environment, actor: Aircraft, interrupt_time: float + ) -> SIMPY_GEN: + yield env.timeout(interrupt_time) + # get the process + network = actor._task_networks["plane_net"] + assert network._current_task_proc is not None + network._current_task_proc.interrupt(cause="a reason") + + # run the queue with the network + net.loop(actor=actor) + env.process(interrupting_proc(env, actor, 1.0)) + env.run() + + # the plane should land still + assert actor._task_queue["plane_net"] == [], "Actor had tasks left" + assert actor.code == 0, "Actor didn't get maintained" + + +def test_interrupting_network_with_cause() -> None: + with EnvironmentContext() as env: + actor = _build_test(env) + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + net = task_fact.make_network() + + # build arguments for the task list + task_name_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + # tell the actor the queue its getting + actor.add_task_network(net) + actor.set_task_queue("plane_net", task_name_list) + + # create a process that interrupts the plane during different times + def interrupting_proc( + env: Environment, actor: Aircraft, interrupt_time: float + ) -> SIMPY_GEN: + yield env.timeout(interrupt_time) + # get the process + # network = actor._task_networks["plane_net"] + # network._current_task_proc.interrupt(cause="Code 4") + actor.interrupt_network("plane_net", cause="Code 4") + + # run the queue with the network + net.loop(actor=actor) + env.process(interrupting_proc(env, actor, 1.0)) + env.run() + + +def test_interrupting_network_with_restart() -> None: + with EnvironmentContext() as env: + actor = _build_test(env) + task_fact = TaskNetworkFactory( + "plane_net", + task_classes, + task_links, + ) + net = task_fact.make_network() + + # build arguments for the task list + task_name_list = [ + "LandingLocationSelection", + "LandingLocationPrep", + "Fly", + "LandingCheck", + "Land", + "MaintenanceWait", + ] + + # tell the actor the queue its getting + actor.add_task_network(net) + actor.set_task_queue("plane_net", task_name_list) + + # create a process that interrupts the plane during different times + def interrupting_proc( + env: Environment, actor: Aircraft, interrupt_time: float + ) -> SIMPY_GEN: + yield env.timeout(interrupt_time) + # get the process + network = actor._task_networks["plane_net"] + assert network._current_task_name is not None and network._current_task_name == "Fly" + assert network._current_task_proc is not None + network._current_task_proc.interrupt(cause="restart") + + # run the queue with the network + net.loop(actor=actor) + env.process(interrupting_proc(env, actor, 0.1)) + env.run() + # the plane should land still + assert actor._task_queue["plane_net"] == [], "Actor had tasks left" + assert actor.code == 0, "Actor didn't get maintained" + # it should take longer than the cancelled version + assert pytest.approx(env.now, abs=0.0001) == 21.464767 + + +def test_rehearsal_time() -> None: + class Thing(Actor): + the_time = LinearChangingState(recording=True) + + class ThingWait(Task): + def task(self, *, actor: Thing) -> TASK_GEN: + actor.activate_state( + state="the_time", + task=self, + rate=1.0, + ) + yield Wait.from_random_uniform(1.0, 2.0) + actor.deactivate_all_states(task=self) + + with EnvironmentContext(): + tasks = {"ThingWait": ThingWait} + task_links = {"ThingWait": TaskLinks(default="ThingWait", allowed=["ThingWait"])} + factory = TaskNetworkFactory("fact", tasks, task_links) + + thing = Thing(name="Actor", the_time=0) + thing.add_task_network(factory.make_network()) + new_thing = thing.rehearse_network("fact", ["ThingWait", "ThingWait"]) + assert new_thing.env.now == new_thing.the_time, "Bad rehearsal env time" diff --git a/src/upstage/test/test_units.py b/src/upstage_des/test/test_units.py similarity index 93% rename from src/upstage/test/test_units.py rename to src/upstage_des/test/test_units.py index 52fd909..de1bb8e 100644 --- a/src/upstage/test/test_units.py +++ b/src/upstage_des/test/test_units.py @@ -7,7 +7,7 @@ import pytest -from upstage.units.convert import CONVERSIONS, unit_convert +from upstage_des.units.convert import CONVERSIONS, unit_convert def test_convert_fail() -> None: diff --git a/src/upstage/type_help.py b/src/upstage_des/type_help.py similarity index 89% rename from src/upstage/type_help.py rename to src/upstage_des/type_help.py index bb4113f..416a93b 100644 --- a/src/upstage/type_help.py +++ b/src/upstage_des/type_help.py @@ -1,16 +1,16 @@ -# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) - -# Licensed under the BSD 3-Clause License. -# See the LICENSE file in the project root for complete license terms and disclaimers. - -"""Help for typing task and simpy generators.""" - -from collections.abc import Generator -from typing import Any - -from simpy import Event as SimEvent - -from upstage.events import BaseEvent - -TASK_GEN = Generator[BaseEvent, Any, None] -SIMPY_GEN = Generator[SimEvent, Any, Any] +# Copyright (C) 2024 by the Georgia Tech Research Institute (GTRI) + +# Licensed under the BSD 3-Clause License. +# See the LICENSE file in the project root for complete license terms and disclaimers. + +"""Help for typing task and simpy generators.""" + +from collections.abc import Generator +from typing import Any + +from simpy import Event as SimEvent + +from upstage_des.events import BaseEvent + +TASK_GEN = Generator[BaseEvent, Any, None] +SIMPY_GEN = Generator[SimEvent, Any, Any] diff --git a/src/upstage/units/__init__.py b/src/upstage_des/units/__init__.py similarity index 100% rename from src/upstage/units/__init__.py rename to src/upstage_des/units/__init__.py diff --git a/src/upstage/units/convert.py b/src/upstage_des/units/convert.py similarity index 100% rename from src/upstage/units/convert.py rename to src/upstage_des/units/convert.py diff --git a/src/upstage/utils.py b/src/upstage_des/utils.py similarity index 100% rename from src/upstage/utils.py rename to src/upstage_des/utils.py From 3ab7ac0e72e39084c26c25951eb30a76c89eed9a Mon Sep 17 00:00:00 2001 From: James Arruda Date: Tue, 10 Dec 2024 13:20:25 -0500 Subject: [PATCH 2/3] Updating documents for library name change. --- docs/source/conf.py | 26 +++++----- docs/source/index.md | 6 +-- .../user_guide/how_tos/active_states.rst | 10 ++-- .../user_guide/how_tos/communications.rst | 8 +-- .../user_guide/how_tos/decision_tasks.rst | 14 ++--- .../user_guide/how_tos/entity_naming.rst | 8 +-- docs/source/user_guide/how_tos/events.rst | 22 ++++---- docs/source/user_guide/how_tos/geography.rst | 52 +++++++++---------- .../user_guide/how_tos/motion_manager.rst | 30 +++++------ docs/source/user_guide/how_tos/nucleus.rst | 4 +- .../user_guide/how_tos/random_numbers.rst | 4 +- .../user_guide/how_tos/resource_states.rst | 2 +- .../user_guide/how_tos/stage_variables.rst | 14 ++--- .../user_guide/how_tos/state_sharing.rst | 4 +- .../user_guide/how_tos/task_networks.rst | 30 +++++------ docs/source/user_guide/how_tos/typing.rst | 14 ++--- .../user_guide/tutorials/best_practices.rst | 2 +- .../user_guide/tutorials/first_simulation.rst | 34 ++++++------ .../user_guide/tutorials/interrupts.rst | 16 +++--- .../source/user_guide/tutorials/rehearsal.rst | 2 +- 20 files changed, 151 insertions(+), 151 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 0f2c09e..0fdfda5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -42,19 +42,19 @@ myst_heading_anchors = 2 myst_substitutions = { "rtd": "[Read the Docs](https://readthedocs.org/)", - "Actor": "{py:class}`Actor `", - "State": "{py:class}`~upstage.states.State`", - "Task": "{py:class}`~upstage.task.Task`", - "TaskNetwork": "{py:class}`~upstage.task_network.TaskNetwork`", - "EnvironmentContext": "{py:class}`~upstage.base.EnvironmentContext`", - "UpstageBase": "{py:class}`~upstage.base.UpstageBase`", - "NamedEntity": "{py:class}`~upstage.base.NamedUpstageEntity`", - "LinearChangingState": "{py:class}`~upstage.states.LinearChangingState`", - "CartesianLocation": "{py:class}`~upstage.data_types.CartesianLocation`", - "GeodeticLocationChangingState": "{py:class}`~upstage.states.GeodeticLocationChangingState`", - "ResourceState": "{py:class}`~upstage.states.ResourceState`", - "SelfMonitoringStore": "{py:class}`~upstage.stores.SelfMonitoringStore`", - "DecisionTask": "{py:class}`~upstage.task.DecisionTask`", + "Actor": "{py:class}`Actor `", + "State": "{py:class}`~upstage_des.states.State`", + "Task": "{py:class}`~upstage_des.task.Task`", + "TaskNetwork": "{py:class}`~upstage_des.task_network.TaskNetwork`", + "EnvironmentContext": "{py:class}`~upstage_des.base.EnvironmentContext`", + "UpstageBase": "{py:class}`~upstage_des.base.UpstageBase`", + "NamedEntity": "{py:class}`~upstage_des.base.NamedUpstageEntity`", + "LinearChangingState": "{py:class}`~upstage_des.states.LinearChangingState`", + "CartesianLocation": "{py:class}`~upstage_des.data_types.CartesianLocation`", + "GeodeticLocationChangingState": "{py:class}`~upstage_des.states.GeodeticLocationChangingState`", + "ResourceState": "{py:class}`~upstage_des.states.ResourceState`", + "SelfMonitoringStore": "{py:class}`~upstage_des.stores.SelfMonitoringStore`", + "DecisionTask": "{py:class}`~upstage_des.task.DecisionTask`", } diff --git a/docs/source/index.md b/docs/source/index.md index a5ccc03..702a6bc 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -52,7 +52,7 @@ Alternatively, you can download UPSTAGE and install it manually. Clone, or downl Note that the tests include the two full examples from the documentation. ```console -(venv) $ pip install uptage[test] +(venv) $ pip install upstage-des[test] (venv) $ pytest ``` @@ -61,8 +61,8 @@ Note that the tests include the two full examples from the documentation. Unless you're adding to the codebase, you won't need to run the `sphinx-apidoc` command. ```console -(venv) $ pip install upstage[docs] -(venv) $ sphinx-apidoc -o .\docs\source\ .\src\upstage\ .\src\upstage\test\ +(venv) $ pip install upstage-des[docs] +(venv) $ sphinx-apidoc -o .\docs\source\ .\src\upstage_des\ .\src\upstage_des\test\ (venv) $ sphinx-build -b html .\docs\source\ .\docs\build\ ``` diff --git a/docs/source/user_guide/how_tos/active_states.rst b/docs/source/user_guide/how_tos/active_states.rst index fd53fe9..2e551c8 100644 --- a/docs/source/user_guide/how_tos/active_states.rst +++ b/docs/source/user_guide/how_tos/active_states.rst @@ -7,7 +7,7 @@ Active States are an UPSTAGE feature where states are told how to update themsel 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.actor.Actor.activate_state` and :py:meth:`~upstage.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. @@ -78,7 +78,7 @@ They accept a speed and list of waypoints in their activation. .. code-block:: python - from upstage.utils import waypoint_time_and_dist + from upstage_des.utils import waypoint_time_and_dist class FlatlandCar(UP.Actor): location: UP.CartesianLocation = UP.CartesianLocationChangingState() @@ -147,7 +147,7 @@ The ``GeodeticLocationChangingState`` works the same way. Creating your own ================= -To create you own Active State, subclass :py:class:`~upstage.states.ActiveState`. +To create you own Active State, subclass :py:class:`~upstage_des.states.ActiveState`. The bare minimum is to implement the ``_active`` method. @@ -156,8 +156,8 @@ Here is an example of an ActiveState that changes according to an exponent. .. code-block:: python :linenos: - from upstage.states import ActiveState - from upstage.actor import Actor + from upstage_des.states import ActiveState + from upstage_des.actor import Actor class ExponentChangingState(ActiveState): """A state that changes according to: x_t = x_0 + at^(b)""" diff --git a/docs/source/user_guide/how_tos/communications.rst b/docs/source/user_guide/how_tos/communications.rst index accfc8d..f9febbd 100644 --- a/docs/source/user_guide/how_tos/communications.rst +++ b/docs/source/user_guide/how_tos/communications.rst @@ -2,11 +2,11 @@ Communications ============== -UPSTAGE provides a built-in method for passing communications between actors. The :py:class:`~upstage.communications.comms.CommsManager` class +UPSTAGE provides a built-in method for passing communications between actors. The :py:class:`~upstage_des.communications.comms.CommsManager` class allows actors to send messages while allowing for simplified retry attempts and timeouts. It also allows for communications blocking to be turned on and off on a point to point basis. -The :py:class:`~upstage.communications.comms.Message` class is used to describe a message, although strings and dictionaries can +The :py:class:`~upstage_des.communications.comms.Message` class is used to describe a message, although strings and dictionaries can also be passed as messages, and UPSTAGE will convert them into the ``Message`` class. The communications manager needs to be instantiated and run, and any number of them can be run, to represent different modes of @@ -15,7 +15,7 @@ comms managers. .. code-block:: python - import upstage.api as UP + import upstage_des.api as UP class Worker(UP.Actor): walkie = UP.CommunicationStore(mode="UHF") @@ -36,7 +36,7 @@ comms managers. loudspeaker_comms.run() The ``CommsManager`` class allows for explicitly connecting actors and the store that will receive messages, but using the -:py:class:`~upstage.states.CommunicationStore` lets the manager auto-discover the proper store for a communications mode, letting +:py:class:`~upstage_des.states.CommunicationStore` lets the manager auto-discover the proper store for a communications mode, letting the simulation designer only need to pass the source actor, destination actor, and message information to the manager. To send a message, use the comm manager's ``make_put`` method to return an UPSTAGE event to yield on to send the message. diff --git a/docs/source/user_guide/how_tos/decision_tasks.rst b/docs/source/user_guide/how_tos/decision_tasks.rst index cfac113..a3e4fb7 100644 --- a/docs/source/user_guide/how_tos/decision_tasks.rst +++ b/docs/source/user_guide/how_tos/decision_tasks.rst @@ -2,19 +2,19 @@ Decision Tasks ============== -Decision tasks are :py:class:`~upstage.task.Task`s that take zero time and were briefly demonstrated in :doc:`Rehearsal `. The purpose of a -Decision task is to allow decision making and :py:class:`~upstage.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 `. 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: -* :py:class:`~upstage.task.DecisionTask.make_decision` -* :py:class:`~upstage.task.DecisionTask.rehearse_decision` +* :py:class:`~upstage_des.task.DecisionTask.make_decision` +* :py:class:`~upstage_des.task.DecisionTask.rehearse_decision` Neither method outputs anything. The expectation is that inside these methods you modify the task network using: -* :py:meth:`upstage.actor.Actor.clear_task_queue`: Empty a task queue -* :py:meth:`upstage.actor.Actor.set_task_queue`: Add tasks to an empty queue (by string name) - you must empty the queue first. -* :py:meth:`upstage.actor.Actor.set_knowledge`: Modify knowledge +* :py:meth:`upstage_des.actor.Actor.clear_task_queue`: Empty a task queue +* :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. diff --git a/docs/source/user_guide/how_tos/entity_naming.rst b/docs/source/user_guide/how_tos/entity_naming.rst index 7958ac5..4cac60a 100644 --- a/docs/source/user_guide/how_tos/entity_naming.rst +++ b/docs/source/user_guide/how_tos/entity_naming.rst @@ -2,12 +2,12 @@ Named Entities ============== -Named entities are an :py:class:`~upstage.base.EnvironmentContext` and :py:class:`~upstage.base.NamedUpstageEntity` enabled feature where you can store instances in particular "entity groups" to gather -them from later. UPSTAGE's :py:class:`~upstage.actor.Actor` inherits from :py:class:`~upstage.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. -All Actors are retrievable with the :py:meth:`~upstage.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.base.UpstageBase.get_all_entity_groups` and :py:meth:`~upstage.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: diff --git a/docs/source/user_guide/how_tos/events.rst b/docs/source/user_guide/how_tos/events.rst index 520c453..16293e9 100644 --- a/docs/source/user_guide/how_tos/events.rst +++ b/docs/source/user_guide/how_tos/events.rst @@ -8,12 +8,12 @@ All events accept a ``rehearsal_time_to_complete`` argument. The available UPSTAGE events are: -:py:class:`~upstage.events.Event` +:py:class:`~upstage_des.events.Event` --------------------------------- Mimics SimPy's raw ``Event``, useful for marking pauses until a success. -See :py:meth:`~upstage.actor.Actor.create_knowledge_event` for a use case. +See :py:meth:`~upstage_des.actor.Actor.create_knowledge_event` for a use case. One use case is the knowledge event, which enables a way to publish and event to an actor, and have some other source ``succeed`` it. @@ -31,7 +31,7 @@ One use case is the knowledge event, which enables a way to publish and event to subordinate.succeed_knowledge_event(name="pause", some_data={...}) -:py:class:`~upstage.events.Wait` +:py:class:`~upstage_des.events.Wait` -------------------------------- A standard SimPy timeout. Can be explicit or generate from a random uniform distribution. @@ -44,7 +44,7 @@ The random uniform distribution accepts an input for the rehearsal time, while t yield UP.Wait.from_random_uniform(low, high, rehearsal_time_to_complete=high) -:py:class:`~upstage.events.Get` +:py:class:`~upstage_des.events.Get` ------------------------------- Get from a store or container. @@ -60,7 +60,7 @@ Get from a store or container. assert get_event.get_value() == amount -:py:class:`~upstage.events.FilterGet` +:py:class:`~upstage_des.events.FilterGet` ------------------------------------- A get with a filter function, used for SimPy's ``FilterStore``. @@ -70,9 +70,9 @@ A get with a filter function, used for SimPy's ``FilterStore``. item = yield get_event -:py:class:`~upstage.resources.sorted.SortedFilterGet` +:py:class:`~upstage_des.resources.sorted.SortedFilterGet` ----------------------------------------------------- -A get with a filter or sorting function, used with :py:class:`~upstage.resources.sorted.SortedFilterStore`, and others. +A get with a filter or sorting function, used with :py:class:`~upstage_des.resources.sorted.SortedFilterStore`, and others. .. code-block:: python @@ -84,7 +84,7 @@ A get with a filter or sorting function, used with :py:class:`~upstage.resources item = yield get_event -:py:class:`~upstage.events.Put` +:py:class:`~upstage_des.events.Put` ------------------------------- Put something into a store or container @@ -99,7 +99,7 @@ Put something into a store or container yield UP.Put(some_store, amount) -:py:class:`~upstage.events.ResourceHold` +:py:class:`~upstage_des.events.ResourceHold` ---------------------------------------- Put and release holds on limited resources. @@ -114,7 +114,7 @@ Put and release holds on limited resources. # Now you've given it back -:py:class:`~upstage.events.All` +:py:class:`~upstage_des.events.All` ------------------------------- Succeed when all passed events succeed. @@ -128,7 +128,7 @@ Succeed when all passed events succeed. assert wait_event.is_complete() -:py:class:`~upstage.events.Any` +:py:class:`~upstage_des.events.Any` ------------------------------- Succeed when any passed events succeed diff --git a/docs/source/user_guide/how_tos/geography.rst b/docs/source/user_guide/how_tos/geography.rst index e23300d..99796c4 100644 --- a/docs/source/user_guide/how_tos/geography.rst +++ b/docs/source/user_guide/how_tos/geography.rst @@ -2,7 +2,7 @@ Geography ========= -UPSTAGE has built-in features for simple geographic math and behaviors. These features are built up into a :py:class:`~upstage.states.GeodeticLocation` state. +UPSTAGE has built-in features for simple geographic math and behaviors. These features are built up into a :py:class:`~upstage_des.states.GeodeticLocation` state. Discrete Event Simulation does not lend itself well to geography and repeated distance checking (for something like a sensor, e.g.), so UPSTAGE provides the capability to schedule intersections of moving actors and stationary sensors. Those features are covered in the :doc:`Motion Manager ` documentation. @@ -19,7 +19,7 @@ Geographic Data Types and State These are the built-in features that use geography: -:py:class:`~upstage.data_types.GeodeticLocation` +:py:class:`~upstage_des.data_types.GeodeticLocation` ------------------------------------------------ This data type stores a Latitude / Longitude / Altitude (optional) for a point around the globe. @@ -34,7 +34,7 @@ Two geodetic locations can be subtracted from each other to get their great circ .. code-block:: python - from upstage.geography import Spherical + from upstage_des.geography import Spherical with UP.EnvironmentContext(): UP.add_stage_variable("stage_model", Spherical) @@ -51,7 +51,7 @@ Two geodetic locations can be subtracted from each other to get their great circ straight_line = loc1.straight_line_distance(loc2) >>> 1049.3621152419862 -Location instances *must* be created within an :py:class:`~upstage.base.EnvironmentContext` context, otherwise they won't have access to the geographic model at runtime. Additionally, these stage variables must be set: +Location instances *must* be created within an :py:class:`~upstage_des.base.EnvironmentContext` context, otherwise they won't have access to the geographic model at runtime. Additionally, these stage variables must be set: * ``stage_model`` must be set to be either ``Spherical`` or ``WGS84``, or whatever class performs ``.distance`` on lat/lon/altitude pairs. * ``distance_units``: One of: "m", "km", "mi", "nmi", or "ft" @@ -65,14 +65,14 @@ The distance between two points is the great circle distance, and altitude is ig that any speeds you specify are implicitly ground speed, which is more useful. However, if a sensor is looking straight up, the distance to an object 30 thousand feet up shouldn't be zero. To account for altitude in the distance, use -:py:func:`~upstage.data_types.GeodeticLocation.dist_with_altitude` or :py:func:`~upstage.data_types.GeodeticLocation.straight_line_distance`. +:py:func:`~upstage_des.data_types.GeodeticLocation.dist_with_altitude` or :py:func:`~upstage_des.data_types.GeodeticLocation.straight_line_distance`. Note that the intersection models (covered elsewhere) do distance checks in ECEF, not with the ``GeodeticLocation`` subtraction method, so you don't have to worry about this distinction for those motion features. Once a ``GeodeticLocation`` is created, it cannot be changed. This is for safety of not changing a location from underneath code that expects to use it a certain way. Some methods are provided to help get copies: -* :py:meth:`~upstage.data_types.GeodeticLocation.copy`: Make a copy of the location -* :py:meth:`~upstage.data_types.GeodeticLocation.to_radians`: Make a copy of the location with the latitude and longitude in radians -* :py:meth:`~upstage.data_types.GeodeticLocation.to_degrees`: Make a copy of the location with the latitude and longitude in degrees +* :py:meth:`~upstage_des.data_types.GeodeticLocation.copy`: Make a copy of the location +* :py:meth:`~upstage_des.data_types.GeodeticLocation.to_radians`: Make a copy of the location with the latitude and longitude in radians +* :py:meth:`~upstage_des.data_types.GeodeticLocation.to_degrees`: Make a copy of the location with the latitude and longitude in degrees For comparison, here's what ``pyproj`` gets for the calculations (pyproj is not currently a dependency for UPSTAGE): @@ -80,7 +80,7 @@ For comparison, here's what ``pyproj`` gets for the calculations (pyproj is not .. code-block:: python import pyproj - from upstage.api import unit_convert + from upstage_des.api import unit_convert # NOTE: Numpy is not a requirement of UPSTAGE import numpy as np @@ -107,7 +107,7 @@ Both distances are within .07% of UPSTAGE's calculations. -:py:class:`~upstage.states.GeodeticLocationChangingState` +: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 @@ -115,7 +115,7 @@ the state's value. Here is its basic usage: .. code-block:: python - from upstage.utils import waypoint_time_and_dist + from upstage_des.utils import waypoint_time_and_dist class Plane(UP.Actor): location: UP.GeodeticLocation = UP.GeodeticLocationChangingState(recording=True) @@ -147,7 +147,7 @@ the state's value. Here is its basic usage: ) ... -The :py:func:`~upstage.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 @@ -155,7 +155,7 @@ Cartesian Locations These aren't geographic, but they serve the same purpose, so we include them here. -:py:class:`~upstage.data_types.CartesianLocation` +:py:class:`~upstage_des.data_types.CartesianLocation` ------------------------------------------------- This data type stores an X / Y / Z (optional) location in 2 or 3D space (z is set to zero if not included). @@ -189,7 +189,7 @@ We still allow you to set distance and altitude units because the 'z' value coul The distance is always implied to be in ``distance_units``, without setting it. If the z component is in a different unit, then we need to know both to get the straight-line distance. -:py:class:`~upstage.states.CartesianLocationChangingState` +: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. @@ -198,9 +198,9 @@ This active state works the exact same as the ``GeodeticLocationChangingState`` Geography Sub-Module ==================== -The :py:mod:`upstage.geography` module contains: +The :py:mod:`upstage_des.geography` module contains: -:py:class:`~upstage.geography.spherical.Spherical` +:py:class:`~upstage_des.geography.spherical.Spherical` -------------------------------------------------- This class contains methods for finding distances, positions, and for segmenting great-circle paths on the assumption of a spherical earth. @@ -209,11 +209,11 @@ Typically, you will not need to use these methods directly, but they are avaiabl The most useful methods, besides distance, may be: -#. :py:meth:`~upstage.geography.spherical.Spherical.geo_linspace`, which will give you evenly spaced points along a great circle route. -#. :py:meth:`~upstage.geography.spherical.Spherical.geo_circle`, which will give you evently spaced points to draw a circle in spherical coordinates -#. :py:meth:`~upstage.geography.spherical.Spherical.point_from_bearing_dist`, which gives you a point relative to a base location at some distance and bearing. +#. :py:meth:`~upstage_des.geography.spherical.Spherical.geo_linspace`, which will give you evenly spaced points along a great circle route. +#. :py:meth:`~upstage_des.geography.spherical.Spherical.geo_circle`, which will give you evently spaced points to draw a circle in spherical coordinates +#. :py:meth:`~upstage_des.geography.spherical.Spherical.point_from_bearing_dist`, which gives you a point relative to a base location at some distance and bearing. -:py:class:`~upstage.geography.wgs84.WGS84` +:py:class:`~upstage_des.geography.wgs84.WGS84` ------------------------------------------- This class contains methods for finding distances, positions, and for segmenting great-circle paths on the assumption of a WGS84 ellipsoid. These methods take longer to run than the Spherical version, @@ -223,14 +223,14 @@ Typically, you will not need to use these methods directly, but they are avaiabl The most useful methods, besides distance, may be: -#. :py:meth:`~upstage.geography.spherical.WGS84.geo_linspace`, which will give you evenly spaced points along a great circle route. -#. :py:meth:`~upstage.geography.spherical.WGS84.geo_circle`, which will give you evently spaced points to draw a circle in spherical coordinates -#. :py:meth:`~upstage.geography.spherical.WGS84.point_from_bearing_dist`, which gives you a point relative to a base location at some distance and bearing. +#. :py:meth:`~upstage_des.geography.spherical.WGS84.geo_linspace`, which will give you evenly spaced points along a great circle route. +#. :py:meth:`~upstage_des.geography.spherical.WGS84.geo_circle`, which will give you evently spaced points to draw a circle in spherical coordinates +#. :py:meth:`~upstage_des.geography.spherical.WGS84.point_from_bearing_dist`, which gives you a point relative to a base location at some distance and bearing. -:py:mod:`upstage.geography.intersections` +:py:mod:`upstage_des.geography.intersections` ------------------------------------------- -The :py:func:`~upstage.geography.intersections.get_intersection_locations` function calculates an intersection between a great circle path and a sphere. It can be passed an instance of ``Spherical`` or ``WGS84`` +The :py:func:`~upstage_des.geography.intersections.get_intersection_locations` function calculates an intersection between a great circle path and a sphere. It can be passed an instance of ``Spherical`` or ``WGS84`` to do distance calculations with. The intersections are calculated by taking evenly spaced points along the great circle path and finding the two points where an intersection occurs between. It then divides that segment more finely, and calculates @@ -245,7 +245,7 @@ Storing Geographic Data While the storage and instantiation of geographic objects is mostly within your control, the main caveat is that a ``GeodeticLocation`` requires the stage to exist. This means that you can only create a ``GeodeticLocation`` within an ``EnvironmentContext``. -To store data in an easily passable format, UPSTAGE has a :py:class:`~upstage.data_types.GeodeticLocationData` class. +To store data in an easily passable format, UPSTAGE has a :py:class:`~upstage_des.data_types.GeodeticLocationData` class. This class instantiates with the same inputs as the ``GeodeticLocation``, and has a single method: ``make_location()``. That method generates the ``GeodeticLocation``, letting you pass around the data object until you're ready for it inside an environment context. diff --git a/docs/source/user_guide/how_tos/motion_manager.rst b/docs/source/user_guide/how_tos/motion_manager.rst index ea1fa79..14dd1e9 100644 --- a/docs/source/user_guide/how_tos/motion_manager.rst +++ b/docs/source/user_guide/how_tos/motion_manager.rst @@ -8,7 +8,7 @@ There are two motion managers. One uses intersection calculations to maintain a motion detection for when the "sensor" and the viewed entities are both moving. The built-in ``<>LocationChangingState`` states work with any of the motion managers in the background, by alerting them when those states are made activate. If you want to control which Actors are visible to the -motion manager, there is the :py:class:`~upstage.states.DetectabilityState` that can be given to an actor and set to ``False``. +motion manager, there is the :py:class:`~upstage_des.states.DetectabilityState` that can be given to an actor and set to ``False``. Define the Motion Manager @@ -17,8 +17,8 @@ Define the Motion Manager .. code-block:: python :linenos: - from upstage.motion.geodetic_model import subdivide_intersection - from upstage.geography.intersections import get_intersection_locations + from upstage_des.motion.geodetic_model import subdivide_intersection + from upstage_des.geography.intersections import get_intersection_locations with UP.EnvironmentContext(): motion = UP.SensorMotionManager( @@ -29,8 +29,8 @@ Define the Motion Manager UP.add_stage_variable("intersection_model", get_intersection_locations) * Line 1-2: Import one of the intersection models and a support function (more on this below) -* Line 5: Create the :py:class:`~upstage.motion.SensorMotionManager` and give it the intersection model - * The other option is the :py:class:`~upstage.stepped_motion.SteppedMotionManager` class. (Does not need an intersection) +* Line 5: Create the :py:class:`~upstage_des.motion.SensorMotionManager` and give it the intersection model + * The other option is the :py:class:`~upstage_des.stepped_motion.SteppedMotionManager` class. (Does not need an intersection) * Line 9: Add the motion manager to the stage so that the ``<>LocationChangingState`` s can find it. * Line 10: Add the intersection helper function to the stage so the SensorMotionManager class can find it. @@ -40,20 +40,20 @@ in the background. Intersection Models ------------------- -There are two intersection models for ``Geodetic`` locations, and one model for ``Cartesian``. The Stepped motion manager does not require one, it uses :py:func:`~upstage.data_types.GeodeticLocation.straight_line_distance` at a given rate. +There are two intersection models for ``Geodetic`` locations, and one model for ``Cartesian``. The Stepped motion manager does not require one, it uses :py:func:`~upstage_des.data_types.GeodeticLocation.straight_line_distance` at a given rate. -* :py:func:`~upstage.motion.geodetic_model.subdivide_intersection`: The approximate intersection method with subdivided search, good for WGS84 coordinates. +* :py:func:`~upstage_des.motion.geodetic_model.subdivide_intersection`: The approximate intersection method with subdivided search, good for WGS84 coordinates. * This requires the stage variable ``intersection_model`` to be set. - * The only available intersection model is :py:func:`~upstage.geography.intersections.get_intersection_locations` + * The only available intersection model is :py:func:`~upstage_des.geography.intersections.get_intersection_locations` -* :py:func:`~upstage.motion.geodetic_model.analytical_intersection`: An exact intersection using a Spherical earth model. Incompatible with the WGS84 stage model. -* :py:func:`~upstage.motion.cartesian_model.cartesian_linear_intersection`: An exact intersection using for XYZ cartesian space. +* :py:func:`~upstage_des.motion.geodetic_model.analytical_intersection`: An exact intersection using a Spherical earth model. Incompatible with the WGS84 stage model. +* :py:func:`~upstage_des.motion.cartesian_model.cartesian_linear_intersection`: An exact intersection using for XYZ cartesian space. -The :py:func:`~upstage.geography.intersections.get_intersection_locations` function, required by the subdividing intersection, is what actually finds the intersections. The ``subdivide_intersection`` is +The :py:func:`~upstage_des.geography.intersections.get_intersection_locations` function, required by the subdividing intersection, is what actually finds the intersections. The ``subdivide_intersection`` is a passthrough function that handles the different earth models, stage variables, and conversion to the format UPSTAGE requires in the ``SensorMotionManager``. The intersection model itself does not have -to know about UPSTAGE. If you created a ``partial`` of a version of the ``subdivide_intersection`` that took the intersection model as an argument, you would get the same result without needing the stage variable. +to know about upstage_des. If you created a ``partial`` of a version of the ``subdivide_intersection`` that took the intersection model as an argument, you would get the same result without needing the stage variable. Sensor Requirements and Example @@ -68,8 +68,8 @@ All UPSTAGE does is call one of those methods according to the schedule. .. code-block:: python - from upstage.utils import waypoint_time_and_dist - from upstage.motion.cartesian_model import cartesian_linear_intersection + from upstage_des.utils import waypoint_time_and_dist + from upstage_des.motion.cartesian_model import cartesian_linear_intersection class Bird(UP.Actor): location = UP.CartesianLocationChangingState() @@ -204,5 +204,5 @@ Notice the slight inaccuracy in the position due to the time stepping. .. note:: The stepped manager is more flexible to the kinds of things that can be detected. You can use - :py:meth:`~upstage.motion.stepped_motion.SteppedMotionManager.add_detectable` to add anything with a + :py:meth:`~upstage_des.motion.stepped_motion.SteppedMotionManager.add_detectable` to add anything with a position. diff --git a/docs/source/user_guide/how_tos/nucleus.rst b/docs/source/user_guide/how_tos/nucleus.rst index 3ff4f3f..ba00402 100644 --- a/docs/source/user_guide/how_tos/nucleus.rst +++ b/docs/source/user_guide/how_tos/nucleus.rst @@ -22,7 +22,7 @@ The basic syntax is this: nuc.add_network(task_net, ["state name to watch", "other state"]) When any state given to the nucleus changes, nucleus pushes an interrupt to the task network. That interrupt is passed down -as a cause to ``on_interrupt`` as an instance of type :py:class:`~upstage.nucleus.NucleusInterrupt`. +as a cause to ``on_interrupt`` as an instance of type :py:class:`~upstage_des.nucleus.NucleusInterrupt`. .. code-block:: python @@ -134,7 +134,7 @@ A use case for Nucleus is when multiple task networks are sharing a single state a task cannot interrept itself. If a TaskNetwork changes a state that it is watching, SimPy will fail. It -What follows is an example that implements a nucleus allocation. This is not recommended, but is included to demonstrate how far you can stretch Nucleus and UPSTAGE. Ultimately, +What follows is an example that implements a nucleus allocation. This is not recommended, but is included to demonstrate how far you can stretch Nucleus and upstage_des. Ultimately, it is just running on SimPy and you can do what you like. Here are some issues/caveats with the following example: * None of the tasks are rehearsal-safe (this is OK if you're not going to rehearse) diff --git a/docs/source/user_guide/how_tos/random_numbers.rst b/docs/source/user_guide/how_tos/random_numbers.rst index 61a20ad..e09483a 100644 --- a/docs/source/user_guide/how_tos/random_numbers.rst +++ b/docs/source/user_guide/how_tos/random_numbers.rst @@ -4,14 +4,14 @@ 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.events.Wait`, in the :py:meth:`~upstage.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: .. code-block:: python from random import Random - from upstage.api import UpstageBase, EnvironmentContext + from upstage_des.api import UpstageBase, EnvironmentContext base = UpstageBase() diff --git a/docs/source/user_guide/how_tos/resource_states.rst b/docs/source/user_guide/how_tos/resource_states.rst index a0722f3..bc5667b 100644 --- a/docs/source/user_guide/how_tos/resource_states.rst +++ b/docs/source/user_guide/how_tos/resource_states.rst @@ -23,7 +23,7 @@ The obvious question is, why? The following works just fine: .. code-block:: python - import upstage.api as UP + import upstage_des.api as UP import simpy as SIM class CheckoutLane(UP.Actor): diff --git a/docs/source/user_guide/how_tos/stage_variables.rst b/docs/source/user_guide/how_tos/stage_variables.rst index cc86e91..056a321 100644 --- a/docs/source/user_guide/how_tos/stage_variables.rst +++ b/docs/source/user_guide/how_tos/stage_variables.rst @@ -4,7 +4,7 @@ Stage Variables The ``stage`` is an UPSTAGE feature to allow thread-safe "global" variables accessible by any Actor or Task. -To add variables to the stage, within the :py:class:`~upstage.base.EnvironmentContext` manager use the :py:func:`~upstage.base.add_stage_variable` function. +To add variables to the stage, within the :py:class:`~upstage_des.base.EnvironmentContext` manager use the :py:func:`~upstage_des.base.add_stage_variable` function. Once you set a stage variable, it cannot be changed. This is intentional, as the stage is meant to be static. Anything that changes should go through SimPy or UPSTAGE tasks, states, or processes. @@ -39,14 +39,14 @@ SimPy or UPSTAGE tasks, states, or processes. Expected Stage Variables =========================== -Some variables are expected to exist on the stage for some features. These are found in the :py:class:`~upstage.base.StageProtocol` protocol, +Some variables are expected to exist on the stage for some features. These are found in the :py:class:`~upstage_des.base.StageProtocol` protocol, and are listed below: -* "altitude_units": A string of "ft", "m", or other distance unit. See :py:func:`~upstage.units.convert.unit_convert` for a list. +* "altitude_units": A string of "ft", "m", or other distance unit. See :py:func:`~upstage_des.units.convert.unit_convert` for a list. * "distance_units": A string of distance units * "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.units.convert.unit_convert` for a list. +* "time_unit": Units of time. See :py:func:`~upstage_des.units.convert.unit_convert` for a list. 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. @@ -54,7 +54,7 @@ If they are not set and you use a feature that needs them, you'll get a warning Accessing Stage through UpstageBase =================================== -The :py:class:`~upstage.base.UpstageBase` class can be inherited to provide access to ``self.env`` and ``self.stage`` in any object, not just +The :py:class:`~upstage_des.base.UpstageBase` class can be inherited to provide access to ``self.env`` and ``self.stage`` in any object, not just actors and tasks. The following snippets shows how you might use it for pure SimPy capabilities. .. code-block:: python @@ -68,14 +68,14 @@ actors and tasks. The following snippets shows how you might use it for pure Sim self.env.process(_proc()) -Accessing Stage through upstage.api +Accessing Stage through upstage_des.api =================================== For convenience, you can also do the following: .. code-block:: python - import upstage.api as UP + import upstage_des.api as UP with UP.EnvironmentContext() as env: UP.add_stage_variable("altitude_units", "centimeters") diff --git a/docs/source/user_guide/how_tos/state_sharing.rst b/docs/source/user_guide/how_tos/state_sharing.rst index 6bd8b63..b8a111b 100644 --- a/docs/source/user_guide/how_tos/state_sharing.rst +++ b/docs/source/user_guide/how_tos/state_sharing.rst @@ -4,7 +4,7 @@ State Sharing State sharing is a way to share a state on an actor between multiple task networks. -The only currently implemented feature that shares state is the :py:class:`~upstage.state_sharing.SharedLinearChangingState`. +The only currently implemented feature that shares state is the :py:class:`~upstage_des.state_sharing.SharedLinearChangingState`. This is an advanced feature that will require a user to subclass and create their own sharing state for their specific use case. @@ -12,7 +12,7 @@ This is an advanced feature that will require a user to subclass and create thei Shared Linear Changing State ---------------------------- -The :py:class:`~upstage.state_sharing.SharedLinearChangingState` allows multiple networks to draw from a linear changing state. See the ``test_nucleus_state_share`` test for a complete example. +The :py:class:`~upstage_des.state_sharing.SharedLinearChangingState` allows multiple networks to draw from a linear changing state. See the ``test_nucleus_state_share`` test for a complete example. In that example, a mothership refuels a flyer, both of which draw from the same ``SharedLinearChangingState`` fuel level. In that example, the flyer actor doesn't directly draw from the mothership. Instead, the flyer tells the mothership that a draw will happen, and the mothership creates a new task network that draws that fuel from itself. That fuel is in addition to fuel burned while flying. diff --git a/docs/source/user_guide/how_tos/task_networks.rst b/docs/source/user_guide/how_tos/task_networks.rst index ef4ea92..fcee40f 100644 --- a/docs/source/user_guide/how_tos/task_networks.rst +++ b/docs/source/user_guide/how_tos/task_networks.rst @@ -68,8 +68,8 @@ Task Networks work by running a defined queue of task names, then by selecting ` You can modify the task network flow using: -* :py:meth:`upstage.actor.Actor.clear_task_queue`: Empty a task queue -* :py:meth:`upstage.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.clear_task_queue`: Empty a task queue +* :py:meth:`upstage_des.actor.Actor.set_task_queue`: Add tasks to an empty queue (by string name) - you must empty the queue first. These two methods are preferred since they prevent the risks of appending to a queue without looking at the queue. @@ -78,30 +78,30 @@ Introspecting the Task Network The task network queues can be viewed using: -* :py:meth:`upstage.actor.Actor.get_task_queue`: This requires the network name. -* :py:meth:`upstage.actor.Actor.get_all_task_queues`: This will return for all the networks on the actor. +* :py:meth:`upstage_des.actor.Actor.get_task_queue`: This requires the network name. +* :py:meth:`upstage_des.actor.Actor.get_all_task_queues`: This will return for all the networks on the actor. You can get the names and processes of tasks that are running (and their network names) using: -* :py:meth:`upstage.actor.Actor.get_running_task`: Returns a dataclass with the task name and process object of the task on the defined network. -* :py:meth:`upstage.actor.Actor.get_running_tasks`: Returns the same as above, but keyed on task network names. +* :py:meth:`upstage_des.actor.Actor.get_running_task`: Returns a dataclass with the task name and process object of the task on the defined network. +* :py:meth:`upstage_des.actor.Actor.get_running_tasks`: Returns the same as above, but keyed on task network names. -You would want the processes to interrupt them, but you can also use :py:meth:`upstage.actor.Actor.interrupt_network` to do that. +You would want the processes to interrupt them, but you can also use :py:meth:`upstage_des.actor.Actor.interrupt_network` to do that. Note that the task queue methods won't return the current tasks, just what's defined to run next. Use the running task methods to find the current task. A note on TaskNetworkFactory ---------------------------- -The :py:class:`~upstage.task_network.TaskNetworkFactory` class has some convience methods for creating factories from typical use cases: +The :py:class:`~upstage_des.task_network.TaskNetworkFactory` class has some convience methods for creating factories from typical use cases: -#. :py:meth:`~upstage.task_network.TaskNetworkFactory.from_single_looping`: From a single task, make a network that loops on it. +#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_single_looping`: From a single task, make a network that loops on it. * Useful for a Singleton task that, for example, receives communications and farms them out or manages other task networks. -#. :py:meth:`~upstage.task_network.TaskNetworkFactory.from_single_terminating`: A network that does one task, then freezes for the rest of the simulation. -#. :py:meth:`~upstage.task_network.TaskNetworkFactory.from_ordered_looping`: A series of tasks with no branching that loops. -#. :py:meth:`~upstage.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_single_terminating`: A network that does one task, then freezes for the rest of the simulation. +#. :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. -A terminating task network contains a :py:class:`~upstage.task.TerminalTask` task at the end, which waits on an un-succeedable event in a rehearsal-safe manner. +A terminating task network contains a :py:class:`~upstage_des.task.TerminalTask` task at the end, which waits on an un-succeedable event in a rehearsal-safe manner. Running Multiple Networks @@ -110,8 +110,8 @@ Running Multiple Networks An actor has no limits to the number of Task Networks it can run. As long as the Actor's states do not overlap in the networks, they can all run in "parallel". Simply keep the network names unique. -When adding parallel task networks, you can avoid a name clash with :py:meth:`upstage.actor.Actor.suggest_network_name`, and use the resulting name to add the network. When you are done with a network, -it can be deleted from the actor's attributes using: :py:meth:`upstage.actor.Actor.delete_task_network`. The task network will still be allowed to run, so make sure it's in a terminal state first. It will +When adding parallel task networks, you can avoid a name clash with :py:meth:`upstage_des.actor.Actor.suggest_network_name`, and use the resulting name to add the network. When you are done with a network, +it can be deleted from the actor's attributes using: :py:meth:`upstage_des.actor.Actor.delete_task_network`. The task network will still be allowed to run, so make sure it's in a terminal state first. It will de-clutter the task network introspection methods, though. See :doc:`Nucleus ` and :doc:`State Sharing ` for features related to inter-Task Networks "communication". diff --git a/docs/source/user_guide/how_tos/typing.rst b/docs/source/user_guide/how_tos/typing.rst index 466e5ae..1c64f14 100644 --- a/docs/source/user_guide/how_tos/typing.rst +++ b/docs/source/user_guide/how_tos/typing.rst @@ -31,11 +31,11 @@ Later, your IDE will know that any ``Gardener`` instance's ``skill_level`` attri These states already have an assigned type, or have a limited scope of types: -1. :py:class:`~upstage.states.DetectabilityState`: This state is a boolean -2. :py:class:`~upstage.states.CartesianLocationChangingState`: The output is of type ``CartesianLocation`` -3. :py:class:`~upstage.states.GeodeticLocationChangingState`: The output is of type ``GeodeticLocation`` -4. :py:class:`~upstage.states.ResourceState`: The type must be a ``simpy.Store`` or ``simpy.Container`` (or a subclass). You can still define the type. -5. :py:class:`~upstage.states.CommunicationStore`: This is of type ``simpy.Store`` +1. :py:class:`~upstage_des.states.DetectabilityState`: This state is a boolean +2. :py:class:`~upstage_des.states.CartesianLocationChangingState`: The output is of type ``CartesianLocation`` +3. :py:class:`~upstage_des.states.GeodeticLocationChangingState`: The output is of type ``GeodeticLocation`` +4. :py:class:`~upstage_des.states.ResourceState`: The type must be a ``simpy.Store`` or ``simpy.Container`` (or a subclass). You can still define the type. +5. :py:class:`~upstage_des.states.CommunicationStore`: This is of type ``simpy.Store`` ---------------------- Task and Process Types @@ -46,8 +46,8 @@ Tasks and simpy processes have output types that are ``Generator`` types. UPSTAG .. code-block:: python from simpy import Environment - from upstage.type_help import TASK_GEN, SIMPY_GEN - from upstage.api import Task, Actor, process, InterruptStates + from upstage_des.type_help import TASK_GEN, SIMPY_GEN + from upstage_des.api import Task, Actor, process, InterruptStates class SomeTask(Task): def task(self, *, actor: Actor) -> TASK_GEN: diff --git a/docs/source/user_guide/tutorials/best_practices.rst b/docs/source/user_guide/tutorials/best_practices.rst index 477b4e8..8211621 100644 --- a/docs/source/user_guide/tutorials/best_practices.rst +++ b/docs/source/user_guide/tutorials/best_practices.rst @@ -85,7 +85,7 @@ When testing for ``PLANNING_FACTOR_OBJECT``, do so in a method on the task that return 3.0 return item["process_time"] - def task(self, *, actor: UP.Actor) -> upstage.type_help.TASK_GEN: + def task(self, *, actor: UP.Actor) -> upstage_des.type_help.TASK_GEN: item = yield UP.Get(actor.some_store, planning_time_to_complete=1.23) time = self._get_time(item) yield UP.Wait(time) diff --git a/docs/source/user_guide/tutorials/first_simulation.rst b/docs/source/user_guide/tutorials/first_simulation.rst index 2d032ef..3ddf3e5 100644 --- a/docs/source/user_guide/tutorials/first_simulation.rst +++ b/docs/source/user_guide/tutorials/first_simulation.rst @@ -22,7 +22,7 @@ We prefer this syntax for importing UPSTAGE and SimPy: .. code-block:: python - import upstage.api as UP + import upstage_des.api as UP import simpy as SIM print("hello world") @@ -58,7 +58,7 @@ 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. 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``, + 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. @@ -170,7 +170,7 @@ Let's define the tasks that wait for a customer and check the customer out. :linenos: from typing import Generator - from upstage.type_help import TASK_GEN + from upstage_des.type_help import TASK_GEN class WaitInLane(UP.Task): @@ -309,23 +309,23 @@ All ``Task`` s should yield UPSTAGE events, with one exception. A SimPy ``Proces The event types are: -#. :py:class:`~upstage.events.Event`: Mimics SimPy's raw ``Event``, useful for marking pauses until a success. +#. :py:class:`~upstage_des.events.Event`: Mimics SimPy's raw ``Event``, useful for marking pauses until a success. - * See :py:meth:`~upstage.actor.Actor.create_knowledge_event` for a use case. + * See :py:meth:`~upstage_des.actor.Actor.create_knowledge_event` for a use case. -#. :py:class:`~upstage.events.All`: Succeed when all passed events succeed +#. :py:class:`~upstage_des.events.All`: Succeed when all passed events succeed -#. :py:class:`~upstage.events.Any`: Succeed when any passed events succeed +#. :py:class:`~upstage_des.events.Any`: Succeed when any passed events succeed -#. :py:class:`~upstage.events.Get`: Get from a store or container +#. :py:class:`~upstage_des.events.Get`: Get from a store or container -#. :py:class:`~upstage.events.FilterGet`: A get with a filter function +#. :py:class:`~upstage_des.events.FilterGet`: A get with a filter function -#. :py:class:`~upstage.events.Put`: Put something into a store or container +#. :py:class:`~upstage_des.events.Put`: Put something into a store or container -#. :py:class:`~upstage.events.ResourceHold`: Put and release holds on limited resources +#. :py:class:`~upstage_des.events.ResourceHold`: Put and release holds on limited resources -#. :py:class:`~upstage.events.Wait`: A standard SimPy timeout +#. :py:class:`~upstage_des.events.Wait`: A standard SimPy timeout ------------------------------------ @@ -391,17 +391,17 @@ You can either start a loop on a single task, or define an initial queue through A note on TaskNetworkFactory ---------------------------- -The :py:class:`~upstage.task_network.TaskNetworkFactory` class has some convience methods for creating factories from typical use cases: +The :py:class:`~upstage_des.task_network.TaskNetworkFactory` class has some convience methods for creating factories from typical use cases: -#. :py:meth:`~upstage.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_looping`: From a single task, make a network that loops itself. * Useful for a Singleton task that, for example, receives communications and farms them out or manages other task networks. -#. :py:meth:`~upstage.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_terminating`: A network that does one task, then freezes for the rest of the simulation. -#. :py:meth:`~upstage.task_network.TaskNetworkFactory.from_ordered_looping`: A series of tasks with no branching that loops. +#. :py:meth:`~upstage_des.task_network.TaskNetworkFactory.from_ordered_looping`: A series of tasks with no branching that loops. -#. :py:meth:`~upstage.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_single_looping`: A series of tasks with no branching that terminates at the end. -------------------- diff --git a/docs/source/user_guide/tutorials/interrupts.rst b/docs/source/user_guide/tutorials/interrupts.rst index 3a3de08..1a39ad2 100644 --- a/docs/source/user_guide/tutorials/interrupts.rst +++ b/docs/source/user_guide/tutorials/interrupts.rst @@ -126,15 +126,15 @@ UPSTAGE's interrupt handling system mitigates these key sources of error or frus To access these features, do the following: -#. Implement ``on_interrupt`` in the :py:class:`~upstage.task.Task` class. +#. Implement ``on_interrupt`` in the :py:class:`~upstage_des.task.Task` class. #. Optionally: use the ``marker`` features in the task and interrupt methods. - * :py:meth:`~upstage.task.Task.set_marker` + * :py:meth:`~upstage_des.task.Task.set_marker` - * :py:meth:`~upstage.task.Task.get_marker` + * :py:meth:`~upstage_des.task.Task.get_marker` - * :py:meth:`~upstage.task.Task.clear_marker` + * :py:meth:`~upstage_des.task.Task.clear_marker` We'll start simple, then add complexity to the interrupt. @@ -143,7 +143,7 @@ Here's what the above process would look like as an UPSTAGE Task: .. code-block:: python :linenos: - import upstage.api as UP + import upstage_des.api as UP import simpy as SIM from typing import Any @@ -199,7 +199,7 @@ Then, when you run it: Now the task is small and informative about what it's supposed to do when its not interrupted. The marker features let us set and get introspection data cleanly. -Notice also that the ``Get()`` call does not need to be cancelled by the user; UPSTAGE does that for us (for all :py:class:`~upstage.events.BaseEvent` subclasses that implement ``cancel``). +Notice also that the ``Get()`` call does not need to be cancelled by the user; UPSTAGE does that for us (for all :py:class:`~upstage_des.events.BaseEvent` subclasses that implement ``cancel``). Some additional details: @@ -216,7 +216,7 @@ Some additional details: INTERRUPT Types and Setting Markers ----------------------------------- -Interrupts allow 4 different outcomes to the task, which are signalled by the :py:class:`~upstage.task.InterruptStates` Enum (or :py:class:`~upstage.task.Task.INTERRUPT` as part of ``self``). The first +Interrupts allow 4 different outcomes to the task, which are signalled by the :py:class:`~upstage_des.task.InterruptStates` Enum (or :py:class:`~upstage_des.task.Task.INTERRUPT` as part of ``self``). The first three can be returned from ``on_interrupt`` to define how to handle the interrupt. #. ``END``: Ends the task right there (and moves on in the task network). This cancels the pending event(s). @@ -316,7 +316,7 @@ The interrupt automatically deactivates all states, keeping your Actors safe fro Getting the Process =================== -If an actor is running a task network, you will need to get the current Task process to send an interrupt. Do that with the :py:meth:`upstage.actor.Actor.get_running_tasks` method. +If an actor is running a task network, you will need to get the current Task process to send an interrupt. Do that with the :py:meth:`upstage_des.actor.Actor.get_running_tasks` method. .. code-block:: python diff --git a/docs/source/user_guide/tutorials/rehearsal.rst b/docs/source/user_guide/tutorials/rehearsal.rst index f17a209..49b9a9a 100644 --- a/docs/source/user_guide/tutorials/rehearsal.rst +++ b/docs/source/user_guide/tutorials/rehearsal.rst @@ -16,7 +16,7 @@ Define an actor and a task where some states change: .. code-block:: python - from upstage.utils import waypoint_time_and_dist + from upstage_des.utils import waypoint_time_and_dist class Plane(UP.Actor): speed = UP.State[float]() From 5841d73aa4ee9d43180483fc9a645f76dff4c82e Mon Sep 17 00:00:00 2001 From: James Arruda Date: Tue, 10 Dec 2024 13:23:20 -0500 Subject: [PATCH 3/3] Removing auto-building docs. --- docs/source/upstage_des.communications.rst | 29 ----- docs/source/upstage_des.geography.rst | 53 -------- docs/source/upstage_des.motion.rst | 53 -------- docs/source/upstage_des.resources.rst | 45 ------- docs/source/upstage_des.rst | 137 --------------------- docs/source/upstage_des.units.rst | 21 ---- 6 files changed, 338 deletions(-) delete mode 100644 docs/source/upstage_des.communications.rst delete mode 100644 docs/source/upstage_des.geography.rst delete mode 100644 docs/source/upstage_des.motion.rst delete mode 100644 docs/source/upstage_des.resources.rst delete mode 100644 docs/source/upstage_des.rst delete mode 100644 docs/source/upstage_des.units.rst diff --git a/docs/source/upstage_des.communications.rst b/docs/source/upstage_des.communications.rst deleted file mode 100644 index 84f9c0b..0000000 --- a/docs/source/upstage_des.communications.rst +++ /dev/null @@ -1,29 +0,0 @@ -upstage\_des.communications package -=================================== - -Submodules ----------- - -upstage\_des.communications.comms module ----------------------------------------- - -.. automodule:: upstage_des.communications.comms - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.communications.processes module --------------------------------------------- - -.. automodule:: upstage_des.communications.processes - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage_des.communications - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage_des.geography.rst b/docs/source/upstage_des.geography.rst deleted file mode 100644 index 6ecdb51..0000000 --- a/docs/source/upstage_des.geography.rst +++ /dev/null @@ -1,53 +0,0 @@ -upstage\_des.geography package -============================== - -Submodules ----------- - -upstage\_des.geography.conversions module ------------------------------------------ - -.. automodule:: upstage_des.geography.conversions - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.geography.geo\_types module ----------------------------------------- - -.. automodule:: upstage_des.geography.geo_types - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.geography.intersections module -------------------------------------------- - -.. automodule:: upstage_des.geography.intersections - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.geography.spherical module ---------------------------------------- - -.. automodule:: upstage_des.geography.spherical - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.geography.wgs84 module ------------------------------------ - -.. automodule:: upstage_des.geography.wgs84 - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage_des.geography - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage_des.motion.rst b/docs/source/upstage_des.motion.rst deleted file mode 100644 index e89000f..0000000 --- a/docs/source/upstage_des.motion.rst +++ /dev/null @@ -1,53 +0,0 @@ -upstage\_des.motion package -=========================== - -Submodules ----------- - -upstage\_des.motion.cartesian\_model module -------------------------------------------- - -.. automodule:: upstage_des.motion.cartesian_model - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.motion.geodetic\_model module ------------------------------------------- - -.. automodule:: upstage_des.motion.geodetic_model - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.motion.great\_circle\_calcs module ------------------------------------------------ - -.. automodule:: upstage_des.motion.great_circle_calcs - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.motion.motion module ---------------------------------- - -.. automodule:: upstage_des.motion.motion - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.motion.stepped\_motion module ------------------------------------------- - -.. automodule:: upstage_des.motion.stepped_motion - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage_des.motion - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage_des.resources.rst b/docs/source/upstage_des.resources.rst deleted file mode 100644 index a4cd774..0000000 --- a/docs/source/upstage_des.resources.rst +++ /dev/null @@ -1,45 +0,0 @@ -upstage\_des.resources package -============================== - -Submodules ----------- - -upstage\_des.resources.container module ---------------------------------------- - -.. automodule:: upstage_des.resources.container - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.resources.monitoring module ----------------------------------------- - -.. automodule:: upstage_des.resources.monitoring - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.resources.reserve module -------------------------------------- - -.. automodule:: upstage_des.resources.reserve - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.resources.sorted module ------------------------------------- - -.. automodule:: upstage_des.resources.sorted - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage_des.resources - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage_des.rst b/docs/source/upstage_des.rst deleted file mode 100644 index 073eb57..0000000 --- a/docs/source/upstage_des.rst +++ /dev/null @@ -1,137 +0,0 @@ -upstage\_des package -==================== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - upstage_des.communications - upstage_des.geography - upstage_des.motion - upstage_des.resources - upstage_des.units - -Submodules ----------- - -upstage\_des.actor module -------------------------- - -.. automodule:: upstage_des.actor - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.api module ------------------------ - -.. automodule:: upstage_des.api - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.base module ------------------------- - -.. automodule:: upstage_des.base - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.constants module ------------------------------ - -.. automodule:: upstage_des.constants - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.data\_types module -------------------------------- - -.. automodule:: upstage_des.data_types - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.events module --------------------------- - -.. automodule:: upstage_des.events - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.math\_utils module -------------------------------- - -.. automodule:: upstage_des.math_utils - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.nucleus module ---------------------------- - -.. automodule:: upstage_des.nucleus - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.state\_sharing module ----------------------------------- - -.. automodule:: upstage_des.state_sharing - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.states module --------------------------- - -.. automodule:: upstage_des.states - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.task module ------------------------- - -.. automodule:: upstage_des.task - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.task\_network module ---------------------------------- - -.. automodule:: upstage_des.task_network - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.type\_help module ------------------------------- - -.. automodule:: upstage_des.type_help - :members: - :undoc-members: - :show-inheritance: - -upstage\_des.utils module -------------------------- - -.. automodule:: upstage_des.utils - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage_des - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/upstage_des.units.rst b/docs/source/upstage_des.units.rst deleted file mode 100644 index bf082f4..0000000 --- a/docs/source/upstage_des.units.rst +++ /dev/null @@ -1,21 +0,0 @@ -upstage\_des.units package -========================== - -Submodules ----------- - -upstage\_des.units.convert module ---------------------------------- - -.. automodule:: upstage_des.units.convert - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: upstage_des.units - :members: - :undoc-members: - :show-inheritance: