diff --git a/.github/ISSUE_TEMPLATE/2-docs.yml b/.github/ISSUE_TEMPLATE/2-docs.yml index a892af1c1..a5de65eac 100644 --- a/.github/ISSUE_TEMPLATE/2-docs.yml +++ b/.github/ISSUE_TEMPLATE/2-docs.yml @@ -1,5 +1,5 @@ name: Documentation -description: Report an issue or enhancement related to https://scenic-lang.readthedocs.io/ +description: Report an issue or enhancement related to https://docs.scenic-lang.org/ labels: - "type: documentation" - "status: triage" @@ -14,9 +14,9 @@ body: attributes: label: Describe the doc issue or enhancement description: > - Please provide a clear and concise description of what content in https://scenic-lang.readthedocs.io/ has an issue or needs enhancement. + Please provide a clear and concise description of what content in https://docs.scenic-lang.org/ has an issue or needs enhancement. placeholder: | - Link to location in the docs: https://scenic-lang.readthedocs.io/ + Link to location in the docs: https://docs.scenic-lang.org/ validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 427eb214e..92854d330 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Questions - url: https://forms.gle/uUhQNuPzQrvvBFJX9 - about: Send your questions via Google Form \ No newline at end of file + url: https://forum.scenic-lang.org/ + about: Post your questions on our community forum \ No newline at end of file diff --git a/.github/workflows/run-simulators.yml b/.github/workflows/run-simulators.yml index 5b35cf85c..3b04f79df 100644 --- a/.github/workflows/run-simulators.yml +++ b/.github/workflows/run-simulators.yml @@ -10,13 +10,42 @@ jobs: runs-on: ubuntu-latest concurrency: group: sim + outputs: + volume_id: ${{ steps.create_volume_step.outputs.volume_id }} + env: + INSTANCE_ID: ${{ secrets.AWS_EC2_INSTANCE_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} steps: + - name: Create Volume from Latest Snapshot and Attach to Instance + id: create_volume_step + run: | + # Retrieve the latest snapshot ID + LATEST_SNAPSHOT_ID=$(aws ec2 describe-snapshots --owner-ids self --query 'Snapshots | sort_by(@, &StartTime) | [-1].SnapshotId' --output text) + echo "Checking availability for snapshot: $LATEST_SNAPSHOT_ID" + + # Wait for the snapshot to complete + aws ec2 wait snapshot-completed --snapshot-ids $LATEST_SNAPSHOT_ID + echo "Snapshot is ready." + + # Create a new volume from the latest snapshot + volume_id=$(aws ec2 create-volume --snapshot-id $LATEST_SNAPSHOT_ID --availability-zone us-west-1b --volume-type gp3 --size 400 --throughput 250 --query "VolumeId" --output text) + echo "Created volume with ID: $volume_id" + + # Set volume_id as output + echo "volume_id=$volume_id" >> $GITHUB_OUTPUT + cat $GITHUB_OUTPUT + + # Wait until the volume is available + aws ec2 wait volume-available --volume-ids $volume_id + echo "Volume is now available" + + # Attach the volume to the instance + aws ec2 attach-volume --volume-id $volume_id --instance-id $INSTANCE_ID --device /dev/sda1 + echo "Volume $volume_id attached to instance $INSTANCE_ID as /dev/sda1" + - name: Start EC2 Instance - env: - INSTANCE_ID: ${{ secrets.AWS_EC2_INSTANCE_ID }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | # Get the instance state instance_state=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID | jq -r '.Reservations[].Instances[].State.Name') @@ -27,7 +56,7 @@ jobs: sleep 10 instance_state=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID | jq -r '.Reservations[].Instances[].State.Name') done - + # Check if instance state is "stopped" if [[ "$instance_state" == "stopped" ]]; then echo "Instance is stopped, starting it..." @@ -42,34 +71,17 @@ jobs: exit 1 fi - # wait for status checks to pass - TIMEOUT=120 # Timeout in seconds - START_TIME=$(date +%s) - END_TIME=$((START_TIME + TIMEOUT)) - while true; do - response=$(aws ec2 describe-instance-status --instance-ids $INSTANCE_ID) - system_status=$(echo "$response" | jq -r '.InstanceStatuses[0].SystemStatus.Status') - instance_status=$(echo "$response" | jq -r '.InstanceStatuses[0].InstanceStatus.Status') - - if [[ "$system_status" == "ok" && "$instance_status" == "ok" ]]; then - echo "Both SystemStatus and InstanceStatus are 'ok'" - exit 0 - fi - - CURRENT_TIME=$(date +%s) - if [[ "$CURRENT_TIME" -ge "$END_TIME" ]]; then - echo "Timeout: Both SystemStatus and InstanceStatus have not reached 'ok' state within $TIMEOUT seconds." - exit 1 - fi - - sleep 10 # Check status every 10 seconds - done + # Wait for instance status checks to pass + echo "Waiting for instance status checks to pass..." + aws ec2 wait instance-status-ok --instance-ids $INSTANCE_ID + echo "Instance is now ready for use." + check_simulator_version_updates: name: check_simulator_version_updates runs-on: ubuntu-latest needs: start_ec2_instance - steps: + steps: - name: Check for Simulator Version Updates env: PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} @@ -109,11 +121,11 @@ jobs: echo "NVIDIA Driver is not set" exit 1 fi - ' + ' - name: NVIDIA Driver is not set if: ${{ failure() }} run: | - echo "NVIDIA SMI is not working, please run the steps here on the instance:" + echo "NVIDIA SMI is not working, please run the steps here on the instance:" echo "https://scenic-lang.atlassian.net/wiki/spaces/KAN/pages/2785287/Setting+Up+AWS+VM?parentProduct=JSW&initialAllowedFeatures=byline-contributors.byline-extensions.page-comments.delete.page-reactions.inline-comments.non-licensed-share&themeState=dark%253Adark%2520light%253Alight%2520spacing%253Aspacing%2520colorMode%253Alight&locale=en-US#Install-NVIDIA-Drivers" run_carla_simulators: @@ -128,17 +140,17 @@ jobs: USER_NAME: ${{secrets.SSH_USERNAME}} run: | echo "$PRIVATE_KEY" > private_key && chmod 600 private_key - ssh -o StrictHostKeyChecking=no -i private_key ${USER_NAME}@${HOSTNAME} ' + ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -i private_key ${USER_NAME}@${HOSTNAME} ' cd /home/ubuntu/actions/Scenic && source venv/bin/activate && carla_versions=($(find /software -maxdepth 1 -type d -name 'carla*')) && for version in "${carla_versions[@]}"; do - echo "============================= CARLA $version =============================" + echo "============================= CARLA $version =============================" export CARLA_ROOT="$version" - pytest tests/simulators/carla/test_carla.py + pytest tests/simulators/carla done ' - + run_webots_simulators: name: run_webots_simulators runs-on: ubuntu-latest @@ -160,42 +172,48 @@ jobs: for version in "${webots_versions[@]}"; do echo "============================= Webots $version =============================" export WEBOTS_ROOT="$version" - pytest tests/simulators/webots/test_webots.py + pytest tests/simulators/webots done + kill %1 ' - + stop_ec2_instance: name: stop_ec2_instance runs-on: ubuntu-latest - needs: [run_carla_simulators, run_webots_simulators] - steps: + needs: [start_ec2_instance, check_simulator_version_updates, check_nvidia_smi, run_carla_simulators, run_webots_simulators] + if: always() + env: + VOLUME_ID: ${{ needs.start_ec2_instance.outputs.volume_id }} + INSTANCE_ID: ${{ secrets.AWS_EC2_INSTANCE_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + steps: - name: Stop EC2 Instance - env: - INSTANCE_ID: ${{ secrets.AWS_EC2_INSTANCE_ID }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | - # Get the instance state + # Get the instance state and stop it if running instance_state=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID | jq -r '.Reservations[].Instances[].State.Name') - - # If the machine is pending wait for it to fully start - while [ "$instance_state" == "pending" ]; do - echo "Instance is pending startup, waiting for it to fully start..." - sleep 10 - instance_state=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID | jq -r '.Reservations[].Instances[].State.Name') - done - - # Check if instance state is "stopped" if [[ "$instance_state" == "running" ]]; then - echo "Instance is running, stopping it..." - aws ec2 stop-instances --instance-ids $INSTANCE_ID - elif [[ "$instance_state" == "stopping" ]]; then - echo "Instance is stopping..." + echo "Instance is running, stopping it..." + aws ec2 stop-instances --instance-ids $INSTANCE_ID + aws ec2 wait instance-stopped --instance-ids $INSTANCE_ID + echo "Instance has stopped." elif [[ "$instance_state" == "stopped" ]]; then - echo "Instance is already stopped..." - exit 0 + echo "Instance is already stopped." else - echo "Unknown instance state: $instance_state" - exit 1 + echo "Unexpected instance state: $instance_state" + exit 1 fi + + - name: Detach Volume + run: | + # Detach the volume + aws ec2 detach-volume --volume-id $VOLUME_ID + aws ec2 wait volume-available --volume-ids $VOLUME_ID + echo "Volume $VOLUME_ID detached." + + - name: Delete Volume + run: | + # Delete the volume after snapshot is complete + aws ec2 delete-volume --volume-id $VOLUME_ID + echo "Volume $VOLUME_ID deleted." diff --git a/.gitignore b/.gitignore index 31bd1e964..d2bf7f6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,5 @@ dmypy.json # generated parser src/scenic/syntax/parser.py + +simulation.gif \ No newline at end of file diff --git a/README.md b/README.md index c8cf05417..a6732338e 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ -# Scenic +[Scenic Logo](https://scenic-lang.org/) -[![Documentation Status](https://readthedocs.org/projects/scenic-lang/badge/?version=latest)](https://scenic-lang.readthedocs.io/en/latest/?badge=latest) +[![Documentation Status](https://readthedocs.org/projects/scenic-lang/badge/?version=latest)](https://docs.scenic-lang.org/en/latest/?badge=latest) [![Tests Status](https://github.com/BerkeleyLearnVerify/Scenic/actions/workflows/run-tests.yml/badge.svg)](https://github.com/BerkeleyLearnVerify/Scenic/actions/workflows/run-tests.yml) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) A compiler and scenario generator for Scenic, a domain-specific probabilistic programming language for modeling the environments of cyber-physical systems. -Please see the [documentation](https://scenic-lang.readthedocs.io/) for installation instructions, as well as tutorials and other information about the Scenic language, its implementation, and its interfaces to various simulators. +Please see the [documentation](https://docs.scenic-lang.org/) for installation instructions, as well as tutorials and other information about the Scenic language, its implementation, and its interfaces to various simulators. For an overview of the language and some of its applications, see our [2022 journal paper](https://link.springer.com/article/10.1007/s10994-021-06120-5) on Scenic 2, which extends our [PLDI 2019 paper](https://arxiv.org/abs/1809.09310) on Scenic 1. The new syntax and features of Scenic 3 are described in our [CAV 2023 paper](https://arxiv.org/abs/2307.03325). -Our [Publications](https://scenic-lang.readthedocs.io/en/latest/publications.html) page lists additional relevant publications. +Our [Publications](https://docs.scenic-lang.org/en/latest/publications.html) page lists additional relevant publications. Scenic was initially designed and implemented by Daniel J. Fremont, Tommaso Dreossi, Shromona Ghosh, Xiangyu Yue, Alberto L. Sangiovanni-Vincentelli, and Sanjit A. Seshia. Additionally, Edward Kim made major contributions to Scenic 2, and Eric Vin, Shun Kashiwa, Matthew Rhea, and Ellen Kalvan to Scenic 3. -Please see our [Credits](https://scenic-lang.readthedocs.io/en/latest/credits.html) page for details and more contributors. +Please see our [Credits](https://docs.scenic-lang.org/en/latest/credits.html) page for details and more contributors. -If you have any problems using Scenic, please submit an issue to [our GitHub repository](https://github.com/BerkeleyLearnVerify/Scenic). +If you have any problems using Scenic, please submit an issue to [our GitHub repository](https://github.com/BerkeleyLearnVerify/Scenic) or start a conversation on our [community forum](https://forum.scenic-lang.org/). The repository is organized as follows: diff --git a/codecov.yml b/codecov.yml index eb91a7567..9b0516a8b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,25 +1,32 @@ -codecov: - coverage: - precision: 2 - round: down - range: "70...100" - status: - project: - default: - target: 80% - threshold: 1% - changes: - target: 80% - threshold: 1% - comment: - layout: "reach, diff, flags, files" - behavior: default +codecov: require_ci_to_pass: true - ignore: - - "src/scenic/simulators/**" - - "tests/**" - - "!src/scenic/simulators/newtonian/**" - - "!src/scenic/simulators/utils/**" + +coverage: + precision: 2 + round: down + range: "70...100" + status: + project: + default: + target: 80% + threshold: 5% + patch: + default: + target: 80% + threshold: 5% +ignore: + - "tests/" + - "docs/" + - "src/scenic/simulators/airsim/" + - "src/scenic/simulators/carla/" + - "src/scenic/simulators/gta/" + - "src/scenic/simulators/lgsvl/" + - "src/scenic/simulators/webots/" + - "src/scenic/simulators/xplane/" + - "!**/*.py" +comment: + layout: "reach, diff, flags, files" + behavior: default cli: plugins: pycoverage: diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 88790a6a8..8968ef400 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -12,6 +12,12 @@ pre {tab-size: 4;} .wy-table-responsive table td, .wy-table-responsive table th {white-space: normal;} /* Increase maximum body width to support 100-character lines */ .wy-nav-content {max-width:900px;} +/* Make SVG logo render at the correct size */ +.wy-side-nav-search .wy-dropdown > a img.logo, .wy-side-nav-search > a img.logo { + width: 100%; +} +/* Modify background color behind logo to make it more readable */ +.wy-side-nav-search {background-color:#48ac92} /* Shrink the sidebar to 270 pixels wide */ .wy-tray-container li, .wy-menu-vertical, .wy-side-nav-search, .wy-nav-side, .rst-versions {width:270px;} diff --git a/docs/conf.py b/docs/conf.py index 16130e15d..d20aee6ea 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ sphinx._buildingScenicDocs = True from scenic.core.simulators import SimulatorInterfaceWarning +import scenic.syntax.compiler from scenic.syntax.translator import CompileOptions import scenic.syntax.veneer as veneer @@ -151,6 +152,13 @@ "custom.css", ] +html_logo = "images/logo-full.svg" +html_favicon = "images/favicon.ico" + +html_theme_options = { + "logo_only": True, +} + # -- Generate lists of keywords for the language reference ------------------- import itertools @@ -178,6 +186,10 @@ def maketable(items, columns=5, gap=4): with open("_build/keywords_soft.txt", "w") as outFile: for row in maketable(ScenicParser.SOFT_KEYWORDS): outFile.write(row + "\n") +with open("_build/builtin_names.txt", "w") as outFile: + for row in maketable(scenic.syntax.compiler.builtinNames): + outFile.write(row + "\n") + # -- Monkeypatch ModuleAnalyzer to handle Scenic modules --------------------- diff --git a/docs/credits.rst b/docs/credits.rst index e73a49ffd..b93567717 100644 --- a/docs/credits.rst +++ b/docs/credits.rst @@ -18,6 +18,7 @@ Shun Kashiwa developed the auto-generated parser for Scenic 3.0 and its support The Scenic tool and example scenarios have benefitted from additional code contributions from: + * Armando BaƱuelos * Johnathan Chiu * Greg Crow * Francis Indaheng diff --git a/docs/images/favicon.ico b/docs/images/favicon.ico new file mode 100644 index 000000000..3a60ee429 Binary files /dev/null and b/docs/images/favicon.ico differ diff --git a/docs/images/logo-full.svg b/docs/images/logo-full.svg new file mode 100644 index 000000000..e9c4caf5e --- /dev/null +++ b/docs/images/logo-full.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 2ef4a5881..cc5fbcd76 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ Our :doc:`publications ` page lists additional papers using Scenic Old code can likely be easily ported; you can also install older releases if necessary from `GitHub `__. -If you have any problems using Scenic, please submit an issue to `our GitHub repository `_ or contact Daniel at dfremont@ucsc.edu. +If you have any problems using Scenic, please submit an issue to `our GitHub repository `_ or ask a question on `our community forum `_. Table of Contents ================= diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 34fe517d0..aeabe83c3 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -171,3 +171,5 @@ Depending on what you'd like to do with Scenic, different parts of the documenta * If you want to control Scenic from Python rather than using the command-line tool (for example if you want to collect data from the generated scenarios), see :doc:`api`. * If you want to add a feature to the language or otherwise need to understand Scenic's inner workings, see our pages on :doc:`developing` and :ref:`internals`. + +If you can't find something in the documentation, or have any question about Scenic, feel free to post on our `community forum `_. diff --git a/docs/reference/functions.rst b/docs/reference/functions.rst index 066d3ede4..b2e589d94 100644 --- a/docs/reference/functions.rst +++ b/docs/reference/functions.rst @@ -70,6 +70,10 @@ localPath --------- The `localPath` function takes a relative path with respect to the directory containing the ``.scenic`` file where it is used, and converts it to an absolute path. Note that the path is returned as a `pathlib.Path` object. +.. versionchanged:: 3.0 + + This function now returns a `pathlib.Path` object instead of a string. + .. _verbosePrint_func: verbosePrint diff --git a/docs/reference/general.rst b/docs/reference/general.rst index 58155e536..6d6e33ef5 100644 --- a/docs/reference/general.rst +++ b/docs/reference/general.rst @@ -29,3 +29,10 @@ To avoid confusion, we recommend not using ``distance``, ``angle``, ``offset``, .. literalinclude:: /_build/keywords_soft.txt :language: text + +.. rubric:: Builtin Names + +The following names are built into Scenic and can be used but not overwritten . + +.. literalinclude:: /_build/builtin_names.txt + :language: text diff --git a/examples/carla/NHTSA_Scenarios/README.md b/examples/carla/NHTSA_Scenarios/README.md index b5eb7435f..3aaa8748e 100644 --- a/examples/carla/NHTSA_Scenarios/README.md +++ b/examples/carla/NHTSA_Scenarios/README.md @@ -4,7 +4,7 @@ This folder includes a library of Scenic programs written for use with the CARLA For questions and concerns, please contact Francis Indaheng at or post an issue to this repo. -*Note:* These scenarios require [VerifAI](https://verifai.readthedocs.io/) to be installed, since they use VerifAI's Halton sampler by default (the sampler type can be configured as explained [here](https://scenic-lang.readthedocs.io/en/latest/modules/scenic.core.external_params.html): for example, you can add `--param verifaiSamplerType random` when running Scenic to use random sampling instead). +*Note:* These scenarios require [VerifAI](https://verifai.readthedocs.io/) to be installed, since they use VerifAI's Halton sampler by default (the sampler type can be configured as explained [here](https://docs.scenic-lang.org/en/latest/modules/scenic.core.external_params.html): for example, you can add `--param verifaiSamplerType random` when running Scenic to use random sampling instead). ## Intersection diff --git a/examples/webots/README.md b/examples/webots/README.md index bcf3b0255..114279468 100644 --- a/examples/webots/README.md +++ b/examples/webots/README.md @@ -2,6 +2,6 @@ This folder contains example Scenic scenarios for use with the Webots robotics simulator. -In the **generic** folder we provide several Webots worlds (``.wbt`` files inside ``webots_data/worlds``) demonstrating scenarios with Scenic's [generic Webots interface](https://scenic-lang.readthedocs.io/en/latest/modules/scenic.simulators.webots.simulator.html). To run these, either install Scenic in the version of Python used by Webots or launch Webots from inside a virtual environment where Scenic is installed (the latter works as of Webots R2023a) then open one of the ``.wbt`` files. Starting the simulation will automatically start Scenic and repeatedly generate scenarios. +In the **generic** folder we provide several Webots worlds (``.wbt`` files inside ``webots_data/worlds``) demonstrating scenarios with Scenic's [generic Webots interface](https://docs.scenic-lang.org/en/latest/modules/scenic.simulators.webots.simulator.html). To run these, either install Scenic in the version of Python used by Webots or launch Webots from inside a virtual environment where Scenic is installed (the latter works as of Webots R2023a) then open one of the ``.wbt`` files. Starting the simulation will automatically start Scenic and repeatedly generate scenarios. __Licensing Note:__ The ``mars.wbt`` file is a modified version of the [Sojourner Rover example](https://cyberbotics.com/doc/guide/sojourner#sojourner-wbt) included in Webots. The original was written by Nicolas Uebelhart and is copyrighted by Cyberbotics Ltd. under the [Webots asset license](https://cyberbotics.com/webots_assets_license). Under the terms of that license, the modified version remains property of Cyberbotics; however, all other files in this directory are covered by the Scenic license. In particular, please feel free to model your own supervisor implementation on ``generic/webots_data/controllers/scenic_supervisor.py``. diff --git a/examples/webots/city_intersection/city_intersection.scenic b/examples/webots/city_intersection/city_intersection.scenic index 8b8533351..562c17402 100644 --- a/examples/webots/city_intersection/city_intersection.scenic +++ b/examples/webots/city_intersection/city_intersection.scenic @@ -52,13 +52,13 @@ class LogImageAction(Action): def applyTo(self, obj, sim): print("Other Car Visible:", self.visible) - target_path = self.path + "/" - target_path += "visible" if self.visible else "invisible" + target_path = self.path + target_path /= "visible" if self.visible else "invisible" if not os.path.exists(target_path): os.makedirs(target_path) - target_path += "/" + str(self.count) + ".jpeg" + target_path /= f"{self.count}.jpeg" print("IMG Path:", target_path) diff --git a/pyproject.toml b/pyproject.toml index e69b47d05..960bc4724 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scenic" -version = "3.0.0b2" +version = "3.0.0" description = "The Scenic scenario description language." authors = [ { name = "Daniel Fremont" }, @@ -37,7 +37,7 @@ dependencies = [ "numpy ~= 1.24", "opencv-python ~= 4.5", "pegen >= 0.3.0", - "pillow ~= 9.1", + "pillow >= 9.1", 'pygame >= 2.1.3.dev8, <3; python_version >= "3.11"', 'pygame ~= 2.0; python_version < "3.11"', "pyglet ~= 1.5", @@ -64,7 +64,7 @@ test-full = [ # like 'test' but adds dependencies for optional features "scenic[test]", # all dependencies from 'test' extra above "scenic[guideways]", # for running guideways modules "astor >= 0.8.1", - 'carla >= 0.9.12; python_version <= "3.8" and (platform_system == "Linux" or platform_system == "Windows")', + 'carla >= 0.9.12; python_version <= "3.10" and (platform_system == "Linux" or platform_system == "Windows")', "dill", "exceptiongroup", "inflect ~= 5.5", @@ -84,8 +84,9 @@ dev = [ ] [project.urls] +Homepage = "https://scenic-lang.org/" Repository = "https://github.com/BerkeleyLearnVerify/Scenic" -Documentation = "https://scenic-lang.readthedocs.io" +Documentation = "https://docs.scenic-lang.org" [project.scripts] scenic = 'scenic.__main__:dummy' @@ -100,6 +101,9 @@ scenic = "scenic.syntax.pygment:ScenicStyle" requires = ["flit_core >= 3.2, <4"] build-backend = "flit_core.buildapi" +[tool.flit.sdist] +include = ["docs/images/logo-full.svg"] + [tool.black] line-length = 90 force-exclude = ''' diff --git a/src/scenic/core/dynamics/scenarios.py b/src/scenic/core/dynamics/scenarios.py index 77ec470e8..61a55e48c 100644 --- a/src/scenic/core/dynamics/scenarios.py +++ b/src/scenic/core/dynamics/scenarios.py @@ -58,7 +58,7 @@ def __init__(self, *args, **kwargs): self._objects = [] # ordered for reproducibility self._sampledObjects = self._objects self._externalParameters = [] - self._pendingRequirements = defaultdict(list) + self._pendingRequirements = [] self._requirements = [] # things needing to be sampled to evaluate the requirements self._requirementDeps = set() @@ -409,9 +409,8 @@ def _registerObject(self, obj): def _addRequirement(self, ty, reqID, req, line, name, prob): """Save a requirement defined at compile-time for later processing.""" - assert reqID not in self._pendingRequirements preq = PendingRequirement(ty, req, line, prob, name, self._ego) - self._pendingRequirements[reqID] = preq + self._pendingRequirements.append((reqID, preq)) def _addDynamicRequirement(self, ty, req, line, name): """Add a requirement defined during a dynamic simulation.""" @@ -429,7 +428,7 @@ def _compileRequirements(self): namespace = self._dummyNamespace if self._dummyNamespace else self.__dict__ requirementSyntax = self._requirementSyntax assert requirementSyntax is not None - for reqID, requirement in self._pendingRequirements.items(): + for reqID, requirement in self._pendingRequirements: syntax = requirementSyntax[reqID] if requirementSyntax else None # Catch the simple case where someone has most likely forgotten the "monitor" diff --git a/src/scenic/core/external_params.py b/src/scenic/core/external_params.py index 3fe6f453d..88848db4b 100644 --- a/src/scenic/core/external_params.py +++ b/src/scenic/core/external_params.py @@ -195,7 +195,7 @@ def __init__(self, params, globalParams): usingProbs = True space = verifai.features.FeatureSpace( { - f"param{index}": verifai.features.Feature(param.domain) + self.nameForParam(index): verifai.features.Feature(param.domain) for index, param in enumerate(self.params) } ) @@ -262,7 +262,12 @@ def getSample(self): return self.sampler.getSample() def valueFor(self, param): - return self.cachedSample[param.index] + return getattr(self.cachedSample, self.nameForParam(param.index)) + + @staticmethod + def nameForParam(i): + """Parameter name for a given index in the Feature Space.""" + return f"param{i}" class ExternalParameter(Distribution): diff --git a/src/scenic/core/object_types.py b/src/scenic/core/object_types.py index 1604c36ca..5230f0c83 100644 --- a/src/scenic/core/object_types.py +++ b/src/scenic/core/object_types.py @@ -1017,7 +1017,7 @@ class Object(OrientedPoint): behavior: Behavior for dynamic agents, if any (see :ref:`dynamics`). Default value ``None``. lastActions: Tuple of :term:`actions` taken by this agent in the last time step - (or `None` if the object is not an agent or this is the first time step). + (an empty tuple if the object is not an agent or this is the first time step). """ _scenic_properties = { @@ -1037,12 +1037,13 @@ class Object(OrientedPoint): "occluding": True, "showVisibleRegion": False, "color": None, + "render": True, "velocity": PropertyDefault((), {"dynamic"}, lambda self: Vector(0, 0, 0)), "speed": PropertyDefault((), {"dynamic"}, lambda self: 0), "angularVelocity": PropertyDefault((), {"dynamic"}, lambda self: Vector(0, 0, 0)), "angularSpeed": PropertyDefault((), {"dynamic"}, lambda self: 0), "behavior": None, - "lastActions": None, + "lastActions": tuple(), # weakref to scenario which created this object, for internal use "_parentScenario": None, } @@ -1550,6 +1551,9 @@ def show3D(self, viewer, highlight=False): if needsSampling(self): raise RuntimeError("tried to show() symbolic Object") + if not self.render: + return + # Render the object object_mesh = self.occupiedSpace.mesh.copy() @@ -1564,7 +1568,12 @@ def show3D(self, viewer, highlight=False): else: assert False - object_mesh.visual.face_colors = [255 * r, 255 * g, 255 * b, 255 * a] + object_mesh.visual.face_colors = [ + int(255 * r), + int(255 * g), + int(255 * b), + int(255 * a), + ] viewer.add_geometry(object_mesh) @@ -1770,11 +1779,10 @@ def __init_subclass__(cls): cls._props_transformed = str(cls) props = cls._scenic_properties - # Raise error if parentOrientation already defined - if "parentOrientation" in props: + # Raise error if parentOrientation and heading already defined + if "parentOrientation" in props and "heading" in props: raise RuntimeError( - "this scenario cannot be run with the --2d flag (the " - f'{cls.__name__} class defines "parentOrientation")' + f'{cls.__name__} defines both "parentOrientation" and "heading"' ) # Map certain properties to their 3D analog diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 529b516a9..4732fda1c 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -999,9 +999,8 @@ def isConvex(self): @property def AABB(self): return ( - tuple(self.mesh.bounds[:, 0]), - tuple(self.mesh.bounds[:, 1]), - tuple(self.mesh.bounds[:, 2]), + tuple(self.mesh.bounds[0]), + tuple(self.mesh.bounds[1]), ) @cached_property @@ -2307,9 +2306,8 @@ def actual_face(index): @property def AABB(self): return ( - tuple(self.voxelGrid.bounds[:, 0]), - tuple(self.voxelGrid.bounds[:, 1]), - tuple(self.voxelGrid.bounds[:, 2]), + tuple(self.voxelGrid.bounds[0]), + tuple(self.voxelGrid.bounds[1]), ) @property @@ -2368,8 +2366,8 @@ def intersect(self, other, triedReversed=False): return PolygonalRegion(polygon=self.polygons, z=other.z).intersect(other) if isinstance(other, PathRegion): - center_z = (other.AABB[2][1] + other.AABB[2][0]) / 2 - height = other.AABB[2][1] - other.AABB[2][0] + 1 + center_z = (other.AABB[0][2] + other.AABB[1][2]) / 2 + height = other.AABB[1][2] - other.AABB[0][2] + 1 return self.approxBoundFootprint(center_z, height).intersect(other) return super().intersect(other, triedReversed) @@ -2700,8 +2698,9 @@ def projectVector(self, point, onDirection): @cached_property def AABB(self): - return tuple( - zip(numpy.amin(self.vertices, axis=0), numpy.amax(self.vertices, axis=0)) + return ( + tuple(numpy.amin(self.vertices, axis=0)), + tuple(numpy.amax(self.vertices, axis=0)), ) def uniformPointInner(self): @@ -2748,7 +2747,7 @@ class PolygonalRegion(Region): def __init__( self, - points=None, + points=(), polygon=None, z=0, orientation=None, @@ -2759,8 +2758,8 @@ def __init__( name, points, polygon, z, *additionalDeps, orientation=orientation ) - # Store main parameter - self._points = points + # Normalize and store main parameters + self._points = () if points is None else tuple(points) self._polygon = polygon self.z = z @@ -2774,7 +2773,6 @@ def __init__( points = tuple(pt[:2] for pt in points) if len(points) == 0: raise ValueError("tried to create PolygonalRegion from empty point list!") - self.points = points polygon = shapely.geometry.Polygon(points) if isinstance(polygon, shapely.geometry.Polygon): @@ -2791,13 +2789,6 @@ def __init__( "tried to create PolygonalRegion with " f"invalid polygon {self.polygons}" ) - if ( - points is None - and len(self.polygons.geoms) == 1 - and len(self.polygons.geoms[0].interiors) == 0 - ): - self.points = tuple(self.polygons.geoms[0].exterior.coords[:-1]) - if self.polygons.is_empty: raise ValueError("tried to create empty PolygonalRegion") shapely.prepare(self.polygons) @@ -2972,6 +2963,16 @@ def unionAll(regions, buf=0): z = 0 if z is None else z return PolygonalRegion(polygon=union, orientation=orientation, z=z) + @property + @distributionFunction + def points(self): + warnings.warn( + "The `points` method is deprecated and will be removed in Scenic 3.3.0." + "Users should use the `boundary` method instead.", + DeprecationWarning, + ) + return self.boundary.points + @property @distributionFunction def boundary(self) -> "PolylineRegion": @@ -3012,7 +3013,7 @@ def projectVector(self, point, onDirection): @property def AABB(self): xmin, ymin, xmax, ymax = self.polygons.bounds - return ((xmin, ymin), (xmax, ymax), (self.z, self.z)) + return ((xmin, ymin, self.z), (xmax, ymax, self.z)) @distributionFunction def buffer(self, amount): @@ -3049,7 +3050,14 @@ def __eq__(self, other): @cached def __hash__(self): - return hash((self.polygons, self.orientation, self.z)) + return hash( + ( + self._points, + self._polygon, + self.orientation, + self.z, + ) + ) class CircularRegion(PolygonalRegion): @@ -3131,7 +3139,7 @@ def uniformPointInner(self): def AABB(self): x, y, _ = self.center r = self.radius - return ((x - r, y - r), (x + r, y + r), (self.z, self.z)) + return ((x - r, y - r, self.z), (x + r, y + r, self.z)) def __repr__(self): return f"CircularRegion({self.center!r}, {self.radius!r})" @@ -3267,7 +3275,9 @@ def __init__(self, position, heading, width, length, name=None): self.circumcircle = (self.position, self.radius) super().__init__( - polygon=self._makePolygons(position, heading, width, length), + polygon=self._makePolygons( + self.position, self.heading, self.width, self.length + ), z=self.position.z, name=name, additionalDeps=deps, @@ -3319,7 +3329,7 @@ def AABB(self): x, y, z = zip(*self.corners) minx, maxx = findMinMax(x) miny, maxy = findMinMax(y) - return ((minx, miny), (maxx, maxy), (self.z, self.z)) + return ((minx, miny, self.z), (maxx, maxy, self.z)) def __repr__(self): return ( @@ -3630,7 +3640,7 @@ def length(self): @property def AABB(self): xmin, ymin, xmax, ymax = self.lineString.bounds - return ((xmin, ymin), (xmax, ymax), (0, 0)) + return ((xmin, ymin, 0), (xmax, ymax, 0)) def show(self, plt, style="r-", **kwargs): plotPolygon(self.lineString, plt, style=style, **kwargs) @@ -3734,6 +3744,10 @@ def intersects(self, other, triedReversed=False): return any(other.containsPoint(pt) for pt in self.points) def intersect(self, other, triedReversed=False): + # Try other way first before falling back to IntersectionRegion with sampler. + if triedReversed is False: + return other.intersect(self) + def sampler(intRegion): o = intRegion.regions[1] center, radius = o.circumcircle @@ -3784,8 +3798,9 @@ def projectVector(self, point, onDirection): @property def AABB(self): - return tuple( - zip(numpy.amin(self.points, axis=0), numpy.amax(self.points, axis=0)) + return ( + tuple(numpy.amin(self.points, axis=0)), + tuple(numpy.amax(self.points, axis=0)), ) def __eq__(self, other): diff --git a/src/scenic/core/requirements.py b/src/scenic/core/requirements.py index 7ea177d21..ab6de91da 100644 --- a/src/scenic/core/requirements.py +++ b/src/scenic/core/requirements.py @@ -9,7 +9,7 @@ import rv_ltl import trimesh -from scenic.core.distributions import Samplable, needsSampling +from scenic.core.distributions import Samplable, needsSampling, toDistribution from scenic.core.errors import InvalidScenarioError from scenic.core.lazy_eval import needsLazyEvaluation from scenic.core.propositions import Atomic, PropositionNode @@ -50,16 +50,21 @@ def __init__(self, ty, condition, line, prob, name, ego): # condition is an instance of Proposition. Flatten to get a list of atomic propositions. atoms = condition.atomics() - bindings = {} + self.globalBindings = {} # bindings to global/builtin names + self.closureBindings = {} # bindings to top-level closure variables + self.cells = [] # cells used in referenced closures atomGlobals = None for atom in atoms: - bindings.update(getAllGlobals(atom.closure)) + gbindings, cbindings, closures = getNameBindings(atom.closure) + self.globalBindings.update(gbindings) + self.closureBindings.update(cbindings) + for closure in closures: + self.cells.extend(closure.__closure__) globs = atom.closure.__globals__ if atomGlobals is not None: assert globs is atomGlobals else: atomGlobals = globs - self.bindings = bindings self.egoObject = ego def compile(self, namespace, scenario, syntax=None): @@ -68,17 +73,28 @@ def compile(self, namespace, scenario, syntax=None): While we're at it, determine whether the requirement implies any relations we can use for pruning, and gather all of its dependencies. """ - bindings, ego, line = self.bindings, self.egoObject, self.line + globalBindings, closureBindings = self.globalBindings, self.closureBindings + cells, ego, line = self.cells, self.egoObject, self.line condition, ty = self.condition, self.ty + # Convert bound values to distributions as needed + for name, value in globalBindings.items(): + globalBindings[name] = toDistribution(value) + for name, value in closureBindings.items(): + closureBindings[name] = toDistribution(value) + cells = tuple((cell, toDistribution(cell.cell_contents)) for cell in cells) + allBindings = dict(globalBindings) + allBindings.update(closureBindings) + # Check whether requirement implies any relations used for pruning canPrune = condition.check_constrains_sampling() if canPrune: - relations.inferRelationsFrom(syntax, bindings, ego, line) + relations.inferRelationsFrom(syntax, allBindings, ego, line) # Gather dependencies of the requirement deps = set() - for value in bindings.values(): + cellVals = (value for cell, value in cells) + for value in itertools.chain(allBindings.values(), cellVals): if needsSampling(value): deps.add(value) if needsLazyEvaluation(value): @@ -89,7 +105,7 @@ def compile(self, namespace, scenario, syntax=None): # If this requirement contains the CanSee specifier, we will need to sample all objects # to meet the dependencies. - if "CanSee" in bindings: + if "CanSee" in globalBindings: deps.update(scenario.objects) if ego is not None: @@ -98,13 +114,18 @@ def compile(self, namespace, scenario, syntax=None): # Construct closure def closure(values, monitor=None): - # rebind any names referring to sampled objects + # rebind any names referring to sampled objects (for require statements, + # rebind all names, since we want their values at the time the requirement + # was created) # note: need to extract namespace here rather than close over value # from above because of https://github.com/uqfoundation/dill/issues/532 namespace = condition.atomics()[0].closure.__globals__ - for name, value in bindings.items(): - if value in values: + for name, value in globalBindings.items(): + if ty == RequirementType.require or value in values: namespace[name] = values[value] + for cell, value in cells: + cell.cell_contents = values[value] + # rebind ego object, which can be referred to implicitly boundEgo = None if ego is None else values[ego] # evaluate requirement condition, reporting errors on the correct line @@ -128,24 +149,34 @@ def closure(values, monitor=None): return CompiledRequirement(self, closure, deps, condition) -def getAllGlobals(req, restrictTo=None): +def getNameBindings(req, restrictTo=None): """Find all names the given lambda depends on, along with their current bindings.""" namespace = req.__globals__ if restrictTo is not None and restrictTo is not namespace: - return {} + return {}, {}, () externals = inspect.getclosurevars(req) - assert not externals.nonlocals # TODO handle these - globs = dict(externals.builtins) - for name, value in externals.globals.items(): - globs[name] = value - if inspect.isfunction(value): - subglobs = getAllGlobals(value, restrictTo=namespace) - for name, value in subglobs.items(): - if name in globs: - assert value is globs[name] - else: - globs[name] = value - return globs + globalBindings = externals.builtins + + closures = set() + if externals.nonlocals: + closures.add(req) + + def handleFunctions(bindings): + for value in bindings.values(): + if inspect.isfunction(value): + if value.__closure__ is not None: + closures.add(value) + subglobs, _, _ = getNameBindings(value, restrictTo=namespace) + for name, value in subglobs.items(): + if name in globalBindings: + assert value is globalBindings[name] + else: + globalBindings[name] = value + + globalBindings.update(externals.globals) + handleFunctions(externals.globals) + handleFunctions(externals.nonlocals) + return globalBindings, externals.nonlocals, closures class BoundRequirement: diff --git a/src/scenic/core/scenarios.py b/src/scenic/core/scenarios.py index 6e339b6ec..133911013 100644 --- a/src/scenic/core/scenarios.py +++ b/src/scenic/core/scenarios.py @@ -533,6 +533,10 @@ def generateDefaultRequirements(self): for obj in filter( lambda x: x.requireVisible and x is not self.egoObject, self.objects ): + if not self.egoObject: + raise InvalidScenarioError( + "requireVisible set to true but no ego is defined" + ) requirements.append(VisibilityRequirement(self.egoObject, obj, self.objects)) return tuple(requirements) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index e66439125..a7c52367a 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -9,6 +9,7 @@ import math import pickle import struct +import types from scenic.core.distributions import Samplable, needsSampling from scenic.core.utils import DefaultIdentityDict @@ -41,6 +42,30 @@ def dumpAsScenicCode(value, stream): stream.write(repr(value)) +## Pickles + +# If dill is installed, register some custom handlers to improve the pickling +# of Scene and Scenario objects. + +try: + import dill +except Exception: + pass +else: + _orig_save_module = dill.Pickler.dispatch[types.ModuleType] + + @dill.register(types.ModuleType) + def patched_save_module(pickler, obj): + # Save Scenic's internal modules by reference to avoid inconsistent versions + # as well as some unpicklable objects (and shrink the size of pickles while + # we're at it). + name = obj.__name__ + if name == "scenic" or name.startswith("scenic."): + pickler.save_reduce(dill._dill._import_module, (name,), obj=obj) + return + _orig_save_module(pickler, obj) + + ## Binary serialization format diff --git a/src/scenic/core/simulators.py b/src/scenic/core/simulators.py index 539c8fdab..832b03632 100644 --- a/src/scenic/core/simulators.py +++ b/src/scenic/core/simulators.py @@ -11,7 +11,7 @@ """ import abc -from collections import OrderedDict, defaultdict +from collections import defaultdict import enum import math import numbers @@ -294,7 +294,10 @@ class Simulation(abc.ABC): timestep (float): Length of each time step in seconds. objects: List of Scenic objects (instances of `Object`) existing in the simulation. This list will change if objects are created dynamically. - agents: List of :term:`agents` in the simulation. + agents: List of :term:`agents` in the simulation. An agent is any object that has + or had a behavior at any point in the simulation. The agents list may have objects + appended to the end as the simulation progresses (if a non-agent object has its + behavior overridden), but once an object is in the agents list its position is fixed. result (`SimulationResult`): Result of the simulation, or `None` if it has not yet completed. This is the primary object which should be inspected to get data out of the simulation: the other undocumented attributes of this class @@ -331,7 +334,6 @@ def __init__( self.result = None self.scene = scene self.objects = [] - self.agents = [] self.trajectory = [] self.records = defaultdict(list) self.currentTime = 0 @@ -398,7 +400,7 @@ def __init__( for obj in self.objects: disableDynamicProxyFor(obj) for agent in self.agents: - if agent.behavior._isRunning: + if agent.behavior and agent.behavior._isRunning: agent.behavior._stop() # If the simulation was terminated by an exception (including rejections), # some scenarios may still be running; we need to clean them up without @@ -441,10 +443,25 @@ def _run(self, dynamicScenario, maxSteps): if maxSteps and self.currentTime >= maxSteps: return TerminationType.timeLimit, f"reached time limit ({maxSteps} steps)" + # Clear lastActions for all objects + for obj in self.objects: + obj.lastActions = tuple() + + # Update agents with any objects that now have behaviors (and are not already agents) + self.agents += [ + obj for obj in self.objects if obj.behavior and obj not in self.agents + ] + # Compute the actions of the agents in this time step - allActions = OrderedDict() + allActions = defaultdict(tuple) schedule = self.scheduleForAgents() + if not set(self.agents) == set(schedule): + raise RuntimeError("Simulator schedule does not contain all agents") for agent in schedule: + # If agent doesn't have a behavior right now, continue + if not agent.behavior: + continue + # Run the agent's behavior to get its actions actions = agent.behavior._step() @@ -472,11 +489,13 @@ def _run(self, dynamicScenario, maxSteps): # Save actions for execution below allActions[agent] = actions + # Log lastActions + agent.lastActions = actions + # Execute the actions if self.verbosity >= 3: for agent, actions in allActions.items(): print(f" Agent {agent} takes action(s) {actions}") - agent.lastActions = actions self.actionSequence.append(allActions) self.executeActions(allActions) @@ -492,6 +511,7 @@ def setup(self): but should call the parent implementation to create the objects in the initial scene (through `createObjectInSimulator`). """ + self.agents = [] for obj in self.scene.objects: self._createObject(obj) @@ -624,9 +644,9 @@ def executeActions(self, allActions): functionality. Args: - allActions: an :obj:`~collections.OrderedDict` mapping each agent to a tuple - of actions. The order of agents in the dict should be respected in case - the order of actions matters. + allActions: a :obj:`~collections.defaultdict` mapping each agent to a tuple + of actions, with the default value being an empty tuple. The order of + agents in the dict should be respected in case the order of actions matters. """ for agent, actions in allActions.items(): for action in actions: diff --git a/src/scenic/core/workspaces.py b/src/scenic/core/workspaces.py index f302f70b3..8daec9aab 100644 --- a/src/scenic/core/workspaces.py +++ b/src/scenic/core/workspaces.py @@ -60,7 +60,7 @@ def show2D(self, plt): aabb = self.region.AABB except (NotImplementedError, TypeError): # unbounded Regions don't support this return - ((xmin, ymin), (xmax, ymax), _) = aabb + ((xmin, ymin, _), (xmax, ymax, _)) = aabb plt.xlim(xmin, xmax) plt.ylim(ymin, ymax) diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index 0dea752f2..988d03072 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -17,6 +17,14 @@ If you are writing a generic scenario that supports multiple maps, you may leave ``map`` parameter undefined; then running the scenario will produce an error unless the user uses the :option:`--param` command-line option to specify the map. +The ``use2DMap`` global parameter determines whether or not maps are generated in 2D. Currently +3D maps are not supported, but are under development. By default, this parameter is `False` +(so that future versions of Scenic will automatically use 3D maps), unless +:ref:`2D compatibility mode` is enabled, in which case the default is `True`. The parameter +can be manually set to `True` to ensure 2D maps are used even if the scenario is not compiled +in 2D compatibility mode. + + .. note:: If you are using a simulator, you may have to also define simulator-specific global @@ -38,6 +46,22 @@ from scenic.domains.driving.behaviors import * from scenic.core.distributions import RejectionException from scenic.simulators.utils.colors import Color +## 2D mode flag & checks + +def is2DMode(): + from scenic.syntax.veneer import mode2D + return mode2D + +param use2DMap = True if is2DMode() else False + +if is2DMode() and not globalParameters.use2DMap: + raise RuntimeError('in 2D mode, global parameter "use2DMap" must be True') + +# Note: The following should be removed when 3D maps are supported +if not globalParameters.use2DMap: + raise RuntimeError('3D maps not supported at this time.' + '(to use 2D maps set global parameter "use2DMap" to True)') + ## Load map and set up workspace if 'map' not in globalParameters: @@ -80,10 +104,6 @@ roadDirection : VectorField = network.roadDirection ## Standard object types -def is2DMode(): - from scenic.syntax.veneer import mode2D - return mode2D - class DrivingObject: """Abstract class for objects in a road network. @@ -126,12 +146,12 @@ class DrivingObject: The simulation is rejected if the object is not in a lane. (Use `DrivingObject._lane` to get `None` instead.) """ - return network.laneAt(self, reject='object is not in a lane') + return network.laneAt(self.position, reject='object is not in a lane') @property def _lane(self) -> Optional[Lane]: """The `Lane` at the object's current position, if any.""" - return network.laneAt(self) + return network.laneAt(self.position) @property def laneSection(self) -> LaneSection: @@ -139,12 +159,12 @@ class DrivingObject: The simulation is rejected if the object is not in a lane. """ - return network.laneSectionAt(self, reject='object is not in a lane') + return network.laneSectionAt(self.position, reject='object is not in a lane') @property def _laneSection(self) -> Optional[LaneSection]: """The `LaneSection` at the object's current position, if any.""" - return network.laneSectionAt(self) + return network.laneSectionAt(self.position) @property def laneGroup(self) -> LaneGroup: @@ -152,12 +172,12 @@ class DrivingObject: The simulation is rejected if the object is not in a lane. """ - return network.laneGroupAt(self, reject='object is not in a lane') + return network.laneGroupAt(self.position, reject='object is not in a lane') @property def _laneGroup(self) -> Optional[LaneGroup]: """The `LaneGroup` at the object's current position, if any.""" - return network.laneGroupAt(self) + return network.laneGroupAt(self.position) @property def oppositeLaneGroup(self) -> LaneGroup: @@ -173,12 +193,12 @@ class DrivingObject: The simulation is rejected if the object is not on a road. """ - return network.roadAt(self, reject='object is not on a road') + return network.roadAt(self.position, reject='object is not on a road') @property def _road(self) -> Optional[Road]: """The `Road` at the object's current position, if any.""" - return network.roadAt(self) + return network.roadAt(self.position) @property def intersection(self) -> Intersection: @@ -186,12 +206,12 @@ class DrivingObject: The simulation is rejected if the object is not in an intersection. """ - return network.intersectionAt(self, reject='object is not in an intersection') + return network.intersectionAt(self.position, reject='object is not in an intersection') @property def _intersection(self) -> Optional[Intersection]: """The `Intersection` at the object's current position, if any.""" - return network.intersectionAt(self) + return network.intersectionAt(self.position) @property def crossing(self) -> PedestrianCrossing: @@ -199,12 +219,12 @@ class DrivingObject: The simulation is rejected if the object is not in a crosswalk. """ - return network.crossingAt(self, reject='object is not in a crossing') + return network.crossingAt(self.position, reject='object is not in a crossing') @property def _crossing(self) -> Optional[PedestrianCrossing]: """The `PedestrianCrossing` at the object's current position, if any.""" - return network.crossingAt(self) + return network.crossingAt(self.position) @property def element(self) -> NetworkElement: @@ -213,12 +233,12 @@ class DrivingObject: See `Network.elementAt` for the details of how this is determined. The simulation is rejected if the object is not in any network element. """ - return network.elementAt(self, reject='object is not on any network element') + return network.elementAt(self.position, reject='object is not on any network element') @property def _element(self) -> Optional[NetworkElement]: """The highest-level `NetworkElement` at the object's current position, if any.""" - return network.elementAt(self) + return network.elementAt(self.position) # Utility functions @@ -250,10 +270,10 @@ class Vehicle(DrivingObject): Properties: position: The default position is uniformly random over the `road`. - heading: The default heading is aligned with `roadDirection`, plus an offset + parentOrientation: The default parentOrientation is aligned with `roadDirection`, plus an offset given by **roadDeviation**. roadDeviation (float): Relative heading with respect to the road direction at - the `Vehicle`'s position. Used by the default value for **heading**. + the `Vehicle`'s position. Used by the default value for **parentOrientation**. regionContainedIn: The default container is :obj:`roadOrShoulder`. viewAngle: The default view angle is 90 degrees. width: The default width is 2 meters. @@ -264,7 +284,7 @@ class Vehicle(DrivingObject): """ regionContainedIn: roadOrShoulder position: new Point on road - heading: (roadDirection at self.position) + self.roadDeviation + parentOrientation: (roadDirection at self.position) + self.roadDeviation roadDeviation: 0 viewAngle: 90 deg width: 2 @@ -290,7 +310,7 @@ class Pedestrian(DrivingObject): Properties: position: The default position is uniformly random over sidewalks and crosswalks. - heading: The default heading is uniformly random. + parentOrientation: The default parentOrientation has uniformly random yaw. viewAngle: The default view angle is 90 degrees. width: The default width is 0.75 m. length: The default length is 0.75 m. @@ -299,7 +319,7 @@ class Pedestrian(DrivingObject): """ regionContainedIn: network.walkableRegion position: new Point on network.walkableRegion - heading: Range(0, 360) deg + parentOrientation: Range(0, 360) deg viewAngle: 90 deg width: 0.75 length: 0.75 diff --git a/src/scenic/domains/driving/roads.py b/src/scenic/domains/driving/roads.py index ff247f8ab..48289f0c8 100644 --- a/src/scenic/domains/driving/roads.py +++ b/src/scenic/domains/driving/roads.py @@ -34,7 +34,6 @@ distributionFunction, distributionMethod, ) -from scenic.core.errors import InvalidScenarioError import scenic.core.geometry as geometry from scenic.core.object_types import Point from scenic.core.regions import PolygonalRegion, PolylineRegion @@ -56,16 +55,9 @@ def _toVector(thing: Vectorlike) -> Vector: return type_support.toVector(thing) -def _rejectSample(message): - if veneer.isActive(): - raise InvalidScenarioError(message) - else: - raise RejectionException(message) - - def _rejectIfNonexistent(element, name="network element"): if element is None: - _rejectSample(f"requested {name} does not exist") + raise RejectionException(f"requested {name} does not exist") return element @@ -1219,7 +1211,7 @@ def findElementWithin(distance): message = reject else: message = "requested element does not exist" - _rejectSample(message) + raise RejectionException(message) return None def _findPointInAll(self, point, things, key=lambda e: e): diff --git a/src/scenic/simulators/carla/model.scenic b/src/scenic/simulators/carla/model.scenic index 030e31bca..66433de53 100644 --- a/src/scenic/simulators/carla/model.scenic +++ b/src/scenic/simulators/carla/model.scenic @@ -18,6 +18,8 @@ Global Parameters: timestep (float): Timestep to use for simulations (i.e., how frequently Scenic interrupts CARLA to run behaviors, check requirements, etc.), in seconds. Default is 0.1 seconds. + snapToGroundDefault (bool): Default value for :prop:`snapToGround` on `CarlaActor` objects. + Default is True if :ref:`2D compatibility mode` is enabled and False otherwise. weather (str or dict): Weather to use for the simulation. Can be either a string identifying one of the CARLA weather presets (e.g. 'ClearSunset') or a @@ -96,6 +98,7 @@ param weather = Uniform( 'MidRainSunset', 'HardRainSunset' ) +param snapToGroundDefault = is2DMode() simulator CarlaSimulator( carla_map=globalParameters.carla_map, @@ -118,12 +121,15 @@ class CarlaActor(DrivingObject): rolename (str): Can be used to differentiate specific actors during runtime. Default value ``None``. physics (bool): Whether physics is enabled for this object in CARLA. Default true. + snapToGround (bool): Whether or not to snap this object to the ground when placed in CARLA. + The default is set by the ``snapToGroundDefault`` global parameter above. """ carlaActor: None blueprint: None rolename: None color: None physics: True + snapToGround: globalParameters.snapToGroundDefault def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -222,12 +228,12 @@ class Prop(CarlaActor): """Abstract class for props, i.e. non-moving objects. Properties: - heading (float): Default value overridden to be uniformly random. + parentOrientation (Orientation): Default value overridden to have uniformly random yaw. physics (bool): Default value overridden to be false. """ regionContainedIn: road position: new Point on road - heading: Range(0, 360) deg + parentOrientation: Range(0, 360) deg width: 0.5 length: 0.5 physics: False @@ -253,7 +259,7 @@ class Chair(Prop): class BusStop(Prop): - blueprint: Uniform(*blueprints.busStopsModels) + blueprint: Uniform(*blueprints.busStopModels) class Advertisement(Prop): diff --git a/src/scenic/simulators/carla/simulator.py b/src/scenic/simulators/carla/simulator.py index 6e7022bae..6469281d7 100644 --- a/src/scenic/simulators/carla/simulator.py +++ b/src/scenic/simulators/carla/simulator.py @@ -205,7 +205,10 @@ def createObjectInSimulator(self, obj): # Set up transform loc = utils.scenicToCarlaLocation( - obj.position, world=self.world, blueprint=obj.blueprint + obj.position, + world=self.world, + blueprint=obj.blueprint, + snapToGround=obj.snapToGround, ) rot = utils.scenicToCarlaRotation(obj.orientation) transform = carla.Transform(loc, rot) diff --git a/src/scenic/simulators/carla/utils/utils.py b/src/scenic/simulators/carla/utils/utils.py index 72047e73b..638161163 100644 --- a/src/scenic/simulators/carla/utils/utils.py +++ b/src/scenic/simulators/carla/utils/utils.py @@ -7,7 +7,7 @@ from scenic.core.vectors import Orientation, Vector -def snapToGround(world, location, blueprint): +def _snapToGround(world, location, blueprint): """Mutates @location to have the same z-coordinate as the nearest waypoint in @world.""" waypoint = world.get_map().get_waypoint(location) # patch to avoid the spawn error issue with vehicles and walkers. @@ -25,11 +25,11 @@ def scenicToCarlaVector3D(x, y, z=0.0): return carla.Vector3D(x, -y, z) -def scenicToCarlaLocation(pos, z=None, world=None, blueprint=None): - if z is None: +def scenicToCarlaLocation(pos, world=None, blueprint=None, snapToGround=False): + if snapToGround: assert world is not None - return snapToGround(world, carla.Location(pos.x, -pos.y, 0.0), blueprint) - return carla.Location(pos.x, -pos.y, z) + return _snapToGround(world, carla.Location(pos.x, -pos.y, 0.0), blueprint) + return carla.Location(pos.x, -pos.y, pos.z) def scenicToCarlaRotation(orientation): @@ -40,13 +40,6 @@ def scenicToCarlaRotation(orientation): return carla.Rotation(pitch=pitch, yaw=yaw, roll=roll) -def scenicSpeedToCarlaVelocity(speed, heading): - currYaw = scenicToCarlaRotation(heading).yaw - xVel = speed * math.cos(currYaw) - yVel = speed * math.sin(currYaw) - return scenicToCarlaVector3D(xVel, yVel) - - def carlaToScenicPosition(loc): return Vector(loc.x, -loc.y, loc.z) diff --git a/src/scenic/simulators/newtonian/car.png b/src/scenic/simulators/newtonian/car.png index 46f39d574..a6f7d014d 100644 Binary files a/src/scenic/simulators/newtonian/car.png and b/src/scenic/simulators/newtonian/car.png differ diff --git a/src/scenic/simulators/newtonian/driving_model.scenic b/src/scenic/simulators/newtonian/driving_model.scenic index 1c01ccab2..a976dbdd0 100644 --- a/src/scenic/simulators/newtonian/driving_model.scenic +++ b/src/scenic/simulators/newtonian/driving_model.scenic @@ -14,7 +14,9 @@ from scenic.domains.driving.model import * # includes basic actions and behavio from scenic.simulators.utils.colors import Color -simulator NewtonianSimulator(network, render=render) +param debugRender = False + +simulator NewtonianSimulator(network, render=render, debug_render=globalParameters.debugRender) class NewtonianActor(DrivingObject): throttle: 0 diff --git a/src/scenic/simulators/newtonian/simulator.py b/src/scenic/simulators/newtonian/simulator.py index da453b0ab..193103ab7 100644 --- a/src/scenic/simulators/newtonian/simulator.py +++ b/src/scenic/simulators/newtonian/simulator.py @@ -5,8 +5,12 @@ from math import copysign, degrees, radians, sin import os import pathlib +import statistics import time +from PIL import Image +import numpy as np + import scenic.core.errors as errors # isort: skip if errors.verbosityLevel == 0: # suppress pygame advertisement at zero verbosity @@ -55,21 +59,33 @@ class NewtonianSimulator(DrivingSimulator): when not otherwise specified is still 0.1 seconds. """ - def __init__(self, network=None, render=False): + def __init__(self, network=None, render=False, debug_render=False, export_gif=False): super().__init__() + self.export_gif = export_gif self.render = render + self.debug_render = debug_render self.network = network def createSimulation(self, scene, **kwargs): - return NewtonianSimulation(scene, self.network, self.render, **kwargs) + simulation = NewtonianSimulation( + scene, self.network, self.render, self.export_gif, self.debug_render, **kwargs + ) + if self.export_gif and self.render: + simulation.generate_gif("simulation.gif") + return simulation class NewtonianSimulation(DrivingSimulation): """Implementation of `Simulation` for the Newtonian simulator.""" - def __init__(self, scene, network, render, timestep, **kwargs): + def __init__( + self, scene, network, render, export_gif, debug_render, timestep, **kwargs + ): + self.export_gif = export_gif self.render = render self.network = network + self.frames = [] + self.debug_render = debug_render if timestep is None: timestep = 0.1 @@ -91,10 +107,31 @@ def setup(self): ) self.screen.fill((255, 255, 255)) x, y, _ = self.objects[0].position - self.min_x, self.max_x = min_x - 50, max_x + 50 - self.min_y, self.max_y = min_y - 50, max_y + 50 + self.min_x, self.max_x = min_x - 40, max_x + 40 + self.min_y, self.max_y = min_y - 40, max_y + 40 self.size_x = self.max_x - self.min_x self.size_y = self.max_y - self.min_y + + # Generate a uniform screen scaling (applied to width and height) + # that includes all of both dimensions. + self.screenScaling = min(WIDTH / self.size_x, HEIGHT / self.size_y) + + # Calculate a screen translation that brings the mean vehicle + # position to the center of the screen. + + # N.B. screenTranslation is initialized to (0, 0) here intentionally. + # so that the actual screenTranslation can be set later based off what + # was computed with this null value. + self.screenTranslation = (0, 0) + + scaled_positions = map( + lambda x: self.scenicToScreenVal(x.position), self.objects + ) + mean_x, mean_y = map(statistics.mean, zip(*scaled_positions)) + + self.screenTranslation = (WIDTH / 2 - mean_x, HEIGHT / 2 - mean_y) + + # Create screen polygon to avoid rendering entirely invisible images self.screen_poly = shapely.geometry.Polygon( ( (self.min_x, self.min_y), @@ -106,9 +143,7 @@ def setup(self): img_path = os.path.join(current_dir, "car.png") self.car = pygame.image.load(img_path) - self.car_width = int(3.5 * WIDTH / self.size_x) - self.car_height = self.car_width - self.car = pygame.transform.scale(self.car, (self.car_width, self.car_height)) + self.parse_network() self.draw_objects() @@ -138,9 +173,14 @@ def addRegion(region, color, width=1): def scenicToScreenVal(self, pos): x, y = pos[:2] - x_prop = (x - self.min_x) / self.size_x - y_prop = (y - self.min_y) / self.size_y - return int(x_prop * WIDTH), HEIGHT - 1 - int(y_prop * HEIGHT) + + screen_x = (x - self.min_x) * self.screenScaling + screen_y = HEIGHT - 1 - (y - self.min_y) * self.screenScaling + + screen_x = screen_x + self.screenTranslation[0] + screen_y = screen_y + self.screenTranslation[1] + + return int(screen_x), int(screen_y) def createObjectInSimulator(self, obj): # Set actor's initial speed @@ -196,25 +236,41 @@ def draw_objects(self): for i, obj in enumerate(self.objects): color = (255, 0, 0) if i == 0 else (0, 0, 255) - h, w = obj.length, obj.width - pos_vec = Vector(-1.75, 1.75) - neg_vec = Vector(w / 2, h / 2) - heading_vec = Vector(0, 10).rotatedBy(obj.heading) - dx, dy = int(heading_vec.x), -int(heading_vec.y) - x, y = self.scenicToScreenVal(obj.position) - rect_x, rect_y = self.scenicToScreenVal(obj.position + pos_vec) + + if self.debug_render: + self.draw_rect(obj, color) + if hasattr(obj, "isCar") and obj.isCar: - self.rotated_car = pygame.transform.rotate( - self.car, math.degrees(obj.heading) - ) - self.screen.blit(self.rotated_car, (rect_x, rect_y)) + self.draw_car(obj) else: - corners = [self.scenicToScreenVal(corner) for corner in obj._corners2D] - pygame.draw.polygon(self.screen, color, corners) + self.draw_rect(obj, color) pygame.display.update() + + if self.export_gif: + frame = pygame.surfarray.array3d(self.screen) + frame = np.transpose(frame, (1, 0, 2)) + self.frames.append(frame) + time.sleep(self.timestep) + def draw_rect(self, obj, color): + corners = [self.scenicToScreenVal(corner) for corner in obj._corners2D] + pygame.draw.polygon(self.screen, color, corners) + + def draw_car(self, obj): + car_width = int(obj.width * self.screenScaling) + car_height = int(obj.height * self.screenScaling) + scaled_car = pygame.transform.scale(self.car, (car_width, car_height)) + rotated_car = pygame.transform.rotate(scaled_car, math.degrees(obj.heading)) + car_rect = rotated_car.get_rect() + car_rect.center = self.scenicToScreenVal(obj.position) + self.screen.blit(rotated_car, car_rect) + + def generate_gif(self, filename="simulation.gif"): + imgs = [Image.fromarray(frame) for frame in self.frames] + imgs[0].save(filename, save_all=True, append_images=imgs[1:], duration=50, loop=0) + def getProperties(self, obj, properties): yaw, _, _ = obj.parentOrientation.globalToLocalAngles(obj.heading, 0, 0) diff --git a/src/scenic/simulators/webots/model.scenic b/src/scenic/simulators/webots/model.scenic index 247f5a19f..0df1b70f7 100644 --- a/src/scenic/simulators/webots/model.scenic +++ b/src/scenic/simulators/webots/model.scenic @@ -303,7 +303,7 @@ class Hill(Terrain): height: 1 spread: 0.25 - color: (0,0,0,0) + render: False def heightAtOffset(self, offset): dx, dy, _ = offset diff --git a/src/scenic/simulators/webots/road/interface.py b/src/scenic/simulators/webots/road/interface.py index 661d460bf..f9eed5356 100644 --- a/src/scenic/simulators/webots/road/interface.py +++ b/src/scenic/simulators/webots/road/interface.py @@ -210,13 +210,13 @@ def computeGeometry(self, crossroads, snapTolerance=0.05): def show(self, plt): if self.hasLeftSidewalk: - x, y = zip(*self.leftSidewalk.points) + x, y = zip(*[p[:2] for p in self.leftSidewalk.boundary.points]) plt.fill(x, y, "#A0A0FF") if self.hasRightSidewalk: - x, y = zip(*self.rightSidewalk.points) + x, y = zip(*[p[:2] for p in self.rightSidewalk.boundary.points]) plt.fill(x, y, "#A0A0FF") self.region.show(plt, style="r:") - x, y = zip(*self.lanes[0].points) + x, y = zip(*[p[:2] for p in self.lanes[0].boundary.points]) plt.fill(x, y, color=(0.8, 1.0, 0.8)) for lane, markers in enumerate(self.laneMarkers): x, y = zip(*markers) @@ -296,7 +296,10 @@ def __init__(self, world): allCells = [] drivableAreas = [] for road in self.roads: - assert road.region.polygons.is_valid, (road.waypoints, road.region.points) + assert road.region.polygons.is_valid, ( + road.waypoints, + road.region.boundary.points, + ) allCells.extend(road.cells) for crossroad in self.crossroads: if crossroad.region is not None: diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index 9c8c567b4..70f1b9f24 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -58,7 +58,7 @@ def compileScenicAST( trackedNames = {"ego", "workspace"} globalParametersName = "globalParameters" -builtinNames = {globalParametersName} +builtinNames = {globalParametersName, "str", "int", "float"} # shorthands for convenience @@ -236,6 +236,16 @@ def makeSyntaxError(self, msg, node: ast.AST) -> ScenicParseError: } +class AtomicCheckTransformer(Transformer): + def visit_Call(self, node: ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in TEMPORAL_PREFIX_OPS: + self.makeSyntaxError( + f'malformed use of the "{func.id}" temporal operator', node + ) + return self.generic_visit(node) + + class PropositionTransformer(Transformer): def __init__(self, filename="") -> None: super().__init__(filename) @@ -260,6 +270,11 @@ def transform( newNode = self._create_atomic_proposition_factory(node) return newNode, self.nextSyntaxId + def generic_visit(self, node): + acv = AtomicCheckTransformer(self.filename) + acv.visit(node) + return node + def _register_requirement_syntax(self, syntax): """register requirement syntax for later use returns an ID for retrieving the syntax @@ -358,14 +373,6 @@ def visit_UnaryOp(self, node): ) return ast.copy_location(newNode, node) - def visit_Call(self, node: ast.Call): - func = node.func - if isinstance(func, ast.Name) and func.id in TEMPORAL_PREFIX_OPS: - self.makeSyntaxError( - f'malformed use of the "{func.id}" temporal operator', node - ) - return self.generic_visit(node) - def visit_Always(self, node: s.Always): value = self.visit(node.value) if not self.is_proposition_factory(value): @@ -540,7 +547,11 @@ def visit_Name(self, node: ast.Name) -> Any: if node.id in builtinNames: if not isinstance(node.ctx, ast.Load): raise self.makeSyntaxError(f'unexpected keyword "{node.id}"', node) - node = ast.copy_location(ast.Call(ast.Name(node.id, loadCtx), [], []), node) + # Convert global parameters name to a call + if node.id == globalParametersName: + node = ast.copy_location( + ast.Call(ast.Name(node.id, loadCtx), [], []), node + ) elif node.id in trackedNames: if not isinstance(node.ctx, ast.Load): raise self.makeSyntaxError( @@ -1078,6 +1089,16 @@ def visit_Call(self, node: ast.Call) -> Any: newArgs.append(self.visit(arg)) newKeywords = [self.visit(kwarg) for kwarg in node.keywords] newFunc = self.visit(node.func) + + # Convert primitive type conversions to their Scenic equivalents + if isinstance(newFunc, ast.Name): + if newFunc.id == "str": + newFunc.id = "_toStrScenic" + elif newFunc.id == "float": + newFunc.id = "_toFloatScenic" + elif newFunc.id == "int": + newFunc.id = "_toIntScenic" + if wrappedStar: newNode = ast.Call( ast.Name("callWithStarArgs", ast.Load()), @@ -1338,11 +1359,12 @@ def createRequirementLike( """Create a call to a function that implements requirement-like features, such as `record` and `terminate when`. Args: - functionName (str): Name of the requirement-like function to call. Its signature must be `(reqId: int, body: () -> bool, lineno: int, name: str | None)` + functionName (str): Name of the requirement-like function to call. Its signature + must be `(reqId: int, body: () -> bool, lineno: int, name: str | None)` body (ast.AST): AST node to evaluate for checking the condition lineno (int): Line number in the source code - name (Optional[str], optional): Optional name for requirements. Defaults to None. - prob (Optional[float], optional): Optional probability for requirements. Defaults to None. + name (Optional[str]): Optional name for requirements. Defaults to None. + prob (Optional[float]): Optional probability for requirements. Defaults to None. """ propTransformer = PropositionTransformer(self.filename) newBody, self.nextSyntaxId = propTransformer.transform(body, self.nextSyntaxId) @@ -1353,7 +1375,7 @@ def createRequirementLike( value=ast.Call( func=ast.Name(functionName, loadCtx), args=[ - ast.Constant(requirementId), # requirement IDre + ast.Constant(requirementId), # requirement ID newBody, # body ast.Constant(lineno), # line number ast.Constant(name), # requirement name diff --git a/src/scenic/syntax/pygment.py b/src/scenic/syntax/pygment.py index b93ac5ce2..8af6d5630 100644 --- a/src/scenic/syntax/pygment.py +++ b/src/scenic/syntax/pygment.py @@ -353,7 +353,7 @@ class ScenicLexer(BetterPythonLexer): filenames = ["*.scenic"] alias_filenames = ["*.sc"] mimetypes = ["application/x-scenic", "text/x-scenic"] - url = "https://scenic-lang.readthedocs.org/" + url = "https://scenic-lang.org/" uni_name = PythonLexer.uni_name obj_name = rf"(?:(ego)|({uni_name}))" diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index af6ce426c..4b550fbdd 100644 --- a/src/scenic/syntax/veneer.py +++ b/src/scenic/syntax/veneer.py @@ -36,10 +36,10 @@ "hypot", "max", "min", + "_toStrScenic", + "_toFloatScenic", + "_toIntScenic", "filter", - "str", - "float", - "int", "round", "len", "range", @@ -249,6 +249,7 @@ import sys import traceback import typing +import warnings from scenic.core.distributions import ( Distribution, @@ -1521,6 +1522,11 @@ def alwaysProvidesOrientation(region): return sample.orientation is not None or sample is nowhere except RejectionException: return False + except Exception as e: + warnings.warn( + f"While sampling internally to determine if a random region provides an orientation, the following exception was raised: {repr(e)}" + ) + return False def OffsetBy(offset): @@ -2068,32 +2074,35 @@ def helper(context): ) -### Primitive functions overriding Python builtins - -# N.B. applying functools.wraps to preserve the metadata of the original -# functions seems to break pickling/unpickling - - -@distributionFunction -def filter(function, iterable): - return list(builtins.filter(function, iterable)) +### Primitive internal functions, utilized after compiler conversion @distributionFunction -def str(*args, **kwargs): +def _toStrScenic(*args, **kwargs) -> str: return builtins.str(*args, **kwargs) @distributionFunction -def float(*args, **kwargs): +def _toFloatScenic(*args, **kwargs) -> float: return builtins.float(*args, **kwargs) @distributionFunction -def int(*args, **kwargs): +def _toIntScenic(*args, **kwargs) -> int: return builtins.int(*args, **kwargs) +### Primitive functions overriding Python builtins + +# N.B. applying functools.wraps to preserve the metadata of the original +# functions seems to break pickling/unpickling + + +@distributionFunction +def filter(function, iterable): + return list(builtins.filter(function, iterable)) + + @distributionFunction def round(*args, **kwargs): return builtins.round(*args, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index e5cfadcde..3fe2f4d92 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import os.path from pathlib import Path import re -import subprocess import sys import pytest @@ -41,7 +40,7 @@ def manager(): return manager -@pytest.fixture +@pytest.fixture(scope="session") def getAssetPath(): base = Path(__file__).parent.parent / "assets" diff --git a/tests/core/test_pickle.py b/tests/core/test_pickle.py index 17955be7b..71ffe82d8 100644 --- a/tests/core/test_pickle.py +++ b/tests/core/test_pickle.py @@ -1,5 +1,8 @@ +import sys + import pytest +import scenic from scenic.core.distributions import ( Normal, Options, @@ -95,6 +98,22 @@ def test_pickle_scene(): tryPickling(scene) +def test_pickle_scenario_2D_module(): + """Tests a nasty bug involving pickling the scenic module in 2D mode.""" + scenario = compileScenic( + """ + import scenic + ego = new Object with favoriteModule scenic + """, + mode2D=True, + ) + sc = tryPickling(scenario) + assert sys.modules["scenic.core.object_types"].Object is Object + ego = sampleEgo(sc) + assert isinstance(ego, Object) + assert ego.favoriteModule is scenic + + def test_pickle_scenario_dynamic(): scenario = compileScenic( """ diff --git a/tests/core/test_regions.py b/tests/core/test_regions.py index ea48796b2..b7293ab3c 100644 --- a/tests/core/test_regions.py +++ b/tests/core/test_regions.py @@ -5,10 +5,11 @@ import shapely.geometry import trimesh.voxel +from scenic.core.distributions import RandomControlFlowError, Range from scenic.core.object_types import Object, OrientedPoint from scenic.core.regions import * from scenic.core.vectors import VectorField -from tests.utils import sampleSceneFrom +from tests.utils import deprecationTest, sampleSceneFrom def sample_ignoring_rejections(region, num_samples): @@ -74,7 +75,7 @@ def test_circular_region(): assert not circ3.intersects(circ) assert circ.distanceTo(Vector(4, -3)) == 0 assert circ.distanceTo(Vector(1, -7)) == pytest.approx(3) - assert circ.AABB == ((2, -5), (6, -1), (0, 0)) + assert circ.AABB == ((2, -5, 0), (6, -1, 0)) def test_circular_sampling(): @@ -107,7 +108,7 @@ def test_rectangular_region(): r3 = RectangularRegion(Vector(2.5, 4.5), 0, 1, 1) assert not rect.intersects(r3) assert rect.distanceTo((1 + 2 * math.sqrt(3), 4)) == pytest.approx(2) - (minx, miny), (maxx, maxy), _ = rect.AABB + (minx, miny, _), (maxx, maxy, _) = rect.AABB assert maxy == pytest.approx(3 + math.sqrt(3) / 2) assert miny == pytest.approx(1 - math.sqrt(3) / 2) assert maxx == pytest.approx(1.5 + math.sqrt(3)) @@ -135,7 +136,7 @@ def test_polyline_region(): assert pl.equallySpacedPoints(3) == list(pl.points) assert pl.pointsSeparatedBy(math.sqrt(2)) == list(pl.points[:-1]) assert pl.length == pytest.approx(2 * math.sqrt(2)) - assert pl.AABB == ((0, 0), (1, 2), (0, 0)) + assert pl.AABB == ((0, 0, 0), (1, 2, 0)) start = pl.start assert isinstance(start, OrientedPoint) assert start.position == (0, 2) @@ -204,7 +205,7 @@ def test_polygon_region(): assert poly.distanceTo((2, 1.1, 4)) == pytest.approx(4) assert poly.containsObject(Object._with(position=(2, 1.25), width=0.49, length=0.49)) assert not poly.containsObject(Object._with(position=(2, 1.25), width=1, length=0.49)) - assert poly.AABB == ((1, 1), (3, 2), (0, 0)) + assert poly.AABB == ((1, 1, 0), (3, 2, 0)) line = PolylineRegion([(1, 1), (2, 1.8)]) assert poly.intersects(line) assert line.intersects(poly) @@ -222,6 +223,14 @@ def test_polygon_region(): PolygonalRegion([(1, 1), (3, 1), (2, 2), (1.3, 1.15)], z=3).uniformPointInner().z == 3 ) + assert i != d + hash(i) + e = CircularRegion((0, 0), Range(1, 3)) + with pytest.raises(RandomControlFlowError): + i == e + with pytest.raises(RandomControlFlowError): + e == i + hash(e) def test_polygon_unionAll(): @@ -390,10 +399,10 @@ def test_path_region(): assert r2.distanceTo(Vector(0, 0, 0)) == pytest.approx(math.sqrt(18)) # Test AABB - assert r1.AABB == ((0, 1), (0, 1), (0, 0)) - assert r2.AABB == ((3, 4), (3, 4), (0, 3)) - assert r3.AABB == ((6, 7), (6, 7), (0, 3)) - assert r4.AABB == ((0, 7), (0, 7), (0, 3)) + assert r1.AABB == ((0, 0, 0), (1, 1, 0)) + assert r2.AABB == ((3, 3, 0), (4, 4, 3)) + assert r3.AABB == ((6, 6, 0), (7, 7, 3)) + assert r4.AABB == ((0, 0, 0), (7, 7, 3)) def test_mesh_polygon_intersection(): @@ -558,7 +567,7 @@ def test_pointset_region(): assert ps.distanceTo((3, 4)) == 0 assert ps.distanceTo((3, 5)) == pytest.approx(1) assert ps.distanceTo((2, 3)) == pytest.approx(math.sqrt(2)) - assert ps.AABB == ((1, 5), (2, 6), (0, 5)) + assert ps.AABB == ((1, 2, 0), (5, 6, 5)) def test_voxel_region(): @@ -594,7 +603,7 @@ def test_voxel_region(): sampled_pt = vr1.uniformPointInner() assert vr1.containsPoint(sampled_pt) - assert vr1.AABB == ((2.5, 5.5), (3.5, 6.5), (4.5, 7.5)) + assert vr1.AABB == ((2.5, 3.5, 4.5), (5.5, 6.5, 7.5)) vg2 = trimesh.voxel.VoxelGrid(encoding=numpy.asarray(encoding)) @@ -732,26 +741,33 @@ def test_orientation_inheritance(): assert c.intersect(r).orientation is v2 -# General test of region combinations +## Automated Region Tests REGIONS = { - MeshVolumeRegion: MeshVolumeRegion(trimesh.creation.box((0.75, 0.75, 0.75))), - MeshSurfaceRegion: MeshSurfaceRegion(trimesh.creation.box((0.5, 0.5, 0.5))), - BoxRegion: BoxRegion(), - SpheroidRegion: SpheroidRegion(), - PolygonalFootprintRegion: PolygonalRegion( - [(0, 0.5), (0, 1), (2, 1), (0, 0)] - ).footprint, - PathRegion: PathRegion(points=[(6, 6), (6, 7, 1), (7, 7, 2), [7, 6, 3]]), - PolygonalRegion: PolygonalRegion([(0, 0.5), (0, 1), (2, 1), (0, 0)]), - CircularRegion: CircularRegion(Vector(29, 34), 5), - SectorRegion: SectorRegion(Vector(29, 34), 5, 1, 0.5), - RectangularRegion: RectangularRegion(Vector(1, 2), math.radians(30), 4, 2), - PolylineRegion: PolylineRegion([(0, 2), (1, 1), (0, 0)]), - PointSetRegion: PointSetRegion("foo", [(1, 2), (3, 4), (5, 6)]), - ViewRegion: ViewRegion(50, (1, 1)), + AllRegion("all"), + EmptyRegion("none"), + MeshVolumeRegion(trimesh.creation.box((0.75, 0.75, 0.75))), + MeshSurfaceRegion(trimesh.creation.box((0.5, 0.5, 0.5))), + BoxRegion(), + SpheroidRegion(), + PolygonalRegion([(0, 0.5), (0, 1), (2, 1), (0, 0)]).footprint, + PathRegion(points=[(6, 6), (6, 7, 1), (7, 7, 2), [7, 6, 3]]), + PolygonalRegion([(0, 0.5), (0, 1), (2, 1), (0, 0)]), + CircularRegion(Vector(29, 34), 5), + SectorRegion(Vector(29, 34), 5, 1, 0.5), + RectangularRegion(Vector(1, 2), math.radians(30), 4, 2), + PolylineRegion([(0, 2), (1, 1), (0, 0)]), + PointSetRegion("foo", [(1, 2), (3, 4), (5, 6)]), + ViewRegion(50, (1, 1)), } + +def regions_id(val): + return type(val).__name__ + + +# General test of region combinations + INVALID_INTERSECTS = ( {MeshSurfaceRegion, PathRegion}, {MeshSurfaceRegion, PolygonalRegion}, @@ -767,21 +783,14 @@ def test_orientation_inheritance(): ) -def regions_id(val): - return val[0].__name__ - - @pytest.mark.slow -@pytest.mark.parametrize( - "A,B", itertools.combinations(REGIONS.items(), 2), ids=regions_id -) +@pytest.mark.parametrize("A,B", itertools.combinations(REGIONS, 2), ids=regions_id) def test_region_combinations(A, B): - type_a, region_a = A - type_b, region_b = B + region_a = A + region_b = B - ## Check type correctness ## - assert isinstance(region_a, type_a) - assert isinstance(region_b, type_b) + type_a = type(A) + type_b = type(B) ## Check all output combinations ## # intersects() @@ -808,3 +817,33 @@ def test_region_combinations(A, B): # difference() difference_out = region_a.difference(region_b) assert isinstance(difference_out, Region) + + +# Test Region AABB +@pytest.mark.slow +@pytest.mark.parametrize("region", REGIONS, ids=regions_id) +def test_region_AABB(region): + # Ensure region actually supports AABB + try: + region.AABB + except (NotImplementedError, TypeError): + return + + # Check general structure + assert isinstance(region.AABB, tuple) + assert all(isinstance(b, tuple) for b in region.AABB) + assert len(region.AABB) == 2 + assert all(len(b) == 3 for b in region.AABB) + + # Sample some points and check that they're all contained + for pt in sample_ignoring_rejections(region, 1000): + for i in range(len(pt)): + assert region.AABB[0][i] <= pt[i] <= region.AABB[1][i] + + +## Deprecation Tests +@deprecationTest("3.3.0") +def test_polygons_points(): + points = ((1, 0, 0), (1, 1, 0), (2, 1, 0), (2, 0, 0)) + poly = PolygonalRegion(points) + assert set(poly.points) == set(points) diff --git a/tests/core/test_simulators.py b/tests/core/test_simulators.py index 70851495e..149c1cad1 100644 --- a/tests/core/test_simulators.py +++ b/tests/core/test_simulators.py @@ -64,3 +64,33 @@ class TestObj: assert result is not None assert result.records["test_val_1"] == [(0, "bar"), (1, "bar"), (2, "bar")] assert result.records["test_val_2"] == result.records["test_val_3"] == "bar" + + +def test_simulator_bad_scheduler(): + class TestSimulation(DummySimulation): + def scheduleForAgents(self): + # Don't include the last agent + return self.agents[:-1] + + class TestSimulator(DummySimulator): + def createSimulation(self, scene, **kwargs): + return TestSimulation(scene, **kwargs) + + scenario = compileScenic( + """ + behavior Foo(): + take 1 + + class TestObj: + allowCollisions: True + behavior: Foo + + for _ in range(5): + new TestObj + """ + ) + + scene, _ = scenario.generate(maxIterations=1) + simulator = TestSimulator() + with pytest.raises(RuntimeError): + result = simulator.simulate(scene, maxSteps=2) diff --git a/tests/domains/driving/test_driving.py b/tests/domains/driving/test_driving.py index f42c330ee..50d09266d 100644 --- a/tests/domains/driving/test_driving.py +++ b/tests/domains/driving/test_driving.py @@ -5,6 +5,7 @@ import pytest from scenic.core.distributions import RejectionException +from scenic.core.errors import InvalidScenarioError from scenic.core.geometry import TriangulationError from scenic.domains.driving.roads import Network from tests.utils import compileScenic, pickle_test, sampleEgo, sampleScene, tryPickling @@ -33,13 +34,32 @@ from tests.domains.driving.conftest import map_params, mapFolder -def compileDrivingScenario(cached_maps, code="", useCache=True, path=None): +def compileDrivingScenario( + cached_maps, code="", useCache=True, path=None, mode2D=True, params={} +): if not path: path = mapFolder / "CARLA" / "Town01.xodr" path = cached_maps[str(path)] preamble = template.format(map=path, cache=useCache) whole = preamble + "\n" + inspect.cleandoc(code) - return compileScenic(whole, mode2D=True) + return compileScenic(whole, mode2D=mode2D, params=params) + + +def test_driving_2D_map(cached_maps): + compileDrivingScenario( + cached_maps, + code=basicScenario, + useCache=False, + mode2D=False, + params={"use2DMap": True}, + ) + + +def test_driving_3D(cached_maps): + with pytest.raises(RuntimeError): + compileDrivingScenario( + cached_maps, code=basicScenario, useCache=False, mode2D=False + ) @pytest.mark.slow @@ -187,3 +207,25 @@ def test_pickle(cached_maps): unpickled = tryPickling(scenario) scene = sampleScene(unpickled, maxIterations=1000) tryPickling(scene) + + +def test_invalid_road_scenario(cached_maps): + with pytest.raises(InvalidScenarioError): + scenario = compileDrivingScenario( + cached_maps, + """ + ego = new Car at 80.6354425964952@-327.5431187869811 + param foo = ego.oppositeLaneGroup.sidewalk + """, + ) + + with pytest.raises(InvalidScenarioError): + # Set regionContainedIn to everywhere to hit driving domain specific code + # instead of high level not contained in workspace rejection. + scenario = compileDrivingScenario( + cached_maps, + """ + ego = new Car at 10000@10000, with regionContainedIn everywhere + param foo = ego.lane + """, + ) diff --git a/tests/simulators/carla/test_actions.py b/tests/simulators/carla/test_actions.py new file mode 100644 index 000000000..7914ad04a --- /dev/null +++ b/tests/simulators/carla/test_actions.py @@ -0,0 +1,124 @@ +import os +from pathlib import Path +import signal +import socket +import subprocess +import time + +import pytest + +try: + import carla + + from scenic.simulators.carla import CarlaSimulator +except ModuleNotFoundError: + pytest.skip("carla package not installed", allow_module_level=True) + +from tests.utils import compileScenic, sampleScene + + +def checkCarlaPath(): + CARLA_ROOT = os.environ.get("CARLA_ROOT") + if not CARLA_ROOT: + pytest.skip("CARLA_ROOT env variable not set.") + return CARLA_ROOT + + +def isCarlaServerRunning(host="localhost", port=2000): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, port)) + return True + except (socket.timeout, ConnectionRefusedError): + return False + + +@pytest.fixture(scope="package") +def getCarlaSimulator(getAssetPath): + carla_process = None + if not isCarlaServerRunning(): + CARLA_ROOT = checkCarlaPath() + carla_process = subprocess.Popen( + f"bash {CARLA_ROOT}/CarlaUE4.sh -RenderOffScreen", shell=True + ) + + for _ in range(180): + if isCarlaServerRunning(): + break + time.sleep(1) + else: + pytest.fail("Unable to connect to CARLA.") + + # Extra 5 seconds to ensure server startup + time.sleep(10) + + base = getAssetPath("maps/CARLA") + + def _getCarlaSimulator(town): + path = os.path.join(base, f"{town}.xodr") + simulator = CarlaSimulator(map_path=path, carla_map=town, timeout=180) + return simulator, town, path + + yield _getCarlaSimulator + + if carla_process: + subprocess.run("killall -9 CarlaUE4-Linux-Shipping", shell=True) + + +def test_throttle(getCarlaSimulator): + simulator, town, mapPath = getCarlaSimulator("Town01") + code = f""" + param map = r'{mapPath}' + param carla_map = '{town}' + param time_step = 1.0/10 + + model scenic.simulators.carla.model + + behavior DriveWithThrottle(): + while True: + take SetThrottleAction(1) + + ego = new Car at (369, -326), with behavior DriveWithThrottle + record ego.speed as CarSpeed + terminate after 5 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + records = simulation.result.records["CarSpeed"] + assert records[len(records) // 2][1] < records[-1][1] + + +def test_brake(getCarlaSimulator): + simulator, town, mapPath = getCarlaSimulator("Town01") + code = f""" + param map = r'{mapPath}' + param carla_map = '{town}' + param time_step = 1.0/10 + + model scenic.simulators.carla.model + + behavior DriveWithThrottle(): + while True: + take SetThrottleAction(1) + + behavior Brake(): + while True: + take SetThrottleAction(0), SetBrakeAction(1) + + behavior DriveThenBrake(): + do DriveWithThrottle() for 2 steps + do Brake() for 6 steps + + ego = new Car at (369, -326), + with blueprint 'vehicle.toyota.prius', + with behavior DriveThenBrake + record final ego.speed as CarSpeed + terminate after 8 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + finalSpeed = simulation.result.records["CarSpeed"] + assert finalSpeed == pytest.approx(0.0, abs=1e-1) diff --git a/tests/simulators/carla/test_carla.py b/tests/simulators/carla/test_basic.py similarity index 100% rename from tests/simulators/carla/test_carla.py rename to tests/simulators/carla/test_basic.py diff --git a/tests/simulators/carla/test_blueprints.py b/tests/simulators/carla/test_blueprints.py new file mode 100644 index 000000000..4b7dcba25 --- /dev/null +++ b/tests/simulators/carla/test_blueprints.py @@ -0,0 +1,98 @@ +import pytest + +try: + import carla +except ModuleNotFoundError: + pytest.skip("carla package not installed", allow_module_level=True) + +from test_actions import getCarlaSimulator + +from scenic.simulators.carla.blueprints import ( + advertisementModels, + atmModels, + barrelModels, + barrierModels, + benchModels, + bicycleModels, + boxModels, + busStopModels, + carModels, + caseModels, + chairModels, + coneModels, + containerModels, + creasedboxModels, + debrisModels, + garbageModels, + gnomeModels, + ironplateModels, + kioskModels, + mailboxModels, + motorcycleModels, + plantpotModels, + tableModels, + trafficwarningModels, + trashModels, + truckModels, + vendingMachineModels, + walkerModels, +) +from tests.utils import compileScenic, sampleScene + + +def model_blueprint(simulator, mapPath, town, modelType, modelName): + code = f""" + param map = r'{mapPath}' + param carla_map = '{town}' + param time_step = 1.0/10 + + model scenic.simulators.carla.model + ego = new {modelType} with blueprint '{modelName}' + terminate after 1 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + obj = simulation.objects[0] + assert obj.blueprint == modelName + + +model_data = { + "Car": carModels, + "Bicycle": bicycleModels, + "Motorcycle": motorcycleModels, + "Truck": truckModels, + "Trash": trashModels, + "Cone": coneModels, + "Debris": debrisModels, + "VendingMachine": vendingMachineModels, + "Chair": chairModels, + "BusStop": busStopModels, + "Advertisement": advertisementModels, + "Garbage": garbageModels, + "Container": containerModels, + "Table": tableModels, + "Barrier": barrierModels, + "PlantPot": plantpotModels, + "Mailbox": mailboxModels, + "Gnome": gnomeModels, + "CreasedBox": creasedboxModels, + "Case": caseModels, + "Box": boxModels, + "Bench": benchModels, + "Barrel": barrelModels, + "ATM": atmModels, + "Kiosk": kioskModels, + "IronPlate": ironplateModels, + "TrafficWarning": trafficwarningModels, + "Pedestrian": walkerModels, +} + + +@pytest.mark.parametrize( + "modelType, modelName", + [(type, name) for type, names in model_data.items() for name in names], +) +def test_model_blueprints(getCarlaSimulator, modelType, modelName): + simulator, town, mapPath = getCarlaSimulator("Town01") + model_blueprint(simulator, mapPath, town, modelType, modelName) diff --git a/tests/simulators/newtonian/driving.scenic b/tests/simulators/newtonian/driving.scenic index 5fb4bd695..9a7b84d51 100644 --- a/tests/simulators/newtonian/driving.scenic +++ b/tests/simulators/newtonian/driving.scenic @@ -21,3 +21,5 @@ third = new Car on visible ego.road, with behavior Potpourri require abs((apparent heading of third) - 180 deg) <= 30 deg new Object visible, with width 0.1, with length 0.1 + +terminate after 2 steps diff --git a/tests/simulators/newtonian/test_newtonian.py b/tests/simulators/newtonian/test_newtonian.py index dae7d82f1..b714479e2 100644 --- a/tests/simulators/newtonian/test_newtonian.py +++ b/tests/simulators/newtonian/test_newtonian.py @@ -1,5 +1,10 @@ +import os +from pathlib import Path + +from PIL import Image as IPImage import pytest +from scenic.domains.driving.roads import Network from scenic.simulators.newtonian import NewtonianSimulator from tests.utils import pickle_test, sampleScene, tryPickling @@ -21,7 +26,7 @@ def test_render(loadLocalScenario): simulator.simulate(scene, maxSteps=3) -def test_driving(loadLocalScenario): +def test_driving_2D(loadLocalScenario): def check(): scenario = loadLocalScenario("driving.scenic", mode2D=True) scene, _ = scenario.generate(maxIterations=1000) @@ -33,6 +38,19 @@ def check(): check() # If we fail here, something is leaking. +@pytest.mark.graphical +def test_gif_creation(loadLocalScenario): + scenario = loadLocalScenario("driving.scenic", mode2D=True) + scene, _ = scenario.generate(maxIterations=1000) + path = Path("assets") / "maps" / "CARLA" / "Town01.xodr" + network = Network.fromFile(path) + simulator = NewtonianSimulator(render=True, network=network, export_gif=True) + simulation = simulator.simulate(scene, maxSteps=100) + gif_path = Path("") / "simulation.gif" + assert os.path.exists(gif_path) + os.remove(gif_path) + + @pickle_test def test_pickle(loadLocalScenario): scenario = tryPickling(loadLocalScenario("basic.scenic")) diff --git a/tests/syntax/test_basic.py b/tests/syntax/test_basic.py index 0a2a75cdc..dd022b208 100644 --- a/tests/syntax/test_basic.py +++ b/tests/syntax/test_basic.py @@ -13,7 +13,13 @@ setDebuggingOptions, ) from scenic.core.object_types import Object -from tests.utils import compileScenic, sampleEgo, sampleParamPFrom, sampleScene +from tests.utils import ( + compileScenic, + sampleEgo, + sampleEgoFrom, + sampleParamPFrom, + sampleScene, +) def test_minimal(): @@ -296,3 +302,31 @@ def test_mode2D_interference(): scene, _ = scenario.generate() assert any(obj.position[2] != 0 for obj in scene.objects) + + +def test_mode2D_heading_parentOrientation(): + program = """ + class Foo: + heading: 0.56 + + class Bar(Foo): + parentOrientation: 1.2 + + ego = new Bar + """ + + obj = sampleEgoFrom(program, mode2D=True) + assert obj.heading == obj.parentOrientation.yaw == 1.2 + + program = """ + class Bar: + parentOrientation: 1.2 + + class Foo(Bar): + heading: 0.56 + + ego = new Foo + """ + + obj = sampleEgoFrom(program, mode2D=True) + assert obj.heading == obj.parentOrientation.yaw == 0.56 diff --git a/tests/syntax/test_distributions.py b/tests/syntax/test_distributions.py index c7fda04fc..c45572988 100644 --- a/tests/syntax/test_distributions.py +++ b/tests/syntax/test_distributions.py @@ -797,3 +797,19 @@ def test_object_expression(): for i in range(3): scene = sampleScene(scenario, maxIterations=50) assert len(scene.objects) == 3 + + +## Rejection vs Invalid Scenario Errors + + +def test_rejection_invalid(): + with pytest.raises(InvalidScenarioError): + compileScenic( + """ + from scenic.core.distributions import RejectionException + def foo(): + raise RejectionException("foo") + return Vector(1,1,1) + new Object at foo() + """ + ) diff --git a/tests/syntax/test_dynamics.py b/tests/syntax/test_dynamics.py index 513796cd1..0bd724b03 100644 --- a/tests/syntax/test_dynamics.py +++ b/tests/syntax/test_dynamics.py @@ -2085,3 +2085,33 @@ def test_record(): (2, (4, 0, 0)), (3, (6, 0, 0)), ) + + +## lastActions Property +def test_lastActions(): + scenario = compileScenic( + """ + behavior Foo(): + for i in range(4): + take i + ego = new Object with behavior Foo, with allowCollisions True + other = new Object with allowCollisions True + record ego.lastActions as ego_lastActions + record other.lastActions as other_lastActions + """ + ) + result = sampleResult(scenario, maxSteps=4) + assert tuple(result.records["ego_lastActions"]) == ( + (0, tuple()), + (1, (0,)), + (2, (1,)), + (3, (2,)), + (4, (3,)), + ) + assert tuple(result.records["other_lastActions"]) == ( + (0, tuple()), + (1, tuple()), + (2, tuple()), + (3, tuple()), + (4, tuple()), + ) diff --git a/tests/syntax/test_errors.py b/tests/syntax/test_errors.py index fded4f7ac..f51ad6e3b 100644 --- a/tests/syntax/test_errors.py +++ b/tests/syntax/test_errors.py @@ -25,6 +25,19 @@ def test_bad_extension(tmpdir): ### Parse errors + +## Reserved names +def test_reserved_type_names(): + with pytest.raises(ScenicSyntaxError): + compileScenic("float = 3") + + with pytest.raises(ScenicSyntaxError): + compileScenic("int = 3") + + with pytest.raises(ScenicSyntaxError): + compileScenic("str = 3") + + ## Constructor definitions diff --git a/tests/syntax/test_modular.py b/tests/syntax/test_modular.py index 2a967831a..a7a89903e 100644 --- a/tests/syntax/test_modular.py +++ b/tests/syntax/test_modular.py @@ -9,6 +9,7 @@ from scenic.core.simulators import DummySimulator, TerminationType from tests.utils import ( compileScenic, + sampleActionsFromScene, sampleEgo, sampleEgoActions, sampleEgoFrom, @@ -809,6 +810,75 @@ def test_override_behavior(): assert tuple(actions) == (1, -1, -2, 2) +def test_override_none_behavior(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object + compose: + wait + do Sub() for 2 steps + wait + scenario Sub(): + setup: + override ego with behavior Bar + behavior Bar(): + x = -1 + while True: + take x + x -= 1 + """, + scenario="Main", + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert tuple(actions) == (None, -1, -2, None) + + +def test_override_leakage(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object with prop 1 + compose: + do Sub1() + scenario Sub1(): + setup: + override ego with prop 2, with behavior Bar + behavior Bar(): + terminate + """, + scenario="Main", + ) + scene = sampleScene(scenario) + assert scene.objects[0].prop == 1 + sampleActionsFromScene(scene) + assert scene.objects[0].prop == 1 + + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object with prop 1 + compose: + do Sub1() + scenario Sub1(): + setup: + override ego with prop 2, with behavior Bar + behavior Bar(): + raise NotImplementedError() + wait + """, + scenario="Main", + ) + scene = sampleScene(scenario) + assert scene.objects[0].prop == 1 + with pytest.raises(NotImplementedError): + sampleActionsFromScene(scene) + assert scene.objects[0].prop == 1 + + def test_override_dynamic(): with pytest.raises(SpecifierError): compileScenic( @@ -1048,3 +1118,42 @@ def test_scenario_signature(body): assert name4 == "qux" assert p4.default is inspect.Parameter.empty assert p4.kind is inspect.Parameter.VAR_KEYWORD + + +# lastActions Property +def test_lastActions_modular(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object + record ego.lastActions as lastActions + compose: + do Sub1() for 2 steps + do Sub2() for 2 steps + do Sub1() for 2 steps + wait + scenario Sub1(): + setup: + override ego with behavior Bar + scenario Sub2(): + setup: + override ego with behavior None + behavior Bar(): + x = -1 + while True: + take x + x -= 1 + """, + scenario="Main", + ) + result = sampleResult(scenario, maxSteps=6) + assert tuple(result.records["lastActions"]) == ( + (0, tuple()), + (1, (-1,)), + (2, (-2,)), + (3, tuple()), + (4, tuple()), + (5, (-1,)), + (6, (-2,)), + ) diff --git a/tests/syntax/test_pruning.py b/tests/syntax/test_pruning.py index 061e44379..6a0c693f6 100644 --- a/tests/syntax/test_pruning.py +++ b/tests/syntax/test_pruning.py @@ -55,14 +55,14 @@ def test_containment_2d_region(): # Test both combined, in a slightly more complicated case. # Specifically, there is a non vertical component to baseOffset - # that should be accounted for and the height is random. + # that should be accounted for. scenario = compileScenic( """ class TestObject: baseOffset: (0.1, 0, self.height/2) workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) - ego = new TestObject on workspace, with height Range(0.1,0.5) + ego = new TestObject on workspace, with height 100 """ ) # Sampling should fail ~30.56% of the time, so diff --git a/tests/syntax/test_requirements.py b/tests/syntax/test_requirements.py index 1495afc19..730ac60c4 100644 --- a/tests/syntax/test_requirements.py +++ b/tests/syntax/test_requirements.py @@ -19,6 +19,87 @@ def test_requirement(): assert all(0 <= x <= 10 for x in xs) +def test_requirement_in_loop(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ Range(-10, 10) + for i in range(2): + require ego.position[i] >= 0 + """ + ) + poss = [sampleEgo(scenario, maxIterations=150).position for i in range(60)] + assert all(0 <= pos.x <= 10 and 0 <= pos.y <= 10 for pos in poss) + + +def test_requirement_in_function(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ Range(-10, 10) + def f(i): + require ego.position[i] >= 0 + for i in range(2): + f(i) + """ + ) + poss = [sampleEgo(scenario, maxIterations=150).position for i in range(60)] + assert all(0 <= pos.x <= 10 and 0 <= pos.y <= 10 for pos in poss) + + +def test_requirement_in_function_helper(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ Range(-10, 10) + m = 0 + def f(): + assert m == 0 + return ego.y + m + def g(): + require ego.x < f() + g() + m = -100 + """ + ) + poss = [sampleEgo(scenario, maxIterations=60).position for i in range(60)] + assert all(pos.x < pos.y for pos in poss) + + +def test_requirement_in_function_random_local(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ 0 + def f(): + local = Range(0, 1) + require ego.x < local + f() + """ + ) + xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] + assert all(-10 <= x <= 1 for x in xs) + + +def test_requirement_in_function_random_cell(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ 0 + def f(i): + def g(): + return i + return g + g = f(Range(0, 1)) # global function with a cell containing a random value + def h(): + local = Uniform(True, False) + def inner(): # local function likewise + return local + require (g() >= 0) and ((ego.x < -5) if inner() else (ego.x > 5)) + h() + """ + ) + xs = [sampleEgo(scenario, maxIterations=150).position.x for i in range(60)] + assert all(x < -5 or x > 5 for x in xs) + assert any(x < -5 for x in xs) + assert any(x > 5 for x in xs) + + def test_soft_requirement(): scenario = compileScenic( """ @@ -497,3 +578,44 @@ def test_random_occlusion(): hasattr(obj, "name") and obj.name == "wall" and (not obj.occluding) for obj in scene.objects ) + + +def test_deep_not(): + """Test that a not deep inside a requirement is interpreted correctly.""" + with pytest.raises(RejectionException): + sampleSceneFrom( + """ + objs = [new Object at 10@10, new Object at 20@20] + require all(not o.x > 0 for o in objs) + """ + ) + + +def test_deep_and(): + with pytest.raises(RejectionException): + sampleSceneFrom( + """ + objs = [new Object at 10@10, new Object at 20@20] + require all(o.x > 0 and o.x < 0 for o in objs) + """ + ) + + +def test_deep_or(): + with pytest.raises(RejectionException): + sampleSceneFrom( + """ + objs = [new Object at 10@10, new Object at 20@20] + require all(o.x < 0 or o.x < -1 for o in objs) + """ + ) + + +def test_temporal_in_atomic(): + with pytest.raises(ScenicSyntaxError): + sampleSceneFrom( + """ + objs = [new Object at 10@10, new Object at 20@20] + require all(eventually(o.x > 0) for o in objs) + """ + ) diff --git a/tests/syntax/test_specifiers.py b/tests/syntax/test_specifiers.py index 07b86fee1..274e875e1 100644 --- a/tests/syntax/test_specifiers.py +++ b/tests/syntax/test_specifiers.py @@ -555,6 +555,11 @@ def test_visible_no_ego(): compileScenic("ego = new Object visible") +def test_visible_no_ego_2(): + with pytest.raises(InvalidScenarioError): + compileScenic("new Object visible") + + def test_visible_from_point(): scenario = compileScenic( "x = new Point at 300@200, with visibleDistance 2\n" @@ -1193,3 +1198,19 @@ def test_color(): ego = new Object with color (1,1,1,1,1) """ sampleEgoFrom(program) + + +# alwaysProvidesOrientation +def test_alwaysProvidesOrientation_exception(): + with pytest.warns(UserWarning): + compileScenic( + """ + from scenic.core.distributions import distributionFunction + + @distributionFunction + def foo(bar): + assert False + + new Object in foo(Range(0,1)) + """ + ) diff --git a/tests/syntax/test_typing.py b/tests/syntax/test_typing.py index e8b6205c5..b70f2dc53 100644 --- a/tests/syntax/test_typing.py +++ b/tests/syntax/test_typing.py @@ -63,3 +63,67 @@ def test_list_as_vector_3(): param p = distance to [-2, -2, 0, 6] """ ) + + +# Builtin Type Conversion Tests +def test_isinstance_str(): + p = sampleParamPFrom( + """ + from scenic.core.type_support import isA + param p = str(1) + assert isinstance(globalParameters.p, str) + assert isA(globalParameters.p, str) + """ + ) + assert isinstance(p, str) + + p = sampleParamPFrom( + """ + from scenic.core.type_support import isA + param p = str(Range(0,2)) + assert isA(globalParameters.p, str) + """ + ) + assert isinstance(p, str) + + +def test_isinstance_float(): + p = sampleParamPFrom( + """ + from scenic.core.type_support import isA + param p = float(1) + assert isinstance(globalParameters.p, float) + assert isA(globalParameters.p, float) + """ + ) + assert isinstance(p, float) + + p = sampleParamPFrom( + """ + from scenic.core.type_support import isA + param p = float(Range(0,2)) + assert isA(globalParameters.p, float) + """ + ) + assert isinstance(p, float) + + +def test_isinstance_int(): + p = sampleParamPFrom( + """ + from scenic.core.type_support import isA + param p = int(1.5) + assert isinstance(globalParameters.p, int) + assert isA(globalParameters.p, int) + """ + ) + assert isinstance(p, int) + + p = sampleParamPFrom( + """ + from scenic.core.type_support import isA + param p = int(Range(0,2)) + assert isA(globalParameters.p, int) + """ + ) + assert isinstance(p, int) diff --git a/tests/syntax/test_verifai_samplers.py b/tests/syntax/test_verifai_samplers.py index be57756c2..27e64607f 100644 --- a/tests/syntax/test_verifai_samplers.py +++ b/tests/syntax/test_verifai_samplers.py @@ -195,3 +195,11 @@ def test_noninterference(): for j in range(5): scene, iterations = scenario.generate(maxIterations=1) assert len(scenario.externalSampler.cachedSample) == 1 + + +def test_feature_order(): + scenario = compileScenic("param p = [VerifaiRange(x, x + 0.5) for x in range(105)]") + values = sampleParamP(scenario) + assert len(values) == 105 + for x, val in enumerate(values): + assert x <= val <= x + 0.5 diff --git a/tests/utils.py b/tests/utils.py index 4d2f6d801..3ae1cf8c1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,12 @@ """Utilities used throughout the test suite.""" +import functools from importlib import metadata +import importlib.metadata import inspect import math import multiprocessing +import re import sys import types import weakref @@ -25,12 +28,12 @@ # Compilation -def compileScenic(code, removeIndentation=True, scenario=None, mode2D=False): +def compileScenic(code, removeIndentation=True, scenario=None, mode2D=False, params={}): if removeIndentation: # to allow indenting code to line up with test function code = inspect.cleandoc(code) checkVeneerIsInactive() - scenario = scenarioFromString(code, scenario=scenario, mode2D=mode2D) + scenario = scenarioFromString(code, scenario=scenario, mode2D=mode2D, params=params) checkVeneerIsInactive() return scenario @@ -92,7 +95,10 @@ def sampleEgoActions( asMapping=False, timestep=timestep, ) - return [actions[0] for actions in allActions] + return [ + actions[0] if actions else (None if singleAction else tuple()) + for actions in allActions + ] def sampleEgoActionsFromScene( @@ -108,7 +114,10 @@ def sampleEgoActionsFromScene( ) if allActions is None: return None - return [actions[0] for actions in allActions] + return [ + actions[0] if actions else (None if singleAction else tuple()) + for actions in allActions + ] def sampleActions( @@ -570,3 +579,21 @@ def ignorable(attr): fail() return False return True + + +def deprecationTest(removalVersion): + def decorator(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + m_ver = tuple(re.split(r"\D+", removalVersion)[:3]) + c_ver = tuple(re.split(r"\D+", importlib.metadata.version("scenic"))[:3]) + assert ( + m_ver > c_ver + ), "Maximum version exceeded. The tested functionality and the test itself should be removed." + + with pytest.deprecated_call(): + return function(*args, **kwargs) + + return wrapper + + return decorator