diff --git a/.coveragerc b/.coveragerc index 4987b253e..b2920e991 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,12 @@ # .coveragerc to control coverage.py [run] +branch = True omit = - src/scenic/simulators/webots/WBT*.py + src/scenic/simulators/carla/* + src/scenic/simulators/gta/* + src/scenic/simulators/lgsvl/* + src/scenic/simulators/webots/* + src/scenic/simulators/xplane/* [report] # Regexes for lines to exclude from consideration @@ -20,3 +25,5 @@ exclude_lines = @(abc\.)?abstractmethod ignore_errors = True +show_missing = True +precision = 2 \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 4d1d93381..d12f5bf46 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,3 +1,7 @@ # .git-blame-ignore-revs # Ran isort and black on the whole codebase 360c67fab09d172498b3014510ee3658643d12da +# Ran black with 2024 stable style +c6c83f95ff370b75c3ee7130dbd8071bfe8b285a +# Cleaned up test quote spacing +995cd182924dc9e3dbbc941c5b75454ea0cdaaca diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml new file mode 100644 index 000000000..21c0d6bce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -0,0 +1,76 @@ +name: Bug Report +description: Create a report to help us reproduce and fix a bug +labels: + - "type: bug" + - "status: triage" + +body: + - type: markdown + attributes: + value: > + #### Thank you for contributing! Before reporting a bug, please make sure it has no duplicate and has not already been addressed by searching through [the existing and past issues](https://github.com/BerkeleyLearnVerify/Scenic/issues) + + - type: textarea + attributes: + label: System Details + description: | + Please provide the following system information to help us diagnose the bug. + 1. Python Version (e.g. Python 3.11.7) + 2. Scenic Version (e.g. Scenic 3.0.0b2) + 3. Operating System / Platform (e.g. Apple M2 Pro macOS Sonoma 14.2.1) + 4. Simulator Version (Optional, only applies if bug is simulator specific) (e.g. CARLA 0.9.14) + + placeholder: | + 1. Python Version: `python --version` + 2. Scenic Version: `scenic --version` + 3. Operating System / Platform: get from system preferences + 4. Simulator Version: simulator version + validations: + required: true + + - type: textarea + attributes: + label: Detailed Description + description: | + Please provide a clear and concise description of what the bug is and paste the error log below. + + 1. Clear and concise description of the bug + 2. `scenic` command you ran locally (e.g. `scenic test.scenic --count 1 -b`, etc.). Please rerun your simulation error with the **`-b`** parameter for a full stack trace. + 3. Error log. It helps improving readability if the error log is wrapped in ```triple quotes blocks```. + + placeholder: | + 1. Scenic has a bug + 2. `scenic test.scenic -b` + 3. Error Log + ``` + # error full stack trace + ``` + validations: + required: true + + - type: textarea + attributes: + label: Steps To Reproduce + description: | + Please provide a minimal example to help us reproduce the bug. Code should be wrapped with ```triple quotes blocks``` to improve readability. + If it is not possible to reproduce the bug with a short self-contained Scenic or Python file, for example if a specific mesh is required, you can attach any required files below or include a link to them. + + If possible/applicable, please specify a seed that will reproduce the issue by running scenic with the seed argument: `scenic -s SEED, --seed SEED random seed` + placeholder: | + 1. First step to reproduce bug + 2. Second step, etc + ... + ``` + # sample code to reproduce the bug + ``` + validations: + required: true + + - type: checkboxes + attributes: + label: Issue Submission Checklist + options: + - label: I am reporting an issue, not asking a question + required: true + - label: I checked the open and closed issues, forum, etc. and have not found any solution + - label: I have provided all necessary code, etc. to reproduce the issue diff --git a/.github/ISSUE_TEMPLATE/2-docs.yml b/.github/ISSUE_TEMPLATE/2-docs.yml new file mode 100644 index 000000000..a5de65eac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-docs.yml @@ -0,0 +1,35 @@ +name: Documentation +description: Report an issue or enhancement related to https://docs.scenic-lang.org/ +labels: + - "type: documentation" + - "status: triage" + +body: + - type: markdown + attributes: + value: > + #### Thank you for contributing! Before submitting a doc issue, please make sure it has no duplicate and has not already been addressed by searching through [the existing and past open and closed issues](https://github.com/BerkeleyLearnVerify/Scenic/issues) + + - type: textarea + attributes: + label: Describe the doc issue or enhancement + description: > + 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://docs.scenic-lang.org/ + validations: + required: true + + - type: textarea + attributes: + label: Fix suggestion + description: > + Tell us how we could improve the documentation in this regard. + + - type: checkboxes + attributes: + label: Issue Submission Checklist + options: + - label: I am reporting an issue, not asking a question + required: true + - label: I checked the open and closed issues, forum, etc. and have not found any solution diff --git a/.github/ISSUE_TEMPLATE/3-feature.yml b/.github/ISSUE_TEMPLATE/3-feature.yml new file mode 100644 index 000000000..80348e675 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-feature.yml @@ -0,0 +1,31 @@ +name: Feature Request +description: Submit a request for new Scenic features +labels: + - "type: feature" + - "status: triage" + +body: + - type: markdown + attributes: + value: > + #### Thank you for contributing! Before submitting a feature request, please make sure it has no duplicate and has not already been addressed by searching through [the existing and past open and closed issues](https://github.com/BerkeleyLearnVerify/Scenic/issues) + + - type: textarea + attributes: + label: Describe the feature and motivation + description: | + Please provide a clear and concise proposal of the feature and outline the motivation. + validations: + required: true + + - type: textarea + attributes: + label: Additional context + description: | + Add any other context, such as pseudo code, links, diagrams, screenshots, to help the community better understand the feature request. + + - type: checkboxes + attributes: + label: Issue Submission Checklist + options: + - label: I checked the open and closed issues, forum, etc. and have not found any solution \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..92854d330 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Questions + url: https://forum.scenic-lang.org/ + about: Post your questions on our community forum \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..7dc981b71 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +### Description + + +### Issue Link + + +### Checklist +- [ ] I have tested the changes locally via `pytest` and/or other means +- [ ] I have added or updated relevant documentation +- [ ] I have autoformatted the code with black and isort +- [ ] I have added test cases (if applicable) + +### Additional Notes + + \ No newline at end of file diff --git a/.github/check_latest_simulators.py b/.github/check_latest_simulators.py new file mode 100644 index 000000000..9864e5608 --- /dev/null +++ b/.github/check_latest_simulators.py @@ -0,0 +1,49 @@ +import os +import re + +import requests + +SIMULATORS = ["carla", "webots"] +SIMULATOR_URLS = { + "carla": "https://github.com/carla-simulator/carla/releases/latest", + "webots": "https://github.com/cyberbotics/webots/releases/latest", +} +SIMULATORS_REGEXES = { + "carla": 'carla-simulator/carla/releases/tag/([^"]+)', + "webots": 'cyberbotics/webots/releases/tag/([^"]+)', +} + + +def check_path_exists(version, simulator): + path = f"/software/{simulator}{version}" + if os.path.exists(path): + print(f"Latest {simulator} version {version} already present on the machine.\n") + else: + s = """ + _ + _ _____ ________ (_)__ ___ _ +| |/|/ / _ `/ __/ _ \/ / _ \/ _ `/ +|__,__/\_,_/_/ /_//_/_/_//_/\_, / + /___/ + """ + print(s) + print( + f"A new {simulator} version ({version}) needs to be installed and tested in CI.\n" + ) + + +def version_check(regex_match, sim_name): + try: + version = regex_match.group(1) + except AttributeError: + print(f"Error: Unable to find the latest {sim_name} version using regex.") + else: + check_path_exists(version, sim_name) + + +for sim in SIMULATORS: + print(f"Checking for {sim}...") + url = SIMULATOR_URLS[sim] + response = requests.get(url) + regex_match = re.search(SIMULATORS_REGEXES[sim], response.text) + version_check(regex_match, sim) diff --git a/.github/slack_oncall_reminder.py b/.github/slack_oncall_reminder.py new file mode 100644 index 000000000..f62707077 --- /dev/null +++ b/.github/slack_oncall_reminder.py @@ -0,0 +1,70 @@ +import argparse + +import requests +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + + +def save_users(users_array): + users = {} + for user in users_array: + # NOTE: some apps, slackbots do not have emails to map to + profile = user["profile"] + if "email" in profile.keys(): + user_email = profile["email"] + username = user_email.split("@")[0] + users[username] = user + return users + + +def grab_whos_on_call(OPS_GENIE_API_TOKEN, ROTATION_SCHEDULE_ID): + url = f"https://api.opsgenie.com/v2/schedules/{ROTATION_SCHEDULE_ID}/on-calls" + headers = {"Authorization": f"GenieKey {OPS_GENIE_API_TOKEN}"} + response = requests.get(url, headers=headers) + if response.status_code == 200: + data = response.json() + else: + print(f"Request failed with status code {response.status_code}") + print("Response content:") + print(response.content.decode("utf-8")) + return data["data"]["onCallParticipants"][0]["name"].split("@")[0] + + +def postSlackMessage(client, CHANNEL_ID, OPS_GENIE_API_TOKEN, ROTATION_SCHEDULE_ID): + try: + result = client.users_list() + users = save_users(result["members"]) + on_call = grab_whos_on_call(OPS_GENIE_API_TOKEN, ROTATION_SCHEDULE_ID) + slack_id = users[on_call]["id"] + + result = client.chat_postMessage( + channel=CHANNEL_ID, + text=f"""🛠️Maintenance On-Call: <@{slack_id}>, you will be on-call for the next week. Resources:\n + 📖 + 🔍 + 📊 + 📋 + 🔧 + """, + ) + except SlackApiError as e: + print(f"SlackAPIError: {e}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Script that notifies on-call rotation daily" + ) + parser.add_argument("--slack_api_token", required=True, type=str) + parser.add_argument("--ops_genie_api_token", required=True, type=str) + args = parser.parse_args() + + SLACK_API_TOKEN = args.slack_api_token + OPS_GENIE_API_TOKEN = args.ops_genie_api_token + # NOTE: Feel free to grab the relevant channel ID to post the message to but ensure the App is installed within the channel + CHANNEL_ID = "C06N9KJHN2J" + # NOTE: Rotation schedule is grabbed directly from within the OpsGenie site + ROTATION_SCHEDULE_ID = "904cd122-f269-418d-8c29-3e6751716bae" + + client = WebClient(token=SLACK_API_TOKEN) + postSlackMessage(client, CHANNEL_ID, OPS_GENIE_API_TOKEN, ROTATION_SCHEDULE_ID) diff --git a/.github/workflows/on-call-reminder.yml b/.github/workflows/on-call-reminder.yml new file mode 100644 index 000000000..1798be663 --- /dev/null +++ b/.github/workflows/on-call-reminder.yml @@ -0,0 +1,30 @@ +name: on_call_reminder + +on: + schedule: + - cron: '0 17 * * 3' # Runs every Wednesday at 9am PST (17:00 UTC) + workflow_dispatch: # Allows manual triggering of the workflow + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests slack_sdk argparse + + - name: Run Python script + env: + SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} + OPS_GENIE_API_TOKEN: ${{ secrets.OPS_GENIE_API_TOKEN }} + run: python .github/slack_oncall_reminder.py --slack_api_token $SLACK_API_TOKEN --ops_genie_api_token $OPS_GENIE_API_TOKEN diff --git a/.github/workflows/run-coverage.yml b/.github/workflows/run-coverage.yml new file mode 100644 index 000000000..7ec93f84f --- /dev/null +++ b/.github/workflows/run-coverage.yml @@ -0,0 +1,65 @@ +name: run_coverage + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + inputs: + ref: + description: Git ref on which to run the tests. + type: string + required: true + workflow_call: + inputs: + ref: + description: Git ref on which to run the tests. + type: string + +jobs: + coverage: + strategy: + fail-fast: true + matrix: + python-version: ["3.11"] + os: [ubuntu-latest] + extras: ["test-full"] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout given ref + uses: actions/checkout@v3 + if: inputs.ref != '' + with: + ref: ${{ inputs.ref }} + + - name: Checkout current branch + uses: actions/checkout@v3 + if: inputs.ref == '' + with: + ref: ${{ github.ref }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Update pip + run: | + python -m pip install --upgrade pip + + - name: Install Scenic and dependencies + run: | + python -m pip install -e ".[${{ matrix.extras }}]" + + - name: Run and report code coverage + run: | + pytest --cov --cov-report json + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: BerkeleyLearnVerify/Scenic \ No newline at end of file diff --git a/.github/workflows/run-simulators.yml b/.github/workflows/run-simulators.yml new file mode 100644 index 000000000..885e386a4 --- /dev/null +++ b/.github/workflows/run-simulators.yml @@ -0,0 +1,202 @@ +name: run_simulators +on: + # IMPORTANT: this workflow should only be triggered manually via the Actions + # portal of the repo!!! Do not modify this workflow's trigger! + workflow_dispatch: + +jobs: + start_ec2_instance: + name: start_ec2_instance + runs-on: ubuntu-latest + concurrency: + group: sim + steps: + - 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') + + # If the machine is stopping wait for it to fully stop + while [ "$instance_state" == "stopping" ]; do + echo "Instance is stopping, waiting for it to fully stop..." + 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..." + aws ec2 start-instances --instance-ids $INSTANCE_ID + elif [[ "$instance_state" == "pending" ]]; then + echo "Instance startup is pending, continuing..." + elif [[ "$instance_state" == "running" ]]; then + echo "Instance is already running..." + exit 0 + else + echo "Unknown instance state: $instance_state" + exit 1 + fi + + # wait for status checks to pass + TIMEOUT=300 # 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 + + check_simulator_version_updates: + name: check_simulator_version_updates + runs-on: ubuntu-latest + needs: start_ec2_instance + steps: + - name: Check for Simulator Version Updates + env: + PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HOSTNAME: ${{ secrets.SSH_HOST }} + USER_NAME: ${{ secrets.SSH_USERNAME }} + GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + run: | + echo "$PRIVATE_KEY" > private_key && chmod 600 private_key + ssh -o StrictHostKeyChecking=no -i private_key ${USER_NAME}@${HOSTNAME} ' + cd /home/ubuntu/actions/ && + rm -rf Scenic && + git clone --branch $(basename "${{ github.ref }}") --single-branch https://$GH_ACCESS_TOKEN@github.com/BerkeleyLearnVerify/Scenic.git && + cd Scenic && + python3 -m venv venv && + source venv/bin/activate && + python3 -m pip install -e .[test-full] && + python3 .github/check_latest_simulators.py + ' + + check_nvidia_smi: + name: check_nvidia_smi + runs-on: ubuntu-latest + needs: start_ec2_instance + continue-on-error: true + steps: + - name: Check NVIDIA SMI + env: + PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HOSTNAME: ${{ secrets.SSH_HOST}} + 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} ' + output=$(nvidia-smi) + echo "$output" + if [ -z "$output" ]; then + 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 "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: + name: run_carla_simulators + runs-on: ubuntu-latest + needs: [check_simulator_version_updates, check_nvidia_smi] + steps: + - name: Run CARLA Tests + env: + PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HOSTNAME: ${{secrets.SSH_HOST}} + 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} ' + 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 =============================" + export CARLA_ROOT="$version" + pytest tests/simulators/carla + done + ' + + run_webots_simulators: + name: run_webots_simulators + runs-on: ubuntu-latest + needs: [check_simulator_version_updates, check_nvidia_smi] + steps: + - name: Run Webots Tests + env: + PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HOSTNAME: ${{secrets.SSH_HOST}} + 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} ' + Xvfb :99 -screen 0 1024x768x16 & + cd /home/ubuntu/actions/Scenic && + source venv/bin/activate && + webots_versions=($(find /software -maxdepth 1 -type d -name 'webots*')) && + export DISPLAY=:99 && + for version in "${webots_versions[@]}"; do + echo "============================= Webots $version =============================" + export WEBOTS_ROOT="$version" + 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: + - 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 + 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..." + elif [[ "$instance_state" == "stopped" ]]; then + echo "Instance is already stopped..." + exit 0 + else + echo "Unknown instance state: $instance_state" + exit 1 + fi diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 704bed0ea..2a5bb55d1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: true matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest, windows-latest] extras: ["test", "test-full"] runs-on: ${{ matrix.os }} @@ -51,35 +51,6 @@ jobs: with: ref: ${{ github.ref }} - - name: Install non-Python dependencies (Linux) - if: ${{ matrix.os == 'ubuntu-latest' }} - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: blender openscad - - - name: Restore cached non-Python dependencies (Windows) - id: windows-cache-deps - if: ${{ matrix.os == 'windows-latest' }} - uses: actions/cache@v3 - with: - path: downloads - key: windows-deps - - - name: Download non-Python dependencies (Windows) - if: ${{ matrix.os == 'windows-latest' && steps.windows-cache-deps.outputs.cache-hit != 'true' }} - run: | - New-Item -Path downloads -ItemType Directory -Force - Invoke-WebRequest https://github.com/openscad/openscad/releases/download/openscad-2021.01/OpenSCAD-2021.01-x86-64.zip -O downloads/openscad.zip - Invoke-WebRequest https://download.blender.org/release/Blender3.6/blender-3.6.0-windows-x64.zip -O downloads/blender.zip - - - name: Install non-Python dependencies (Windows) - if: ${{ matrix.os == 'windows-latest' }} - run: | - Expand-Archive -Path downloads/openscad.zip -DestinationPath openscad - Move-Item -Path openscad/openscad-2021.01 -Destination $Env:Programfiles\OpenSCAD - Expand-Archive -Path downloads/blender.zip -DestinationPath blender - Move-Item -Path blender/blender-3.6.0-windows-x64 -Destination "$Env:Programfiles\Blender Foundation\Blender" - - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/sync-issues-with-jira.yml b/.github/workflows/sync-issues-with-jira.yml new file mode 100644 index 000000000..c07924afa --- /dev/null +++ b/.github/workflows/sync-issues-with-jira.yml @@ -0,0 +1,64 @@ +name: sync_issues_with_jira +on: + issues: + types: [opened] + +jobs: + generate-issue-link: + runs-on: ubuntu-latest + steps: + - name: Get issue details + id: get_issue_details + uses: actions/github-script@v4 + with: + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + script: | + const repoName = context.payload.repository.full_name; + const issueNumber = context.payload.issue.number; + const issueTitle = context.payload.issue.title; + const issueLink = `https://github.com/${repoName}/issues/${issueNumber}`; + console.log(`::set-output name=issueTitle::${issueTitle}`); + console.log(`::set-output name=issueLink::${issueLink}`); + + - name: Create Jira Ticket + env: + JIRA_DOMAIN: ${{ secrets.JIRA_DOMAIN }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + ISSUE_TITLE: ${{ steps.get_issue_details.outputs.issueTitle }} + ISSUE_LINK: ${{ steps.get_issue_details.outputs.issueLink }} + run: | + echo "Issue Title: $ISSUE_TITLE" + echo "Issue Link: $ISSUE_LINK" + + curl --request POST \ + --url "https://$JIRA_DOMAIN.atlassian.net/rest/api/3/issue" \ + --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --data '{ + "fields": { + "description": { + "content": [ + { + "content": [ + { + "text": "'"$ISSUE_LINK"'", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 + }, + "summary": "'"$ISSUE_TITLE"'", + "issuetype": { + "id": "10001" + }, + "project": { + "key": "SCENIC" + } + } + }' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0d000ad43..b4ca07ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ poetry.lock *.sublime-project *.sublime-workspace +# VSCode files +*.vscode + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -66,6 +69,7 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ +coverage.json # Translations *.mo @@ -136,5 +140,3 @@ dmypy.json # generated parser src/scenic/syntax/parser.py -# airsim -src/scenic/simulators/airsim/more 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 new file mode 100644 index 000000000..9b0516a8b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,33 @@ +codecov: + require_ci_to_pass: true + +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: + report_type: "json" \ No newline at end of file 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/_templates/installation.rst b/docs/_templates/installation.rst index daedbf981..bd1af936f 100644 --- a/docs/_templates/installation.rst +++ b/docs/_templates/installation.rst @@ -1,4 +1,4 @@ -Next, activate the `virtual environment `_ in which you want to install Scenic. +Activate the `virtual environment `_ in which you want to install Scenic. To create and activate a new virtual environment called :file:`venv`, you can run the following commands: .. venv-setup-start diff --git a/docs/conf.py b/docs/conf.py index b92aa39ce..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 @@ -129,7 +130,7 @@ "scipy": ("https://docs.scipy.org/doc/scipy/", None), "sphinx": ("https://www.sphinx-doc.org/en/master/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), - "trimesh": ("https://trimsh.org/", None), + "trimesh": ("https://trimesh.org/", None), } highlight_language = "scenic" @@ -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 --------------------- @@ -202,7 +214,8 @@ def maketable(items, columns=5, gap=4): ) from sphinx.ext.autodoc import ClassDocumenter, FunctionDocumenter -from scenic.core.dynamics import Behavior, DynamicScenario, Monitor +from scenic.core.dynamics.behaviors import Behavior, Monitor +from scenic.core.dynamics.scenarios import DynamicScenario class ScenicBehavior(PyFunction): @@ -557,7 +570,8 @@ def key(entry): from sphinx.ext.autodoc import ClassDocumenter -from scenic.core.dynamics import Behavior, DynamicScenario +from scenic.core.dynamics.behaviors import Behavior +from scenic.core.dynamics.scenarios import DynamicScenario orig_add_directive_header = ClassDocumenter.add_directive_header diff --git a/docs/credits.rst b/docs/credits.rst index c43863ffd..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 @@ -32,6 +33,7 @@ The Scenic tool and example scenarios have benefitted from additional code contr * Jay Shenoy * Mirco Theile * Kesav Viswanadha + * Qiancheng Wu * Wilson Wu Finally, many other people provided helpful advice and discussions, including: diff --git a/docs/developing.rst b/docs/developing.rst index 044f88ced..97bdfe8e0 100644 --- a/docs/developing.rst +++ b/docs/developing.rst @@ -58,6 +58,21 @@ If you're running the test suite on a headless server or just want to stop windo popping up during testing, use the :command:`--no-graphics` option to skip graphical tests. +Prior to finalizing a PR or other substantial changes, it's a good idea to run the test suite under all major versions of Python that Scenic supports, in fresh virtual environments. +You can do this automatically with the command :command:`tox`, which by default will test all supported major versions both with and without optional dependencies (this will take a long time). +Some variations: + +* :command:`tox -p` will run the various combinations in parallel. + +* :command:`tox -m basic` skips testing installations with the optional dependencies. + +* :command:`tox -- --fast` only runs the "fast" tests. In general, any arguments after the :command:`--` will get passed to ``pytest``. For example, + +* :command:`tox -- tests/syntax/test_specifiers.py` only runs the tests in the given file. + +See the `Tox `_ website for more information about the available options and how to configure Tox. + + .. _debugging: Debugging diff --git a/docs/glossary.rst b/docs/glossary.rst index 4a6c7de61..e0c5dee79 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -47,7 +47,7 @@ Glossary footprint The infinite extrusion of a 2D `region` in the positive and negative Z directions. Testing containment of an `object` in a 2D region automatically uses its footprint, so that the object is considered contained if and only if its projection into the plane of the region is contained in the region. - Footprints are represented internally by instances of the `PolygonalFootprintRegion` class. + Footprints are represented internally by instances of the `PolygonalFootprintRegion` class, and can be accessed using the ``footprint`` attribute. global parameters Parameters of a scene like weather or time of day which are not associated with any object. 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/new.rst b/docs/new.rst index 715806d33..044760379 100644 --- a/docs/new.rst +++ b/docs/new.rst @@ -46,6 +46,8 @@ Backwards-incompatible semantics changes: * The :specifier:`offset by` specifier now optionally specifies :prop:`parentOrientation`. + * The :specifier:`visible` and :specifier:`not visible` specifiers now take into account occlusion and the shapes of objects. In previous versions, they only checked whether the center of the object was visible/not visible, ignoring occlusion. + Backwards-incompatible API changes: * The **maxIterations** argument of `Simulator.simulate` now has default value 1, rather than 100. diff --git a/docs/options.rst b/docs/options.rst index 78aeb2eda..1df985596 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -33,6 +33,11 @@ General Scenario Control The equivalent of this option for the Python API is the ``params`` argument to `scenic.scenarioFromFile` (which, however, does not attempt to convert strings to numbers). +.. option:: --count + + Number of successful scenes to generate or simulations to run (i.e., not counting rejected scenes/simulations). + The default is to run forever. + .. option:: -s , --seed Specify the random seed used by Scenic, to make sampling deterministic. @@ -74,11 +79,6 @@ Dynamic Simulations Maximum number of time steps to run each simulation (the default is infinity). Simulations may end earlier if termination criteria defined in the scenario are met (see :keyword:`terminate when` and :keyword:`terminate`). -.. option:: --count - - Number of successful simulations to run (i.e., not counting rejected simulations). - The default is to run forever. - Debugging --------- diff --git a/docs/porting.rst b/docs/porting.rst index f24266132..9762780bb 100644 --- a/docs/porting.rst +++ b/docs/porting.rst @@ -53,5 +53,7 @@ Specifically: * The specifier :specifier:`with heading {X}` is replaced with :specifier:`facing {X}`. +* The :specifier:`visible` and :specifier:`not visible` will behave as they did in Scenic 2, requiring the *center* of the object to be visible rather than *any* part of the object. More precisely, :specifier:`visible` will specify :prop:`position` to be uniformly random in the observing object's :term:`visible region` and :specifier:`not visible` will specify :prop:`position` to be uniformly random in the difference of the :term:`workspace` and the observing object's visible region. + Note that despite these changes, Scenic will still use 3D geometry internally. For example, if you write :scenic:`ego = new Object at (1, 2)` the value of :scenic:`ego.position` will be the 3D vector :scenic:`(1, 2, 0)`. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2fe5bf7b5..aeabe83c3 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -25,20 +25,16 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions .. tab:: macOS - Start by downloading `Blender `__ and `OpenSCAD `__ and installing them into your :file:`Applications` directory. - .. include:: _templates/installation.rst .. tab:: Linux - Start by installing the Python-Tk interface, Blender, and OpenSCAD. + Start by installing the Python-Tk interface. You can likely use your system's package manager; e.g. on Debian/Ubuntu run: .. code-block:: text - sudo apt-get install python3-tk blender openscad - - For other Linux distributions or if you need to install from source, see the download pages for `Blender `__ and `OpenSCAD `__. + sudo apt-get install python3-tk .. include:: _templates/installation.rst @@ -46,8 +42,6 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions These instructions cover installing Scenic natively on Windows; if you are using the `Windows Subsystem for Linux `_ (on Windows 10 and newer), see the WSL tab instead. - Start by downloading and running the installers for `Blender `__ and `OpenSCAD `__. - .. include:: _templates/installation.rst :end-before: .. venv-setup-start @@ -64,12 +58,12 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions These instructions cover installing Scenic on the Windows Subsystem for Linux (WSL). If you haven't already installed WSL, you can do that by running :command:`wsl --install` (in either Command Prompt or PowerShell) and restarting your computer. - Then open a WSL terminal and run the following commands to install Python, the Python-Tk interface, Blender, and OpenSCAD: + Then open a WSL terminal and run the following commands to install Python and the Python-Tk interface: .. code-block:: text sudo apt-get update - sudo apt-get install python3 python3-tk blender openscad + sudo apt-get install python3 python3-tk .. include:: _templates/installation.rst :end-before: .. venv-setup-start @@ -177,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/docs/reference/operators.rst b/docs/reference/operators.rst index 5c4df83f1..4a39539e9 100644 --- a/docs/reference/operators.rst +++ b/docs/reference/operators.rst @@ -67,6 +67,14 @@ See the :ref:`Visibility System ` reference for a discussion of the ---------------------------------- Whether a position or `Object` lies in the `Region`; for the latter, the object must be completely contained in the region. +.. _({Object} | {region}) intersects ({Object} | {region}): + +(*Object* | *region*) intersects (*Object* | *region*) +------------------------------------------------------ +Whether an `Object`/`Region` intersects another `Object`/`Region`, i.e. whether any portion of the occupied spaces intersect. + +When working with 2D regions, it can be useful to check intersection with the :term:`footprint` of a region, e.g. when checking whether a car intersects a given lane. In this case, one would write :scenic:`car intersects lane.footprint` instead of :scenic:`car intersects lane`. For more details, see :term:`footprint`. + Orientation Operators ===================== @@ -85,9 +93,12 @@ The orientation specified by the vector field at the given position .. _{direction} relative to {direction}: -(*heading* | *vectorField*) relative to (*heading* | *vectorField*) +(*direction*) relative to (*direction*) ------------------------------------------------------------------- -The first heading/vector field, interpreted as an offset relative to the second heading/vector field. For example, :scenic:`-5 deg relative to 90 deg` is simply 85 degrees. If either direction is a vector field, then this operator yields an expression depending on the :prop:`position` property of the object being specified. +The orientation obtained by starting in the second direction and then rotating according to the first direction. For example, :scenic:`-5 deg relative to 90 deg` is simply 85 degrees. If either direction is a vector field, then this operator yields an expression depending on the :prop:`position` property of the object being specified. Both operator values must be of type `heading`, `Orientation`, or `vectorField`, not tuples, as tuples are by default intepreted as `Vector` objects. + +.. note:: + This operator is not necessarily commutative, for example, when composing two 3D orientations. Vector Operators diff --git a/docs/reference/region_types.rst b/docs/reference/region_types.rst index ffe44d1aa..76507693c 100644 --- a/docs/reference/region_types.rst +++ b/docs/reference/region_types.rst @@ -78,8 +78,6 @@ When checking containment of an `Object` in a 2D region, Scenic will atuomatical Most 3D regions inherit from either `MeshVolumeRegion` or `MeshSurfaceRegion`, which represent the volume (of a watertight mesh) and the surface of a mesh respectively. Various region classes are also provided to create primitive shapes. `MeshVolumeRegion` can be converted to `MeshSurfaceRegion` (and vice versa) using the the ``getSurfaceRegion`` and ``getVolumeRegion`` methods. -Mesh regions can use one of two engines for mesh operations: Blender or OpenSCAD. This can be controlled using the ``engine`` parameter, passing ``"blender"`` or ``"scad"`` respectively. Blender is generally more tolerant but can produce unreliable output, such as meshes that have microscopic holes. OpenSCAD is generally more precise, but may crash on certain inputs that it considers ill-defined. By default, Scenic uses Blender internally. - PolygonalFootprintRegions represent the :term:`footprint` of a 2D region. See `2D Regions` for more details. .. autoclass:: scenic.core.regions.MeshVolumeRegion diff --git a/docs/reference/specifiers.rst b/docs/reference/specifiers.rst index 6dc4d42b6..d88ee75ed 100644 --- a/docs/reference/specifiers.rst +++ b/docs/reference/specifiers.rst @@ -98,10 +98,13 @@ contained in *region* Like :sampref:`in {region}`, but also enforces that the object be entirely contained in the given `Region`. .. _on {region}: +.. _on ({region} | {Object}): +.. _on ({region} | {Object} | {vector}): +.. _on {vector}: .. _on: -on *region* ------------ +on (*region* | *Object* | *vector*) +----------------------------------- **Specifies**: @@ -110,12 +113,10 @@ on *region* **Dependencies**: :prop:`baseOffset` • :prop:`contactTolerance` • :prop:`onDirection` -If :prop:`position` is not already specified with priority 1, positions the *base* of the object uniformly at random in the given `Region`, offset by :prop:`contactTolerance` (to avoid a collision). +If :prop:`position` is not already specified with priority 1, positions the *base* of the object uniformly at random in the given `Region`, on the :prop:`onSurface` of the given `Object`, or with the base of the object at the given vector. The position is always offset by half of :prop:`contactTolerance` (to avoid a collision). The base of the object is determined by adding the object's :prop:`baseOffset` to its :prop:`position`. -Note that while :specifier:`on` can be used with `Region`, `Object` and `Vector`, it cannot be used with a distribution containing anything other than `Region`. When used with an object the base of the object being placed is placed on the target object's `onSurface` and when used with a vector the base of the object being placed is set to that position. - -If instead :prop:`position` has already been specified with priority 1, then its value is modified by projecting it onto the given region. +If instead :prop:`position` has already been specified with priority 1, then its value is modified by projecting it onto the given region (or the :prop:`onSurface` of the given object). Note that this modifying version of the specifier does not accept a vector. More precisely, we find the closest point in the region along :prop:`onDirection` (or its negation [1]_), and place the base of the object at that point. If :prop:`onDirection` is not specified, a default value is inferred from the region. A region can either specify a default value to be used, or for volumes straight up is used and for surfaces the mean of the face normal values is used (weighted by the area of the faces). If the region has a :term:`preferred orientation` (a vector field), :prop:`parentOrientation` is specified to be equal to that orientation at the object’s :prop:`position` (whether or not this specifier is being used as a modifying specifier). @@ -187,12 +188,19 @@ visible [from (*Point* | *OrientedPoint*)] * :prop:`position` with priority 3 * also adds a requirement (see below) -**Dependencies**: None +**Dependencies**: :prop:`regionContainedIn` Requires that this object is visible from the :scenic:`ego` or the given `Point`/`OrientedPoint`. See the :ref:`Visibility System ` reference for a discussion of the visibility model. -Also optionally specifies :prop:`position` to be a uniformly random point in the :term:`visible region` of the ego, or of the given Point/OrientedPoint if given. -Note that the position set by this specifier is slightly stricter than simply adding a requirement that the ego :keyword:`can see` the object: the specifier makes the *center* of the object (its :prop:`position`) visible, while the :keyword:`can see` condition will be satisfied even if the center is not visible as long as some other part of the object is visible. +Also optionally specifies :prop:`position` to be uniformly random over all points that could result in a visible object (note that the above requirement will ensure the object is in fact visible). + +.. versionchanged:: 3.0 + + This specifier now specifies :prop:`position` uniformly randomly over all points that could result in a visible object. This allows for objects whose :prop:`position` might be out of the visible region, but which have a portion of their occupied space visible (e.g. a corner that is visible). With the previous semantics, such configurations would never be generated because the *center* of the object was required to be visible. + +.. note:: + + As an implementation detail, :prop:`position` is initially set to be sampled from `everywhere` (or the :term:`workspace` if one has been set). Scenic will then attempt to further restrict the sample region via various pruning techniques, but sometimes this is not possible. If this occurs and Scenic has not been able to further restrict the sampled region from `everywhere`, an error will be raised at compile time. The simplest way to remedy this is by setting a workspace or specifying :prop:`position` with a higher priority using a different specifier. .. _not visible [from ({Point} | {OrientedPoint})]: @@ -208,8 +216,11 @@ not visible [from (*Point* | *OrientedPoint*)] Requires that this object is *not* visible from the ego or the given `Point`/`OrientedPoint`. -Similarly to :sampref:`visible [from ({Point} | {OrientedPoint})]`, this specifier can position the object uniformly at random in the *non-visible* region of the ego. -However, it depends on :prop:`regionContainedIn`, in order to restrict the non-visible region to the :term:`container` of the object being created, which is hopefully a bounded region (if the non-visible region is unbounded, it cannot be uniformly sampled from and an error will be raised). +Similarly to :sampref:`visible [from ({Point} | {OrientedPoint})]`, this specifier can optionally position the object uniformly at random over all points that could result in a non-visible object (note that the above requirement will ensure the object is in fact not visible). + +.. versionchanged:: 3.0 + + This specifier now specifies :prop:`position` uniformly randomly over all points that could result in a non-visible object. This disallows objects whose :prop:`position` is out of the visible region, but which have a portion of their occupied space visible (e.g. a corner that is visible). With the previous semantics, such configurations would sometimes be generated because only the *center* of the object was required to be non-visible. .. _(left | right) of {vector} [by {scalar}]: .. _left of: diff --git a/docs/reference/statements.rst b/docs/reference/statements.rst index 3e3991ede..f44a0fcf8 100644 --- a/docs/reference/statements.rst +++ b/docs/reference/statements.rst @@ -131,7 +131,7 @@ If there are multiple ``interrupt`` clauses, successive clauses take precedence Likewise, if ``try-interrupt`` statements are nested, the outermost statement takes precedence and can interrupt the inner statement at any time. When one handler interrupts another and then completes, the original handler is resumed (and it may even be interrupted again before control finally returns to the ``try`` block). -The ``try-interrupt`` statement may conclude with any number of ``except`` blocks, which function identically to their :ref:`Python counterparts `. +The ``try-interrupt`` statement may conclude with any number of ``except`` blocks, which function identically to their :ref:`Python counterparts ` (though Scenic does not allow ``except*`` blocks). Simple Statements ================= diff --git a/docs/simulators.rst b/docs/simulators.rst index a34312403..bc64d2116 100644 --- a/docs/simulators.rst +++ b/docs/simulators.rst @@ -55,18 +55,23 @@ Generate World Info ^^^^^^^^^^^^^^^^^^^ Before running Scenic, it’s essential to generate world information. To -achieve this, utilize the -`createWorldInfo.py `__ script located at -Scenic/src/scenic/simulators/airsim/generators/createWorldInfo.py. If -you require assistance, run the script with the -h flag to access usage -instructions. +achieve this, utilize the scripts located at +:file:`src/scenic/simulators/airsim/generators/` -Next, in your Scenic file, specify the path for your world info like -this: +Using Unreal Engine (recommended) +""""""""""""""""""""""""""""""""" -.. code:: python +First, run the :file:`generateUnrealWorldInfo.py` script inside Unreal Engine by +simply entering in the file path in the engine’s python console. - param worldInfoPath = "[YOUR PATH HERE]" +Then run +the :file:`generateWorldInfoFromUnrealWorldInfo.py` +script + +Using Airsim +"""""""""""" + +Run the :file:`generateWorldInfo.py` script **Configure AirSim Settings** ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/syntax_guide.rst b/docs/syntax_guide.rst index 6ed669cbe..c93be21ab 100644 --- a/docs/syntax_guide.rst +++ b/docs/syntax_guide.rst @@ -208,8 +208,10 @@ Additional specifiers for the :prop:`position` and :prop:`orientation` propertie - Positions the object uniformly at random in the given Region * - :sampref:`contained in {region}` - Positions the object uniformly at random entirely contained in the given Region - * - :sampref:`on {region}` - - Positions the base of the object uniformly at random in the given Region, or modifies the position so that the base is in the Region. + * - :sampref:`on {vector}` + - Positions the base of the object at the given global coordinates + * - :sampref:`on ({region} | {Object})` + - Positions the object uniformly at random or modifies the position so that base of the Object is in the given Region/on the given Object. * - :sampref:`offset by {vector}` - Positions the object at the given coordinates in the local coordinate system of ego (which must already be defined) * - :sampref:`offset along {direction} by {vector}` @@ -217,9 +219,9 @@ Additional specifiers for the :prop:`position` and :prop:`orientation` propertie * - :sampref:`beyond {vector} by ({vector} | {scalar}) [from ({vector} | {OrientedPoint})]` - Positions the object with respect to the line of sight from a point or the ego * - :sampref:`visible [from ({Point} | {OrientedPoint})]` - - Ensures the object is visible from the ego, or from the given Point/OrientedPoint if given, while optionally specifying position to be in the appropriate visible region. + - Ensures the object is visible from the ego, or from the given Point/OrientedPoint if given, while optionally specifying position to be uniformly random over all positions that result in a visible object. * - :sampref:`not visible [from ({Point} | {OrientedPoint})]` - - Ensures the object is not visible from the ego, or from the given Point/OrientedPoint if given, while optionally specifying position to be outside the appropriate visible region. + - Ensures the object is not visible from the ego, or from the given Point/OrientedPoint if given, while optionally specifying position to be uniformly random over all positions that result in a non-visible object. * - :sampref:`(left | right) of ({vector} | {OrientedPoint} | {Object}) [by {scalar}] ` - Positions the object to the left/right by the given scalar distance. * - :sampref:`(ahead of | behind) ({vector} | {OrientedPoint} | {Object}) [by {scalar}] ` @@ -282,9 +284,11 @@ In the following tables, operators are grouped by the type of value they return. * - Boolean Operators - Meaning * - :sampref:`({Point} | {OrientedPoint}) can see ({vector} | {Object})` - - Whether or not a position or `Object` is visible from a `Point` or `OrientedPoint`. + - Whether or not a position or `Object` is visible from a `Point` or `OrientedPoint` * - :sampref:`({vector} | {Object}) in {region}` - - Whether a position or `Object` lies in the region + - Whether a position or `Object` lies in the region + * - :sampref:`({Object} | {region}) intersects ({Object} | {region})` + - Whether an `Object`/`Region` intersects an `Object`/`Region`. .. list-table:: diff --git a/examples/airsim/multi_drone.scenic b/examples/airsim/multiDrone.scenic similarity index 92% rename from examples/airsim/multi_drone.scenic rename to examples/airsim/multiDrone.scenic index d350cb68c..6abdb9d12 100644 --- a/examples/airsim/multi_drone.scenic +++ b/examples/airsim/multiDrone.scenic @@ -1,12 +1,12 @@ -# NOTE: add your world info path here -# param worldInfoPath = "[YOUR PATH HERE]" - -model scenic.simulators.airsim.model - - -ground = getPrexistingObj("ground") - - -for i in range(3): - new Drone at (Range(-10,10),Range(-10,10),Range(0,10)), - with behavior FlyToPosition((Range(-10,10),Range(-10,10),Range(0,10))) +# NOTE: add your world info path here +# param worldInfoPath = "[YOUR PATH HERE]" + +model scenic.simulators.airsim.model + + +ground = getPrexistingObj("ground") + + +for i in range(3): + new Drone at (Range(-10,10),Range(-10,10),Range(0,10)), + with behavior FlyToPosition((Range(-10,10),Range(-10,10),Range(0,10))) \ No newline at end of file diff --git a/examples/airsim/patrol.scenic b/examples/airsim/patrol.scenic index 53d0eba8c..fe7df1c23 100644 --- a/examples/airsim/patrol.scenic +++ b/examples/airsim/patrol.scenic @@ -1,8 +1,8 @@ -# NOTE: add your world info path here -# param worldInfoPath = "[YOUR PATH HERE]" - -model scenic.simulators.airsim.model - - -drone1 = new Drone at (0,0,0), +# NOTE: add your world info path here +# param worldInfoPath = "[YOUR PATH HERE]" + +model scenic.simulators.airsim.model + + +drone1 = new Drone at (0,0,0), with behavior Patrol([(-1,2,2),(1,4,2),(-1,4,2),(-1,2,4),(1,2,4),(1,4,4),(-1,4,4)],True) \ No newline at end of file diff --git a/examples/airsim/worldGen.scenic b/examples/airsim/worldGen.scenic index 2f36f2252..5119ca212 100644 --- a/examples/airsim/worldGen.scenic +++ b/examples/airsim/worldGen.scenic @@ -1,39 +1,38 @@ -# NOTE: add your world info path here -# param worldInfoPath = "[YOUR PATH HERE]" - -model scenic.simulators.airsim.model -from scenic.simulators.airsim.utils import getPrexistingObj -import random - -ground = getPrexistingObj("ground") - -ground.highlight() - - -ego = new StaticObj on ground, - with assetName "Cone", - with width 10, - with length 10, - with height 10 - - -centerArea = RectangularRegion(Vector(0,200,30), 0, 100,100) - -blocks = [] -blockCount = 10 - - - -for i in range(blockCount): - blocks.append(new StaticObj on ground, - contained in centerArea, - with assetName "Cube", - with width Range(3,10), - with length Range(3,10), - with height 10) - - -ranBlock = blocks[random.randint(0,blockCount-1)] - -drone = new Drone on ranBlock, with behavior Patrol([(10,10,10),(2,2,2)]) - +# NOTE: add your world info path here +# param worldInfoPath = "[YOUR PATH HERE]" + +model scenic.simulators.airsim.model +from scenic.simulators.airsim.utils import getPrexistingObj +import random + +ground = getPrexistingObj("ground") + +ground.highlight() + + +ego = new StaticObj on ground, + with assetName "Cone", + with width 10, + with length 10, + with height 10 + + +centerArea = RectangularRegion(Vector(0,200,30), 0, 100,100) + +blocks = [] +blockCount = 10 + + + +for i in range(blockCount): + blocks.append(new StaticObj on ground, + contained in centerArea, + with assetName "Cube", + with width Range(3,10), + with length Range(3,10), + with height 10) + + +ranBlock = Uniform(*blocks) + +drone = new Drone on ranBlock, with behavior Patrol([(10,10,10),(2,2,2)]) \ No newline at end of file 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 28409513f..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" }, @@ -32,11 +32,12 @@ dependencies = [ "dotmap ~= 1.3", "mapbox_earcut >= 0.12.10", "matplotlib ~= 3.2", + "manifold3d == 2.3.0", "networkx >= 2.6", "numpy ~= 1.24", "opencv-python ~= 4.5", - "pegen >= 0.2", - "pillow ~= 9.1", + "pegen >= 0.3.0", + "pillow >= 9.1", 'pygame >= 2.1.3.dev8, <3; python_version >= "3.11"', 'pygame ~= 2.0; python_version < "3.11"', "pyglet ~= 1.5", @@ -46,7 +47,7 @@ dependencies = [ "scikit-image ~= 0.21", "scipy ~= 1.7", "shapely ~= 2.0", - "trimesh >=3.22.5, <4", + "trimesh >=4.0.9, <5", ] [project.optional-dependencies] @@ -56,34 +57,36 @@ guideways = [ ] test = [ # minimum dependencies for running tests (used for tox virtualenvs) "pytest >= 7.0.0, <8", + "pytest-cov >= 3.0.0", "pytest-randomly ~= 3.2", ] 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", "pygments ~= 2.11", + "sphinx >= 5.0.0, <6", + "sphinx_rtd_theme >= 0.5.2", + "sphinx-tabs ~= 3.4.1", "verifai >= 2.1.0b1", ] dev = [ "scenic[test-full]", - "black ~= 23.0", - "inflect ~= 5.5", + "black ~= 24.0", "isort ~= 5.11", "pre-commit ~= 3.0", "pytest-cov >= 3.0.0", - "sphinx >= 5.0.0, <6", - "sphinx_rtd_theme >= 0.5.2", - "sphinx-tabs ~= 3.4.1", - "tox ~= 3.14", + "tox ~= 4.0", ] [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' @@ -98,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 = ''' @@ -120,3 +126,6 @@ extend_skip_glob = [ [tool.pytest.ini_options] norecursedirs = ["examples"] + +[tool.coverage.run] +source = ["src"] \ No newline at end of file diff --git a/src/scenic/__main__.py b/src/scenic/__main__.py index 6e2658918..dd72bf8b4 100644 --- a/src/scenic/__main__.py +++ b/src/scenic/__main__.py @@ -50,6 +50,12 @@ action="append", metavar=("PARAM", "VALUE"), ) +mainOptions.add_argument( + "--count", + help="number of successful scenes to generate or simulations to run (default infinity)", + type=int, + default=0, +) mainOptions.add_argument( "-m", "--model", help="specify a Scenic world model", default=None ) @@ -65,12 +71,7 @@ simOpts.add_argument( "--time", help="time bound for simulations (default none)", type=int, default=None ) -simOpts.add_argument( - "--count", - help="number of successful simulations to run (default infinity)", - type=int, - default=0, -) + simOpts.add_argument( "--max-sims-per-scene", type=int, @@ -254,7 +255,8 @@ def runSimulation(scene): try: - if args.gather_stats is None: # Generate scenes interactively until killed + if args.gather_stats is None: + # Generate scenes interactively until killed/count reached if not args.simulate: # will need matplotlib to draw scene schematic import matplotlib import matplotlib.pyplot as plt @@ -272,9 +274,8 @@ def runSimulation(scene): success = runSimulation(scene) if success: successCount += 1 - if 0 < args.count <= successCount: - break else: + successCount += 1 if mode2D: if delay is None: scene.show2D(zoom=args.zoom) @@ -285,6 +286,9 @@ def runSimulation(scene): else: scene.show(axes=args.axes) + if 0 < args.count <= successCount: + break + else: # Gather statistics over the specified number of scenes/iterations its = [] maxIterations = 2000 diff --git a/src/scenic/core/distributions.py b/src/scenic/core/distributions.py index 112545288..f31165985 100644 --- a/src/scenic/core/distributions.py +++ b/src/scenic/core/distributions.py @@ -330,8 +330,8 @@ def __getattr__(self, name): return object.__getattribute__(self, name) return AttributeDistribution(name, self) - def __call__(self, *args): - return OperatorDistribution("__call__", self, args) + def __call__(self, *args, **kwargs): + return OperatorDistribution("__call__", self, args, kwargs) def __iter__(self): raise RandomControlFlowError(f"cannot iterate through a random value") @@ -684,7 +684,7 @@ def supportInterval(self): return unionOfSupports(supportInterval(attr) for attr in attrs) return None, None - def __call__(self, *args): + def __call__(self, *args, **kwargs): vty = self.object._valueType retTy = None if vty is not object: @@ -693,7 +693,7 @@ def __call__(self, *args): if isinstance(func, property): func = func.fget retTy = get_type_hints(func).get("return") - return OperatorDistribution("__call__", self, args, valueType=retTy) + return OperatorDistribution("__call__", self, args, kwargs, valueType=retTy) def __repr__(self): return f"{self.object!r}.{self.attribute}" @@ -704,15 +704,18 @@ class OperatorDistribution(Distribution): _deterministic = True - def __init__(self, operator, obj, operands, valueType=None): + def __init__(self, operator, obj, operands, kwoperands, valueType=None): operands = tuple(toDistribution(arg) for arg in operands) + kwoperands = {key: toDistribution(kwarg) for key, kwarg in kwoperands.items()} + dependencies = operands + tuple(kwoperands.values()) if valueType is None: ty = type_support.underlyingType(obj) - valueType = self.inferType(ty, operator, operands) - super().__init__(obj, *operands, valueType=valueType) + valueType = self.inferType(ty, operator, operands, kwoperands) + super().__init__(obj, *dependencies, valueType=valueType) self.operator = operator self.object = obj self.operands = operands + self.kwoperands = kwoperands self.symbol = allowedReversibleOperators.get(operator) if self.symbol: if operator[:3] == "__r": @@ -723,7 +726,7 @@ def __init__(self, operator, obj, operands, valueType=None): self.reverse = None @staticmethod - def inferType(ty, operator, operands): + def inferType(ty, operator, operands, kwoperands): """Attempt to infer the result type of the given operator application.""" # If the object's type is known, see if we have a return type annotation. origin = type_support.get_type_origin(ty) @@ -741,7 +744,9 @@ def inferType(ty, operator, operands): # None does not support this operator; using it will raise an # exception, so we can ignore this case for type inference. continue - res = OperatorDistribution.inferType(option, operator, operands) + res = OperatorDistribution.inferType( + option, operator, operands, kwoperands + ) types.append(res) return type_support.unifierOfTypes(types) if types else object @@ -796,10 +801,11 @@ def scalar(thing): def sampleGiven(self, value): first = value[self.object] rest = [value[child] for child in self.operands] + kwargs = {key: value[child] for key, child in self.kwoperands.items()} op = getattr(first, self.operator) - result = op(*rest) + result = op(*rest, **kwargs) if result is NotImplemented and self.reverse: - assert len(rest) == 1 + assert len(rest) == 1 and len(kwargs) == 0 rop = getattr(rest[0], self.reverse) result = rop(first) if result is NotImplemented and self.symbol: @@ -812,7 +818,10 @@ def sampleGiven(self, value): def evaluateInner(self, context): obj = valueInContext(self.object, context) operands = tuple(valueInContext(arg, context) for arg in self.operands) - return OperatorDistribution(self.operator, obj, operands) + kwoperands = { + key: valueInContext(arg, context) for key, kwarg in self.kwoperands.items() + } + return OperatorDistribution(self.operator, obj, operands, kwoperands) def supportInterval(self): if self.operator in ( @@ -825,7 +834,7 @@ def supportInterval(self): "__truediv__", "__rtruediv__", ): - assert len(self.operands) == 1 + assert len(self.operands) == 1 and len(self.kwoperands) == 0 l1, r1 = supportInterval(self.object) l2, r2 = supportInterval(self.operands[0]) if l1 is None or l2 is None or r1 is None or r2 is None: @@ -859,7 +868,7 @@ def supportInterval(self): raise AssertionError(f"unexpected operator {self.operator}") return l, r elif self.operator in ("__neg__", "__abs__"): - assert len(self.operands) == 0 + assert len(self.operands) == 0 and len(self.kwoperands) == 0 l, r = supportInterval(self.object) if self.operator == "__neg__": return -r, -l @@ -921,7 +930,7 @@ def handler(self, arg): and arg == 0 ): return self - return OperatorDistribution(op, self, (arg,), valueType=ty) + return OperatorDistribution(op, self, (arg,), {}, valueType=ty) elif op in ("__mul__", "__rmul__"): @@ -933,7 +942,7 @@ def handler(self, arg): if issubclass(self._valueType, Orientation) and arg == globalOrientation: return self - return OperatorDistribution(op, self, (arg,), valueType=ty) + return OperatorDistribution(op, self, (arg,), {}, valueType=ty) elif op in ("__truediv__", "__floordiv__", "__pow__"): @@ -944,12 +953,12 @@ def handler(self, arg): and arg == 1 ): return self - return OperatorDistribution(op, self, (arg,), valueType=ty) + return OperatorDistribution(op, self, (arg,), {}, valueType=ty) else: # The general case. def handler(self, *args): - return OperatorDistribution(op, self, args, valueType=ty) + return OperatorDistribution(op, self, args, {}, valueType=ty) return handler diff --git a/src/scenic/core/dynamics/__init__.py b/src/scenic/core/dynamics/__init__.py new file mode 100644 index 000000000..c5e80c454 --- /dev/null +++ b/src/scenic/core/dynamics/__init__.py @@ -0,0 +1,18 @@ +"""Support for dynamic behaviors and modular scenarios. + +A few classes are exposed here for external use, including: + +* `Action`; +* `GuardViolation`, `InvariantViolation`, and `PreconditionViolation`; +* `StuckBehaviorWarning`. + +Everything else defined in the submodules is an implementation detail and +should not be used outside of Scenic (it may change at any time). +""" + +from .actions import Action +from .guards import GuardViolation, InvariantViolation, PreconditionViolation +from .utils import RejectSimulationException, StuckBehaviorWarning + +#: Timeout in seconds after which a `StuckBehaviorWarning` will be raised. +stuckBehaviorWarningTimeout = 10 diff --git a/src/scenic/core/dynamics/actions.py b/src/scenic/core/dynamics/actions.py new file mode 100644 index 000000000..66e1673a4 --- /dev/null +++ b/src/scenic/core/dynamics/actions.py @@ -0,0 +1,65 @@ +"""Actions taken by dynamic agents.""" + +import abc + + +class Action(abc.ABC): + """An :term:`action` which can be taken by an agent for one step of a simulation.""" + + def canBeTakenBy(self, agent): + """Whether this action is allowed to be taken by the given agent. + + The default implementation always returns True. + """ + return True + + @abc.abstractmethod + def applyTo(self, agent, simulation): + """Apply this action to the given agent in the given simulation. + + This method should call simulator APIs so that the agent will take this action + during the next simulated time step. Depending on the simulator API, it may be + necessary to batch each agent's actions into a single update: in that case you + can have this method set some state on the agent, then apply the actual update + in an overridden implementation of `Simulation.executeActions`. For examples, + see the CARLA interface: `scenic.simulators.carla.actions` has some CARLA-specific + actions which directly call CARLA APIs, while the generic steering and braking + actions from `scenic.domains.driving.actions` are implemented using the batching + approach (see for example the ``setThrottle`` method of the class + `scenic.simulators.carla.model.Vehicle`, which sets state later read by + ``CarlaSimulation.executeActions`` in `scenic.simulators.carla.simulator`). + """ + raise NotImplementedError + + +class _EndSimulationAction(Action): + """Special action indicating it is time to end the simulation. + + Only for internal use. + """ + + def __init__(self, line): + self.line = line + + def __str__(self): + return f'"terminate simulation" executed on line {self.line}' + + def applyTo(self, agent, simulation): + assert False + + +class _EndScenarioAction(Action): + """Special action indicating it is time to end the current scenario. + + Only for internal use. + """ + + def __init__(self, scenario, line): + self.scenario = scenario + self.line = line + + def __str__(self): + return f'"terminate" executed on line {self.line}' + + def applyTo(self, agent, simulation): + assert False diff --git a/src/scenic/core/dynamics/behaviors.py b/src/scenic/core/dynamics/behaviors.py new file mode 100644 index 000000000..7c12ef7c2 --- /dev/null +++ b/src/scenic/core/dynamics/behaviors.py @@ -0,0 +1,157 @@ +"""Behaviors and monitors.""" + +import functools +import inspect +import itertools +import sys +import warnings + +from scenic.core.distributions import Samplable, toDistribution +import scenic.core.dynamics as dynamics +from scenic.core.errors import InvalidScenarioError +from scenic.core.type_support import CoercionFailure +from scenic.core.utils import alarm + +from .invocables import Invocable +from .utils import StuckBehaviorWarning + + +class Behavior(Invocable, Samplable): + """Dynamic behaviors of agents. + + Behavior statements are translated into definitions of subclasses of this class. + """ + + _noActionsMsg = ( + 'does not take any actions (perhaps you forgot to use "take" or "do"?)' + ) + + def __init_subclass__(cls): + if "__signature__" in cls.__dict__: + # We're unpickling a behavior; skip this step. + return + + if cls.__module__ is not __name__: + import scenic.syntax.veneer as veneer + + if veneer.currentScenario: + veneer.currentScenario._behaviors.append(cls) + + target = cls.makeGenerator + target = functools.partial(target, 0, 0) # account for Scenic-inserted args + cls.__signature__ = inspect.signature(target) + + def __init__(self, *args, **kwargs): + args = tuple(toDistribution(arg) for arg in args) + kwargs = {name: toDistribution(arg) for name, arg in kwargs.items()} + + # Validate arguments to the behavior + sig = inspect.signature(self.makeGenerator) + sig.bind(None, *args, **kwargs) # raises TypeError on incompatible arguments + Samplable.__init__(self, itertools.chain(args, kwargs.values())) + Invocable.__init__(self, *args, **kwargs) + + if not inspect.isgeneratorfunction(self.makeGenerator): + raise InvalidScenarioError(f"{self} {self._noActionsMsg}") + + @classmethod + def _canCoerceType(cls, ty): + return issubclass(ty, cls) or ty in (type, type(None)) + + @classmethod + def _coerce(cls, thing): + if thing is None or isinstance(thing, cls): + return thing + elif issubclass(thing, cls): + return thing() + else: + raise CoercionFailure(f"expected type of behavior, got {thing}") + + def sampleGiven(self, value): + args = (value[arg] for arg in self._args) + kwargs = {name: value[val] for name, val in self._kwargs.items()} + return type(self)(*args, **kwargs) + + def _assignTo(self, agent): + if self._agent and agent is self._agent._dynamicProxy: + # Assigned again (e.g. by override) to same agent; do nothing. + return + if self._isRunning: + raise InvalidScenarioError( + f"tried to reuse behavior object {self} already assigned to {self._agent}" + ) + self._start(agent) + + def _start(self, agent): + super()._start() + self._agent = agent + self._runningIterator = self.makeGenerator(agent, *self._args, **self._kwargs) + self._checkAllPreconditions() + + def _step(self): + import scenic.syntax.veneer as veneer + + super()._step() + assert self._runningIterator + + def alarmHandler(signum, frame): + # NOTE: if using pytest-cov, sys.gettrace() set to CTracer(), but we still want timeout warnings enabled + if sys.gettrace() and "coverage" not in str(type(sys.gettrace())): + return # skip the warning if we're in the debugger + warnings.warn( + f"the behavior {self} is taking a long time to take an action; " + "maybe you have an infinite loop with no take/wait statements?", + StuckBehaviorWarning, + ) + + timeout = dynamics.stuckBehaviorWarningTimeout + with veneer.executeInBehavior(self), alarm(timeout, alarmHandler): + try: + actions = self._runningIterator.send(None) + except StopIteration: + actions = () # behavior ended early + return actions + + def _stop(self, reason=None): + super()._stop(reason) + self._agent = None + self._runningIterator = None + + @property + def _isFinished(self): + return self._runningIterator is None + + def _invokeInner(self, agent, subs): + import scenic.syntax.veneer as veneer + + assert len(subs) == 1 + sub = subs[0] + if not isinstance(sub, Behavior): + raise TypeError(f"expected a behavior, got {sub}") + sub._start(agent) + with veneer.executeInBehavior(sub): + try: + yield from sub._runningIterator + finally: + if sub._isRunning: + sub._stop() + + def __repr__(self): + items = itertools.chain( + (repr(arg) for arg in self._args), + (f"{key}={repr(val)}" for key, val in self._kwargs.items()), + ) + allArgs = ", ".join(items) + return f"{self.__class__.__name__}({allArgs})" + + +class Monitor(Behavior): + """Monitors for dynamic simulations. + + Monitor statements are translated into definitions of subclasses of this class. + """ + + _noActionsMsg = 'does not take any actions (perhaps you forgot to use "wait"?)' + + def _start(self): + return super()._start(None) diff --git a/src/scenic/core/dynamics/guards.py b/src/scenic/core/dynamics/guards.py new file mode 100644 index 000000000..0a7a9d8d0 --- /dev/null +++ b/src/scenic/core/dynamics/guards.py @@ -0,0 +1,42 @@ +"""Preconditions and invariants of behaviors and scenarios.""" + + +class GuardViolation(Exception): + """Abstract exception raised when a guard of a behavior is violated. + + This will never be raised directly; either of the subclasses `PreconditionViolation` + or `InvariantViolation` will be used, as appropriate. + """ + + violationType = "guard" + + def __init__(self, behavior, lineno): + self.behaviorName = behavior.__class__.__name__ + self.lineno = lineno + + def __str__(self): + return ( + f"violated {self.violationType} of {self.behaviorName} on line {self.lineno}" + ) + + +class PreconditionViolation(GuardViolation): + """Exception raised when a precondition is violated. + + Raised when a precondition is violated when invoking a behavior + or when a precondition encounters a `RejectionException`, so that + rejections count as precondition violations. + """ + + violationType = "precondition" + + +class InvariantViolation(GuardViolation): + """Exception raised when an invariant is violated. + + Raised when an invariant is violated when invoking/resuming a behavior + or when an invariant encounters a `RejectionException`, so that + rejections count as invariant violations. + """ + + violationType = "invariant" diff --git a/src/scenic/core/dynamics/invocables.py b/src/scenic/core/dynamics/invocables.py new file mode 100644 index 000000000..7f3eb1a8d --- /dev/null +++ b/src/scenic/core/dynamics/invocables.py @@ -0,0 +1,233 @@ +"""General code for invocables, i.e. behaviors, monitors, and modular scenarios.""" + +import enum +import types + +from scenic.core.distributions import Options +from scenic.core.errors import InvalidScenarioError +from scenic.core.lazy_eval import DelayedArgument, needsLazyEvaluation + +from .guards import GuardViolation +from .utils import RejectSimulationException + + +class Invocable: + """Abstract class with common code for behaviors and modular scenarios. + + Both of these types of objects can be called like functions, can have guards, and can + suspend their own execution to invoke sub-behaviors/scenarios. + """ + + def __init__(self, *args, **kwargs): + import scenic.syntax.veneer as veneer + + if veneer.evaluatingGuard: + raise InvalidScenarioError( + "tried to invoke behavior/scenario from inside guard or interrupt condition" + ) + self._args = args + self._kwargs = kwargs + self._agent = None + self._runningIterator = None + self._isRunning = False + + def _start(self): + assert not self._isRunning + self._isRunning = True + self._finalizeArguments() + + def _step(self): + assert self._isRunning + + def _stop(self, reason=None): + assert self._isRunning + self._isRunning = False + + def _finalizeArguments(self): + # Evaluate any lazy arguments whose evaluation was deferred until just before + # this invocable starts running. + args = [] + for arg in self._args: + if needsLazyEvaluation(arg): + assert isinstance(arg, DelayedArgument) + args.append(arg.evaluateInner(None)) + else: + args.append(arg) + self._args = tuple(args) + for name, arg in self._kwargs.items(): + if needsLazyEvaluation(arg): + assert isinstance(arg, DelayedArgument) + self._kwargs[name] = arg.evaluateInner(None) + + def _invokeSubBehavior(self, agent, subs, modifier=None, schedule=None): + def pickEnabledInvocable(opts): + enabled = {} + if isinstance(opts, dict): + for sub, weight in opts.items(): + if sub._isEnabledForAgent(agent): + enabled[sub] = weight + else: + for sub in opts: + if sub._isEnabledForAgent(agent): + enabled[sub] = 1 + if not enabled: + raise RejectSimulationException('deadlock in "do choose/shuffle"') + if len(enabled) == 1: + choice = list(enabled)[0] + else: + choice = Options(enabled) + return choice + + scheduler = None + if schedule == "choose": + if len(subs) == 1 and isinstance(subs[0], dict): + subs = subs[0] + subs = (pickEnabledInvocable(subs),) + elif schedule == "shuffle": + if len(subs) == 1 and isinstance(subs[0], dict): + subs = subs[0] + else: + subs = {item: 1 for item in subs} + + def scheduler(): + while subs: + choice = pickEnabledInvocable(subs) + subs.pop(choice) + yield from self._invokeInner(agent, (choice,)) + + else: + assert schedule is None + if not scheduler: + + def scheduler(): + yield from self._invokeInner(agent, subs) + + if modifier: + if modifier.name == "for": # do X for Y [seconds | steps] + import scenic.syntax.veneer as veneer + + timeLimit = modifier.value + if not isinstance(timeLimit, (float, int)): + raise TypeError('"do X for Y" with Y not a number') + assert modifier.terminator in (None, "seconds", "steps") + if modifier.terminator != "steps": + timeLimit /= veneer.currentSimulation.timestep + startTime = veneer.currentSimulation.currentTime + condition = ( + lambda: veneer.currentSimulation.currentTime - startTime >= timeLimit + ) + elif modifier.name == "until": # do X until Y + condition = modifier.value + else: + raise RuntimeError( + f"internal parsing error: impossible modifier {modifier}" + ) + + def body(behavior, agent): + yield from scheduler() + + def handler(behavior, agent): + for sub in subs: + if sub._isRunning: + sub._stop(f'"{modifier.name}" condition met') + return BlockConclusion.ABORT + + yield from runTryInterrupt(self, agent, body, [condition], [handler]) + else: + yield from scheduler() + + def _invokeInner(self, agent, subs): + """Run the given sub-behavior/scenario(s) in parallel. + + Implemented by subclasses. + """ + raise NotImplementedError + + def _checkAllPreconditions(self): + self.checkPreconditions(self._agent, *self._args, **self._kwargs) + self.checkInvariants(self._agent, *self._args, **self._kwargs) + + def _isEnabledForAgent(self, agent): + assert not self._isRunning + assert self._agent is None + try: + self._agent = agent # in case `self` is used in a precondition + self._checkAllPreconditions() + return True + except GuardViolation: + return False + finally: + self._agent = None + + +# Try-interrupt blocks + + +def runTryInterrupt(behavior, agent, body, conditions, handlers): + body = InterruptBlock(None, body) + interrupts = [InterruptBlock(c, h) for c, h in zip(conditions, handlers)] + while True: + # find next block to run, if any + block = body + for interrupt in interrupts: + if interrupt.isEnabled or interrupt.isRunning: + block = interrupt + break + result, concluded = block.step(behavior, agent) + if concluded: + if result is BlockConclusion.FINISHED and block is not body: + continue # interrupt handler finished + else: + return result # entire try-interrupt statement will terminate + else: + yield result + behavior.checkInvariants(None, *behavior._args, **behavior._kwargs) + + +@enum.unique +class BlockConclusion(enum.Enum): + FINISHED = enum.auto() + ABORT = enum.auto() + RETURN = enum.auto() + BREAK = enum.auto() + CONTINUE = enum.auto() + + def __call__(self, value): + self.return_value = value + return self + + +class InterruptBlock: + def __init__(self, condition, body): + self.condition = condition + self.body = body + self.runningIterator = None + + @property + def isEnabled(self): + import scenic.syntax.veneer as veneer + + with veneer.executeInGuard(): + result = self.condition() + if isinstance(result, DelayedArgument): + # Condition cannot yet be evaluated because it depends on a scenario + # local not yet initialized; we consider it to be false. + return False + return bool(result) + + @property + def isRunning(self): + return self.runningIterator is not None + + def step(self, behavior, agent): + if not self.runningIterator: + it = self.body(behavior, agent) + if not isinstance(it, types.GeneratorType): + return (it, True) + self.runningIterator = it + try: + result = self.runningIterator.send(None) + return (result, False) + except StopIteration as e: + self.runningIterator = None + return (e.value, True) diff --git a/src/scenic/core/dynamics.py b/src/scenic/core/dynamics/scenarios.py similarity index 54% rename from src/scenic/core/dynamics.py rename to src/scenic/core/dynamics/scenarios.py index 402c1f9b7..77ec470e8 100644 --- a/src/scenic/core/dynamics.py +++ b/src/scenic/core/dynamics/scenarios.py @@ -1,201 +1,30 @@ -"""Support for dynamic behaviors and modular scenarios.""" +"""Dynamic scenarios.""" +import ast from collections import defaultdict import dataclasses -import enum import functools import inspect -import itertools -import sys -import types -import warnings import weakref import rv_ltl -from scenic.core.distributions import Options, Samplable, needsSampling, toDistribution -from scenic.core.errors import InvalidScenarioError +import scenic +import scenic.core.dynamics as dynamics +from scenic.core.errors import InvalidScenarioError, ScenicSyntaxError from scenic.core.lazy_eval import DelayedArgument, needsLazyEvaluation from scenic.core.requirements import ( DynamicRequirement, PendingRequirement, RequirementType, ) -from scenic.core.simulators import ( - EndScenarioAction, - EndSimulationAction, - RejectSimulationException, -) -from scenic.core.type_support import CoercionFailure from scenic.core.utils import alarm, argsToString from scenic.core.workspaces import Workspace -# Utilities - - -class StuckBehaviorWarning(UserWarning): - """Warning issued when a behavior/scenario may have gotten stuck. - - When a behavior or compose block of a modular scenario executes for a long - time without yielding control, there is no way to tell whether it has - entered an infinite loop with no take/wait statements, or is actually doing - some long computation. But since forgetting a wait statement in a wait loop - is an easy mistake, we raise this warning after a behavior/scenario has run - for `stuckBehaviorWarningTimeout` seconds without yielding. - """ - - pass - - -#: Timeout in seconds after which a `StuckBehaviorWarning` will be raised. -stuckBehaviorWarningTimeout = 10 - -# Scenarios - - -class Invocable: - """Abstract class with common code for behaviors and modular scenarios. - - Both of these types of objects can be called like functions, can have guards, and can - suspend their own execution to invoke sub-behaviors/scenarios. - """ - - def __init__(self, *args, **kwargs): - if veneer.evaluatingGuard: - raise InvalidScenarioError( - "tried to invoke behavior/scenario from inside guard or interrupt condition" - ) - self._args = args - self._kwargs = kwargs - self._agent = None - self._runningIterator = None - self._isRunning = False - - def _start(self): - assert not self._isRunning - self._isRunning = True - self._finalizeArguments() - - def _step(self): - assert self._isRunning - - def _stop(self, reason=None): - assert self._isRunning - self._isRunning = False - - def _finalizeArguments(self): - # Evaluate any lazy arguments whose evaluation was deferred until just before - # this invocable starts running. - args = [] - for arg in self._args: - if needsLazyEvaluation(arg): - assert isinstance(arg, DelayedArgument) - args.append(arg.evaluateInner(None)) - else: - args.append(arg) - self._args = tuple(args) - for name, arg in self._kwargs.items(): - if needsLazyEvaluation(arg): - assert isinstance(arg, DelayedArgument) - self._kwargs[name] = arg.evaluateInner(None) - - def _invokeSubBehavior(self, agent, subs, modifier=None, schedule=None): - def pickEnabledInvocable(opts): - enabled = {} - if isinstance(opts, dict): - for sub, weight in opts.items(): - if sub._isEnabledForAgent(agent): - enabled[sub] = weight - else: - for sub in opts: - if sub._isEnabledForAgent(agent): - enabled[sub] = 1 - if not enabled: - raise RejectSimulationException('deadlock in "do choose/shuffle"') - if len(enabled) == 1: - choice = list(enabled)[0] - else: - choice = Options(enabled) - return choice - - scheduler = None - if schedule == "choose": - if len(subs) == 1 and isinstance(subs[0], dict): - subs = subs[0] - subs = (pickEnabledInvocable(subs),) - elif schedule == "shuffle": - if len(subs) == 1 and isinstance(subs[0], dict): - subs = subs[0] - else: - subs = {item: 1 for item in subs} - - def scheduler(): - while subs: - choice = pickEnabledInvocable(subs) - subs.pop(choice) - yield from self._invokeInner(agent, (choice,)) - - else: - assert schedule is None - if not scheduler: - - def scheduler(): - yield from self._invokeInner(agent, subs) - - if modifier: - if modifier.name == "for": # do X for Y [seconds | steps] - timeLimit = modifier.value - if not isinstance(timeLimit, (float, int)): - raise TypeError('"do X for Y" with Y not a number') - assert modifier.terminator in (None, "seconds", "steps") - if modifier.terminator != "steps": - timeLimit /= veneer.currentSimulation.timestep - startTime = veneer.currentSimulation.currentTime - condition = ( - lambda: veneer.currentSimulation.currentTime - startTime >= timeLimit - ) - elif modifier.name == "until": # do X until Y - condition = modifier.value - else: - raise RuntimeError( - f"internal parsing error: impossible modifier {modifier}" - ) - - def body(behavior, agent): - yield from scheduler() - - def handler(behavior, agent): - for sub in subs: - if sub._isRunning: - sub._stop(f'"{modifier.name}" condition met') - return BlockConclusion.ABORT - - yield from runTryInterrupt(self, agent, body, [condition], [handler]) - else: - yield from scheduler() - - def _invokeInner(self, agent, subs): - """Run the given sub-behavior/scenario(s) in parallel. - - Implemented by subclasses. - """ - raise NotImplementedError - - def _checkAllPreconditions(self): - self.checkPreconditions(self._agent, *self._args, **self._kwargs) - self.checkInvariants(self._agent, *self._args, **self._kwargs) - - def _isEnabledForAgent(self, agent): - assert not self._isRunning - assert self._agent is None - try: - self._agent = agent # in case `self` is used in a precondition - self._checkAllPreconditions() - return True - except GuardViolation: - return False - finally: - self._agent = None +from .actions import _EndScenarioAction, _EndSimulationAction +from .behaviors import Behavior, Monitor +from .invocables import Invocable +from .utils import RejectSimulationException, StuckBehaviorWarning class DynamicScenario(Invocable): @@ -206,6 +35,8 @@ class DynamicScenario(Invocable): """ def __init_subclass__(cls, *args, **kwargs): + import scenic.syntax.veneer as veneer + veneer.registerDynamicScenarioClass(cls) target = cls._setup or cls._compose or (lambda self, agent: 0) @@ -309,6 +140,8 @@ def _bindTo(self, scene): def _prepare(self, delayPreconditionCheck=False): """Prepare the scenario for execution, executing its setup block.""" + import scenic.syntax.veneer as veneer + assert not self._prepared self._prepared = True @@ -338,6 +171,8 @@ def _bindGlobals(cls, globs): def _start(self): """Start the scenario, starting its compose block, behaviors, and monitors.""" + import scenic.syntax.veneer as veneer + super()._start() assert self._prepared @@ -382,6 +217,8 @@ def _step(self): `None` if the scenario will continue executing; otherwise a string describing why it has terminated. """ + import scenic.syntax.veneer as veneer + super()._step() # Check temporal requirements @@ -413,12 +250,11 @@ def alarmHandler(signum, frame): StuckBehaviorWarning, ) - with veneer.executeInScenario(self), alarm( - stuckBehaviorWarningTimeout, alarmHandler - ): + timeout = dynamics.stuckBehaviorWarningTimeout + with veneer.executeInScenario(self), alarm(timeout, alarmHandler): try: result = self._runningIterator.send(None) - if isinstance(result, (EndSimulationAction, EndScenarioAction)): + if isinstance(result, (_EndSimulationAction, _EndScenarioAction)): return self._stop(result) except StopIteration: self._runningIterator = None @@ -443,6 +279,8 @@ def alarmHandler(signum, frame): def _stop(self, reason, quiet=False): """Stop the scenario's execution, for the given reason.""" + import scenic.syntax.veneer as veneer + assert self._isRunning # Stop monitors and subscenarios. @@ -483,7 +321,7 @@ def _invokeInner(self, agent, subs): newSubs = [] for sub in self._subScenarios: terminationReason = sub._step() - if isinstance(terminationReason, EndSimulationAction): + if isinstance(terminationReason, _EndSimulationAction): yield terminationReason assert False, self # should never get here since simulation ends elif terminationReason is None: @@ -521,9 +359,9 @@ def _runMonitors(self): for monitor in self._monitors: action = monitor._step() # do not exit early, since subsequent monitors could reject the simulation - if isinstance(action, EndSimulationAction): + if isinstance(action, _EndSimulationAction): terminationReason = action - elif isinstance(action, EndScenarioAction): + elif isinstance(action, _EndScenarioAction): assert action.scenario is None endScenario = action for sub in self._subScenarios: @@ -593,6 +431,23 @@ def _compileRequirements(self): assert requirementSyntax is not None for reqID, requirement in self._pendingRequirements.items(): syntax = requirementSyntax[reqID] if requirementSyntax else None + + # Catch the simple case where someone has most likely forgotten the "monitor" + # keyword. + if ( + (not requirement.ty == RequirementType.monitor) + and isinstance(syntax, ast.Call) + and isinstance(syntax.func, ast.Name) + and syntax.func.id in namespace + and isinstance(namespace[syntax.func.id], type) + and issubclass( + namespace[syntax.func.id], scenic.core.dynamics.behaviors.Monitor + ) + ): + raise ScenicSyntaxError( + f"Missing 'monitor' keyword after 'require' when instantiating '{syntax.func.id}'" + ) + compiledReq = requirement.compile(namespace, self, syntax) self._registerCompiledRequirement(compiledReq) @@ -668,274 +523,3 @@ def __str__(self): else: args = argsToString(self._args, self._kwargs) return f"{self.__class__.__name__}({args})" - - -# Behaviors - - -class Behavior(Invocable, Samplable): - """Dynamic behaviors of agents. - - Behavior statements are translated into definitions of subclasses of this class. - """ - - def __init_subclass__(cls): - if "__signature__" in cls.__dict__: - # We're unpickling a behavior; skip this step. - return - - if cls.__module__ is not __name__: - if veneer.currentScenario: - veneer.currentScenario._behaviors.append(cls) - - target = cls.makeGenerator - target = functools.partial(target, 0, 0) # account for Scenic-inserted args - cls.__signature__ = inspect.signature(target) - - def __init__(self, *args, **kwargs): - args = tuple(toDistribution(arg) for arg in args) - kwargs = {name: toDistribution(arg) for name, arg in kwargs.items()} - - # Validate arguments to the behavior - sig = inspect.signature(self.makeGenerator) - sig.bind(None, *args, **kwargs) # raises TypeError on incompatible arguments - Samplable.__init__(self, itertools.chain(args, kwargs.values())) - Invocable.__init__(self, *args, **kwargs) - - if not inspect.isgeneratorfunction(self.makeGenerator): - raise InvalidScenarioError( - f"{self} does not take any actions" - ' (perhaps you forgot to use "take" or "do"?)' - ) - - @classmethod - def _canCoerceType(cls, ty): - return issubclass(ty, cls) or ty in (type, type(None)) - - @classmethod - def _coerce(cls, thing): - if thing is None or isinstance(thing, cls): - return thing - elif issubclass(thing, cls): - return thing() - else: - raise CoercionFailure(f"expected type of behavior, got {thing}") - - def sampleGiven(self, value): - args = (value[arg] for arg in self._args) - kwargs = {name: value[val] for name, val in self._kwargs.items()} - return type(self)(*args, **kwargs) - - def _assignTo(self, agent): - if self._agent and agent is self._agent._dynamicProxy: - # Assigned again (e.g. by override) to same agent; do nothing. - return - if self._isRunning: - raise InvalidScenarioError( - f"tried to reuse behavior object {self} already assigned to {self._agent}" - ) - self._start(agent) - - def _start(self, agent): - super()._start() - self._agent = agent - self._runningIterator = self.makeGenerator(agent, *self._args, **self._kwargs) - self._checkAllPreconditions() - - def _step(self): - super()._step() - assert self._runningIterator - - def alarmHandler(signum, frame): - if sys.gettrace(): - return # skip the warning if we're in the debugger - warnings.warn( - f"the behavior {self} is taking a long time to take an action; " - "maybe you have an infinite loop with no take/wait statements?", - StuckBehaviorWarning, - ) - - with veneer.executeInBehavior(self), alarm( - stuckBehaviorWarningTimeout, alarmHandler - ): - try: - actions = self._runningIterator.send(None) - except StopIteration: - actions = () # behavior ended early - return actions - - def _stop(self, reason=None): - super()._stop(reason) - self._agent = None - self._runningIterator = None - - @property - def _isFinished(self): - return self._runningIterator is None - - def _invokeInner(self, agent, subs): - assert len(subs) == 1 - sub = subs[0] - if not isinstance(sub, Behavior): - raise TypeError(f"expected a behavior, got {sub}") - sub._start(agent) - with veneer.executeInBehavior(sub): - try: - yield from sub._runningIterator - finally: - if sub._isRunning: - sub._stop() - - def __repr__(self): - items = itertools.chain( - (repr(arg) for arg in self._args), - (f"{key}={repr(val)}" for key, val in self._kwargs.items()), - ) - allArgs = ", ".join(items) - return f"{self.__class__.__name__}({allArgs})" - - -def _makeTerminationAction(agent, line): - assert not veneer.isActive() - if agent: - scenario = agent._parentScenario() - assert scenario is not None - else: - scenario = None - return EndScenarioAction(scenario, line) - - -def _makeSimulationTerminationAction(line): - assert not veneer.isActive() - return EndSimulationAction(line) - - -# Monitors - - -class Monitor(Behavior): - """Monitors for dynamic simulations. - - Monitor statements are translated into definitions of subclasses of this class. - """ - - def _start(self): - return super()._start(None) - - -# Guards - - -class GuardViolation(Exception): - """Abstract exception raised when a guard of a behavior is violated. - - This will never be raised directly; either of the subclasses `PreconditionViolation` - or `InvariantViolation` will be used, as appropriate. - """ - - violationType = "guard" - - def __init__(self, behavior, lineno): - self.behaviorName = behavior.__class__.__name__ - self.lineno = lineno - - def __str__(self): - return ( - f"violated {self.violationType} of {self.behaviorName} on line {self.lineno}" - ) - - -class PreconditionViolation(GuardViolation): - """Exception raised when a precondition is violated - - Raised when a precondition is violated when invoking a behavior - or when a precondition encounters a `RejectionException`, so that - rejections count as precondition violations. - """ - - violationType = "precondition" - - -class InvariantViolation(GuardViolation): - """Exception raised when an invariant is violated - - Raised when an invariant is violated when invoking/resuming a behavior - or when an invariant encounters a `RejectionException`, so that - rejections count as invariant violations. - """ - - violationType = "invariant" - - -# Try-interrupt blocks - - -def runTryInterrupt(behavior, agent, body, conditions, handlers): - body = InterruptBlock(None, body) - interrupts = [InterruptBlock(c, h) for c, h in zip(conditions, handlers)] - while True: - # find next block to run, if any - block = body - for interrupt in interrupts: - if interrupt.isEnabled or interrupt.isRunning: - block = interrupt - break - result, concluded = block.step(behavior, agent) - if concluded: - if result is BlockConclusion.FINISHED and block is not body: - continue # interrupt handler finished - else: - return result # entire try-interrupt statement will terminate - else: - yield result - behavior.checkInvariants(None, *behavior._args, **behavior._kwargs) - - -@enum.unique -class BlockConclusion(enum.Enum): - FINISHED = enum.auto() - ABORT = enum.auto() - RETURN = enum.auto() - BREAK = enum.auto() - CONTINUE = enum.auto() - - def __call__(self, value): - self.return_value = value - return self - - -class InterruptBlock: - def __init__(self, condition, body): - self.condition = condition - self.body = body - self.runningIterator = None - - @property - def isEnabled(self): - with veneer.executeInGuard(): - result = self.condition() - if isinstance(result, DelayedArgument): - # Condition cannot yet be evaluated because it depends on a scenario - # local not yet initialized; we consider it to be false. - return False - return bool(result) - - @property - def isRunning(self): - return self.runningIterator is not None - - def step(self, behavior, agent): - if not self.runningIterator: - it = self.body(behavior, agent) - if not isinstance(it, types.GeneratorType): - return (it, True) - self.runningIterator = it - try: - result = self.runningIterator.send(None) - return (result, False) - except StopIteration as e: - self.runningIterator = None - return (e.value, True) - - -import scenic.syntax.veneer as veneer diff --git a/src/scenic/core/dynamics/utils.py b/src/scenic/core/dynamics/utils.py new file mode 100644 index 000000000..edd494b94 --- /dev/null +++ b/src/scenic/core/dynamics/utils.py @@ -0,0 +1,21 @@ +"""Assorted utilities and classes used throughout the dynamics package.""" + + +class RejectSimulationException(Exception): + """Exception indicating a requirement was violated at runtime.""" + + pass + + +class StuckBehaviorWarning(UserWarning): + """Warning issued when a behavior/scenario may have gotten stuck. + + When a behavior or compose block of a modular scenario executes for a long + time without yielding control, there is no way to tell whether it has + entered an infinite loop with no take/wait statements, or is actually doing + some long computation. But since forgetting a wait statement in a while loop + is an easy mistake, we raise this warning after a behavior/scenario has run + for `stuckBehaviorWarningTimeout` seconds without yielding. + """ + + pass diff --git a/src/scenic/core/external_params.py b/src/scenic/core/external_params.py index 3b37e3de5..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) } ) @@ -206,10 +206,12 @@ def __init__(self, params, globalParams): if usingProbs and samplerType == "ce": if samplerParams is None: samplerParams = DotMap() + else: + samplerParams = samplerParams.copy() # avoid mutating original if "cont" in samplerParams or "disc" in samplerParams: raise RuntimeError( "CE distributions specified in both VerifaiParameters" - "and verifaiSamplerParams" + " and verifaiSamplerParams" ) cont_buckets = [] cont_dists = [] @@ -260,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/lazy_eval.py b/src/scenic/core/lazy_eval.py index 2f6e0d808..e44f63e3d 100644 --- a/src/scenic/core/lazy_eval.py +++ b/src/scenic/core/lazy_eval.py @@ -262,7 +262,7 @@ def needsLazyEvaluation(thing): def dependencies(thing): """Dependencies which must be sampled before this value.""" - return getattr(thing, "_dependencies", ()) + return getattr(getattr(thing, "_conditioned", thing), "_dependencies", ()) def needsSampling(thing): diff --git a/src/scenic/core/object_types.py b/src/scenic/core/object_types.py index cd4ee9961..5230f0c83 100644 --- a/src/scenic/core/object_types.py +++ b/src/scenic/core/object_types.py @@ -27,6 +27,7 @@ import trimesh from scenic.core.distributions import ( + FunctionDistribution, MultiplexerDistribution, RandomControlFlowError, Samplable, @@ -45,7 +46,12 @@ normalizeAngle, pointIsInCone, ) -from scenic.core.lazy_eval import LazilyEvaluable, isLazy, needsLazyEvaluation +from scenic.core.lazy_eval import ( + LazilyEvaluable, + isLazy, + needsLazyEvaluation, + valueInContext, +) from scenic.core.regions import ( BoxRegion, CircularRegion, @@ -114,6 +120,21 @@ def __init_subclass__(cls): # transformed by __init_subclass__, so we skip it now. return + # Identify cached properties/methods which will need to be cleared + # each time step during dynamic simulations. + clearers = {} + for attr, value in cls.__dict__.items(): + if isinstance(value, property): + value = value.fget + if clearer := getattr(value, "_scenic_cache_clearer", None): + clearers[attr] = clearer + for sc in cls.__mro__: + if sclearers := getattr(sc, "_cache_clearers", None): + for attr, clearer in sclearers.items(): + if attr not in clearers: + clearers[attr] = clearer + cls._cache_clearers = clearers + # Find all defaults provided by the class or its superclasses allDefs = collections.defaultdict(list) @@ -126,29 +147,37 @@ def __init_subclass__(cls): resolvedDefs = {} dyns = [] finals = [] + dynFinals = {} for prop, defs in allDefs.items(): primary, rest = defs[0], defs[1:] spec = primary.resolveFor(prop, rest) resolvedDefs[prop] = spec - if any(defn.isDynamic for defn in defs): + if isDynamic := any(defn.isDynamic for defn in defs): dyns.append(prop) if primary.isFinal: finals.append(prop) + if isDynamic: + dynFinals[prop] = primary.value cls._defaults = resolvedDefs - cls._finalProperties = tuple(finals) + cls._finalProperties = frozenset(finals) # Determine types of dynamic properties dynTypes = {} - inst = None + defaultValues = None # compute only if necessary for prop in dyns: ty = super(cls, cls)._dynamicProperties.get(prop) if not ty: - # First time this property has been defined; create a dummy object to - # run specifier resolution and determine the property's default value - if not inst: - inst = cls._withSpecifiers((), register=False) - ty = underlyingType(getattr(inst, prop)) + # First time this property has been defined; get the type of + # its default value. + if not defaultValues: + # N.B. Here we evaluate the default value expressions, which is + # risky since global state like the workspace may not have been set + # up yet. For this reason we only compute default values when they + # are actually needed; a better solution would be to have syntax for + # annotating the types of dynamic properties. + defaultValues, _ = cls._resolveSpecifiers(()) + ty = underlyingType(defaultValues[prop]) dynTypes[prop] = ty cls._dynamicProperties = dynTypes cls._simulatorProvidedProperties = { @@ -157,6 +186,17 @@ def __init_subclass__(cls): if prop not in cls._finalProperties } + # Extract order in which to recompute dynamic final properties each time step + if defaultValues: + recomputers = {} + for prop in defaultValues: # order is that from specifier resolution + if prop in dynFinals: + recomputers[prop] = dynFinals[prop] + cls._dynamicFinalProperties = recomputers + else: + # No new dynamic properties: just inherit the order from the superclass + pass + def __new__(cls, *args, _internal=False, **kwargs): if not _internal: # Catch users trying to instantiate a Scenic class like a Python class @@ -408,6 +448,14 @@ def dfs(spec): ) return properties, constProps + def _recomputeDynamicFinals(self): + # Evaluate default value expression for each dynamic final property + # and assign the obtained value + for prop, recomputer in self._dynamicFinalProperties.items(): + rawVal = recomputer(self) + value = valueInContext(rawVal, self) + self._specify(self, prop, value) + @classmethod def _specify(cls, context, prop, value): # Normalize types of some built-in properties @@ -449,6 +497,9 @@ def _specify(cls, context, prop, value): "Color property contains value not between 0 and 1 (inclusive)." ) + if not 3 <= len(value) <= 4: + raise ValueError(f"Color property has incorrect length {len(value)}.") + object.__setattr__(context, prop, value) def _register(self): @@ -504,12 +555,16 @@ def _copyWith(self, **overrides): props = { prop: val for prop, val in self._allProperties().items() - if prop not in set(self._finalProperties) + if prop not in self._finalProperties } props.update(overrides) constProps = self._constProps.difference(overrides) return self._withProperties(props, constProps=constProps) + def _clearCaches(self): + for clearer in self._cache_clearers.values(): + clearer(self) + def dumpAsScenicCode(self, stream, skipConstProperties=True): stream.write(f"new {self.__class__.__name__}") first = True @@ -558,15 +613,15 @@ class Mutator: """ def appliedTo(self, obj): - """Return a mutated copy of the given object. Implemented by subclasses. + """Return a mutated version of the given object. Implemented by subclasses. The mutator may inspect the ``mutationScale`` attribute of the given object to scale its effect according to the scale given in ``mutate O by S``. Returns: - A pair consisting of the mutated copy of the object (which is most easily - created using `_copyWith`) together with a Boolean indicating whether the - mutator inherited from the superclass (if any) should also be applied. + A pair consisting of the mutated version of the object together with a + Boolean indicating whether the mutator inherited from the superclass + (if any) should also be applied. """ raise NotImplementedError @@ -587,8 +642,8 @@ def appliedTo(self, obj): random.gauss(0, self.stddevs[1] * obj.mutationScale), random.gauss(0, self.stddevs[2] * obj.mutationScale), ) - pos = obj.position + noise - return (obj._copyWith(position=pos), True) # allow further mutation + obj.position += noise + return (obj, True) # allow further mutation def __eq__(self, other): if type(other) is not type(self): @@ -610,13 +665,11 @@ def __init__(self, stddevs): self.stddevs = tuple(stddevs) def appliedTo(self, obj): - yaw = obj.yaw + random.gauss(0, self.stddevs[0] * obj.mutationScale) - pitch = obj.pitch + random.gauss(0, self.stddevs[1] * obj.mutationScale) - roll = obj.roll + random.gauss(0, self.stddevs[2] * obj.mutationScale) + obj.yaw += random.gauss(0, self.stddevs[0] * obj.mutationScale) + obj.pitch += random.gauss(0, self.stddevs[1] * obj.mutationScale) + obj.roll += random.gauss(0, self.stddevs[2] * obj.mutationScale) - new_obj = obj._copyWith(yaw=yaw, pitch=pitch, roll=roll) - - return (new_obj, True) # allow further mutation + return (obj, True) # allow further mutation def __eq__(self, other): if type(other) is not type(self): @@ -748,6 +801,7 @@ def sampleGiven(self, value): sample, proceed = mutator.appliedTo(sample) if not proceed: break + sample._recomputeDynamicFinals() return sample # Points automatically convert to Vectors when needed @@ -755,9 +809,7 @@ def __getattr__(self, attr): if hasattr(Vector, attr): return getattr(self.toVector(), attr) else: - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{attr}'" - ) + self.__getattribute__(attr) ## OrientedPoint @@ -766,14 +818,11 @@ def __getattr__(self, attr): class OrientedPoint(Point): """The Scenic class ``OrientedPoint``. - The default mutator for `OrientedPoint` adds Gaussian noise to ``yaw`` while - leaving ``pitch`` and ``roll`` unchanged, using the three standard deviations - (for yaw/pitch/roll respectively) given by the ``orientationStdDev`` property. - It then also applies the mutator for `Point`. - The default mutator for `OrientedPoint` adds Gaussian noise to ``yaw``, ``pitch`` - and ``roll`` according to ``orientationStdDev``. By default the standard deviations - for ``pitch`` and ``roll`` are zero so that, by default, only ``yaw`` is mutated. + and ``roll``, using the three standard deviations (for yaw/pitch/roll respectively) + given by the ``orientationStdDev`` property. It then also applies the mutator for `Point`. + By default the standard deviations for ``pitch`` and ``roll`` are zero so that, by + default, only ``yaw`` is mutated. Properties: yaw (float; dynamic): Yaw of the `OrientedPoint` in radians in the local coordinate system @@ -808,8 +857,8 @@ class OrientedPoint(Point): {"yaw", "pitch", "roll", "parentOrientation"}, {"dynamic", "final"}, lambda self: ( - Orientation.fromEuler(self.yaw, self.pitch, self.roll) - * self.parentOrientation + self.parentOrientation + * Orientation.fromEuler(self.yaw, self.pitch, self.roll) ), ), # Heading is equal to orientation.yaw, which is equal to self.yaw if this OrientedPoint's @@ -818,9 +867,11 @@ class OrientedPoint(Point): "heading": PropertyDefault( {"orientation"}, {"dynamic", "final"}, - lambda self: self.yaw - if alwaysGlobalOrientation(self.parentOrientation) - else self.orientation.yaw, + lambda self: ( + self.yaw + if alwaysGlobalOrientation(self.parentOrientation) + else self.orientation.yaw + ), ), "viewAngle": math.tau, # Primarily for backwards compatibility. Set viewAngles instead. "viewAngles": PropertyDefault( @@ -966,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 = { @@ -986,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, } @@ -1066,9 +1118,16 @@ def distanceTo(self, point): @cached_method def intersects(self, other): - """Whether or not this object intersects another object""" - # For objects that are boxes and flat, we can take a fast route - if self._isPlanarBox and other._isPlanarBox: + """Whether or not this object intersects another object or region""" + ## Type Checking ## + if not isinstance(other, (Object, Region)): + raise TypeError( + f"Cannot compute intersection of Scenic Object with {type(other)}." + ) + + ## Heuristic Fast Paths ## + # For two objects that are boxes and flat, we can take a fast route + if self._isPlanarBox and (isinstance(other, Object) and other._isPlanarBox): if abs(self.position.z - other.position.z) > (self.height + other.height) / 2: return False @@ -1076,12 +1135,27 @@ def intersects(self, other): other_poly = other._boundingPolygon return self_poly.intersects(other_poly) - if isLazy(self.occupiedSpace) or isLazy(other.occupiedSpace): + # For an object that is a box and flat with a polygonal region, we can + # also take a fast route. + if self._isPlanarBox and ( + isinstance(other, PolygonalRegion) + and abs(self.position.z - other.z) <= self.height / 2 + ): + return self._boundingPolygon.intersects(other.polygons) + + ## Default Case + # Extract other's occupied space if it's an object + if isinstance(other, Object): + other_occupied_space = other.occupiedSpace + else: + other_occupied_space = other + + if isLazy(self.occupiedSpace) or isLazy(other_occupied_space): raise RandomControlFlowError( "Cannot compute intersection between Objects with non-fixed values." ) - return self.occupiedSpace.intersects(other.occupiedSpace) + return self.occupiedSpace.intersects(other_occupied_space) @cached_property def left(self): @@ -1223,11 +1297,15 @@ def _corners2D(self): @cached_property def occupiedSpace(self): """A region representing the space this object occupies""" + shape = self.shape return MeshVolumeRegion( - mesh=self.shape.mesh, + mesh=shape.mesh, dimensions=(self.width, self.length, self.height), position=self.position, rotation=self.orientation, + centerMesh=False, + _internal=True, + _isConvex=shape.isConvex, ) @property @@ -1255,67 +1333,125 @@ def boundingBox(self): @cached_property def inradius(self): """A lower bound on the inradius of this object""" - # First check if all needed variables are defined. If so, we can - # compute the inradius exactly. - width, length, height = self.width, self.length, self.height - shape = self.shape - if not any(needsSampling(val) for val in (width, length, height, shape)): - shapeRegion = MeshVolumeRegion( - mesh=shape.mesh, dimensions=(width, length, height) - ) - return shapeRegion.inradius - # If we havea uniform distribution over shapes and a supportInterval for each dimension, - # we can compute a supportInterval for this object's inradius + # Define a helper function that computes the support of the inradius, + # given the sub supports. + def inradiusSupport(width_s, length_s, height_s, shape_s): + # Unpack the dimension supports (and ignore the shape support) + min_width, max_width = width_s + min_length, max_length = length_s + min_height, max_height = height_s + + if None in [ + min_width, + max_width, + min_length, + max_length, + min_height, + max_height, + ]: + # Can't get a bound on one or more dimensions, abort + return None, None + + min_bounds = np.array([min_width, min_length, min_height]) + max_bounds = np.array([max_width, max_length, max_height]) + + # Extract a list of possible shapes + if isinstance(self.shape, Shape): + shapes = [self.shape] + elif isinstance(self.shape, MultiplexerDistribution) and all( + isinstance(opt, Shape) for opt in self.shape.options + ): + shapes = self.shape.options + else: + # Something we don't recognize, abort + return None, None - # Define helper class - class InradiusHelper: - def __init__(self, support): - self.support = support + # Get the inradius for each shape with the min and max bounds + min_distances = [ + MeshVolumeRegion(mesh=shape.mesh, dimensions=min_bounds).inradius + for shape in shapes + ] + max_distances = [ + MeshVolumeRegion(mesh=shape.mesh, dimensions=max_bounds).inradius + for shape in shapes + ] - def supportInterval(self): - return self.support + distance_range = (min(min_distances), max(max_distances)) - # Extract bounds on all dimensions - min_width, max_width = supportInterval(width) - min_length, max_length = supportInterval(length) - min_height, max_height = supportInterval(height) + return distance_range - if None in [min_width, max_width, min_length, max_length, min_height, max_height]: - # Can't get a bound on one or more dimensions, abort - return 0 + # Define a helper function that computes the actual inradius + @distributionFunction(support=inradiusSupport) + def inradiusActual(width, length, height, shape): + return MeshVolumeRegion( + mesh=shape.mesh, dimensions=(width, length, height) + ).inradius - min_bounds = np.array([min_width, min_length, min_height]) - max_bounds = np.array([max_width, max_length, max_height]) + # Return the inradius (possibly a distribution) with proper support information + return inradiusActual(self.width, self.length, self.height, self.shape) - # Extract a list of possible shapes - if isinstance(shape, Shape): - shapes = [shape] - elif isinstance(shape, MultiplexerDistribution): - if all(isinstance(opt, Shape) for opt in shape.options): - shapes = shape.options + @cached_property + def planarInradius(self): + """A lower bound on the planar inradius of this object. + + This is defined as the inradius of the polygon of the occupiedSpace + of this object projected into the XY plane, assuming that pitch and + roll are both 0. + """ + + # Define a helper function that computes the support of the inradius, + # given the sub supports. + def planarInradiusSupport(width_s, length_s, shape_s): + # Unpack the dimension supports (and ignore the shape support) + min_width, max_width = width_s + min_length, max_length = length_s + + if None in [min_width, max_width, min_length, max_length]: + # Can't get a bound on one or more dimensions, abort + return None, None + + min_bounds = np.array([min_width, min_length, 1]) + max_bounds = np.array([max_width, max_length, 1]) + + # Extract a list of possible shapes + if isinstance(self.shape, Shape): + shapes = [self.shape] + elif isinstance(self.shape, MultiplexerDistribution) and all( + isinstance(opt, Shape) for opt in self.shape.options + ): + shapes = self.shape.options else: # Something we don't recognize, abort - return 0 + return None, None + + # Get the inradius of the projected for each shape with the min and max bounds + min_distances = [ + MeshVolumeRegion( + mesh=shape.mesh, dimensions=min_bounds + ).boundingPolygon.inradius + for shape in shapes + ] + max_distances = [ + MeshVolumeRegion( + mesh=shape.mesh, dimensions=max_bounds + ).boundingPolygon.inradius + for shape in shapes + ] - # Check that all possible shapes contain the origin - if not all(shape.containsCenter for shape in shapes): - # One or more shapes has inradius 0 - return 0 + distance_range = (min(min_distances), max(max_distances)) - # Get the inradius for each shape with the min and max bounds - min_distances = [ - MeshVolumeRegion(mesh=shape.mesh, dimensions=min_bounds).inradius - for shape in shapes - ] - max_distances = [ - MeshVolumeRegion(mesh=shape.mesh, dimensions=max_bounds).inradius - for shape in shapes - ] + return distance_range - distance_range = (min(min_distances), max(max_distances)) + # Define a helper function that computes the actual planarInradius + @distributionFunction(support=planarInradiusSupport) + def planarInradiusActual(width, length, shape): + return MeshVolumeRegion( + mesh=shape.mesh, dimensions=(width, length, 1) + ).boundingPolygon.inradius - return InradiusHelper(support=distance_range) + # Return the planar inradius (possibly a distribution) with proper support information + return planarInradiusActual(self.width, self.length, self.shape) @cached_property def surface(self): @@ -1415,13 +1551,29 @@ 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() if highlight: object_mesh.visual.face_colors = [30, 179, 0, 255] elif self.color is not None: - object_mesh.visual.face_colors = self.color + if len(self.color) == 3: + r, g, b = self.color + a = 1 + elif len(self.color) == 4: + r, g, b, a = self.color + else: + assert False + + object_mesh.visual.face_colors = [ + int(255 * r), + int(255 * g), + int(255 * b), + int(255 * a), + ] viewer.add_geometry(object_mesh) @@ -1627,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/pruning.py b/src/scenic/core/pruning.py index 00d28d486..9d03ecb52 100644 --- a/src/scenic/core/pruning.py +++ b/src/scenic/core/pruning.py @@ -5,11 +5,14 @@ """ import builtins +import collections import math import time +import numpy import shapely.geometry import shapely.geos +from trimesh.transformations import translation_matrix from scenic.core.distributions import ( AttributeDistribution, @@ -17,6 +20,7 @@ MethodDistribution, OperatorDistribution, Samplable, + dependencies, needsSampling, supportInterval, underlyingFunction, @@ -25,10 +29,18 @@ from scenic.core.geometry import hypot, normalizeAngle, plotPolygon, polygonUnion from scenic.core.object_types import Object, Point import scenic.core.regions as regions -from scenic.core.regions import EmptyRegion, MeshSurfaceRegion, MeshVolumeRegion +from scenic.core.regions import ( + EmptyRegion, + MeshSurfaceRegion, + MeshVolumeRegion, + PolygonalRegion, + Region, + VoxelRegion, +) from scenic.core.type_support import TypecheckedDistribution from scenic.core.vectors import ( PolygonalVectorField, + Vector, VectorField, VectorMethodDistribution, VectorOperatorDistribution, @@ -36,9 +48,11 @@ from scenic.core.workspaces import Workspace from scenic.syntax.relations import DistanceRelation, RelativeHeadingRelation -### Utilities +### Constants +PRUNING_PITCH = 0.15 +### Utilities def currentPropValue(obj, prop): """Get the current value of an object's property, taking into account prior pruning.""" value = getattr(obj, prop) @@ -59,19 +73,23 @@ def isFunctionCall(thing, function): return thing.function is underlyingFunction(function) +def unpackWorkspace(reg): + if isinstance(reg, Workspace): + return reg.region + else: + return reg + + def matchInRegion(position): """Match uniform samples from a `Region` - Returns the Region, if any, and a lower and upper bound - on the distance the object will be placed along with any - offset that should be added to the base. + Returns the Region, if any, the offset that should be added to the base, and + the PointInRegionDistribution itself. """ # Case 1: Position is simply a point in a region if isinstance(position, regions.PointInRegionDistribution): - reg = position.region - if isinstance(reg, Workspace): - reg = reg.region - return reg, 0, 0, None + reg = unpackWorkspace(position.region) + return reg, None, position # Case 2: Position is a point in a region with a vector offset. if isinstance(position, VectorOperatorDistribution) and position.operator in ( @@ -79,16 +97,13 @@ def matchInRegion(position): "__radd__", ): if isinstance(position.object, regions.PointInRegionDistribution): - reg = position.object.region + reg = unpackWorkspace(position.object.region) assert len(position.operands) == 1 offset = position.operands[0] - # TODO: Proper vector supportInterval calculations. Right now this gives us None - # if value is not exact - lower, upper = supportInterval(offset.norm()) - return reg, lower, upper, offset + return reg, offset, position.object - return None, 0, 0, None + return None, None, None def matchPolygonalField(heading, position): @@ -155,6 +170,7 @@ def prune(scenario, verbosity=1): pruneContainment(scenario, verbosity) pruneRelativeHeading(scenario, verbosity) + pruneVisibility(scenario, verbosity) if verbosity >= 1: totalTime = time.time() - startTime @@ -162,8 +178,6 @@ def prune(scenario, verbosity=1): ## Pruning based on containment - - def pruneContainment(scenario, verbosity): """Prune based on the requirement that individual Objects fit within their container. @@ -174,7 +188,7 @@ def pruneContainment(scenario, verbosity): """ for obj in scenario.objects: # Extract the base region and container region, while doing minor checks. - base, _, maxDistance, offset = matchInRegion(obj.position) + base, offset, _ = matchInRegion(obj.position) if base is None or needsSampling(base): continue @@ -190,19 +204,65 @@ def pruneContainment(scenario, verbosity): if isinstance(container, regions.EmptyRegion): raise InvalidScenarioError(f"Object {obj} contained in empty region") - # Erode the container region if possible. - minRadius, _ = supportInterval(obj.inradius) - + # Compute the maximum distance the object can be from the sampled point + if offset is not None: + # TODO: Support interval doesn't really work here for random values. + if isinstance(base, PolygonalRegion): + # Special handling for 2D regions that ignores vertical component of offset + offset_2d = Vector(offset.x, offset.y, 0) + _, maxDistance = supportInterval(offset_2d.norm()) + else: + _, maxDistance = supportInterval(offset.norm()) + else: + maxDistance = 0 + + # Compute the minimum radius of the object, with respect to the + # bounded dimensions of the container. + if ( + isinstance(base, PolygonalRegion) + and supportInterval(obj.pitch) == (0, 0) + and supportInterval(obj.roll) == (0, 0) + ): + # Special handling for 2D regions with no pitch or roll, + # using planar inradius instead. + minRadius, _ = supportInterval(obj.planarInradius) + else: + # For most regions, use full object inradius. + minRadius, _ = supportInterval(obj.inradius) + + # Erode the container if possible and productive if ( - hasattr(container, "buffer") - and maxDistance is not None + maxDistance is not None and minRadius is not None + and (maxErosion := minRadius - maxDistance) > 0 ): - maxErosion = minRadius - maxDistance - if maxErosion > 0: + if hasattr(container, "buffer"): + # We can do an exact erosion container = container.buffer(-maxErosion) + elif isinstance(container, MeshVolumeRegion): + current_pitch = PRUNING_PITCH + eroded_container = None + + while eroded_container is None: + # We can attempt to erode a voxel approximation of the MeshVolumeRegion. + eroded_container = container._erodeOverapproximate( + maxErosion, PRUNING_PITCH + ) - # Restrict the base region to the container, unless + if isinstance(eroded_container, VoxelRegion): + eroded_container = eroded_container.mesh + + current_pitch = min(2 * current_pitch, 1) + + # Now check if this erosion is valid and useful, i.e. do we have less volume + # to sample from. If so, replace the original container. + if ( + eroded_container is not None + and eroded_container.size < container.size + ): + container = eroded_container + + # Restrict the base region to the possibly eroded container, unless # they're the same in which case we're done if base is container: continue @@ -215,30 +275,28 @@ def pruneContainment(scenario, verbosity): if isinstance(base, MeshVolumeRegion) and isinstance(newBase, MeshSurfaceRegion): continue + # Check newBase properties if isinstance(newBase, EmptyRegion): raise InvalidScenarioError(f"Object {obj} does not fit in container") - if verbosity >= 1: - if ( - base.dimensionality is None - or newBase.dimensionality is None - or base.dimensionality != newBase.dimensionality - ): + percentage_pruned = percentagePruned(base, newBase) + + if percentage_pruned is None: + if verbosity >= 1: print( f" Region containment constraint pruning attempted but could not compute percentage for {base} and {newBase}." ) - elif base.dimensionality == newBase.dimensionality: - ratio = newBase.size / base.size - percent = max(0, 100 * (1.0 - ratio)) - - if percent <= 0.001: - # We didn't really prune anything, don't bother setting new position - continue + else: + if percentage_pruned <= 0.001: + # We didn't really prune anything, don't bother setting new position + continue + if verbosity >= 1: print( - f" Region containment constraint pruned {percent:.1f}% of space." + f" Region containment constraint pruned {percentage_pruned:.1f}% of space." ) + # Condition object to pruned position newPos = regions.Region.uniformPointIn(newBase) if offset is not None: @@ -248,8 +306,6 @@ def pruneContainment(scenario, verbosity): ## Pruning based on orientation - - def pruneRelativeHeading(scenario, verbosity): """Prune based on requirements bounding the relative heading of an Object. @@ -279,7 +335,7 @@ def pruneRelativeHeading(scenario, verbosity): # Check for relative heading relations among such objects for obj, (field, offsetL, offsetR) in fields.items(): position = currentPropValue(obj, "position") - base, _, _, offset = matchInRegion(position) + base, offset, _ = matchInRegion(position) # obj must be positioned uniformly in a Region if base is None or needsSampling(base): @@ -333,6 +389,134 @@ def pruneRelativeHeading(scenario, verbosity): obj.position.conditionTo(newPos) +# Pruning based on visibility +def pruneVisibility(scenario, verbosity): + ego = scenario.egoObject + + for obj in scenario.objects: + # Extract the base region if it exists + position = currentPropValue(obj, "position") + base, offset, pir_dist = matchInRegion(position) + + # Compute the maximum distance the object can be from the sampled point + if offset is not None: + _, maxDistance = supportInterval(offset.norm()) + else: + maxDistance = 0 + + if ( + base is None + or needsSampling(base) + or needsSampling(obj.radius) + or maxDistance is None + ): + continue + + currBase = base + currDist = pir_dist + currPos = position + + # Define a helper function to attempt to buffer an observer's visibleRegion, resulting + # in a region that contains all points that could feasibly be the position + # of obj, if it is visible from the observer. If possible buffer exactly, otherwise + # try to buffer approximately, and if that is also not feasible just return the viewRegion. + def bufferHelper(viewRegion): + buffer_quantity = obj.radius + maxDistance + if hasattr(viewRegion, "buffer"): + return viewRegion.buffer(buffer_quantity) + elif hasattr(viewRegion, "_bufferOverapproximate"): + if needsSampling(viewRegion): + return viewRegion._bufferOverapproximate(buffer_quantity, 1) + else: + current_pitch = PRUNING_PITCH + buffered_container = None + + while buffered_container is None: + buffered_container = viewRegion._bufferOverapproximate( + buffer_quantity, current_pitch + ) + + if isinstance(buffered_container, VoxelRegion): + buffered_container = buffered_container.mesh + + current_pitch = min(2 * current_pitch, 1) + + assert buffered_container is not None + + return buffered_container + else: + assert False + + # Prune based off visibility/non-visibility requirements + if obj.requireVisible and obj is not ego: + # We can restrict the base region to the buffered visible region + # of the ego. + if verbosity >= 1: + print( + f" Pruning restricted base region of {obj} to visible region of ego." + ) + candidateBase = currBase.intersect(bufferHelper(ego.visibleRegion)) + candidateDist = regions.Region.uniformPointIn(candidateBase) + + # Condition object to pruned position + if offset is not None: + candidatePos = candidateDist + offset + else: + candidatePos = candidateDist + + if not checkConditionedCycle(candidatePos, currPos): + currBase = candidateBase + currDist = candidateDist + if offset is not None: + currPos = currDist + offset + else: + currPos = currDist + + if obj._observingEntity: + # We can restrict the base region to the buffered visible region + # of the observing entity. + if verbosity >= 1: + print( + f" Pruning restricted base region of {obj} to visible region of {obj._observingEntity}." + ) + candidateBase = currBase.intersect( + bufferHelper(obj._observingEntity.visibleRegion) + ) + candidateDist = regions.Region.uniformPointIn(candidateBase) + + if not checkConditionedCycle(candidateDist, currDist): + currBase = candidateBase + currDist = candidateDist + if offset is not None: + currPos = currDist + offset + else: + currPos = currDist + + # Check currBase properties + if isinstance(currBase, EmptyRegion): + raise InvalidScenarioError( + f"Object {obj} can not satisfy visibility/non-visibility constraints." + ) + + percentage_pruned = percentagePruned(base, currBase) + + if percentage_pruned is None: + if verbosity >= 1: + print( + f" Visibility pruning attempted but could not compute percentage for {base} and {currBase}." + ) + else: + if percentage_pruned <= 0.001: + # We didn't really prune anything, skip conditioning + continue + + if verbosity >= 1: + print(f" Visibility pruning pruned {percentage_pruned:.1f}% of space.") + + # Condition position value to pruned position + obj.position.conditionTo(currPos) + + def maxDistanceBetween(scenario, obj, target): """Upper bound the distance between the given Objects.""" # visDist is initialized to infinity. Then we can use @@ -428,3 +612,51 @@ def relativeHeadingRange( tPoints.extend((math.pi, -math.pi)) rhs = [tp - p for tp in tPoints for p in points] # TODO improve return min(rhs), max(rhs) + + +def percentagePruned(base, newBase): + if needsSampling(base) or needsSampling(newBase): + return None + + if ( + base.dimensionality + and newBase.dimensionality + and base.dimensionality == newBase.dimensionality + ): + ratio = newBase.size / base.size + percent = max(0, 100 * (1.0 - ratio)) + return percent + + return None + + +def checkConditionedCycle(A, B): + """Returns true if A depends on B""" + if A is B: + return True + + deps = set() + unseen_deps = conditionedDeps(A) + + A = conditionedVal(A) + B = conditionedVal(B) + + while unseen_deps: + target_dep = unseen_deps.pop() + new_deps = conditionedDeps(target_dep) + + if target_dep is B or any(d is B for d in new_deps): + return True + + unseen_deps += [d for d in new_deps if d not in deps] + deps.update(new_deps) + + return False + + +def conditionedDeps(samp): + return [conditionedVal(s) for s in dependencies(conditionedVal(samp))] + + +def conditionedVal(samp): + return samp._conditioned if isinstance(samp, Samplable) else samp diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 10bc40383..07f4c58b7 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -21,10 +21,12 @@ import shapely.ops import trimesh from trimesh.transformations import ( - concatenate_matrices, + compose_matrix, + identity_matrix, quaternion_matrix, translation_matrix, ) +import trimesh.voxel warnings.filterwarnings( "ignore", module="trimesh" @@ -54,7 +56,7 @@ ) from scenic.core.lazy_eval import isLazy, valueInContext from scenic.core.type_support import toOrientation, toScalar, toVector -from scenic.core.utils import cached, cached_method, cached_property, loadMesh, unifyMesh +from scenic.core.utils import cached, cached_method, cached_property, unifyMesh from scenic.core.vectors import ( Orientation, OrientedVector, @@ -182,6 +184,14 @@ def difference(self, other) -> "Region": def sampleGiven(self, value): return self + def _trueContainsPoint(self, point) -> bool: + """Whether or not this region could produce point when sampled. + + By default this method calls `containsPoint`, but should be overwritten if + `containsPoint` does not properly represent the points that can be sampled. + """ + return self.containsPoint(point) + ## Generic Methods (not to be overriden by subclasses) ## @cached_method def containsRegion(self, reg, tolerance=0): @@ -207,9 +217,9 @@ def containsRegion(self, reg, tolerance=0): return self.containsRegionInner(reg, tolerance) @staticmethod - def uniformPointIn(region): + def uniformPointIn(region, tag=None): """Get a uniform `Distribution` over points in a `Region`.""" - return PointInRegionDistribution(region) + return PointInRegionDistribution(region, tag=tag) def __contains__(self, thing) -> bool: """Check if this `Region` contains an object or vector.""" @@ -243,9 +253,10 @@ def __repr__(self): class PointInRegionDistribution(VectorDistribution): """Uniform distribution over points in a Region""" - def __init__(self, region): + def __init__(self, region, tag=None): super().__init__(region) self.region = region + self.tag = tag def sampleGiven(self, value): return value[self.region].uniformPointInner() @@ -462,32 +473,41 @@ def uniformPointInner(self): @staticmethod def genericSampler(intersection): regs = intersection.regions + # Filter out all regions with known dimensionality greater than the minimum + known_dim_regions = [ + r.dimensionality for r in regs if r.dimensionality is not None + ] + min_dim = min(known_dim_regions) if known_dim_regions else float("inf") + sampling_regions = [ + r for r in regs if r.dimensionality is None or r.dimensionality <= min_dim + ] - # Get a candidate point from each region - points = [] - + # Try to sample a point from all sampling regions num_regs_undefined = 0 - for reg in regs: + for reg in sampling_regions: try: - points.append(reg.uniformPointInner()) + point = reg.uniformPointInner() except UndefinedSamplingException: num_regs_undefined += 1 - pass + continue + except RejectionException: + continue + + if all(region._trueContainsPoint(point) for region in regs): + return point - if num_regs_undefined == len(regs): + # No points were successfully sampled. + # If all regions were undefined for sampling, raise the appropriate exception. + # Otherwise, reject. + if num_regs_undefined == len(sampling_regions): # All regions do not support sampling, so the # intersection doesn't either. raise UndefinedSamplingException( - f"All regions in {regs}" + f"All regions in {sampling_regions}" " do not support sampling, so the intersection doesn't either." ) - # Check each point for containment each region. - for point in points: - if all(region.containsPoint(point) for region in regs): - return point - raise RejectionException(f"sampling intersection of Regions {regs}") def __repr__(self): @@ -565,7 +585,7 @@ def uniformPointInner(self): @staticmethod def genericSampler(union): - regs = intersection.regions + regs = union.regions # Check that all regions have well defined dimensionality if any(reg.dimensionality is None for reg in regs): @@ -589,7 +609,7 @@ def genericSampler(union): point = target_reg.uniformPointInner() # Potentially reject based on containment of the sample - containment_count = sum(int(reg.containsPoint(point)) for reg in regs) + containment_count = sum(int(reg._trueContainsPoint(point)) for reg in regs) if random.random() < 1 - 1 / containment_count: raise RejectionException("rejected sample from UnionRegion") @@ -668,7 +688,7 @@ def uniformPointInner(self): def genericSampler(difference): regionA, regionB = difference.regionA, difference.regionB point = regionA.uniformPointInner() - if regionB.containsPoint(point): + if regionB._trueContainsPoint(point): raise RejectionException( f"sampling difference of Regions {regionA} and {regionB}" ) @@ -767,7 +787,6 @@ class MeshRegion(Region): tolerance: Tolerance for internal computations. centerMesh: Whether or not to center the mesh after copying and before transformations. onDirection: The direction to use if an object being placed on this region doesn't specify one. - engine: Which engine to use for mesh operations. Either "blender" or "scad". additionalDeps: Any additional sampling dependencies this region relies on. """ @@ -781,7 +800,6 @@ def __init__( tolerance=1e-6, centerMesh=True, onDirection=None, - engine="scad", name=None, additionalDeps=[], ): @@ -794,7 +812,6 @@ def __init__( self.tolerance = tolerance self.centerMesh = centerMesh self.onDirection = onDirection - self.engine = engine # Initialize superclass with samplables super().__init__( @@ -812,7 +829,7 @@ def __init__( return # Convert extract mesh - if isinstance(mesh, trimesh.primitives._Primitive): + if isinstance(mesh, trimesh.primitives.Primitive): self._mesh = mesh.to_mesh() elif isinstance(mesh, trimesh.base.Trimesh): self._mesh = mesh.copy() @@ -825,33 +842,22 @@ def __init__( if centerMesh: self.mesh.vertices -= self.mesh.bounding_box.center_mass - # If dimensions are provided, scale mesh to those dimension + # Apply scaling, rotation, and translation, if any if self.dimensions is not None: - scale = self.mesh.extents / numpy.array(self.dimensions) - - scale_matrix = numpy.eye(4) - scale_matrix[:3, :3] /= scale - - self.mesh.apply_transform(scale_matrix) - - # If rotation is provided, apply rotation + scale = numpy.array(self.dimensions) / self.mesh.extents + else: + scale = None if self.rotation is not None: - rotation_matrix = quaternion_matrix( - (self.rotation.w, self.rotation.x, self.rotation.y, self.rotation.z) - ) - self.mesh.apply_transform(rotation_matrix) - - # If position is provided, translate mesh. - if self.position is not None: - position_matrix = translation_matrix(self.position) - self.mesh.apply_transform(position_matrix) + angles = self.rotation._trimeshEulerAngles() + else: + angles = None + matrix = compose_matrix(scale=scale, angles=angles, translate=self.position) + self.mesh.apply_transform(matrix) self.orientation = orientation @classmethod - def fromFile( - cls, path, filetype=None, compressed=None, binary=False, unify=True, **kwargs - ): + def fromFile(cls, path, unify=True, **kwargs): """Load a mesh region from a file, attempting to infer filetype and compression. For example: "foo.obj.bz2" is assumed to be a compressed .obj file. @@ -868,7 +874,7 @@ def fromFile( unify (bool): Whether or not to attempt to unify this mesh. kwargs: Additional arguments to the MeshRegion initializer. """ - mesh = loadMesh(path, filetype, compressed, binary) + mesh = trimesh.load(path, force="mesh") if unify and issubclass(cls, MeshVolumeRegion): mesh = unifyMesh(mesh, verbose=True) @@ -889,11 +895,14 @@ def sampleGiven(self, value): dimensions=value[self.dimensions], position=value[self.position], rotation=value[self.rotation], - orientation=value[self.orientation], + orientation=( + True + if self.__dict__.get("_usingDefaultOrientation", False) + else value[self.orientation] + ), tolerance=self.tolerance, centerMesh=self.centerMesh, onDirection=self.onDirection, - engine=self.engine, name=self.name, ) @@ -909,7 +918,11 @@ def evaluateInner(self, context): dimensions = valueInContext(self.dimensions, context) position = valueInContext(self.position, context) rotation = valueInContext(self.rotation, context) - orientation = valueInContext(self.orientation, context) + orientation = ( + True + if self.__dict__.get("_usingDefaultOrientation", False) + else valueInContext(self.orientation, context) + ) return cls( mesh, @@ -920,7 +933,6 @@ def evaluateInner(self, context): tolerance=self.tolerance, centerMesh=self.centerMesh, onDirection=self.onDirection, - engine=self.engine, name=self.name, ) @@ -989,16 +1001,20 @@ def AABB(self): return ( tuple(self.mesh.bounds[0]), tuple(self.mesh.bounds[1]), - tuple(self.mesh.bounds[2]), ) + @cached_property + def _boundingPolygonHull(self): + assert not isLazy(self) + return shapely.multipoints(self.mesh.vertices).convex_hull + @cached_property def _boundingPolygon(self): assert not isLazy(self) # Relatively fast case for convex regions if self.isConvex: - return shapely.geometry.MultiPoint(self.mesh.vertices).convex_hull + return self._boundingPolygonHull # Generic case for arbitrary shapes if self.mesh.is_watertight: @@ -1057,11 +1073,11 @@ class MeshVolumeRegion(MeshRegion): tolerance: Tolerance for internal computations. centerMesh: Whether or not to center the mesh after copying and before transformations. onDirection: The direction to use if an object being placed on this region doesn't specify one. - engine: Which engine to use for mesh operations. Either "blender" or "scad". """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, _internal=False, _isConvex=None, **kwargs): super().__init__(*args, **kwargs) + self._isConvex = _isConvex if isLazy(self): return @@ -1072,8 +1088,8 @@ def __init__(self, *args, **kwargs): if dim <= 0: raise ValueError(f"{name} of MeshVolumeRegion must be positive") - # Ensure the mesh is watertight so volume is well defined - if not self._mesh.is_volume: + # Ensure the mesh is a well defined volume + if not _internal and not self._mesh.is_volume: raise ValueError( "A MeshVolumeRegion cannot be defined with a mesh that does not have a well defined volume." " Consider using scenic.core.utils.repairMesh." @@ -1106,11 +1122,13 @@ def intersects(self, other, triedReversed=False): # Check if bounding boxes intersect. If not, volumes cannot intersect. # For bounding boxes to intersect there must be overlap of the bounds # in all 3 dimensions. - range_overlaps = [ - (self.mesh.bounds[0, dim] <= other.mesh.bounds[1, dim]) - and (other.mesh.bounds[0, dim] <= self.mesh.bounds[1, dim]) + bounds = self._mesh.bounds + obounds = other._mesh.bounds + range_overlaps = ( + (bounds[0, dim] <= obounds[1, dim]) + and (obounds[0, dim] <= bounds[1, dim]) for dim in range(3) - ] + ) bb_overlap = all(range_overlaps) if not bb_overlap: @@ -1418,12 +1436,7 @@ def intersect(self, other, triedReversed=False): other_mesh = other.mesh # Compute intersection using Trimesh - try: - new_mesh = self.mesh.intersection(other_mesh, engine=self.engine) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.intersection(other_mesh) if new_mesh.is_empty: return nowhere @@ -1432,7 +1445,6 @@ def intersect(self, other, triedReversed=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1629,12 +1641,7 @@ def union(self, other, triedReversed=False): other_mesh = other.mesh # Compute union using Trimesh - try: - new_mesh = self.mesh.union(other_mesh, engine=self.engine) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.union(other_mesh) if new_mesh.is_empty: return nowhere @@ -1643,7 +1650,6 @@ def union(self, other, triedReversed=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1669,14 +1675,7 @@ def difference(self, other, debug=False): other_mesh = other.mesh # Compute difference using Trimesh - try: - new_mesh = self.mesh.difference( - other_mesh, engine=self.engine, debug=debug - ) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.difference(other_mesh) if new_mesh.is_empty: return nowhere @@ -1685,7 +1684,6 @@ def difference(self, other, debug=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1735,17 +1733,22 @@ def distanceTo(self, point): return abs(dist) @cached_property + @distributionFunction def inradius(self): center_point = self.mesh.bounding_box.center_mass pq = trimesh.proximity.ProximityQuery(self.mesh) - region_distance = abs(pq.signed_distance([center_point])[0]) + region_distance = pq.signed_distance([center_point])[0] if region_distance < 0: return 0 else: return region_distance + @cached_property + def isConvex(self): + return self.mesh.is_convex if self._isConvex is None else self._isConvex + @property def dimensionality(self): return 3 @@ -1755,17 +1758,78 @@ def size(self): return self.mesh.mass / self.mesh.density ## Utility Methods ## + def voxelized(self, pitch, lazy=False): + """Returns a VoxelRegion representing a filled voxelization of this mesh""" + return VoxelRegion(voxelGrid=self.mesh.voxelized(pitch).fill(), lazy=lazy) + + @distributionFunction + def _erodeOverapproximate(self, maxErosion, pitch): + """Compute an overapproximation of this region eroded. + + Erode as much as possible, but no more than maxErosion, outputting + a VoxelRegion. Note that this can sometimes return a larger region + than the original mesh + """ + # Compute a voxel overapproximation of the mesh. Technically this is not + # an overapproximation, but one dilation with a rank 3 structuring unit + # with connectivity 3 is. To simplify, we just erode one fewer time than + # needed. + target_pitch = pitch * max(self.mesh.extents) + voxelized_mesh = self.voxelized(target_pitch, lazy=True) + + # Erode the voxel region. Erosion is done with a rank 3 structuring unit with + # connectivity 3 (a 3x3x3 cube of voxels). Each erosion pass can erode by at + # most math.hypot([pitch]*3). Therefore we can safely make at most + # floor(maxErosion/math.hypot([pitch]*3)) passes without eroding more + # than maxErosion. We also subtract 1 iteration for the reasons above. + iterations = math.floor(maxErosion / math.hypot(*([target_pitch] * 3))) - 1 + + eroded_mesh = voxelized_mesh.dilation(iterations=-iterations) + + return eroded_mesh + + @distributionFunction + def _bufferOverapproximate(self, minBuffer, pitch): + """Compute an overapproximation of this region buffered. + + Buffer as little as possible, but at least minBuffer. If pitch is + less than 1, the output is a VoxelRegion. If pitch is 1, a fast + path is taken which returns a BoxRegion. + """ + if pitch >= 1: + # First extract the bounding box of the mesh, and then extend each dimension + # by minBuffer. + bounds = self.mesh.bounds + midpoint = numpy.mean(bounds, axis=0) + extents = numpy.diff(bounds, axis=0)[0] + 2 * minBuffer + return BoxRegion(position=toVector(midpoint), dimensions=list(extents)) + else: + # Compute a voxel overapproximation of the mesh. Technically this is not + # an overapproximation, but one dilation with a rank 3 structuring unit + # with connectivity 3 is. To simplify, we just dilate one additional time + # than needed. + target_pitch = pitch * max(self.mesh.extents) + voxelized_mesh = self.voxelized(target_pitch, lazy=True) + + # Dilate the voxel region. Dilation is done with a rank 3 structuring unit with + # connectivity 3 (a 3x3x3 cube of voxels). Each dilation pass must dilate by at + # least pitch. Therefore we must make at least ceil(minBuffer/pitch) passes to + # guarantee dilating at least minBuffer. We also add 1 iteration for the reasons above. + iterations = math.ceil(minBuffer / pitch) + 1 + + dilated_mesh = voxelized_mesh.dilation(iterations=iterations) + + return dilated_mesh + @cached_method def getSurfaceRegion(self): """Return a region equivalent to this one, except as a MeshSurfaceRegion""" return MeshSurfaceRegion( self.mesh, self.name, - orientation=self.orientation, tolerance=self.tolerance, centerMesh=False, onDirection=self.onDirection, - engine=self.engine, ) def getVolumeRegion(self): @@ -1801,8 +1865,16 @@ class MeshSurfaceRegion(MeshRegion): onDirection: The direction to use if an object being placed on this region doesn't specify one. """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, orientation=True, **kwargs): + if orientation is True: + orientation = VectorField( + "DefaultSurfaceVectorField", self.getFlatOrientation + ) + self._usingDefaultOrientation = True + else: + self._usingDefaultOrientation = False + + super().__init__(*args, orientation=orientation, **kwargs) # Validate dimensions if self.dimensions is not None: @@ -1810,12 +1882,6 @@ def __init__(self, *args, **kwargs): if dim < 0: raise ValueError(f"{name} of MeshSurfaceRegion must be nonnegative") - # Set default orientation to one inferred from face norms if none is provided. - if self.orientation is None: - self.orientation = VectorField( - "DefaultSurfaceVectorField", lambda pos: self.getFlatOrientation(pos) - ) - # Property testing methods # @distributionFunction def intersects(self, other, triedReversed=False): @@ -1953,11 +2019,9 @@ def getVolumeRegion(self): return MeshVolumeRegion( self.mesh, self.name, - orientation=self.orientation, tolerance=self.tolerance, centerMesh=False, onDirection=self.onDirection, - engine=self.engine, ) def getSurfaceRegion(self): @@ -1989,7 +2053,6 @@ def sampleGiven(self, value): rotation=value[self.rotation], orientation=value[self.orientation], tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2005,7 +2068,6 @@ def evaluateInner(self, context): rotation=rotation, orientation=orientation, tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2034,7 +2096,6 @@ def sampleGiven(self, value): rotation=value[self.rotation], orientation=value[self.orientation], tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2050,11 +2111,214 @@ def evaluateInner(self, context): rotation=rotation, orientation=orientation, tolerance=self.tolerance, - engine=self.engine, name=self.name, ) +class VoxelRegion(Region): + """(WIP) Region represented by a voxel grid in 3D space. + + NOTE: This region is a work in progress and is currently only recommended for internal use. + + Args: + voxelGrid: The Trimesh voxelGrid to be used. + orientation: An optional vector field describing the preferred orientation at every point in + the region. + name: An optional name to help with debugging. + lazy: Whether or not to be lazy about pre-computing internal values. Set this to True if this + VoxelRegion is unlikely to be used outside of an intermediate step in compiling/pruning. + """ + + def __init__(self, voxelGrid, orientation=None, name=None, lazy=False): + # Initialize superclass + super().__init__(name, orientation=orientation) + + # Check that the encoding isn't empty. In that case, raise an error. + if voxelGrid.encoding.is_empty: + raise ValueError("Tried to create an empty VoxelRegion.") + + # Store voxel grid and extract points and scale + self.voxelGrid = voxelGrid + self.voxel_points = self.voxelGrid.points + self.scale = self.voxelGrid.scale + + @cached_property + def kdTree(self): + return scipy.spatial.KDTree(self.voxel_points) + + def containsPoint(self, point): + point = toVector(point) + + # Find closest voxel point + _, index = self.kdTree.query(point) + closest_point = self.voxel_points[index] + + # Check voxel containment + voxel_low = closest_point - self.scale / 2 + voxel_high = closest_point + self.scale / 2 + + return numpy.all(voxel_low <= point) & numpy.all(point <= voxel_high) + + def containsObject(self, obj): + raise NotImplementedError + + def containsRegionInner(self, reg): + raise NotImplementedError + + def distanceTo(self, point): + raise NotImplementedError + + def projectVector(self, point, onDirection): + raise NotImplementedError + + def uniformPointInner(self): + # First generate a point uniformly in a box with dimensions + # equal to scale, centered at the origin. + base_pt = numpy.random.random_sample(3) - 0.5 + scaled_pt = base_pt * self.scale + + # Pick a random voxel point and add it to base_pt. + voxel_base = self.voxel_points[random.randrange(len(self.voxel_points))] + offset_pt = voxel_base + scaled_pt + + return Vector(*offset_pt) + + def dilation(self, iterations, structure=None): + """Returns a dilated/eroded version of this VoxelRegion. + + Args: + iterations: How many times repeat the dilation/erosion. A positive + number indicates a dilation and a negative number indicates an + erosion. + structure: The structure to use. If none is provided, a rank 3 + structuring unit with connectivity 3 is used. + """ + # Parse parameters + if iterations == 0: + return self + + if iterations > 0: + morphology_func = scipy.ndimage.binary_dilation + else: + morphology_func = scipy.ndimage.binary_erosion + + iterations = abs(iterations) + + if structure == None: + structure = scipy.ndimage.generate_binary_structure(3, 3) + + # Compute a dilated/eroded encoding + new_encoding = trimesh.voxel.encoding.DenseEncoding( + morphology_func( + trimesh.voxel.morphology._dense(self.voxelGrid.encoding, rank=3), + structure=structure, + iterations=iterations, + ) + ) + + # Check if the encoding is empty, in which case we should return the empty region. + if new_encoding.is_empty: + return nowhere + + # Otherwise, return a VoxelRegion representing the eroded region. + new_voxel_grid = trimesh.voxel.VoxelGrid( + new_encoding, transform=self.voxelGrid.transform + ) + return VoxelRegion(voxelGrid=new_voxel_grid) + + @cached_property + def mesh(self): + """(WIP) Return a MeshVolumeRegion representation of this region. + + NOTE: This region is a WIP and will sometimes return None if the transformation + is not feasible. + """ + # Extract values for original voxel grid and the surface of the voxel grid. + dense_encoding = self.voxelGrid.encoding.dense + hpitch = self.voxelGrid.pitch[0] / 2 + hollow_vr = trimesh.voxel.VoxelGrid( + trimesh.voxel.morphology.surface(self.voxelGrid.encoding), + transform=self.voxelGrid.transform, + ) + + surface_indices = numpy.argwhere(hollow_vr.encoding.dense == True) + surface_centers = hollow_vr.indices_to_points(hollow_vr.sparse_indices) + + # Determine which faces should be added for each voxel in our extracted surface. + point_face_mask_list = [] + + def index_in_bounds(index): + return all((0, 0, 0) <= index) and all(index < dense_encoding.shape) + + def actual_face(index): + return not index_in_bounds(index) or not dense_encoding[tuple(index)] + + offsets = ( + [1, 0, 0], + [-1, 0, 0], + [0, 1, 0], + [0, -1, 0], + [0, 0, 1], + [0, 0, -1], + ) + + # fmt: off + pitch_signs = [ + ([[1, 1, -1], [1, 1, 1], [1, -1, 1]], + [[1, 1, -1], [1, -1, 1], [1, -1, -1]]), + ([[-1, 1, -1], [-1, -1, 1], [-1, 1, 1]], + [[-1, 1, -1], [-1, -1, -1], [-1, -1, 1]]), + ([[1, 1, -1], [-1, 1, 1], [1, 1, 1]], + [[1, 1, -1], [-1, 1, -1], [-1, 1, 1]]), + ([[1, -1, -1], [1, -1, 1], [-1, -1, 1]], + [[1, -1, -1], [-1, -1, 1], [-1, -1, -1]]), + ([[1, -1, 1], [1, 1, 1], [-1, 1, 1]], + [[1, -1, 1], [-1, 1, 1], [-1, -1, 1]]), + ([[1, -1, -1], [-1, 1, -1], [1, 1, -1]], + [[1, -1, -1], [-1, -1, -1], [-1, 1, -1]]), + ] + # fmt: on + + triangles = [] + + for i in range(len(surface_indices)): + base_index = surface_indices[i] + base_center = surface_centers[i] + + for i, offset in enumerate(offsets): + if actual_face(base_index + offset): + for t_num in range(2): + triangles.append( + [ + base_center + hpitch * numpy.array(signs) + for signs in pitch_signs[i][t_num] + ] + ) + + out_mesh = trimesh.Trimesh(**trimesh.triangles.to_kwargs(triangles)) + + # TODO: Ensure the mesh is a proper volume + if not out_mesh.is_volume: + return None + else: + return MeshVolumeRegion(out_mesh, centerMesh=False) + + @property + def AABB(self): + return ( + tuple(self.voxelGrid.bounds[0]), + tuple(self.voxelGrid.bounds[1]), + ) + + @property + def size(self): + return self.voxelGrid.volume + + @property + def dimensionality(self): + return 3 + + class PolygonalFootprintRegion(Region): """Region that contains all points in a polygonal footprint, regardless of their z value. @@ -2102,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) @@ -2161,7 +2425,18 @@ def containsObject(self, obj): Args: obj: An object to be checked for containment. """ - # Check containment using the bounding polygon of the object. + # Fast path for convex objects, whose bounding polygons are relatively + # easy to compute. + if obj._isConvex: + return self.polygons.contains(obj._boundingPolygon) + + # Quick check using the projected convex hull of the object, which + # overapproximates the actual bounding polygon. + hullPoly = obj.occupiedSpace._boundingPolygonHull + if self.polygons.contains(hullPoly): + return True + + # Need to compute exact bounding polygon. return self.polygons.contains(obj._boundingPolygon) def containsRegionInner(self, reg, tolerance): @@ -2298,11 +2573,22 @@ class PathRegion(Region): Args: points: A list of points defining a single polyline. polylines: A list of list of points, defining multiple polylines. + orientation (optional): :term:`preferred orientation` to use, or `True` to use an + orientation aligned with the direction of the path (the default). tolerance: Tolerance used internally. """ - def __init__(self, points=None, polylines=None, tolerance=1e-8, name=None): - super().__init__(name) + def __init__( + self, points=None, polylines=None, tolerance=1e-8, orientation=True, name=None + ): + if orientation is True: + orientation = VectorField("Path", self.defaultOrientation) + self._usingDefaultOrientation = True + else: + self._usingDefaultOrientation = False + + super().__init__(name, orientation=orientation) + # Standardize inputs if points is not None and polylines is not None: raise ValueError("Both points and polylines passed to PathRegion initializer") @@ -2374,6 +2660,16 @@ def containsRegionInner(self, reg, tolerance): raise NotImplementedError def distanceTo(self, point): + return self._segmentDistanceHelper(point).min() + + def nearestSegmentTo(self, point): + nearest_segment = self._edgeVectorArray[ + self._segmentDistanceHelper(point).argmin() + ] + return toVector(nearest_segment[0:3]), toVector(nearest_segment[3:6]) + + def _segmentDistanceHelper(self, point): + """Returns distance to point from each line segment""" p = numpy.asarray(toVector(point)) a = self._edgeVectorArray[:, 0:3] b = self._edgeVectorArray[:, 3:6] @@ -2391,15 +2687,20 @@ def distanceTo(self, point): ) perp_dist = numpy.linalg.norm(numpy.cross(a_min_p, d), axis=1) - return numpy.hypot(parallel_dist, perp_dist).min() + return numpy.hypot(parallel_dist, perp_dist) + + def defaultOrientation(self, point): + start, end = self.nearestSegmentTo(point) + return Orientation.fromEuler(start.azimuthTo(end), start.altitudeTo(end), 0) def projectVector(self, point, onDirection): raise NotImplementedError @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): @@ -2446,7 +2747,7 @@ class PolygonalRegion(Region): def __init__( self, - points=None, + points=(), polygon=None, z=0, orientation=None, @@ -2457,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 @@ -2472,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): @@ -2489,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) @@ -2533,6 +2826,10 @@ def footprint(self): def containsPoint(self, point): return self.footprint.containsPoint(point) + @distributionFunction + def _trueContainsPoint(self, point): + return point.z == self.z and self.containsPoint(point) + @distributionFunction def containsObject(self, obj): return self.footprint.containsObject(obj) @@ -2643,10 +2940,14 @@ def difference(self, other): @distributionFunction def unionAll(regions, buf=0): regions = tuple(regions) - if not all([r.z == regions[0].z for r in regions]): - raise ValueError( - "union of PolygonalRegions with different z values is undefined." - ) + z = None + for reg in regions: + if z is not None and isinstance(reg, PolygonalRegion) and reg.z != z: + raise ValueError( + "union of PolygonalRegions with different z values is undefined." + ) + if isinstance(reg, PolygonalRegion) and z is None: + z = reg.z regs, polys = [], [] for reg in regions: @@ -2659,7 +2960,18 @@ def unionAll(regions, buf=0): raise TypeError(f"cannot take union of regions {regions}") union = polygonUnion(polys, buf=buf) orientation = VectorField.forUnionOf(regs, tolerance=buf) - return PolygonalRegion(polygon=union, orientation=orientation) + 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 @@ -2680,6 +2992,19 @@ def distanceTo(self, point): dist2D = shapely.distance(self.polygons, makeShapelyPoint(point)) return math.hypot(dist2D, point[2] - self.z) + @cached_property + @distributionFunction + def inradius(self): + minx, miny, maxx, maxy = self.polygons.bounds + center = makeShapelyPoint(((minx + maxx) / 2, (maxy + miny) / 2)) + + # Check if center is contained + if not self.polygons.contains(center): + return 0 + + # Return the distance to the nearest boundary + return shapely.distance(self.polygons.boundary, center) + def projectVector(self, point, onDirection): raise NotImplementedError( f'{type(self).__name__} does not yet support projection using "on"' @@ -2688,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): @@ -2725,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): @@ -2807,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})" @@ -2987,7 +3319,7 @@ def uniformPointInner(self): hw, hl = self.hw, self.hl rx = random.uniform(-hw, hw) ry = random.uniform(-hl, hl) - pt = self.position.offsetRotated(self.heading, Vector(rx, ry, self.position.z)) + pt = self.position.offsetRotated(self.heading, Vector(rx, ry, 0)) return self.orient(pt) @property @@ -2995,7 +3327,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 ( @@ -3023,9 +3355,9 @@ class PolylineRegion(Region): def __init__(self, points=None, polyline=None, orientation=True, name=None): if orientation is True: orientation = VectorField("Polyline", self.defaultOrientation) - self.usingDefaultOrientation = True + self._usingDefaultOrientation = True else: - self.usingDefaultOrientation = False + self._usingDefaultOrientation = False super().__init__(name, orientation=orientation) if points is not None: @@ -3115,7 +3447,7 @@ def start(self): there is one (the default orientation pointing along the polyline). """ pointA, pointB = self.segments[0] - if self.usingDefaultOrientation: + if self._usingDefaultOrientation: orientation = headingOfSegment(pointA, pointB) elif self.orientation is not None: orientation = self.orientation[Vector(*pointA)] @@ -3139,7 +3471,7 @@ def end(self): there is one (the default orientation pointing along the polyline). """ pointA, pointB = self.segments[-1] - if self.usingDefaultOrientation: + if self._usingDefaultOrientation: orientation = headingOfSegment(pointA, pointB) elif self.orientation is not None: orientation = self.orientation[Vector(*pointB)].yaw @@ -3167,7 +3499,7 @@ def uniformPointInner(self): )[0] interpolation = random.random() x, y = averageVectors(pointA, pointB, weight=interpolation) - if self.usingDefaultOrientation: + if self._usingDefaultOrientation: return OrientedVector(x, y, 0, headingOfSegment(pointA, pointB)) else: return self.orient(Vector(x, y, 0)) @@ -3306,7 +3638,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) @@ -3410,6 +3742,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 @@ -3460,8 +3796,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): @@ -3591,13 +3928,10 @@ class ViewRegion(MeshVolumeRegion): * Case 1: viewAngles[1] = 180 degrees - * Case 2.a viewAngles[0] = 360 degrees => Sphere - * Case 2.b viewAngles[0] < 360 degrees => Sphere & CylinderSectionRegion - - * Case 2: viewAngles[1] < 180 degrees + * Case 1.a viewAngles[0] = 360 degrees => Sphere + * Case 1.b viewAngles[0] < 360 degrees => Sphere & CylinderSectionRegion - * Case 2.a viewAngles[0] = 360 degrees => Sphere - (Cone + Cone) (Cones on z axis expanding from origin) - * Case 2.b viewAngles[0] < 360 degrees => Sphere & ViewSectionRegion + * Case 2: viewAngles[1] < 180 degrees => Sphere & ViewSectionRegion When making changes to this class you should run ``pytest -k test_viewRegion --exhaustive``. @@ -3607,8 +3941,6 @@ class ViewRegion(MeshVolumeRegion): name: An optional name to help with debugging. position: An optional position, which determines where the center of the region will be. rotation: An optional Orientation object which determines the rotation of the object in space. - orientation: An optional vector field describing the preferred orientation at every point in - the region. angleCutoff: How close to 180/360 degrees an angle has to be to be mapped to that value. tolerance: Tolerance for collision computations. """ @@ -3620,61 +3952,45 @@ def __init__( name=None, position=Vector(0, 0, 0), rotation=None, - orientation=None, - angleCutoff=0.01, + angleCutoff=0.017, tolerance=1e-8, ): # Bound viewAngles from either side. if min(viewAngles) <= 0: raise ValueError("viewAngles cannot have a component less than or equal to 0") + # TODO True surface representation + viewAngles = (max(viewAngles[0], angleCutoff), max(viewAngles[1], angleCutoff)) + + if math.tau - angleCutoff <= viewAngles[0]: + viewAngles = (math.tau, viewAngles[1]) + + if math.pi - angleCutoff <= viewAngles[1]: + viewAngles = (viewAngles[0], math.pi) + view_region = None diameter = 2 * visibleDistance - base_sphere = SpheroidRegion( - dimensions=(diameter, diameter, diameter), engine="scad" - ) + base_sphere = SpheroidRegion(dimensions=(diameter, diameter, diameter)) if math.pi - angleCutoff <= viewAngles[1]: # Case 1 - if math.tau - angleCutoff <= viewAngles[0]: + if viewAngles[0] == math.tau: # Case 1.a view_region = base_sphere else: + # Case 1.b view_region = base_sphere.intersect( CylinderSectionRegion(visibleDistance, viewAngles[0]) ) else: # Case 2 - if math.tau - angleCutoff <= viewAngles[0]: - # Case 2.a - # Create cone with yaw oriented around (0,0,-1) - padded_height = visibleDistance * 2 - radius = padded_height * math.tan((math.pi - viewAngles[1]) / 2) - - cone_mesh = trimesh.creation.cone(radius=radius, height=padded_height) - - position_matrix = translation_matrix((0, 0, -1 * padded_height)) - cone_mesh.apply_transform(position_matrix) - - # Create two cones around the yaw axis - orientation_1 = Orientation._fromEuler(0, 0, 0) - orientation_2 = Orientation._fromEuler(0, 0, math.pi) - - cone_1 = MeshVolumeRegion( - mesh=cone_mesh, rotation=orientation_1, centerMesh=False - ) - cone_2 = MeshVolumeRegion( - mesh=cone_mesh, rotation=orientation_2, centerMesh=False - ) - - view_region = base_sphere.difference(cone_1).difference(cone_2) - else: - # Case 2.b - view_region = base_sphere.intersect( - ViewSectionRegion(visibleDistance, viewAngles) - ) + view_region = base_sphere.intersect( + ViewSectionRegion(visibleDistance, viewAngles) + ) assert view_region is not None + assert isinstance(view_region, MeshVolumeRegion) + assert view_region.containsPoint(Vector(0, 0, 0)) # Initialize volume region super().__init__( @@ -3682,7 +3998,6 @@ def __init__( name=name, position=position, rotation=rotation, - orientation=orientation, tolerance=tolerance, centerMesh=False, ) @@ -3712,8 +4027,9 @@ def __init__(self, visibleDistance, viewAngles, rotation=None, resolution=32): triangles.append((bot_line[li], bot_line[li + 1], top_line[li + 1])) # Side triangles - triangles.append((bot_line[0], top_line[0], (0, 0, 0))) - triangles.append((top_line[-1], bot_line[-1], (0, 0, 0))) + if viewAngles[0] < math.tau: + triangles.append((bot_line[0], top_line[0], (0, 0, 0))) + triangles.append((top_line[-1], bot_line[-1], (0, 0, 0))) # Top/Bottom triangles for li in range(len(top_line) - 1): diff --git a/src/scenic/core/requirements.py b/src/scenic/core/requirements.py index 7ea177d21..f143ae552 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 @@ -71,6 +71,10 @@ def compile(self, namespace, scenario, syntax=None): bindings, ego, line = self.bindings, self.egoObject, self.line condition, ty = self.condition, self.ty + # Convert bound values to distributions as needed + for name, value in bindings.items(): + bindings[name] = toDistribution(value) + # Check whether requirement implies any relations used for pruning canPrune = condition.check_constrains_sampling() if canPrune: diff --git a/src/scenic/core/scenarios.py b/src/scenic/core/scenarios.py index b900b0bc5..133911013 100644 --- a/src/scenic/core/scenarios.py +++ b/src/scenic/core/scenarios.py @@ -18,11 +18,16 @@ distributionFunction, needsSampling, ) -from scenic.core.dynamics import Behavior, Monitor +from scenic.core.dynamics.behaviors import Behavior, Monitor from scenic.core.errors import InvalidScenarioError, optionallyDebugRejection from scenic.core.external_params import ExternalSampler from scenic.core.lazy_eval import needsLazyEvaluation -from scenic.core.regions import AllRegion, EmptyRegion, convertToFootprint +from scenic.core.regions import ( + AllRegion, + EmptyRegion, + PointInRegionDistribution, + convertToFootprint, +) from scenic.core.requirements import ( BlanketCollisionRequirement, BoundRequirement, @@ -311,8 +316,6 @@ def __init__( self._instances + paramDeps + tuple(requirementDeps) + tuple(behaviorDeps) ) - self.validate() - # Setup the default checker self.defaultRequirements = self.generateDefaultRequirements() self.setSampleChecker(WeightedAcceptanceChecker(bufferSize=100)) @@ -342,6 +345,19 @@ def validate(self): # Trivial case where container is empty if isinstance(container, EmptyRegion): raise InvalidScenarioError(f"Container region of {oi} is empty") + # Ensure we are not sampling position from AllRegion + if isinstance( + oi.position._conditioned, PointInRegionDistribution + ) and isinstance(oi.position._conditioned.region, AllRegion): + if oi.position.tag == "visible": + raise InvalidScenarioError( + f"Object {oi} uses the visible specifier to specify position, but it lacks enough information to do so." + f" The simplest solution to this is to define a workspace or specify position in some other fashion." + ) + else: + raise InvalidScenarioError( + f"Object {oi} has position sampled from everywhere." + ) # skip objects with unknown positions or bounding boxes if not staticBounds[i]: continue @@ -517,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/shapes.py b/src/scenic/core/shapes.py index 14dbed16e..2a57c1f37 100644 --- a/src/scenic/core/shapes.py +++ b/src/scenic/core/shapes.py @@ -11,7 +11,7 @@ ) from scenic.core.type_support import toOrientation -from scenic.core.utils import cached_property, loadMesh, unifyMesh +from scenic.core.utils import cached_property, unifyMesh from scenic.core.vectors import Orientation ################################################################################################### @@ -122,9 +122,7 @@ def __init__(self, mesh, dimensions=None, scale=1, initial_rotation=None): super().__init__(dimensions, scale) @classmethod - def fromFile( - cls, path, filetype=None, compressed=None, binary=False, unify=True, **kwargs - ): + def fromFile(cls, path, unify=True, **kwargs): """Load a mesh shape from a file, attempting to infer filetype and compression. For example: "foo.obj.bz2" is assumed to be a compressed .obj file. @@ -141,7 +139,12 @@ def fromFile( unify (bool): Whether or not to attempt to unify this mesh. kwargs: Additional arguments to the MeshShape initializer. """ - mesh = loadMesh(path, filetype, compressed, binary) + mesh = trimesh.load(path, force="mesh") + if not mesh.is_volume: + raise ValueError( + "A MeshShape cannot be defined with a mesh that does not have a well defined volume." + " Consider using scenic.core.utils.repairMesh." + ) if unify: mesh = unifyMesh(mesh, verbose=True) return cls(mesh, **kwargs) diff --git a/src/scenic/core/simulators.py b/src/scenic/core/simulators.py index 04e74efd1..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 @@ -19,7 +19,8 @@ import types from scenic.core.distributions import RejectionException -import scenic.core.dynamics as dynamics +from scenic.core.dynamics import GuardViolation, RejectSimulationException +from scenic.core.dynamics.actions import Action, _EndScenarioAction, _EndSimulationAction import scenic.core.errors as errors from scenic.core.errors import InvalidScenarioError, optionallyDebugRejection from scenic.core.object_types import ( @@ -54,12 +55,6 @@ class DivergenceError(Exception): pass -class RejectSimulationException(Exception): - """Exception indicating a requirement was violated at runtime.""" - - pass - - class Simulator(abc.ABC): """A simulator which can execute dynamic simulations from Scenic scenes. @@ -219,17 +214,13 @@ def _runSingleSimulation( verbosity=verbosity, **kwargs, ) - except ( - RejectSimulationException, - RejectionException, - dynamics.GuardViolation, - ) as e: + except (RejectSimulationException, RejectionException, GuardViolation) as e: if verbosity >= 2: print( f" Rejected simulation {name} at time step " - f"{simulation.currentTime} because: {e}" + f"{e.simulation.currentTime} because: {e}" ) - if raiseGuardViolations and isinstance(e, dynamics.GuardViolation): + if raiseGuardViolations and isinstance(e, GuardViolation): raise else: optionallyDebugRejection(e) @@ -303,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 @@ -340,7 +334,6 @@ def __init__( self.result = None self.scene = scene self.objects = [] - self.agents = [] self.trajectory = [] self.records = defaultdict(list) self.currentTime = 0 @@ -397,13 +390,17 @@ def __init__( self.records, ) self.result = result - return self + except (RejectSimulationException, RejectionException, GuardViolation) as e: + # This simulation will be thrown out, but attach it to the exception + # to aid in debugging. + e.simulation = self + raise finally: self.destroy() 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 @@ -446,17 +443,32 @@ 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() # Handle pseudo-actions marking the end of a simulation/scenario - if isinstance(actions, EndSimulationAction): + if isinstance(actions, _EndSimulationAction): return TerminationType.terminatedByBehavior, str(actions) - elif isinstance(actions, EndScenarioAction): + elif isinstance(actions, _EndScenarioAction): scenario = actions.scenario if scenario._isRunning: scenario._stop(actions) @@ -477,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) @@ -497,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) @@ -629,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: @@ -653,12 +668,13 @@ def updateObjects(self): subroutine `getProperties` below. """ for obj in self.objects: - # Get latest values of dynamic properties from simulation + # Get latest values of dynamic properties from simulation and assign them dynTypes = obj._simulatorProvidedProperties properties = set(dynTypes) values = self.getProperties(obj, properties) assert properties == set(values), properties ^ set(values) for prop, value in values.items(): + # Check new value has the expected type ty = dynTypes[prop] if ty is float and isinstance(value, numbers.Real): # Special case for scalars so that we don't penalize simulator interfaces @@ -677,6 +693,9 @@ def updateObjects(self): f"with type {actual} instead of expected {expected}" ) + # Assign the new value + setattr(obj, prop, value) + # If saving a replay with divergence-checking support, save all the new values; # if running a replay with such support, check for divergence. if self._replayOut and self._writeDivergenceData: @@ -702,13 +721,12 @@ def updateObjects(self): else: raise DivergenceError(msg) - # Preserve some other properties which are assigned internally by Scenic - for prop in self.mutableProperties(obj): - values[prop] = getattr(obj, prop) + # Recompute dynamic final properties + obj._recomputeDynamicFinals() - # Make a new copy of the object to ensure that computed properties like - # visibleRegion, etc. are recomputed - setDynamicProxyFor(obj, obj._copyWith(**values)) + # Clear caches to ensure that cached properties like visibleRegion, etc. + # are recomputed + obj._clearCaches() def valuesHaveDiverged(self, obj, prop, expected, actual): """Decide whether the value of a dynamic property has diverged from the replay. @@ -741,9 +759,6 @@ def valuesHaveDiverged(self, obj, prop, expected, actual): else: return actual != expected - def mutableProperties(self, obj): - return {"lastActions", "behavior"} - @abc.abstractmethod def getProperties(self, obj, properties): """Read the values of the given properties of the object from the simulator. @@ -849,68 +864,6 @@ def getProperties(self, obj, properties): return vals -class Action(abc.ABC): - """An :term:`action` which can be taken by an agent for one step of a simulation.""" - - def canBeTakenBy(self, agent): - """Whether this action is allowed to be taken by the given agent. - - The default implementation always returns True. - """ - return True - - @abc.abstractmethod - def applyTo(self, agent, simulation): - """Apply this action to the given agent in the given simulation. - - This method should call simulator APIs so that the agent will take this action - during the next simulated time step. Depending on the simulator API, it may be - necessary to batch each agent's actions into a single update: in that case you - can have this method set some state on the agent, then apply the actual update - in an overridden implementation of `Simulation.executeActions`. For examples, - see the CARLA interface: `scenic.simulators.carla.actions` has some CARLA-specific - actions which directly call CARLA APIs, while the generic steering and braking - actions from `scenic.domains.driving.actions` are implemented using the batching - approach (see for example the ``setThrottle`` method of the class - `scenic.simulators.carla.model.Vehicle`, which sets state later read by - ``CarlaSimulation.executeActions`` in `scenic.simulators.carla.simulator`). - """ - raise NotImplementedError - - -class EndSimulationAction(Action): - """Special action indicating it is time to end the simulation. - - Only for internal use. - """ - - def __init__(self, line): - self.line = line - - def __str__(self): - return f'"terminate simulation" executed on line {self.line}' - - def applyTo(self, agent, simulation): - assert False - - -class EndScenarioAction(Action): - """Special action indicating it is time to end the current scenario. - - Only for internal use. - """ - - def __init__(self, scenario, line): - self.scenario = scenario - self.line = line - - def __str__(self): - return f'"terminate" executed on line {self.line}' - - def applyTo(self, agent, simulation): - assert False - - @enum.unique class TerminationType(enum.Enum): """Enum describing the possible ways a simulation can end.""" diff --git a/src/scenic/core/specifiers.py b/src/scenic/core/specifiers.py index 1054c8e4c..e600f7ed3 100644 --- a/src/scenic/core/specifiers.py +++ b/src/scenic/core/specifiers.py @@ -102,6 +102,8 @@ def enabled(thing, default): self.isFinal = enabled("final", False) for attr in attributes: raise RuntimeError(f'unknown property attribute "{attr}"') + if self.isAdditive and self.isDynamic: + raise InvalidScenarioError("additive properties cannot be dynamic") @staticmethod def forValue(value): diff --git a/src/scenic/core/type_support.py b/src/scenic/core/type_support.py index c84741eeb..7b8c6cb81 100644 --- a/src/scenic/core/type_support.py +++ b/src/scenic/core/type_support.py @@ -154,12 +154,12 @@ def canCoerceType(typeA, typeB): return issubclass(typeA, typeB) -def canCoerce(thing, ty): +def canCoerce(thing, ty, exact=False): """Can this value be coerced into the given type?""" tt = underlyingType(thing) if canCoerceType(tt, ty): return True - elif isinstance(thing, Distribution): + elif (not exact) and isinstance(thing, Distribution): return True # fall back on type-checking at runtime else: return False @@ -259,6 +259,8 @@ class TypecheckedDistribution(Distribution): `Point` will be converted to a `Vector` in a context which expects the latter). """ + _deterministic = True + def __init__(self, dist, ty, errorMessage, coercer=None): super().__init__(dist, valueType=ty) self._dist = dist diff --git a/src/scenic/core/utils.py b/src/scenic/core/utils.py index 04b45d1b5..4549afdad 100644 --- a/src/scenic/core/utils.py +++ b/src/scenic/core/utils.py @@ -45,6 +45,14 @@ def wrapper(self): setattr(self, storageName, value) return value + def clearer(self): + try: + delattr(self, storageName) + except AttributeError: + pass + + wrapper._scenic_cache_clearer = clearer + return wrapper @@ -69,6 +77,14 @@ def wrapper(self, *args, **kwargs): caches[name] = cachedMethod return cachedMethod(self, *args, **kwargs) + def clearer(self): + caches = _methodCaches.get(self, collections.defaultdict(dict)) + cachedMethod = caches.get(name) + if cachedMethod: + cachedMethod.cache_clear() + + wrapper._scenic_cache_clearer = clearer + return wrapper @@ -108,49 +124,16 @@ def alarm(seconds, handler=None, noNesting=False): signal.signal(signal.SIGALRM, signal.SIG_DFL) -def loadMesh(path, filetype, compressed, binary): - working_path = path - - if binary: - mode = "rb" - else: - mode = "r" - - # Check if file is compressed - if compressed is None: - root, ext = os.path.splitext(working_path) - - if ext == ".bz2": - compressed = True - working_path = root - else: - compressed = False - - # Check mesh filetype - if filetype is None: - root, ext = os.path.splitext(working_path) - - if ext == "": - raise ValueError("Mesh filetype not provided, but could not be extracted") - - filetype = ext - - if compressed: - open_function = bz2.open - else: - open_function = open - - with open_function(path, mode) as mesh_file: - mesh = trimesh.load(mesh_file, file_type=filetype) - - return mesh - - def unifyMesh(mesh, verbose=False): - """Attempt to merge mesh bodies, aborting if something fails. + """Attempt to merge mesh bodies, raising a `ValueError` if something fails. - Should only be used with meshes that are volumes. Returns the - original mesh if something goes wrong. + Should only be used with meshes that are volumes. + + If a mesh is composed of multiple bodies, the following process + is applied: + 1. Split mesh into volumes and holes. + 2. From each volume, subtract each hole that is fully contained. + 3. Union all the resulting volumes. """ assert mesh.is_volume @@ -160,30 +143,45 @@ def unifyMesh(mesh, verbose=False): mesh_bodies = mesh.split() - if not all(m.is_volume for m in mesh_bodies): - if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " but Scenic was unable to unify it because some of those bodies" - " are non-volumetric (e.g. hollow portions of a volume). This is probably" - " not an issue, but note that if any of these bodies have" - " intersecting faces, Scenic may give undefined resuls. To suppress" - " this warning in the future, consider adding the 'unify=False' parameter" - " to your fromFile call." - ) - return mesh + if all(m.is_volume for m in mesh_bodies): + # If all mesh bodies are volumes, we can just return the union. + unified_mesh = trimesh.boolean.union(mesh_bodies) - try: - unified_mesh = trimesh.boolean.union(mesh_bodies, engine="scad") - except CalledProcessError: - # Something went wrong, return the original mesh + else: + # Split the mesh bodies into volumes and holes. + volumes = [] + holes = [] + for m in mesh_bodies: + if m.is_volume: + volumes.append(m) + else: + m.fix_normals() + assert m.is_volume + holes.append(m) + + # For each volume, subtract all holes fully contained in the volume, + # keeping track of which holes are fully contained in at least one solid. + differenced_volumes = [] + contained_holes = set() + + for v in volumes: + for h in filter(lambda h: h.volume < v.volume, holes): + if h.difference(v).is_empty: + contained_holes.add(h) + v = v.difference(h) + differenced_volumes.append(v) + + # If one or more holes was not fully contained (and thus ignored), + # raise a warning. if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " but Scenic was unable to unify it because OpenSCAD raised" - " an error." - ) - return mesh + if contained_holes != set(holes): + warnings.warn( + "One or more holes in the provided mesh was not fully contained" + " in any solid (and was ignored)." + ) + + # Union all the differenced volumes together. + unified_mesh = trimesh.boolean.union(differenced_volumes) # Check that the output is still a valid mesh if unified_mesh.is_volume: @@ -191,30 +189,23 @@ def unifyMesh(mesh, verbose=False): if unified_mesh.body_count == 1: warnings.warn( "The mesh that you loaded was composed of multiple bodies," - " but Scenic was able to unify it into one single body. To save on compile" + " but Scenic was able to unify it into one single body (though" + " you should verify that the result is correct). To save on compile" " time in the future, consider running unifyMesh on your mesh outside" " of Scenic and using that output instead." ) elif unified_mesh.body_count < mesh.body_count: warnings.warn( "The mesh that you loaded was composed of multiple bodies," - " but Scenic was able to unify it into fewer bodies. To save on compile" + " but Scenic was able to unify it into fewer bodies (though" + " you should verify that the result is correct). To save on compile" " time in the future, consider running unifyMesh on your mesh outside" - " of Scenic and using that output instead. Note that if any of these" - " bodies have intersecting faces, Scenic may give undefined resuls." + " of Scenic and using that output instead." ) return unified_mesh else: - if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " and Scenic was unable to unify it into fewer bodies. To save on compile" - " time in the future, consider adding the 'unify=False' parameter to your" - " fromFile call. Note that if any of these bodies have intersecting faces," - " Scenic may give undefined resuls." - ) - return mesh + raise ValueError("Unable to unify mesh.") def repairMesh(mesh, pitch=(1 / 2) ** 6, verbose=True): @@ -351,6 +342,7 @@ def __repr__(self): ): get_type_hints = typing.get_type_hints else: + import types def get_type_hints(obj, globalns=None, localns=None): if not isinstance(obj, (type, types.ModuleType)) and globalns is None: diff --git a/src/scenic/core/vectors.py b/src/scenic/core/vectors.py index 1c53a39fe..0ec044df8 100644 --- a/src/scenic/core/vectors.py +++ b/src/scenic/core/vectors.py @@ -311,6 +311,9 @@ def eulerAngles(self) -> typing.Tuple[float, float, float]: """Global intrinsic Euler angles yaw, pitch, roll.""" return self.r.as_euler("ZXY", degrees=False) + def _trimeshEulerAngles(self): + return self.r.as_euler("xyz", degrees=False) + def getRotation(self): return self.r @@ -324,6 +327,14 @@ def _inverseRotation(self): # will be converted to a distributionMethod after the class definition def __mul__(self, other) -> Orientation: + """Apply a rotation to this orientation, yielding a new orientation. + + As we represent orientations as intrinsic rotations, rotation A followed by rotation B is + given by the quaternion product A*B, not B*A. + + See https://en.wikipedia.org/wiki/Davenport_chained_rotations#Conversion_to_extrinsic_rotations + for more details. + """ if type(other) is not Orientation: return NotImplemented # Preserve existing orientation objects when possible to help pruning. @@ -339,7 +350,7 @@ def __add__(self, other) -> Orientation: other = Orientation._fromHeading(other) elif type(other) is not Orientation: return NotImplemented - return other * self + return self * other @distributionMethod def __radd__(self, other) -> Orientation: @@ -347,7 +358,7 @@ def __radd__(self, other) -> Orientation: other = Orientation._fromHeading(other) elif type(other) is not Orientation: return NotImplemented - return self * other + return other * self def __repr__(self): return f"Orientation.fromEuler{tuple(self.eulerAngles)!r}" @@ -362,7 +373,7 @@ def localAnglesFor(self, orientation) -> typing.Tuple[float, float, float]: That is, considering ``self`` as the parent orientation, find the Euler angles expressing the given orientation. """ - local = orientation * self.inverse + local = self.inverse * orientation return local.eulerAngles @distributionFunction @@ -384,6 +395,18 @@ def approxEq(self, other, tol=1e-10): return NotImplemented return abs(numpy.dot(self.q, other.q)) > 1 - tol + @classmethod + def encodeTo(cls, orientation, stream): + stream.write(struct.pack(" 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 2d814662e..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 @@ -518,6 +510,13 @@ class LaneGroup(LinearElement): #: Opposite lane group of the same road, if any. _opposite: Union[LaneGroup, None] = None + def __attrs_post_init__(self): + super().__attrs_post_init__() + + # Ensure lanes do not overlap + for i in range(len(self.lanes) - 1): + assert not self.lanes[i].polygon.overlaps(self.lanes[i + 1].polygon) + @property def sidewalk(self) -> Sidewalk: """The adjacent sidewalk; rejects if there is none.""" @@ -916,10 +915,19 @@ def __attrs_post_init__(self): self.shoulderRegion = PolygonalRegion.unionAll(self.shoulders) if self.drivableRegion is None: - self.drivableRegion = self.laneRegion.union(self.intersectionRegion) + self.drivableRegion = PolygonalRegion.unionAll( + ( + self.laneRegion, + self.roadRegion, # can contain points slightly outside laneRegion + self.intersectionRegion, + ) + ) assert self.drivableRegion.containsRegion( self.laneRegion, tolerance=self.tolerance ) + assert self.drivableRegion.containsRegion( + self.roadRegion, tolerance=self.tolerance + ) assert self.drivableRegion.containsRegion( self.intersectionRegion, tolerance=self.tolerance ) @@ -973,7 +981,7 @@ def _currentFormatVersion(cls): :meta private: """ - return 30 + return 33 class DigestMismatchError(Exception): """Exception raised when loading a cached map not matching the original file.""" @@ -1203,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/formats/opendrive/xodr_parser.py b/src/scenic/formats/opendrive/xodr_parser.py index 3d5e14af7..e01d9c49d 100644 --- a/src/scenic/formats/opendrive/xodr_parser.py +++ b/src/scenic/formats/opendrive/xodr_parser.py @@ -569,13 +569,13 @@ def calc_geometry_for_type(self, lane_types, num, tolerance, calc_gap=False): next_lane_polys[id_] = [cur_sec_lane_polys[id_]] for id_ in cur_lane_polys: poly = buffer_union(cur_lane_polys[id_], tolerance=tolerance) + self.lane_secs[i - 1].get_lane(id_).parent_lane_poly = len(lane_polys) lane_polys.append(poly) - self.lane_secs[i - 1].get_lane(id_).parent_lane_poly = poly cur_lane_polys = next_lane_polys for id_ in cur_lane_polys: poly = buffer_union(cur_lane_polys[id_], tolerance=tolerance) + cur_sec.get_lane(id_).parent_lane_poly = len(lane_polys) lane_polys.append(poly) - cur_sec.get_lane(id_).parent_lane_poly = poly union_poly = buffer_union(sec_polys, tolerance=tolerance) if last_lefts and last_rights: self.end_bounds_left.update(last_lefts) @@ -600,6 +600,13 @@ def calc_geometry_for_type(self, lane_types, num, tolerance, calc_gap=False): lane_polys[i] = lane_polys[i].difference(lane_polys[i + 1]).buffer(-1e-6) assert not lane_polys[i].overlaps(lane_polys[i + 1]) + # Set parent lane polygon references to corrected polygons + for sec in self.lane_secs: + for lane in sec.lanes.values(): + parentIndex = lane.parent_lane_poly + if isinstance(parentIndex, int): + lane.parent_lane_poly = lane_polys[parentIndex] + return (sec_points, sec_polys, sec_lane_polys, lane_polys, union_poly) def calculate_geometry( diff --git a/src/scenic/simulators/airsim/actions.py b/src/scenic/simulators/airsim/actions.py index 78f34fb22..6ccf34713 100644 --- a/src/scenic/simulators/airsim/actions.py +++ b/src/scenic/simulators/airsim/actions.py @@ -1,26 +1,26 @@ -from scenic.core.simulators import Action -from scenic.core.type_support import toVector - -from .utils import ( - airsimToScenicLocation, - airsimToScenicOrientation, - scenicToAirsimOrientation, - scenicToAirsimScale, - scenicToAirsimVector, -) - - -class SetVelocity(Action): - def __init__(self, velocity): - self.newVelocity = scenicToAirsimVector(toVector(velocity)) - - def applyTo(self, obj, sim): - client = sim.client - client.cancelLastTask(vehicle_name=obj.realObjName) - client.moveByVelocityAsync( - self.newVelocity.x_val, - self.newVelocity.y_val, - self.newVelocity.z_val, - duration=5, - vehicle_name=obj.realObjName, - ) +from scenic.core.simulators import Action +from scenic.core.type_support import toVector + +from .utils import ( + airsimToScenicLocation, + airsimToScenicOrientation, + scenicToAirsimOrientation, + scenicToAirsimScale, + scenicToAirsimVector, +) + + +class SetVelocity(Action): + def __init__(self, velocity): + self.newVelocity = scenicToAirsimVector(toVector(velocity)) + + def applyTo(self, obj, sim): + client = sim.client + client.cancelLastTask(vehicle_name=obj.realObjName) + client.moveByVelocityAsync( + self.newVelocity.x_val, + self.newVelocity.y_val, + self.newVelocity.z_val, + duration=5, + vehicle_name=obj.realObjName, + ) diff --git a/src/scenic/simulators/airsim/behaviors.scenic b/src/scenic/simulators/airsim/behaviors.scenic index 243f10233..0f253c10e 100644 --- a/src/scenic/simulators/airsim/behaviors.scenic +++ b/src/scenic/simulators/airsim/behaviors.scenic @@ -1,118 +1,118 @@ - - -import airsim -import time -import threading -import sys -import math -from promise import Promise -from scenic.core.type_support import toVector -from .utils import ( - scenicToAirsimVector, - scenicToAirsimOrientation, - airsimToScenicLocation, - airsimToScenicOrientation, - scenicToAirsimScale, -) -from scenic.simulators.airsim.actions import * - - -def magnitude(v): - return math.hypot(v.x, v.y, v.z) - -# creates a promise from an msgpackrpc future (the futures that are used in airsim) -def createPromise(future): - def promFunc(resolve, reject): - - def joinAsync(): - future.join() - resolve(True) - - def waitAsync(): - while not future._set_flag: - time.sleep(.01) - resolve(True) - - if not future._loop: - threading.Thread(target=joinAsync).start() - else: - threading.Thread(target=waitAsync).start() - - prom = Promise(promFunc) - - return prom - -# waits for a promise to be fulfilled -behavior waitForPromise(promise): - while not promise.is_fulfilled: - wait - -# Flies the drone to a position -behavior FlyToPosition(newPos, speed = 5,tolerance = 1,pidMode = True): - # pidMode is true if we want the drone to slow down as it reaches its destination - - client = simulation().client - - - - if pidMode: - newPos = scenicToAirsimVector(toVector(newPos)) - do waitForPromise(createPromise( - client.moveToPositionAsync( - newPos.x_val, - newPos.y_val, - newPos.z_val, - velocity=speed, - vehicle_name=self.realObjName, - ) - )) - else: - while True: - direction = newPos -self.position - distance = magnitude(direction) - - if distance < tolerance: - break - direction= (direction/distance)*speed - take SetVelocity(direction) - wait - - - return - - - - -behavior Patrol(positions, loop=True, smooth = False, speed = 5,tolerance = 2): - while True: - for pos in positions: - do FlyToPosition(pos,speed=speed,pidMode= not smooth,tolerance=tolerance) - - if not loop: - return - - - - -behavior MoveByVelocity(velocity,seconds): - client = simulation().client - - newVelocity = scenicToAirsimVector(toVector(velocity)) - - do waitForPromise(createPromise( - client.moveByVelocityAsync( - newVelocity.x_val, - newVelocity.y_val, - newVelocity.z_val, - duration=seconds, - vehicle_name=self.realObjName, - ) - )) - - - - -behavior FlyToStart(): - do FlyToPosition(self._startPos) - - + + +import airsim +import time +import threading +import sys +import math +from promise import Promise +from scenic.core.type_support import toVector +from .utils import ( + scenicToAirsimVector, + scenicToAirsimOrientation, + airsimToScenicLocation, + airsimToScenicOrientation, + scenicToAirsimScale, +) +from scenic.simulators.airsim.actions import * + + +def magnitude(v): + return math.hypot(v.x, v.y, v.z) + +# creates a promise from an msgpackrpc future (the futures that are used in airsim) +def createPromise(future): + def promFunc(resolve, reject): + + def joinAsync(): + future.join() + resolve(True) + + def waitAsync(): + while not future._set_flag: + time.sleep(.01) + resolve(True) + + if not future._loop: + threading.Thread(target=joinAsync).start() + else: + threading.Thread(target=waitAsync).start() + + prom = Promise(promFunc) + + return prom + +# waits for a promise to be fulfilled +behavior waitForPromise(promise): + while not promise.is_fulfilled: + wait + +# Flies the drone to a position +behavior FlyToPosition(newPos, speed = 5,tolerance = 1,pidMode = True): + # pidMode is true if we want the drone to slow down as it reaches its destination + + client = simulation().client + + + + if pidMode: + newPos = scenicToAirsimVector(toVector(newPos)) + do waitForPromise(createPromise( + client.moveToPositionAsync( + newPos.x_val, + newPos.y_val, + newPos.z_val, + velocity=speed, + vehicle_name=self.realObjName, + ) + )) + else: + while True: + direction = newPos -self.position + distance = magnitude(direction) + + if distance < tolerance: + break + direction= (direction/distance)*speed + take SetVelocity(direction) + wait + + + return + + + + +behavior Patrol(positions, loop=True, smooth = False, speed = 5,tolerance = 2): + while True: + for pos in positions: + do FlyToPosition(pos,speed=speed,pidMode= not smooth,tolerance=tolerance) + + if not loop: + return + + + + +behavior MoveByVelocity(velocity,seconds): + client = simulation().client + + newVelocity = scenicToAirsimVector(toVector(velocity)) + + do waitForPromise(createPromise( + client.moveByVelocityAsync( + newVelocity.x_val, + newVelocity.y_val, + newVelocity.z_val, + duration=seconds, + vehicle_name=self.realObjName, + ) + )) + + + + +behavior FlyToStart(): + do FlyToPosition(self._startPos) + + \ No newline at end of file diff --git a/src/scenic/simulators/airsim/generators/exportObjs.py b/src/scenic/simulators/airsim/generators/exportObjs.py deleted file mode 100644 index 5b9a58ca4..000000000 --- a/src/scenic/simulators/airsim/generators/exportObjs.py +++ /dev/null @@ -1,176 +0,0 @@ -import json -import os -import pprint -import re -import sys -import tempfile -import time -from warnings import warn - -import airsim -import cv2 -import numpy as np -import trimesh - -from scenic.core.utils import repairMesh -from scenic.simulators.airsim.utils import ( - airsimToScenicLocationTuple, - airsimToScenicOrientationTuple, -) - -# get output directory -if len(sys.argv) < 2: - raise RuntimeError("please specify output directory as first argument") -outputDirectory = sys.argv[1] + "/" - -# start airsim client -client = None -try: - client = airsim.MultirotorClient() - client.confirmConnection() - client.simPause(True) -except Exception: - raise RuntimeError("Airsim must be running on before executing scenic") - - -os.makedirs(outputDirectory + "assets", exist_ok=True) -os.makedirs(outputDirectory + "objectMeshes", exist_ok=True) - - -def getAssetName(meshName): - return re.sub(r"_\d+$", "", meshName) - - -assets = client.simListAssets() - -# create objects of the assets -objNameDict = {} - -for asset in assets: - objName = client.simSpawnObject( - object_name=asset, - asset_name=asset, - pose=airsim.Pose(position_val=airsim.Vector3r(0, 0, 0)), - scale=airsim.Vector3r(1, 1, 1), - ) - objNameDict[asset] = objName.lower() - -meshes = client.simGetMeshPositionVertexBuffers() - -print("got meshes") -meshDict = {} -for asset in assets: - objName = objNameDict[asset] - for mesh in meshes: - if mesh.name == objName: - # print(mesh.name) - meshDict[asset] = mesh - break - - -def makeTrimsh(mesh): - vertex_list = np.array(mesh.vertices, dtype=np.float32) - indices = np.array(mesh.indices, dtype=np.uint32) - - num_vertices = int(len(vertex_list) / 3) - num_indices = len(indices) - - vertices_reshaped = vertex_list.reshape((num_vertices, 3)) - indices_reshaped = indices.reshape((int(num_indices / 3), 3)) - vertices_reshaped = vertices_reshaped.astype(np.float64) - indices_reshaped = indices_reshaped.astype(np.int64) - - tmesh = trimesh.Trimesh( - vertices=vertices_reshaped, faces=indices_reshaped, process=True - ) - - if tmesh.body_count > 1: - tmesh.fix_normals(multibody=True) - else: - tmesh.fix_normals() - - try: - tmesh = repairMesh(tmesh, verbose=True) - except Exception as e: - warn(e) - print("could not repair mesh:", mesh.name) - return None - - return tmesh - - -# function for creating a default mesh if needed -_defaultMesh = None - - -def defaultMesh(): - if not _defaultMesh: - _defaultMesh = trimesh.creation.box((1, 1, 1)) - return _defaultMesh - - -# save an obj file for each asset -for assetName in assets: - if not (assetName in meshDict): - continue - mesh = meshDict[assetName] - tmesh = makeTrimsh(mesh) - if not tmesh: - tmesh = defaultMesh() - - with open( - outputDirectory + "assets/" + assetName + ".obj", - "w", - ) as outfile: - outfile.write(trimesh.exchange.obj.export_obj(tmesh)) - - -# ----------------- extract world info - - -cleanedMeshes = [] -for mesh in meshes: - found = False - - # check if mesh is in the created meshes - for mesh2 in meshDict.values(): - if mesh.name == mesh2.name: - found = True - break - - # check if mesh is a vehicle - for vehicle in client.listVehicles(): - if mesh.name == vehicle: - found = True - break - - # if mesh was not found in checks, add it to cleanedMeshes - if not found: - cleanedMeshes.append(mesh) - -worldInfo = [] -for mesh in cleanedMeshes: - tmesh = makeTrimsh(mesh) - objectName = mesh.name - - pose = client.simGetObjectPose(objectName) - position = airsimToScenicLocationTuple(pose.position) - orientation = airsimToScenicOrientationTuple(pose.orientation) - - worldInfo.append( - dict( - name=objectName, - position=position, - orientation=orientation, - ), - ) - - with open( - outputDirectory + "objectMeshes/" + objectName + ".obj", - "w", - ) as outfile: - outfile.write(trimesh.exchange.obj.export_obj(tmesh)) - - -with open(outputDirectory + "worldInfo.json", "w") as outfile: - json.dump(worldInfo, outfile, indent=4) diff --git a/src/scenic/simulators/airsim/generators/generateAirsimSettings.py b/src/scenic/simulators/airsim/generators/generateAirsimSettings.py index 847be4068..1cd193aed 100644 --- a/src/scenic/simulators/airsim/generators/generateAirsimSettings.py +++ b/src/scenic/simulators/airsim/generators/generateAirsimSettings.py @@ -1,96 +1,96 @@ -import argparse -import json - -DEFAULT_DRONE_SETTINGS = { - "VehicleType": "SimpleFlight", - "AutoCreate": False, - "Sensors": { - "frontDistance": {"SensorType": 5, "Enabled": True, "DrawDebugPoints": True}, - "rightDistance": { - "SensorType": 5, - "Enabled": True, - "DrawDebugPoints": True, - "Yaw": 90, - "Pitch": 0, - "Roll": 0, - }, - "leftDistance": { - "SensorType": 5, - "Enabled": True, - "DrawDebugPoints": True, - "Yaw": -90, - "Pitch": 0, - "Roll": 0, - }, - "rearDistance": { - "SensorType": 5, - "Enabled": True, - "DrawDebugPoints": True, - "Yaw": 180, - "Pitch": 0, - "Roll": 0, - }, - }, -} -DEFAULT_MAX_DRONES = 10 - - -parser = argparse.ArgumentParser() -parser.add_argument( - "-o", - "--outfile", - type=str, - help="The json file that the settings are written to.", - required=True, -) -parser.add_argument( - "-m", - "--maxdrones", - type=int, - help="The maximum amount of drones your scenic program will support.", - default=DEFAULT_MAX_DRONES, -) -parser.add_argument( - "-dc", - "--droneconfigpath", - type=argparse.FileType("r"), - help="The JSON containing only the config for 1 drone that will apply to all drones created. (not required)", -) - -args = parser.parse_args() - -if not args.outfile.endswith("json"): - raise argparse.ArgumentTypeError("The --outfile argument must be a json file.") - -droneConfig = None -if args.droneconfigpath: - if not args.droneconfigpath.name.endswith("json"): - raise argparse.ArgumentTypeError( - "The --droneconfigpath argument must be a json file." - ) - - droneConfig = json.load(args.droneconfigpath) -else: - droneConfig = DEFAULT_DRONE_SETTINGS - -maxDrones = args.maxdrones - -settings = { - "SettingsVersion": 1.2, - "SimMode": "Multirotor", - "ClockSpeed": 1, - "Vehicles": {}, -} - - -for i in range(maxDrones): - settings["Vehicles"]["Drone" + str(i)] = droneConfig.copy() - drone = settings["Vehicles"]["Drone" + str(i)] - if i == 0: - drone["AutoCreate"] = True - - -with open(args.outfile, "w") as outfile: - json.dump(settings, outfile, indent=4) - -print("created settings at", args.outfile) +import argparse +import json + +DEFAULT_DRONE_SETTINGS = { + "VehicleType": "SimpleFlight", + "AutoCreate": False, + "Sensors": { + "frontDistance": {"SensorType": 5, "Enabled": True, "DrawDebugPoints": True}, + "rightDistance": { + "SensorType": 5, + "Enabled": True, + "DrawDebugPoints": True, + "Yaw": 90, + "Pitch": 0, + "Roll": 0, + }, + "leftDistance": { + "SensorType": 5, + "Enabled": True, + "DrawDebugPoints": True, + "Yaw": -90, + "Pitch": 0, + "Roll": 0, + }, + "rearDistance": { + "SensorType": 5, + "Enabled": True, + "DrawDebugPoints": True, + "Yaw": 180, + "Pitch": 0, + "Roll": 0, + }, + }, +} +DEFAULT_MAX_DRONES = 10 + + +parser = argparse.ArgumentParser() +parser.add_argument( + "-o", + "--outfile", + type=str, + help="The json file that the settings are written to.", + required=True, +) +parser.add_argument( + "-m", + "--maxdrones", + type=int, + help="The maximum amount of drones your scenic program will support.", + default=DEFAULT_MAX_DRONES, +) +parser.add_argument( + "-dc", + "--droneconfigpath", + type=argparse.FileType("r"), + help="The JSON containing only the config for 1 drone that will apply to all drones created. (not required)", +) + +args = parser.parse_args() + +if not args.outfile.endswith("json"): + raise argparse.ArgumentTypeError("The --outfile argument must be a json file.") + +droneConfig = None +if args.droneconfigpath: + if not args.droneconfigpath.name.endswith("json"): + raise argparse.ArgumentTypeError( + "The --droneconfigpath argument must be a json file." + ) + + droneConfig = json.load(args.droneconfigpath) +else: + droneConfig = DEFAULT_DRONE_SETTINGS + +maxDrones = args.maxdrones + +settings = { + "SettingsVersion": 1.2, + "SimMode": "Multirotor", + "ClockSpeed": 1, + "Vehicles": {}, +} + + +for i in range(maxDrones): + settings["Vehicles"]["Drone" + str(i)] = droneConfig.copy() + drone = settings["Vehicles"]["Drone" + str(i)] + if i == 0: + drone["AutoCreate"] = True + + +with open(args.outfile, "w") as outfile: + json.dump(settings, outfile, indent=4) + +print("created settings at", args.outfile) diff --git a/src/scenic/simulators/airsim/generators/generateUnrealWorldInfo.py b/src/scenic/simulators/airsim/generators/generateUnrealWorldInfo.py new file mode 100644 index 000000000..9f5ff7a14 --- /dev/null +++ b/src/scenic/simulators/airsim/generators/generateUnrealWorldInfo.py @@ -0,0 +1,94 @@ +import unreal +from pathlib import Path +import os +import argparse +import json + +# get output directory +parser = argparse.ArgumentParser() +parser.add_argument( + "-o", + "--outputDirectory", + type=str, + help="the directory where the fbx info should be dumped. This should be a directory that doesn't exist.", + required=True, +) + + +args = parser.parse_args() + +outputDirectory = args.outputDirectory + "/" +asset_dir = outputDirectory + "/assets" +actorInfo_dir = outputDirectory + "/actorInfo.json" + +# make output dirs +os.makedirs(outputDirectory, exist_ok=False) +os.makedirs(asset_dir, exist_ok=False) + + +assetSubsystem = unreal.get_editor_subsystem(unreal.EditorAssetSubsystem) +actorSubsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) +staticmeshsubsystem = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) +asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() +system_lib = unreal.SystemLibrary() + + +# get all static mesh actors +actors = actorSubsystem.get_all_level_actors() +actors = unreal.EditorFilterLibrary.by_class( + actors, unreal.StaticMeshActor.static_class() +) + +# get all static mesh assets +assets = asset_registry.get_all_assets() +assets = asset_registry.get_assets_by_class( + unreal.TopLevelAssetPath("/Script/Engine", "StaticMesh") +) + +# export all asset meshes +for i, asset in enumerate(assets): + assetname = asset.asset_name + object_path = str(asset.package_name) + "." + str(asset.asset_name) + loaded_asset = assetSubsystem.load_asset(object_path) + + task = unreal.AssetExportTask() + task.object = loaded_asset + task.filename = asset_dir + "/" + str(assetname) + ".fbx" + task.automated = True # skip export options prompt + task.replace_identical = True + task.options = unreal.FbxExportOption() + + unreal.Exporter.run_asset_export_task(task) + + +# get all actor info +actor_info_list = [] +for i, actor in enumerate(actors): + actor_name = actor.get_actor_label() + + static_mesh_component = actor.get_component_by_class(unreal.StaticMeshComponent) + static_mesh = static_mesh_component.static_mesh + + def vecToTuple(vec): + return (vec.x, vec.y, vec.z) + + def rotorToTuple(rotor): + return {"pitch": rotor.pitch, "yaw": rotor.yaw, "roll": rotor.roll} + + scale = vecToTuple(actor.get_actor_scale3d()) + loc = vecToTuple(actor.get_actor_location()) + rot = rotorToTuple(actor.get_actor_rotation()) + + actor_info_list.append( + { + "name": actor_name, + "scale": scale, + "location": loc, + "rotation": rot, + "meshName": static_mesh.get_name(), + } + ) + +# export actor info +with open(actorInfo_dir, "w") as outfile: + json.dump(actor_info_list, outfile, indent=4, sort_keys=True) diff --git a/src/scenic/simulators/airsim/generators/createWorldInfo.py b/src/scenic/simulators/airsim/generators/generateWorldInfo.py similarity index 100% rename from src/scenic/simulators/airsim/generators/createWorldInfo.py rename to src/scenic/simulators/airsim/generators/generateWorldInfo.py diff --git a/src/scenic/simulators/airsim/generators/generateWorldInfoFromUnrealWorldInfo.py b/src/scenic/simulators/airsim/generators/generateWorldInfoFromUnrealWorldInfo.py new file mode 100644 index 000000000..2bd1cbf3f --- /dev/null +++ b/src/scenic/simulators/airsim/generators/generateWorldInfoFromUnrealWorldInfo.py @@ -0,0 +1,148 @@ +import argparse +import json +import os +from warnings import warn +import numpy as np +import trimesh +import bpy +import shutil +from scenic.core.utils import repairMesh + + +WORLD_SCALE = 10000 +DEFAULT_MESH = trimesh.creation.box((1, 1, 1)) + +# get args +parser = argparse.ArgumentParser() +parser.add_argument( + "-o", + "--outputDirectory", + type=str, + help="the directory where the world info should be dumped. This should be a directory that doesn't exist.", + required=True, +) +parser.add_argument( + "-i", + "--inputDirectory", + type=str, + help="the directory where the unreal world info resides", + required=True, +) +args = parser.parse_args() + + +outputDirectory = args.outputDirectory + "/" +inputDirectory = args.inputDirectory + "/" + +# make dirs +try: + os.makedirs(args.outputDirectory, exist_ok=False) +except: + raise RuntimeError("output directory already exists") + +assetDir = outputDirectory + "assets/" +objectMeshesDir = outputDirectory + "objectMeshes/" +dumpDir = outputDirectory + "dump/" + +os.makedirs(assetDir, exist_ok=False) +os.makedirs(dumpDir, exist_ok=False) +os.makedirs(objectMeshesDir, exist_ok=False) + + +# export asset objects +assetsInputDir = inputDirectory + "assets/" +tmeshes = {} +for filename in os.listdir(assetsInputDir): + if filename.endswith(".fbx"): + + # make fbx into stl so that trimesh can read it + name = os.path.splitext(filename)[0].lower() + filepath = os.path.join(assetsInputDir, filename) + newFilePath = dumpDir + name + ".stl" + + bpy.ops.import_scene.fbx(filepath=filepath) + bpy.ops.export_mesh.stl(filepath=newFilePath, use_selection=True) + + # make tmesh + tmesh = trimesh.load_mesh(newFilePath) + + if len(tmesh.vertices) == 0: + print("mesh", name, "has no vertices") + continue + + # fix tmesh for scenic + if tmesh.body_count > 1: + tmesh.fix_normals(multibody=True) + else: + tmesh.fix_normals() + + try: + tmesh = repairMesh(tmesh, verbose=True) + except Exception as e: + warn(str(e)) + print("could not repair mesh:", name) + tmesh = DEFAULT_MESH + + # save tmesh + + scale = np.array([1, 1, 1]) + matrix = trimesh.transformations.compose_matrix(scale=scale) + tmesh.apply_transform(matrix) + + tmeshes[name] = tmesh + with open( + assetDir + name + ".obj", + "w", + ) as outfile: + outfile.write(trimesh.exchange.obj.export_obj(tmesh)) + +bpy.ops.wm.quit_blender() + +# delete dumpDir +shutil.rmtree(dumpDir) + +worldInfo = [] + +# export objectMeshes and save their world info +with open(inputDirectory + "/actorInfo.json") as file: + actorInfoList = json.load(file) + for actorInfo in actorInfoList: + + if not (actorInfo["meshName"].lower() in tmeshes): + continue + + # save scaled tmesh + tmesh = tmeshes[actorInfo["meshName"].lower()].copy() + + scale = np.array(actorInfo["scale"]) + matrix = trimesh.transformations.compose_matrix(scale=scale) + tmesh.apply_transform(matrix) + + with open( + objectMeshesDir + actorInfo["name"] + ".obj", + "w", + ) as outfile: + outfile.write(trimesh.exchange.obj.export_obj(tmesh)) + + loc = actorInfo["location"] + rot = actorInfo["rotation"] + + # save actor data to world info + worldInfo.append( + dict( + name=actorInfo["name"], + position=[ + loc[1] / WORLD_SCALE, + loc[0] / WORLD_SCALE, + loc[2] / WORLD_SCALE, + ], + orientation=[rot["yaw"], rot["pitch"], rot["roll"]], + ), + ) + + +# export the worldinfo.json +with open(outputDirectory + "worldInfo.json", "w") as outfile: + json.dump(worldInfo, outfile, indent=4) + +print("created world info at:", outputDirectory + "worldInfo.json") diff --git a/src/scenic/simulators/airsim/utils.py b/src/scenic/simulators/airsim/utils.py index 9ebb7adf8..62e7d5ea7 100644 --- a/src/scenic/simulators/airsim/utils.py +++ b/src/scenic/simulators/airsim/utils.py @@ -1,68 +1,68 @@ -import airsim -import scipy - -from scenic.core.type_support import toVector -from scenic.core.vectors import Orientation, Vector - - -def tupleToVector3r(tuple): - return airsim.Vector3r(tuple[0], tuple[1], tuple[2]) - - -def scenicToAirsimOrientation(orientation): - pitch, yaw, roll = orientation.r.as_euler("XZY", degrees=False) - return airsim.to_quaternion(pitch, roll, yaw) - - -def airsimToScenicOrientation(orientation): - r = scipy.spatial.transform.Rotation.from_euler( - seq="XZY", angles=airsimToScenicOrientationTuple(orientation), degrees=False - ) - return Orientation(r) - - -def airsimToScenicOrientationTuple(orientation): - # intrinsic angles - pitch, roll, yaw = airsim.to_eularian_angles(orientation) - angles = (pitch, yaw, roll) - - return angles - - -def scenicToAirsimVector(position): - position = toVector(position) - return airsim.Vector3r(position.y, position.x, -position.z) - - -def airsimToScenicLocation(position): - return Vector( - position.y_val, - position.x_val, - -position.z_val, - ) - - -def airsimToScenicLocationTuple(position): - return ( - position.y_val, - position.x_val, - -position.z_val, - ) - - -def scenicToAirsimScale(obj): - # movment function in meters - # drone size in blender is 98.1694 m - # coords scaled by 100? https://microsoft.github.io/AirSim/apis/#:~:text=All%20AirSim%20API%20uses%20NED,in%20centimeters%20instead%20of%20meters. - return airsim.Vector3r(obj.width, obj.length, obj.height) - - -_prexistingObjs = {} - - -def _addPrexistingObj(obj): - _prexistingObjs[obj.name] = obj - - -def getPrexistingObj(objName): - return _prexistingObjs[objName] +import airsim +import scipy + +from scenic.core.type_support import toVector +from scenic.core.vectors import Orientation, Vector + + +def tupleToVector3r(tuple): + return airsim.Vector3r(tuple[0], tuple[1], tuple[2]) + + +def scenicToAirsimOrientation(orientation): + pitch, yaw, roll = orientation.r.as_euler("XZY", degrees=False) + return airsim.to_quaternion(pitch, roll, yaw) + + +def airsimToScenicOrientation(orientation): + r = scipy.spatial.transform.Rotation.from_euler( + seq="XZY", angles=airsimToScenicOrientationTuple(orientation), degrees=False + ) + return Orientation(r) + + +def airsimToScenicOrientationTuple(orientation): + # intrinsic angles + pitch, roll, yaw = airsim.to_eularian_angles(orientation) + angles = (pitch, yaw, roll) + + return angles + + +def scenicToAirsimVector(position): + position = toVector(position) + return airsim.Vector3r(position.y, position.x, -position.z) + + +def airsimToScenicLocation(position): + return Vector( + position.y_val, + position.x_val, + -position.z_val, + ) + + +def airsimToScenicLocationTuple(position): + return ( + position.y_val, + position.x_val, + -position.z_val, + ) + + +def scenicToAirsimScale(obj): + # movment function in meters + # drone size in blender is 98.1694 m + # coords scaled by 100? https://microsoft.github.io/AirSim/apis/#:~:text=All%20AirSim%20API%20uses%20NED,in%20centimeters%20instead%20of%20meters. + return airsim.Vector3r(obj.width, obj.length, obj.height) + + +_prexistingObjs = {} + + +def _addPrexistingObj(obj): + _prexistingObjs[obj.name] = obj + + +def getPrexistingObj(objName): + return _prexistingObjs[objName] diff --git a/src/scenic/simulators/carla/model.scenic b/src/scenic/simulators/carla/model.scenic index 6a9fa954d..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 @@ -39,7 +41,7 @@ Global Parameters: .. _carla.WeatherParameters: https://carla.readthedocs.io/en/latest/python_api/#carlaweatherparameters """ - +import pathlib from scenic.domains.driving.model import * import scenic.simulators.carla.blueprints as blueprints @@ -70,7 +72,8 @@ except ModuleNotFoundError: class _CarlaVehicle: pass class _CarlaPedestrian: pass -param carla_map = None +map_town = pathlib.Path(globalParameters.map).stem +param carla_map = map_town param address = '127.0.0.1' param port = 2000 param timeout = 10 @@ -95,6 +98,7 @@ param weather = Uniform( 'MidRainSunset', 'HardRainSunset' ) +param snapToGroundDefault = is2DMode() simulator CarlaSimulator( carla_map=globalParameters.carla_map, @@ -117,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) @@ -221,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 @@ -252,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 44cc05b36..6469281d7 100644 --- a/src/scenic/simulators/carla/simulator.py +++ b/src/scenic/simulators/carla/simulator.py @@ -139,6 +139,8 @@ def setup(self): if not os.path.exists(self.record): os.mkdir(self.record) name = "{}/scenario{}.log".format(self.record, self.scenario_number) + # Carla is looking for an absolute path, so convert it if necessary. + name = os.path.abspath(name) self.client.start_recorder(name) # Create objects. @@ -203,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 fc46b6d50..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,26 +25,21 @@ 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): - pitch, yaw, roll = orientation.r.as_euler("XZY", degrees=True) + # CARLA uses intrinsic yaw, pitch, roll rotations (in that order), like Scenic, + # but with yaw being left-handed and with zero yaw being East. + yaw, pitch, roll = orientation.r.as_euler("ZXY", degrees=True) yaw = -yaw - 90 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) @@ -54,9 +49,9 @@ def carlaToScenicElevation(loc): def carlaToScenicOrientation(rot): - angles = (rot.pitch, -rot.yaw - 90, rot.roll) + angles = (-rot.yaw - 90, rot.pitch, rot.roll) r = scipy.spatial.transform.Rotation.from_euler( - seq="XZY", angles=angles, degrees=True + seq="ZXY", angles=angles, degrees=True ) return Orientation(r) diff --git a/src/scenic/simulators/gta/interface.py b/src/scenic/simulators/gta/interface.py index ab29c4148..6bee5d842 100644 --- a/src/scenic/simulators/gta/interface.py +++ b/src/scenic/simulators/gta/interface.py @@ -55,10 +55,9 @@ def Config(scene): cameraHeading = GTA.langToGTAHeading(ego.heading) params = dict(scene.params) - time = int(round(params.pop("time"))) - minute = time % 60 - hour = int((time - minute) / 60) - assert hour < 24 + time = int(round(params.pop("time"))) % 1440 + hour, minute = divmod(time, 60) + assert hour < 24, scene.params["time"] weather = params.pop("weather") for param in params: print(f'WARNING: unused scene parameter "{param}"') diff --git a/src/scenic/simulators/newtonian/driving_model.scenic b/src/scenic/simulators/newtonian/driving_model.scenic index 89bd9c47c..1c01ccab2 100644 --- a/src/scenic/simulators/newtonian/driving_model.scenic +++ b/src/scenic/simulators/newtonian/driving_model.scenic @@ -61,5 +61,5 @@ class Pedestrian(Pedestrian, NewtonianActor, Walks): class Debris: """Abstract class for debris scattered randomly in the workspace.""" - position: Point in workspace + position: new Point in workspace yaw: Range(0, 360) deg diff --git a/src/scenic/simulators/newtonian/simulator.py b/src/scenic/simulators/newtonian/simulator.py index da453b0ab..fd38aa427 100644 --- a/src/scenic/simulators/newtonian/simulator.py +++ b/src/scenic/simulators/newtonian/simulator.py @@ -7,6 +7,9 @@ import pathlib 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 +58,29 @@ 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, export_gif=False): super().__init__() + self.export_gif = export_gif self.render = 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, **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, timestep, **kwargs): + self.export_gif = export_gif self.render = render self.network = network + self.frames = [] if timestep is None: timestep = 0.1 @@ -213,8 +224,18 @@ def draw_objects(self): pygame.draw.polygon(self.screen, color, corners) 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 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/utils/colors.py b/src/scenic/simulators/utils/colors.py index 20dc6ea52..7f3a0bed9 100644 --- a/src/scenic/simulators/utils/colors.py +++ b/src/scenic/simulators/utils/colors.py @@ -88,7 +88,7 @@ def __init__(self, baseColor, hueNoise, satNoise, lightNoise): @staticmethod def addNoiseTo(color, hueNoise, lightNoise, satNoise): try: - hue, lightness, saturation = colorsys.rgb_to_hls(*color) + hue, lightness, saturation = colorsys.rgb_to_hls(*color[:3]) except ZeroDivisionError: hue, lightness, saturation = 0.0, 1.0, 0.0 hue = max(0, min(1, hue + hueNoise)) @@ -119,7 +119,7 @@ def appliedTo(self, obj): hueNoise = random.gauss(0, stddev) satNoise = random.gauss(0, stddev) lightNoise = random.gauss(0, stddev) - color = NoisyColorDistribution.addNoiseTo( + obj.color = NoisyColorDistribution.addNoiseTo( obj.color, hueNoise, lightNoise, satNoise ) - return (obj._copyWith(color=color), True) # allow further mutation + return (obj, True) # allow further mutation 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/ast.py b/src/scenic/syntax/ast.py index 57e5779ef..e64e3be48 100644 --- a/src/scenic/syntax/ast.py +++ b/src/scenic/syntax/ast.py @@ -1250,3 +1250,13 @@ def __init__(self, left: ast.AST, right: ast.AST, *args: any, **kwargs: any) -> self.left = left self.right = right self._fields = ["left", "right"] + + +class IntersectsOp(AST): + __match_args__ = ("left", "right") + + def __init__(self, left: ast.AST, right: ast.AST, *args: any, **kwargs: any) -> None: + super().__init__(*args, **kwargs) + self.left = left + self.right = right + self._fields = ["left", "right"] diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index ea0417735..5328c0c6d 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): @@ -495,9 +502,11 @@ def check_and_visit(self: "ScenicToPythonTransformer", node: ast.AST): ctx = "inside a compose block" if ctx: raise self.makeSyntaxError( - f'Cannot use "{node.__class__.__name__}" {ctx}' - if errorBuilder is None - else errorBuilder(ctx), + ( + f'Cannot use "{node.__class__.__name__}" {ctx}' + if errorBuilder is None + else errorBuilder(ctx) + ), node, ) return visitor(self, node) @@ -538,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( @@ -1076,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()), @@ -1108,9 +1131,11 @@ def visit_Mutate(self, node: s.Mutate): value=ast.Call( func=ast.Name(id="mutate", ctx=loadCtx), args=[self.visit(el) for el in node.elts], - keywords=[ast.keyword(arg="scale", value=self.visit(node.scale))] - if node.scale is not None - else [], + keywords=( + [ast.keyword(arg="scale", value=self.visit(node.scale))] + if node.scale is not None + else [] + ), ) ) @@ -1354,9 +1379,11 @@ def createRequirementLike( ast.Constant(lineno), # line number ast.Constant(name), # requirement name ], - keywords=[ast.keyword(arg="prob", value=ast.Constant(prob))] - if prob is not None - else [], + keywords=( + [ast.keyword(arg="prob", value=ast.Constant(prob))] + if prob is not None + else [] + ), ) ) @@ -1468,9 +1495,11 @@ def visit_BeyondSpecifier(self, node: s.BeyondSpecifier): return ast.Call( func=ast.Name(id="Beyond", ctx=loadCtx), args=[self.visit(node.position), self.visit(node.offset)], - keywords=[ast.keyword(arg="fromPt", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="fromPt", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) def visit_VisibleSpecifier(self, node: s.VisibleSpecifier): @@ -1524,9 +1553,11 @@ def visit_FollowingSpecifier(self, node: s.FollowingSpecifier): return ast.Call( func=ast.Name(id="Following", ctx=loadCtx), args=[self.visit(node.field), self.visit(node.distance)], - keywords=[ast.keyword(arg="fromPt", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="fromPt", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) def visit_FacingSpecifier(self, node: s.FacingSpecifier): @@ -1570,9 +1601,11 @@ def visit_ApparentlyFacingSpecifier(self, node: s.ApparentlyFacingSpecifier): return ast.Call( func=ast.Name(id="ApparentlyFacing", ctx=loadCtx), args=[self.visit(node.heading)], - keywords=[ast.keyword(arg="fromPt", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="fromPt", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) # Operators @@ -1581,45 +1614,55 @@ def visit_RelativePositionOp(self, node: s.RelativePositionOp): return ast.Call( func=ast.Name(id="RelativePosition", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_RelativeHeadingOp(self, node: s.RelativeHeadingOp): return ast.Call( func=ast.Name(id="RelativeHeading", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_ApparentHeadingOp(self, node: s.ApparentHeadingOp): return ast.Call( func=ast.Name(id="ApparentHeading", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_DistanceFromOp(self, node: s.DistanceFromOp): return ast.Call( func=ast.Name(id="DistanceFrom", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[ast.keyword(arg="Y", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="Y", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) def visit_DistancePastOp(self, node: s.DistancePastOp): return ast.Call( func=ast.Name(id="DistancePast", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_AngleFromOp(self, node: s.AngleFromOp): @@ -1746,3 +1789,13 @@ def visit_CanSeeOp(self, node: s.CanSeeOp): ], keywords=[], ) + + def visit_IntersectsOp(self, node: s.IntersectsOp): + return ast.Call( + func=ast.Name(id="Intersects", ctx=loadCtx), + args=[ + self.visit(node.left), + self.visit(node.right), + ], + keywords=[], + ) 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/scenic.gram b/src/scenic/syntax/scenic.gram index 2c0e64783..fa181e190 100644 --- a/src/scenic/syntax/scenic.gram +++ b/src/scenic/syntax/scenic.gram @@ -11,7 +11,7 @@ import os import sys import token from typing import ( - Any, Callable, Iterator, List, Literal, Tuple, TypeVar, Union, NoReturn + Any, Callable, Iterator, List, Literal, NoReturn, Sequence, Tuple, TypeVar, Union ) from pegen.tokenizer import Tokenizer @@ -231,18 +231,97 @@ class Parser(Parser): node.ctx = context return node - def ensure_real(self, number: ast.Constant): + def ensure_real(self, number: ast.Constant) -> float: value = ast.literal_eval(number.string) if type(value) is complex: self.raise_syntax_error_known_location("real number required in complex literal", number) return value - def ensure_imaginary(self, number: ast.Constant): + def ensure_imaginary(self, number: ast.Constant) -> complex: value = ast.literal_eval(number.string) if type(value) is not complex: self.raise_syntax_error_known_location("imaginary number required in complex literal", number) return value + def check_fstring_conversion(self, mark: tokenize.TokenInfo, name: tokenize.TokenInfo) -> tokenize.TokenInfo: + if mark.lineno != name.lineno or mark.col_offset != name.col_offset: + self.raise_syntax_error_known_range( + "f-string: conversion type must come right after the exclamanation mark", + mark, + name + ) + + s = name.string + if len(s) > 1 or s not in ("s", "r", "a"): + self.raise_syntax_error_known_location( + f"f-string: invalid conversion character '{s}': expected 's', 'r', or 'a'", + name, + ) + + return name + + def _concat_strings_in_constant(self, parts) -> Union[str, bytes]: + s = ast.literal_eval(parts[0].string) + for ss in parts[1:]: + s += ast.literal_eval(ss.string) + args = dict( + value=s, + lineno=parts[0].start[0], + col_offset=parts[0].start[1], + end_lineno=parts[-1].end[0], + end_col_offset=parts[0].end[1], + ) + if parts[0].string.startswith("u"): + args["kind"] = "u" + return ast.Constant(**args) + + + def concatenate_strings(self, parts): + """Concatenate multiple tokens and ast.JoinedStr""" + # Get proper start and stop + start = end = None + if isinstance(parts[0], ast.JoinedStr): + start = parts[0].lineno, parts[0].col_offset + if isinstance(parts[-1], ast.JoinedStr): + end = parts[-1].end_lineno, parts[-1].end_col_offset + + # Combine the different parts + seen_joined = False + values = [] + ss = [] + for p in parts: + if isinstance(p, ast.JoinedStr): + seen_joined = True + if ss: + values.append(self._concat_strings_in_constant(ss)) + ss.clear() + values.extend(p.values) + else: + ss.append(p) + + if ss: + values.append(self._concat_strings_in_constant(ss)) + + consolidated = [] + for p in values: + if consolidated and isinstance(consolidated[-1], ast.Constant) and isinstance(p, ast.Constant): + consolidated[-1].value += p.value + consolidated[-1].end_lineno = p.end_lineno + consolidated[-1].end_col_offset = p.end_col_offset + else: + consolidated.append(p) + + if not seen_joined and len(values) == 1 and isinstance(values[0], ast.Constant): + return values[0] + else: + return ast.JoinedStr( + values=consolidated, + lineno=start[0] if start else values[0].lineno, + col_offset=start[1] if start else values[0].col_offset, + end_lineno=end[0] if end else values[-1].end_lineno, + end_col_offset=end[1] if end else values[-1].end_col_offset, + ) + def generate_ast_for_string(self, tokens): """Generate AST nodes for strings.""" err_args = None @@ -397,15 +476,18 @@ class Parser(Parser): def expect_forced(self, res: Any, expectation: str) -> Optional[tokenize.TokenInfo]: if res is None: last_token = self._tokenizer.diagnose() + end = last_token.start + if sys.version_info >= (3, 12) or (sys.version_info >= (3, 11) and last_token.type != 4): # i.e. not a \n + end = last_token.end self.raise_raw_syntax_error( - f"expected {expectation}", last_token.start, last_token.start + f"expected {expectation}", last_token.start, end ) return res def raise_syntax_error(self, message: str) -> NoReturn: """Raise a syntax error.""" tok = self._tokenizer.diagnose() - raise self._build_syntax_error(message, tok.start, tok.end if tok.type != 4 else tok.start) + raise self._build_syntax_error(message, tok.start, tok.end if sys.version_info >= (3, 12) or tok.type != 4 else tok.start) def raise_syntax_error_known_location( self, message: str, node: Union[ast.AST, tokenize.TokenInfo] @@ -467,6 +549,10 @@ class Parser(Parser): self.raise_syntax_error_known_location(msg, invalid_target) + def raise_syntax_error_on_next_token(self, message: str) -> NoReturn: + next_token = self._tokenizer.peek() + raise self._build_syntax_error(message, next_token.start, next_token.end) + # scenic helpers def extend_new_specifiers(self, node: s.New, specifiers: List[ast.AST]) -> s.New: node.specifiers.extend(specifiers) @@ -514,6 +600,7 @@ scenic_stmts[list]: # will throw a SyntaxError. simple_stmt (memo): | assignment + | &"type" type_alias | e=star_expressions { ast.Expr(value=e, LOCATIONS) } | &'return' return_stmt | &('import' | 'from') import_stmt @@ -659,7 +746,10 @@ assert_stmt[ast.Assert]: 'assert' a=expression b=[',' z=expression { z }] { ast.Assert(test=a, msg=b, LOCATIONS) } -import_stmt[ast.Import]: import_name | import_from +import_stmt[ast.Import]: + | invalid_import + | import_name + | import_from # Import statements # ----------------- @@ -728,14 +818,26 @@ class_def[ast.ClassDef]: class_def_raw[ast.ClassDef]: | invalid_class_def_raw - | 'class' a=NAME b=['(' z=[arguments] ')' { z }] &&':' c=scenic_class_def_block { - ast.ClassDef( - a.string, - bases=b[0] if b else [], - keywords=b[1] if b else [], - body=c, - decorator_list=[], - LOCATIONS, + | 'class' a=NAME t=[type_params] b=['(' z=[arguments] ')' { z }] &&':' c=scenic_class_def_block { + ( + ast.ClassDef( + a.string, + bases=b[0] if b else [], + keywords=b[1] if b else [], + body=c, + decorator_list=[], + type_params=t or [], + LOCATIONS, + ) + if sys.version_info >= (3, 12) else + ast.ClassDef( + a.string, + bases=b[0] if b else [], + keywords=b[1] if b else [], + body=c, + decorator_list=[], + LOCATIONS, + ) ) } @@ -914,21 +1016,18 @@ function_def[Union[ast.FunctionDef, ast.AsyncFunctionDef]]: function_def_raw[Union[ast.FunctionDef, ast.AsyncFunctionDef]]: | invalid_def_raw - | 'def' n=NAME &&'(' params=[params] ')' a=['->' z=expression { z }] &&':' tc=[func_type_comment] b=block { - ast.FunctionDef( - name=n.string, - args=params or self.make_arguments(None, [], None, [], None), - returns=a, - body=b, - type_comment=tc, - LOCATIONS, - ) - } - | 'async' 'def' n=NAME &&'(' params=[params] ')' a=['->' z=expression { z }] &&':' tc=[func_type_comment] b=block { - self.check_version( - (3, 5), - "Async functions are", - ast.AsyncFunctionDef( + | 'def' n=NAME t=[type_params] &&'(' params=[params] ')' a=['->' z=expression { z }] &&':' tc=[func_type_comment] b=block { + ( + ast.FunctionDef( + name=n.string, + args=params or self.make_arguments(None, [], None, [], None), + returns=a, + body=b, + type_comment=tc, + type_params=t or [], + LOCATIONS, + ) if sys.version_info >= (3, 12) else + ast.FunctionDef( name=n.string, args=params or self.make_arguments(None, [], None, [], None), returns=a, @@ -938,6 +1037,35 @@ function_def_raw[Union[ast.FunctionDef, ast.AsyncFunctionDef]]: ) ) } + | 'async' 'def' n=NAME t=[type_params] &&'(' params=[params] ')' a=['->' z=expression { z }] &&':' tc=[func_type_comment] b=block { + ( + self.check_version( + (3, 5), + "Async functions are", + ast.AsyncFunctionDef( + name=n.string, + args=params or self.make_arguments(None, [], None, [], None), + returns=a, + body=b, + type_comment=tc, + type_params=t or [], + LOCATIONS, + ) + ) if sys.version_info >= (3, 12) else + self.check_version( + (3, 5), + "Async functions are", + ast.AsyncFunctionDef( + name=n.string, + args=params or self.make_arguments(None, [], None, [], None), + returns=a, + body=b, + type_comment=tc, + LOCATIONS, + ) + ) + ) + } # Function parameters # ------------------- @@ -981,6 +1109,7 @@ slash_with_default[List[Tuple[ast.arg, Any]]]: star_etc[Tuple[Optional[ast.arg], List[Tuple[ast.arg, Any]], Optional[ast.arg]]]: | invalid_star_etc | '*' a=param_no_default b=param_maybe_default* c=[kwds] { (a, b, c) } + | '*' a=param_no_default_star_annotation b=param_maybe_default* c=[kwds] { (a, b, c) } | '*' ',' b=param_maybe_default+ c=[kwds] { (None, b, c) } | a=kwds { (None, [], a) } @@ -1004,6 +1133,9 @@ kwds[ast.arg]: param_no_default[ast.arg]: | a=param ',' tc=TYPE_COMMENT? { self.set_arg_type_comment(a, tc) } | a=param tc=TYPE_COMMENT? &')' { self.set_arg_type_comment(a, tc) } +param_no_default_star_annotation[ast.arg]: + | a=param_star_annotation ',' tc=TYPE_COMMENT? { self.set_arg_type_comment(a, tc) } + | a=param_star_annotation tc=TYPE_COMMENT? &')' { self.set_arg_type_comment(a, tc) } param_with_default[Tuple[ast.arg, Any]]: | a=param c=default ',' tc=TYPE_COMMENT? { (self.set_arg_type_comment(a, tc), c) } | a=param c=default tc=TYPE_COMMENT? &')' { (self.set_arg_type_comment(a, tc), c) } @@ -1011,7 +1143,11 @@ param_maybe_default[Tuple[ast.arg, Any]]: | a=param c=default? ',' tc=TYPE_COMMENT? { (self.set_arg_type_comment(a, tc), c) } | a=param c=default? tc=TYPE_COMMENT? &')' { (self.set_arg_type_comment(a, tc), c) } param: a=NAME b=annotation? { ast.arg(arg=a.string, annotation=b, LOCATIONS) } +param_star_annotation: a=NAME b=star_annotation { + ast.arg(arg=a.string, annotations=b, LOCATIONS) + } annotation: ':' a=expression { a } +star_annotation: ':' a=star_expression { a } default: '=' a=expression { a } | invalid_default # If statement @@ -1101,6 +1237,17 @@ try_stmt[ast.Try]: | 'try' &&':' b=block ex=except_block+ el=[else_block] f=[finally_block] { ast.Try(body=b, handlers=ex, orelse=el or [], finalbody=f or [], LOCATIONS) } + | 'try' &&':' b=block ex=except_star_block+ el=[else_block] f=[finally_block] { + self.check_version( + (3, 11), + "Exception groups are", + ( + ast.TryStar(body=b, handlers=ex, orelse=el or [], finalbody=f or [], LOCATIONS) + if sys.version_info >= (3, 11) + else None + ) + ) + } scenic_try_interrupt_stmt[s.TryInterrupt]: | 'try' &&':' b=block iw=interrupt_when_block+ ex=except_block* el=[else_block] f=[finally_block] { @@ -1129,6 +1276,12 @@ except_block[ast.ExceptHandler]: ast.ExceptHandler(type=e, name=t, body=b, LOCATIONS) } | 'except' ':' b=block { ast.ExceptHandler(type=None, name=None, body=b, LOCATIONS) } | invalid_except_stmt +except_star_block[ast.ExceptHandler]: + | invalid_except_star_stmt_indent + | 'except' '*' e=expression t=['as' z=NAME { z.string }] ':' b=block { + ast.ExceptHandler(type=e, name=t, body=b, LOCATIONS) + } + | invalid_except_stmt finally_block[list]: | invalid_finally_stmt | 'finally' &&':' a=block { a } @@ -1358,6 +1511,82 @@ keyword_patterns: keyword_pattern: | arg=NAME '=' value=pattern { (arg.string, value) } +# Type statement +# --------------- + +type_alias["ast.TypeAlias"]: + | "type" n=NAME t=[type_params] '=' b=expression { + self.check_version( + (3, 12), + "Type statement is", + ( + ast.TypeAlias( + name=ast.Name( + id=n.string, + ctx=Store, + lineno=n.start[0], + col_offset=n.start[1], + end_lineno=n.end[0], + end_col_offset=n.end[1], + ), + type_params=t or [], + value=b, + LOCATIONS + ) + if sys.version_info >= (3, 12) + else None + ) + ) + } + +# Type parameter declaration +# -------------------------- + +type_params[list]: '[' t=type_param_seq ']' { + self.check_version( + (3, 12), + "Type parameter lists are", + t + ) + } + +type_param_seq: a=','.type_param+ [','] { a } + +type_param (memo): + | a=NAME b=[type_param_bound] { + ast.TypeVar(name=a.string, bound=b, LOCATIONS) + if sys.version_info >= (3, 12) + else object() + } + | '*' a=NAME colon=":" e=expression { + self.raise_syntax_error_starting_from( + "cannot use constraints with TypeVarTuple" + if isinstance(e, ast.Tuple) + else "cannot use bound with TypeVarTuple", + colon + ) + } + | '*' a=NAME { + ast.TypeVarTuple(name=a.string, LOCATIONS) + if sys.version_info >= (3, 12) + else object() + } + | '**' a=NAME colon=":" e=expression { + self.raise_syntax_error_starting_from( + "cannot use constraints with ParamSpec" + if isinstance(e, ast.Tuple) + else "cannot use bound with ParamSpec", + colon + ) + } + | '**' a=NAME { + ast.ParamSpec(name=a.string, LOCATIONS) + if sys.version_info >= (3, 12) + else object() + } + +type_param_bound: ":" e=expression { e } + # EXPRESSIONS # ----------- @@ -1573,6 +1802,7 @@ bitwise_or: | scenic_visible_from | scenic_not_visible_from | scenic_can_see + | scenic_intersects | a=bitwise_or '|' b=bitwise_xor { ast.BinOp(left=a, op=ast.BitOr(), right=b, LOCATIONS) } | bitwise_xor @@ -1582,6 +1812,8 @@ scenic_not_visible_from: a=bitwise_or "not" "visible" 'from' b=bitwise_xor { s.N scenic_can_see: a=bitwise_or "can" "see" b=bitwise_xor { s.CanSeeOp(left=a, right=b, LOCATIONS) } +scenic_intersects: a=bitwise_or "intersects" b=bitwise_xor { s.IntersectsOp(left=a, right=b, LOCATIONS) } + bitwise_xor: | scenic_offset_along | a=bitwise_xor '^' b=bitwise_and { ast.BinOp(left=a, op=ast.BitXor(), right=b, LOCATIONS) } @@ -1730,7 +1962,7 @@ primary: slices: | a=slice !',' { a } - | a=','.slice+ [','] { + | a=','.(slice | starred_expression)+ [','] { ast.Tuple(elts=a, ctx=Load, LOCATIONS) if sys.version_info >= (3, 9) else ( @@ -1774,7 +2006,7 @@ atom: if sys.version_info >= (3, 9) else ast.Constant(value=None, kind=None, LOCATIONS) } - | &STRING strings + | &(STRING|FSTRING_START) strings | a=NUMBER { ast.Constant(value=ast.literal_eval(a.string), LOCATIONS) if sys.version_info >= (3, 9) else @@ -1949,10 +2181,53 @@ scenic_simulator_stmt: "simulator" e=expression { s.Simulator(value=e, LOCATIONS # LITERALS # ======== -strings[ast.Str] (memo): a=STRING+ { self.generate_ast_for_string(a) } +fstring_mid: + | fstring_replacement_field + | t=FSTRING_MIDDLE { ast.Constant(value=t.string, LOCATIONS) } +fstring_replacement_field: + | '{' a=(yield_expr | star_expressions) debug_expr="="? conversion=[fstring_conversion] format=[fstring_full_format_spec] rbrace='}' { + ast.FormattedValue( + value=a, + conversion=( + conversion.decode()[0] + if conversion else + (b'r'[0] if debug_expr else -1) + ), + format_spec=format, + LOCATIONS + ) + } + | invalid_replacement_field +fstring_conversion[int]: + | conv_token="!" conv=NAME { self.check_fstring_conversion(conv_token, conv) } +fstring_full_format_spec: + | ':' spec=fstring_format_spec* { + ast.JoinedStr( + values=spec if spec and (len(spec) > 1 or spec[0].value) else [], + LOCATIONS, + ) + } +fstring_format_spec: + | t=FSTRING_MIDDLE { ast.Constant(value=t.string, LOCATIONS) } + | fstring_replacement_field +fstring: + | a=FSTRING_START b=fstring_mid* c=FSTRING_END { + ast.JoinedStr(values=b, LOCATIONS) + } + +strings (memo): a=(fstring|STRING)+ { + self.concatenate_strings(a) if sys.version_info >= (3, 12) else self.generate_ast_for_string(a) + } + list[ast.List]: | '[' a=[star_named_expressions] ']' { ast.List(elts=a or [], ctx=Load, LOCATIONS) } + | a='**' expression '=' b=expression { + self.raise_syntax_error_known_range( + "cannot assign to keyword argument unpacking", a, b + ) + } + tuple[ast.Tuple]: | '(' a=[y=star_named_expression ',' z=[star_named_expressions] { [y] + (z or []) } ] ')' { @@ -2038,6 +2313,7 @@ kwargs[list]: | ','.kwarg_or_double_starred+ starred_expression: + | invalid_starred_expression | '*' a=expression { ast.Starred(value=a, ctx=Load, LOCATIONS) } kwarg_or_starred: @@ -2173,6 +2449,9 @@ invalid_arguments[NoReturn]: "invalid syntax. Maybe you meant '==' or ':=' instead of '='?", a, b ) } + | (args ',')? a=NAME b='=' &(',' | ')') { + self.raise_syntax_error_known_range("expected argument value expression", a, b) + } | a=args b=for_if_clauses { self.raise_syntax_error_known_range( "Generator expression must be parenthesized", @@ -2223,7 +2502,7 @@ expression_without_invalid[ast.AST]: | disjunction | lambdef invalid_legacy_expression: - | a=NAME !'(' b=expression_without_invalid { + | a=NAME !'(' b=star_expressions { self.raise_syntax_error_known_range( f"Missing parentheses in call to '{a.string}' . Did you mean {a.string}(...)?", a, b, ) if a.string in ("exec", "print") else @@ -2243,6 +2522,11 @@ invalid_expression[NoReturn]: | a=disjunction 'if' b=disjunction !('else'|':') { self.raise_syntax_error_known_range("expected 'else' after 'if' expression", a, b) } + | a='lambda' [lambda_params] b=':' &(FSTRING_MIDDLE | fstring_replacement_field) { + self.raise_syntax_error_known_range( + "f-string: lambda expressions are not allowed without parentheses", a, b + ) + } invalid_named_expression[NoReturn]: | a=expression ':=' expression { self.raise_syntax_error_known_location( @@ -2346,18 +2630,22 @@ invalid_dict_comprehension[NoReturn]: self.raise_syntax_error_known_location("dict unpacking cannot be used in dict comprehension", a) } invalid_parameters[NoReturn]: - | param_no_default* invalid_parameters_helper a=param_no_default { - self.raise_syntax_error_known_location("non-default argument follows default argument", a) - } - | param_no_default* a='(' param_no_default+ ','? b=')' { - self.raise_syntax_error_known_range("Function parameters cannot be parenthesized", a, b) - } | a="/" ',' { self.raise_syntax_error_known_location("at least one argument must precede /", a) } | (slash_no_default | slash_with_default) param_maybe_default* a='/' { self.raise_syntax_error_known_location("/ may appear only once", a) } + | slash_no_default? param_no_default* invalid_parameters_helper a=param_no_default { + self.raise_syntax_error_known_location( + "parameter without a default follows parameter with a default", a + ) + } + | param_no_default* a='(' param_no_default+ ','? b=')' { + self.raise_syntax_error_known_range( + "Function parameters cannot be parenthesized", a, b + ) + } | (slash_no_default | slash_with_default)? param_maybe_default* '*' (',' | param_no_default) param_maybe_default* a='/' { self.raise_syntax_error_known_location("/ must be ahead of *", a) } @@ -2393,18 +2681,22 @@ invalid_parameters_helper: # This is only there to avoid type errors | a=slash_with_default { [a] } | a=param_with_default+ invalid_lambda_parameters[NoReturn]: - | lambda_param_no_default* invalid_lambda_parameters_helper a=lambda_param_no_default { - self.raise_syntax_error_known_location("non-default argument follows default argument", a) - } - | lambda_param_no_default* a='(' ','.lambda_param+ ','? b=')' { - self.raise_syntax_error_known_range("Lambda expression parameters cannot be parenthesized", a, b) - } | a="/" ',' { self.raise_syntax_error_known_location("at least one argument must precede /", a) } | (lambda_slash_no_default | lambda_slash_with_default) lambda_param_maybe_default* a='/' { self.raise_syntax_error_known_location("/ may appear only once", a) } + | lambda_slash_no_default? lambda_param_no_default* invalid_lambda_parameters_helper a=lambda_param_no_default { + self.raise_syntax_error_known_location( + "parameter without a default follows parameter with a default", a + ) + } + | lambda_param_no_default* a='(' ','.lambda_param+ ','? b=')' { + self.raise_syntax_error_known_range( + "Lambda expression parameters cannot be parenthesized", a, b + ) + } | (lambda_slash_no_default | lambda_slash_with_default)? lambda_param_maybe_default* '*' (',' | lambda_param_no_default) lambda_param_maybe_default* a='/' { self.raise_syntax_error_known_location("/ must be ahead of *", a) } @@ -2455,6 +2747,12 @@ invalid_group[NoReturn]: | '(' a='**' expression ')' { self.raise_syntax_error_known_location("cannot use double starred expression here", a) } +invalid_import: + | a='import' ','.dotted_name+ 'from' dotted_name { + self.raise_syntax_error_starting_from( + "Did you mean to use 'from ... import ...' instead?", a + ) + } invalid_import_from_targets[NoReturn]: | import_from_as_names ',' NEWLINE { self.raise_syntax_error("trailing comma not allowed without surrounding parentheses") @@ -2484,12 +2782,25 @@ invalid_try_stmt[NoReturn]: | 'try' ':' block !('except' | 'finally') { self.raise_syntax_error("expected 'except' or 'finally' block") } + | 'try' ':' block* except_block+ a='except' b='*' expression ['as' NAME] ':' { + self.raise_syntax_error_known_range( + "cannot have both 'except' and 'except*' on the same 'try'", a, b + ) + } + | 'try' ':' block* except_star_block+ a='except' [expression ['as' NAME]] ':' { + self.raise_syntax_error_known_location( + "cannot have both 'except' and 'except*' on the same 'try'", a + ) + } invalid_except_stmt[None]: - | 'except' a=expression ',' expressions ['as' NAME ] ':' { + | 'except' '*'? a=expression ',' expressions ['as' NAME ] ':' { self.raise_syntax_error_starting_from("multiple exception types must be parenthesized", a) } - | a='except' expression ['as' NAME ] NEWLINE { self.raise_syntax_error("expected ':'") } - | a='except' NEWLINE { self.raise_syntax_error("expected ':'") } + | a='except' '*'? expression ['as' NAME ] NEWLINE { self.raise_syntax_error("expected ':'") } + | a='except' '*'? NEWLINE { self.raise_syntax_error("expected ':'") } + | a='except' '*' (NEWLINE | ':') { + self.raise_syntax_error("expected one or more exception types") + } invalid_finally_stmt[NoReturn]: | a='finally' ':' NEWLINE !INDENT { self.raise_indentation_error( @@ -2507,6 +2818,12 @@ invalid_except_stmt_indent[NoReturn]: f"expected an indented block after 'except' statement on line {a.start[0]}" ) } +invalid_except_star_stmt_indent: + | a='except' '*' expression ['as' NAME ] ':' NEWLINE !INDENT { + self.raise_indentation_error( + f"expected an indented block after 'except*' statement on line {a.start[0]}" + ) + } invalid_match_stmt[NoReturn]: | "match" subject_expr !':' { self.check_version( @@ -2574,19 +2891,21 @@ invalid_while_stmt[NoReturn]: ) } invalid_for_stmt[NoReturn]: + | [ASYNC] 'for' star_targets 'in' star_expressions NEWLINE { self.raise_syntax_error("expected ':'") } | ['async'] a='for' star_targets 'in' star_expressions ':' NEWLINE !INDENT { self.raise_indentation_error( f"expected an indented block after 'for' statement on line {a.start[0]}" ) } invalid_def_raw[NoReturn]: - | ['async'] a='def' NAME '(' [params] ')' ['->' expression] ':' NEWLINE !INDENT { + | ['async'] a='def' NAME [type_params] '(' [params] ')' ['->' expression] ':' NEWLINE !INDENT { self.raise_indentation_error( f"expected an indented block after function definition on line {a.start[0]}" ) } invalid_class_def_raw[NoReturn]: - | a='class' NAME ['(' [arguments] ')'] ':' NEWLINE !INDENT { + | 'class' NAME [type_params] ['(' [arguments] ')'] NEWLINE { self.raise_syntax_error("expected ':'") } + | a='class' NAME [type_params] ['(' [arguments] ')'] ':' NEWLINE !INDENT { self.raise_indentation_error( f"expected an indented block after class definition on line {a.start[0]}" ) @@ -2611,6 +2930,46 @@ invalid_kvpair[None]: | expression ':' a='*' bitwise_or { self.raise_syntax_error_starting_from("cannot use a starred expression in a dictionary value", a) } + | expression a=':' &('}'|',') { + self.raise_syntax_error_known_location( + "expression expected after dictionary key and ':'", a + ) + } | expression a=':' { self.raise_syntax_error_known_location("expression expected after dictionary key and ':'", a) - } \ No newline at end of file + } +invalid_starred_expression: + | a='*' expression '=' b=expression { + self.raise_syntax_error_known_range( + "cannot assign to iterable argument unpacking", a, b + ) + } +invalid_replacement_field: + | '{' a='=' { self.raise_syntax_error_known_location("f-string: valid expression required before '='", a) } + | '{' a='!' { self.raise_syntax_error_known_location("f-string: valid expression required before '!'", a) } + | '{' a=':' { self.raise_syntax_error_known_location("f-string: valid expression required before ':'", a) } + | '{' a='}' { self.raise_syntax_error_known_location("f-string: valid expression required before '}'", a) } + | '{' !(yield_expr | star_expressions) { + self.raise_syntax_error_on_next_token( + "f-string: expecting a valid expression after '{'" + ) + } + | '{' (yield_expr | star_expressions) !('=' | '!' | ':' | '}') { + self.raise_syntax_error_on_next_token("f-string: expecting '=', or '!', or ':', or '}'") } + | '{' (yield_expr | star_expressions) '=' !('!' | ':' | '}') { + self.raise_syntax_error_on_next_token("f-string: expecting '!', or ':', or '}'") + } + | '{' (yield_expr | star_expressions) '='? invalid_conversion_character + | '{' (yield_expr | star_expressions) '='? ['!' NAME] !(':' | '}') { + self.raise_syntax_error_on_next_token("f-string: expecting ':' or '}'") + } + | '{' (yield_expr | star_expressions) '='? ['!' NAME] ':' fstring_format_spec* !'}' { + self.raise_syntax_error_on_next_token("f-string: expecting '}', or format specs") + } + | '{' (yield_expr | star_expressions) '='? ['!' NAME] !'}' { + self.raise_syntax_error_on_next_token("f-string: expecting '}'") + } + +invalid_conversion_character: + | '!' &(':' | '}') { self.raise_syntax_error_on_next_token("f-string: missing conversion character") } + | '!' !NAME { self.raise_syntax_error_on_next_token("f-string: invalid conversion character") } diff --git a/src/scenic/syntax/translator.py b/src/scenic/syntax/translator.py index 64f20a68f..994e65b8b 100644 --- a/src/scenic/syntax/translator.py +++ b/src/scenic/syntax/translator.py @@ -36,7 +36,7 @@ from typing import Optional from scenic.core.distributions import RejectionException, toDistribution -import scenic.core.dynamics as dynamics +from scenic.core.dynamics.scenarios import DynamicScenario import scenic.core.errors as errors from scenic.core.errors import InvalidScenarioError, PythonCompileError from scenic.core.lazy_eval import needsLazyEvaluation @@ -653,7 +653,7 @@ def constructScenarioFrom(namespace, scenarioName=None): modularScenarios = namespace["_scenarios"] def isModularScenario(thing): - return isinstance(thing, type) and issubclass(thing, dynamics.DynamicScenario) + return isinstance(thing, type) and issubclass(thing, DynamicScenario) if not scenarioName and isModularScenario(namespace.get("Main", None)): scenarioName = "Main" @@ -687,4 +687,7 @@ def isModularScenario(thing): if usePruning: pruning.prune(scenario, verbosity=errors.verbosityLevel) + # Validate scenario + scenario.validate() + return scenario diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index 28557b4dd..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", @@ -79,6 +79,7 @@ "RelativeTo", "OffsetAlong", "CanSee", + "Intersects", "Until", "Implies", "VisibleFromOp", @@ -189,18 +190,14 @@ TruncatedNormal, Uniform, ) -from scenic.core.dynamics import ( - Behavior, - BlockConclusion, - DynamicScenario, +from scenic.core.dynamics.behaviors import Behavior, Monitor +from scenic.core.dynamics.guards import ( GuardViolation, InvariantViolation, - Monitor, PreconditionViolation, - _makeSimulationTerminationAction, - _makeTerminationAction, - runTryInterrupt, ) +from scenic.core.dynamics.invocables import BlockConclusion, runTryInterrupt +from scenic.core.dynamics.scenarios import DynamicScenario from scenic.core.external_params import ( VerifaiDiscreteRange, VerifaiOptions, @@ -252,6 +249,7 @@ import sys import traceback import typing +import warnings from scenic.core.distributions import ( Distribution, @@ -264,6 +262,7 @@ needsSampling, toDistribution, ) +from scenic.core.dynamics.actions import _EndScenarioAction, _EndSimulationAction import scenic.core.errors as errors from scenic.core.errors import InvalidScenarioError, ScenicSyntaxError from scenic.core.external_params import ExternalParameter @@ -317,6 +316,7 @@ evaluatingGuard = False mode2D = False _originalConstructibles = (Point, OrientedPoint, Object) +BUFFERING_PITCH = 0.1 ## APIs used internally by the rest of Scenic @@ -396,6 +396,8 @@ def deactivate(): # Instance/Object creation + + def registerInstance(inst): """Add a Scenic instance to the global list of created objects. @@ -631,6 +633,21 @@ def executeInGuard(): evaluatingGuard = False +def _makeTerminationAction(agent, line): + assert activity == 0 + if agent: + scenario = agent._parentScenario() + assert scenario is not None + else: + scenario = None + return _EndScenarioAction(scenario, line) + + +def _makeSimulationTerminationAction(line): + assert activity == 0 + return _EndSimulationAction(line) + + ### Parsing support @@ -1353,6 +1370,15 @@ def canSeeHelper(X, Y, objects): return canSeeHelper(X, Y, objects) +@distributionFunction +def Intersects(X, Y): + """The :scenic:`{X} intersects {Y}` operator.""" + if isA(X, Object): + return X.intersects(Y) + else: + return Y.intersects(X) + + ### Specifiers @@ -1425,15 +1451,15 @@ def On(thing): if isA(thing, Object): # Target is an Object: use its onSurface. target = thing.onSurface + elif canCoerce(thing, Vector, exact=True): + # Target is a vector + target = toVector(thing) elif canCoerce(thing, Region): # Target is a region (or could theoretically be coerced to one), # so we can use it as a target. - target = thing + target = toType(thing, Region) else: - # Target is a vector, so we can use it as a target. - target = toType( - thing, Vector, 'specifier "on R" with R not a Region, Object, or Vector' - ) + raise TypeError('specifier "on R" with R not a Region, Object, or Vector') props = {"position": 1} @@ -1496,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): @@ -1588,10 +1619,28 @@ def VisibleFrom(base): if not isA(base, Point): raise TypeError('specifier "visible from O" with O not a Point') + def helper(self): + if mode2D: + position = Region.uniformPointIn(base.visibleRegion) + else: + containing_region = ( + currentScenario._workspace.region + if self.regionContainedIn is None + and currentScenario._workspace is not None + else self.regionContainedIn + ) + position = ( + Region.uniformPointIn(everywhere, tag="visible") + if containing_region is None + else Region.uniformPointIn(containing_region) + ) + + return {"position": position, "_observingEntity": base} + return Specifier( "Visible/VisibleFrom", {"position": 3, "_observingEntity": 1}, - {"position": Region.uniformPointIn(base.visibleRegion), "_observingEntity": base}, + DelayedArgument({"regionContainedIn"}, helper), ) @@ -1625,9 +1674,8 @@ def helper(self): if mode2D: position = Region.uniformPointIn(region.difference(base.visibleRegion)) else: - position = Region.uniformPointIn( - convertToFootprint(region).difference(base.visibleRegion) - ) + # We can't limit the available region since any spot could potentially be occluded. + position = Region.uniformPointIn(convertToFootprint(region)) return {"position": position, "_nonObservingEntity": base} @@ -2026,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 33dc99495..b7293ab3c 100644 --- a/tests/core/test_regions.py +++ b/tests/core/test_regions.py @@ -3,11 +3,23 @@ import pytest 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): + samples = [] + for _ in range(num_samples): + try: + samples.append(region.uniformPointInner()) + except RejectionException: + pass + return samples def test_all_region(): @@ -63,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(): @@ -77,6 +89,11 @@ def test_circular_sampling(): xs, ys, zs = zip(*pts) assert sum(x >= 4 for x in xs) >= 1250 assert sum(y >= -3 for y in ys) >= 1250 + assert CircularRegion(Vector(4, 5, 2), 2).uniformPointInner().z == 2 + + +def test_sector_sampling(): + assert SectorRegion(Vector(4, 5, 2), 2, 1, 1).uniformPointInner().z == 2 def test_rectangular_region(): @@ -91,11 +108,12 @@ 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)) assert minx == pytest.approx(0.5 - math.sqrt(3)) + assert RectangularRegion(Vector(2.5, 4.5, 3), 1, 1, 1).uniformPointInner().z == 3 def test_polyline_region(): @@ -118,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) @@ -187,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) @@ -201,6 +219,36 @@ def test_polygon_region(): d = poly.difference(poly2) assert isinstance(d, PolygonalRegion) assert not d.containsPoint((2, 2)) + assert ( + 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(): + poly1 = PolygonalRegion([(1, 0), (1, 1), (2, 1), (2, 0)], z=2) + poly2 = PolygonalRegion([(-1, 0), (-1, 1), (0, 1), (0, 0)], z=2) + union = PolygonalRegion.unionAll((poly1, nowhere, poly2)) + assert isinstance(union, PolygonalRegion) + assert union.z == 2 + assert union.containsPoint((1.5, 0.5)) + assert union.containsPoint((-0.5, 0.5)) + assert not union.containsPoint((0.5, 0.5)) + + poly3 = PolygonalRegion([(0, 0), (1, 1), (1, 0)], z=1) + with pytest.raises(ValueError): + PolygonalRegion.unionAll((poly1, poly3)) + + with pytest.raises(TypeError): + PolygonalRegion.unionAll((poly1, everywhere)) def test_polygon_sampling(): @@ -219,6 +267,14 @@ def test_polygon_sampling(): assert sum(y >= 1.5 for y in ys) >= 1250 +def test_polygon_trueContainsPoint(): + r = CircularRegion((0, 0), 1, resolution=64) + + assert r._trueContainsPoint(Vector(0, 0, 0)) + assert not r._trueContainsPoint(Vector(0, 0, 1)) + assert not r._trueContainsPoint(Vector(1000, 1000, 0)) + + def test_mesh_region_fromFile(getAssetPath): MeshVolumeRegion.fromFile( getAssetPath("meshes/classic_plane.obj.bz2"), @@ -245,18 +301,13 @@ def test_mesh_surface_region_negative_dimension(): MeshSurfaceRegion(mesh, dimensions=dims) -def test_mesh_operation_blender(): - r1 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1), engine="blender") - r2 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2), engine="blender") - - r = r1.intersect(r2) +def test_mesh_operation(): + r1 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2)) + r2 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1)) - -def test_mesh_operation_scad(): - r1 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1), engine="scad") - r2 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2), engine="scad") - - r = r1.intersect(r2) + r1.intersect(r2) + r1.union(r2) + r1.difference(r2) def test_mesh_volume_region_sampling(): @@ -288,19 +339,17 @@ def test_mesh_intersects(): def test_mesh_empty_intersection(): - for engine in ["blender", "scad"]: - r1 = BoxRegion(position=(0, 0, 0), engine=engine) - r2 = BoxRegion(position=(10, 10, 10), engine=engine) + r1 = BoxRegion(position=(0, 0, 0)) + r2 = BoxRegion(position=(10, 10, 10)) - assert isinstance(r1.intersect(r2), EmptyRegion) + assert isinstance(r1.intersect(r2), EmptyRegion) def test_mesh_empty_difference(): - for engine in ["blender", "scad"]: - r1 = BoxRegion(dimensions=(1, 1, 1), engine=engine) - r2 = BoxRegion(dimensions=(2, 2, 2), engine=engine) + r1 = BoxRegion(dimensions=(1, 1, 1)) + r2 = BoxRegion(dimensions=(2, 2, 2)) - assert isinstance(r1.difference(r2), EmptyRegion) + assert isinstance(r1.difference(r2), EmptyRegion) def test_path_region(): @@ -350,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(): @@ -518,13 +567,123 @@ 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(): + encoding = [ + [[0, 0, 0], [0, 1, 0], [0, 0, 0]], + [[0, 1, 1], [0, 1, 0], [1, 1, 0]], + [[0, 0, 0], [0, 1, 0], [0, 0, 0]], + ] + + vg1 = trimesh.voxel.VoxelGrid(encoding=numpy.asarray(encoding)) + + centering_matrix = translation_matrix((vg1.scale - vg1.extents) / 2) + vg1.apply_transform(centering_matrix) + + scale = vg1.extents / numpy.array((3, 3, 3)) + scale_matrix = numpy.eye(4) + scale_matrix[:3, :3] /= scale + vg1.apply_transform(scale_matrix) + + position_matrix = translation_matrix((4, 5, 6)) + vg1.apply_transform(position_matrix) + + vr1 = VoxelRegion(vg1) + + assert vr1.containsPoint((4, 5, 6)) + assert vr1.containsPoint((4, 6, 5)) + assert vr1.containsPoint((4, 4, 7)) + assert not vr1.containsPoint((4, 6, 7)) + assert not vr1.containsPoint((4, 4, 5)) + assert not vr1.containsPoint((100, 100, 100)) + + for _ in range(100): + sampled_pt = vr1.uniformPointInner() + assert vr1.containsPoint(sampled_pt) + + assert vr1.AABB == ((2.5, 3.5, 4.5), (5.5, 6.5, 7.5)) + + vg2 = trimesh.voxel.VoxelGrid(encoding=numpy.asarray(encoding)) + + centering_matrix = translation_matrix((vg2.scale - vg2.extents) / 2) + vg2.apply_transform(centering_matrix) + + scale = vg2.extents / numpy.array((5, 5, 3)) + scale_matrix = numpy.eye(4) + scale_matrix[:3, :3] /= scale + vg2.apply_transform(scale_matrix) + + vr2 = VoxelRegion(vg2) + + assert vr2.size == pytest.approx((7 / 27) * 5 * 5 * 3) + assert vr2.dimensionality == 3 + + +def test_mesh_voxelization(getAssetPath): + plane_region = MeshVolumeRegion.fromFile(getAssetPath("meshes/classic_plane.obj.bz2")) + vr = plane_region.voxelized(max(plane_region.mesh.extents) / 100) + + for sampled_pt in trimesh.sample.volume_mesh(plane_region.mesh, 100): + assert vr.containsPoint(sampled_pt) + + for _ in range(100): + sampled_pt = vr.uniformPointInner() + assert vr.containsPoint(sampled_pt) + + +def test_voxel_to_mesh(): + orig_mesh = BoxRegion(rotation=(math.pi / 4, math.pi / 4, 0), position=(1, 1, 1)) + voxel = orig_mesh.voxelized(max(orig_mesh.mesh.extents) / 10) + mesh = voxel.mesh + + assert isinstance(mesh, MeshVolumeRegion) + + voxel_pts = sample_ignoring_rejections(voxel, 100) + mesh_pts = sample_ignoring_rejections(mesh, 100) + + assert all(voxel.containsPoint(pt) for pt in mesh_pts) + assert all(mesh.containsPoint(pt) for pt in voxel_pts) + + +def test_empty_erosion(): + box_region = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1)) + vr = box_region.voxelized(pitch=0.1) + erosion = vr.dilation(iterations=-6) + assert isinstance(erosion, EmptyRegion) + + +def test_intersection_sampler(): + reg1 = BoxRegion(position=(0, 0, 0), dimensions=(0.5, 1, 1)) + reg2 = AllRegion("all") + reg3 = CircularRegion((0, 0, 0), 1) + reg4 = reg3.footprint + + regions = (reg1, reg2, reg3, reg4) + + intersection_region = IntersectionRegion(*regions) + + for pt in sample_ignoring_rejections(intersection_region, 100): + assert all(reg.containsPoint(pt) for reg in regions) + + +def test_union_sampler(): + reg1 = BoxRegion(position=(0, 0, 0), dimensions=(0.5, 1, 1)) + reg2 = CircularRegion((0, 0, 0), 1) + + regions = (reg1, reg2) + + union_region = UnionRegion(*regions) + + for pt in sample_ignoring_rejections(union_region, 100): + assert any(reg.containsPoint(pt) for reg in regions) # ViewRegion tests -H_ANGLES = [0.1, 45, 90, 135, 179.9, 180, 180.1, 225, 270, 315, 359.9, 360] +H_ANGLES = [0.95, 45, 90, 135, 177.5, 180, 180.01, 225, 270, 315, 358.99, 360] -V_ANGLES = [0.1, 45, 90, 135, 179.9, 180] +V_ANGLES = [0.95, 45, 90, 135, 177.5, 180] VISIBLE_DISTANCES = [1, 25, 50] @@ -582,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}, @@ -617,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() @@ -658,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_serialization.py b/tests/core/test_serialization.py index 79ca6c839..af0b6705e 100644 --- a/tests/core/test_serialization.py +++ b/tests/core/test_serialization.py @@ -5,6 +5,7 @@ """ import io +import math import random import subprocess import sys @@ -171,9 +172,12 @@ def test_object_with_encodeTo(self): from scenic.simulators.utils.colors import Color checkValueEncoding(Color(0.5, 1.0, 0.2), Color) - from scenic.core.vectors import Vector + from scenic.core.vectors import Orientation, Vector checkValueEncoding(Vector(-7.5, 42), Vector) + checkValueEncoding( + Orientation.fromEuler(0.2 * math.pi, 0.6 * math.pi, 0), Orientation + ) class Foo: def __init__(self, x): @@ -202,6 +206,19 @@ def test_simple_scene(self): assert scenario.sceneToBytes(scene2) == data assertSceneEquivalence(scene1, scene2) + def test_scene_random_orientation(self): + program = """ + box = BoxRegion(dimensions=(5,5,5)) + new Object in box, facing (Range(0,360) deg, Range(0,360) deg, Range(0,360) deg) + new Object on box.getSurfaceRegion() + """ + scenario = compileScenic(program) + scene1 = sampleScene(scenario, maxIterations=10) + data = scenario.sceneToBytes(scene1) + scene2 = scenario.sceneFromBytes(data) + assert scenario.sceneToBytes(scene2) == data + assertSceneEquivalence(scene1, scene2) + def test_scene_comment(self): """Adding comments to a scenario should not break deserialization.""" sc1 = compileScenic(simpleScenario) 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/core/test_utils.py b/tests/core/test_utils.py index f82d44799..370dc29b8 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -4,18 +4,12 @@ import pytest import trimesh -from scenic.core.utils import loadMesh, repairMesh +from scenic.core.utils import repairMesh, unifyMesh @pytest.mark.slow def test_mesh_repair(getAssetPath): - plane_mesh = loadMesh( - path=getAssetPath("meshes/classic_plane.obj.bz2"), - filetype="obj", - compressed=True, - binary=False, - ) - + plane_mesh = trimesh.load(getAssetPath("meshes/classic_plane.obj.bz2"), force="mesh") # Test simple fix inverted_mesh = plane_mesh.copy() inverted_mesh.invert() @@ -46,3 +40,23 @@ def test_mesh_repair(getAssetPath): with pytest.raises(ValueError): repairMesh(plane) + + +@pytest.mark.slow +def test_unify_mesh(): + # Create nested sphere + nested_sphere = ( + trimesh.creation.icosphere(radius=5) + .difference(trimesh.creation.icosphere(radius=4)) + .union(trimesh.creation.icosphere(radius=3)) + .difference(trimesh.creation.icosphere(radius=2)) + ) + + # Manually append a box + bad_mesh = trimesh.util.concatenate( + nested_sphere, trimesh.creation.box(bounds=((0, 0, 0), (3, 5, 3))) + ) + + fixed_mesh = unifyMesh(bad_mesh) + assert fixed_mesh.is_volume + assert fixed_mesh.body_count == 3 diff --git a/tests/core/test_vectors.py b/tests/core/test_vectors.py index 44b046301..82f8be7ff 100644 --- a/tests/core/test_vectors.py +++ b/tests/core/test_vectors.py @@ -1,3 +1,5 @@ +import pytest + from scenic.core.distributions import Options, underlyingFunction from scenic.core.lazy_eval import ( DelayedArgument, @@ -33,6 +35,23 @@ def test_orientation_equality(): assert o1 != o3 and not o1.approxEq(o3) +def test_orientation_localAnglesFor(): + parent = Orientation.fromEuler(math.pi / 4, math.pi / 4, 0) + target = Orientation.fromEuler(-3 * math.pi / 4, math.pi / 4, math.pi / 4) + angles = parent.localAnglesFor(target) + assert angles == pytest.approx((-3 * math.pi / 4, math.pi / 2, 0)) + + for i in range(100): + parent = Orientation.fromEuler( + *[random.uniform(-math.pi, math.pi) for _ in range(3)] + ) + target = Orientation.fromEuler( + *[random.uniform(-math.pi, math.pi) for _ in range(3)] + ) + local = Orientation.fromEuler(*parent.localAnglesFor(target)) + assert target.approxEq(parent * local) + + def test_distribution_method_encapsulation(): vf = VectorField("Foo", lambda pos: 0) pt = vf.followFrom(Vector(0, 0), Options([1, 2]), steps=1) 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/domains/driving/test_network.py b/tests/domains/driving/test_network.py index 0104acf46..4f1394198 100644 --- a/tests/domains/driving/test_network.py +++ b/tests/domains/driving/test_network.py @@ -33,22 +33,22 @@ def test_element_tolerance(cached_maps, pytestconfig): tol = 0.05 network = Network.fromFile(path, tolerance=tol) drivable = network.drivableRegion + toofar = drivable.buffer(2 * tol).difference(drivable.buffer(1.5 * tol)) + toofar_noint = toofar.difference(network.intersectionRegion) road = network.roads[0] nearby = road.buffer(tol).difference(road) - rounds = 1 if pytestconfig.getoption("--fast") else 20 + rounds = 30 if pytestconfig.getoption("--fast") else 300 for i in range(rounds): pt = None while not pt or pt in drivable: pt = nearby.uniformPointInner() assert network.elementAt(pt) is not None assert network.roadAt(pt) is not None - toofar = drivable.buffer(2 * tol).difference(drivable.buffer(1.5 * tol)) pt = toofar.uniformPointInner() assert network.roadAt(pt) is None with pytest.raises(RejectionException): network.roadAt(pt, reject=True) - toofar = toofar.difference(network.intersectionRegion) - pt = toofar.uniformPointInner() + pt = toofar_noint.uniformPointInner() assert network.elementAt(pt) is None assert not network.nominalDirectionsAt(pt) @@ -137,8 +137,9 @@ def checkGroup(group): assert section.group is group assert section.road is road assert section.isForward == (group is road.forwardLanes) - pt = section.uniformPointInner() - assert lane.sectionAt(pt) is section + for i in range(30): + pt = section.uniformPointInner() + assert lane.sectionAt(pt) is section fastSlow = (section._fasterLane, section._slowerLane) leftRight = (section._laneToLeft, section._laneToRight) if section._fasterLane: @@ -179,8 +180,9 @@ def checkGroup(group): section.laneToRight for maneuver in lane.maneuvers: assert maneuver.startLane is lane - pt = lane.uniformPointInner() - assert group.laneAt(pt) is lane + for i in range(30): + pt = lane.uniformPointInner() + assert group.laneAt(pt) is lane if road.forwardLanes: checkGroup(road.forwardLanes) diff --git a/tests/simulators/carla/test_actions.py b/tests/simulators/carla/test_actions.py new file mode 100644 index 000000000..f0aede475 --- /dev/null +++ b/tests/simulators/carla/test_actions.py @@ -0,0 +1,122 @@ +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(30): + if isCarlaServerRunning(): + break + time.sleep(1) + + # Extra 5 seconds to ensure server startup + time.sleep(5) + + base = getAssetPath("maps/CARLA") + + def _getCarlaSimulator(town): + path = os.path.join(base, f"{town}.xodr") + simulator = CarlaSimulator(map_path=path, carla_map=town) + 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 78% rename from tests/simulators/carla/test_carla.py rename to tests/simulators/carla/test_basic.py index 2dc99c65b..205865fc1 100644 --- a/tests/simulators/carla/test_carla.py +++ b/tests/simulators/carla/test_basic.py @@ -8,6 +8,17 @@ ) +def test_map_param_parse(getAssetPath): + mapPath = getAssetPath("maps/CARLA/Town01.xodr") + code = f""" + param map = r'{mapPath}' + model scenic.simulators.carla.model + ego = new Car + """ + scenario = compileScenic(code, mode2D=True) + assert scenario.params["carla_map"] == "Town01" + + def test_basic(loadLocalScenario): scenario = loadLocalScenario("basic.scenic", mode2D=True) scenario.generate(maxIterations=1000) 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/gta/test_gta.py b/tests/simulators/gta/test_gta.py index 0e9b50a8a..d211ea29c 100644 --- a/tests/simulators/gta/test_gta.py +++ b/tests/simulators/gta/test_gta.py @@ -31,6 +31,21 @@ def test_bumper_to_bumper(loadLocalScenario): GTA.Config(scene) +def test_time_near_midnight(): + scenario = compileScenic( + f""" + from scenic.simulators.gta.map import setLocalMap + setLocalMap(r"{__file__}", "map.npz") + model scenic.simulators.gta.model + param time = 1439.9 + ego = new EgoCar + """, + mode2D=True, + ) + scene, _ = scenario.generate(maxIterations=50) + GTA.Config(scene) + + def test_make_map(request, tmp_path): from scenic.simulators.gta.interface import Map 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/simulators/utils/test_colors.py b/tests/simulators/utils/test_colors.py index f01f377cd..388fa6ab3 100644 --- a/tests/simulators/utils/test_colors.py +++ b/tests/simulators/utils/test_colors.py @@ -1,6 +1,16 @@ import pytest -from scenic.simulators.utils.colors import NoisyColorDistribution +from scenic.simulators.utils.colors import Color, NoisyColorDistribution +from tests.utils import sampleEgoFrom + + +def test_color_class(): + program = """ + from scenic.simulators.utils.colors import Color + ego = new Object with color Color.defaultCarColor() + """ + ego = sampleEgoFrom(program) + assert isinstance(ego.color, Color) def test_add_noise(): diff --git a/tests/syntax/test_basic.py b/tests/syntax/test_basic.py index 842eeecf4..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(): @@ -98,7 +104,7 @@ def test_param_read(): ego = new Object param q = Range(3, 5) param p = globalParameters.q + 10 - """ + """ ) assert 13 <= p <= 15 @@ -111,14 +117,18 @@ def test_param_write(): def test_mutate(): scenario = compileScenic( """ - ego = new Object at 3@1, facing 0 + class Thing: + foo: self.heading + + ego = new Thing at 3@1, facing 0 mutate - """ + """ ) ego1 = sampleEgo(scenario) assert ego1.position.x != pytest.approx(3) assert ego1.position.y != pytest.approx(1) assert ego1.heading != pytest.approx(0) + assert ego1.foo == 0 def test_mutate_object(): @@ -127,7 +137,7 @@ def test_mutate_object(): ego = new Object at 30@1, facing 0 other = new Object mutate other - """ + """ ) scene = sampleScene(scenario) ego, other = scene.objects @@ -144,7 +154,7 @@ def test_mutate_scaled(): """ ego = new Object at 3@1, facing 0 mutate ego by 4 - """ + """ ) ego1 = sampleEgo(scenario) assert ego1.position.x != pytest.approx(3) @@ -158,7 +168,7 @@ def test_mutate_nonobject(): """ ego = new Object mutate sin - """ + """ ) @@ -206,7 +216,7 @@ def test_show2D_zoom(): """ ego = new Object new Object at 10@20 - """ + """ ) scene = sampleScene(scenario) scene.show2D(zoom=1, block=False) @@ -226,7 +236,7 @@ def test_mode2D(): test_obj_1 = new Object in p.visibleRegion test_obj_2 = new Object in op.visibleRegion test_obj_3 = new Object in ego.visibleRegion - """, + """, mode2D=True, ) for _ in range(5): @@ -261,7 +271,7 @@ class TestClass: heading: 40 deg ego = new TestClass - """, + """, mode2D=True, ) scene, _ = scenario.generate() @@ -278,7 +288,7 @@ def test_mode2D_interference(): test_obj_1 = new Object in p.visibleRegion test_obj_2 = new Object in op.visibleRegion test_obj_3 = new Object in ego.visibleRegion - """ + """ scenario = compileScenic(program, mode2D=True) for _ in range(5): @@ -292,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_classes.py b/tests/syntax/test_classes.py index 76fa84392..65628adc3 100644 --- a/tests/syntax/test_classes.py +++ b/tests/syntax/test_classes.py @@ -14,7 +14,7 @@ def test_old_constructor_statement(): constructor Foo: blah: (19, -3) ego = new Foo with blah 12 - """ + """ ) @@ -25,7 +25,7 @@ class Foo(object): def __init__(self, x): self.x = x ego = new Object with width Foo(4).x - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -38,7 +38,7 @@ def test_invalid_attribute(): """ class Foo:\n blah[baloney_attr]: 4 - """ + """ ) @@ -48,7 +48,7 @@ def test_invalid_attribute_2(): """ class Foo:\n blah[additive, baloney_attr]: 4 - """ + """ ) @@ -58,7 +58,7 @@ def test_invalid_attribute_3(): """ class Foo:\n blah[additive, 'dynamic']: 4 - """ + """ ) @@ -68,7 +68,7 @@ def test_invalid_attribute_4(): """ class Foo:\n blah[additive + dynamic]: 4 - """ + """ ) @@ -79,7 +79,7 @@ class Foo: position: (3, 9, 0) flubber: -12 ego = new Foo - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -95,7 +95,7 @@ class Foo: bar: self.position.x + self.baz baz: 5 ego = new Foo at (10, 3) - """ + """ ) assert ego.bar == 15 @@ -106,7 +106,7 @@ def test_property_raw_self(): """ class Foo: bar: self - """ + """ ) @@ -118,7 +118,7 @@ class Foo: class Bar(Foo): flubber: 7 ego = new Bar - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -134,7 +134,7 @@ class Foo: class Bar(Foo): flubber[additive]: 7 ego = new Bar - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -151,7 +151,7 @@ class Parent: class Child(Parent): foo[additive]: 2 ego = new Child - """ + """ ) assert ego.foo == (2, 1) @@ -195,7 +195,7 @@ class Foo: pass new Object at (20, 0) if issubclass(Foo, Point): new Object at (30, 0) - """ + """ ) scene = sampleScene(scenario) assert len(scene.objects) == 4 diff --git a/tests/syntax/test_compiler.py b/tests/syntax/test_compiler.py index fe3e0fe85..1af4dbf9a 100644 --- a/tests/syntax/test_compiler.py +++ b/tests/syntax/test_compiler.py @@ -2154,6 +2154,14 @@ def test_can_see_op(self): case _: assert False + def test_intersects_op(self): + node, _ = compileScenicAST(IntersectsOp(Name("X"), Name("Y"))) + match node: + case Call(Name("Intersects"), [Name("X"), Name("Y")]): + assert True + case _: + assert False + # Test cases inherited from the old translator for checking edge cases diff --git a/tests/syntax/test_distributions.py b/tests/syntax/test_distributions.py index 1dd7fc4ff..c45572988 100644 --- a/tests/syntax/test_distributions.py +++ b/tests/syntax/test_distributions.py @@ -25,7 +25,7 @@ def lazyTestScenario(expr, offset="0"): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = ({offset} relative to vf).yaw ego = new Object at 0.5 @ 0, with output {expr} - """ + """ ) @@ -225,7 +225,7 @@ def test_method(): field = VectorField("Foo", lambda pos: pos[1]) ang = field[0 @ Range(1, 2)].yaw ego = new Object with output ang - """ + """ ) angles = [sampleEgo(scenario).output for i in range(60)] assert all(1 <= x <= 2 for x in angles) @@ -243,7 +243,7 @@ def bar(self, arg): return -arg vf = VectorField("Baz", lambda pos: 1 + pos.x) ego = new Object with foo Foo().bar(Range(100, 200) * (0 relative to vf).yaw) - """ + """ ) values = [sampleEgo(scenario).foo for i in range(60)] assert all(-200 <= x <= -100 for x in values) @@ -262,7 +262,7 @@ def bar(self, arg): return -arg.yaw * Range(100, 200) vf = VectorField("Baz", lambda pos: 1 + pos.x) ego = new Object with foo Foo().bar(0 relative to vf) - """ + """ ) values = [sampleEgo(scenario).foo for i in range(60)] assert all(-200 <= x <= -100 for x in values) @@ -276,7 +276,7 @@ def test_method_lazy_3(): reg = PolylineRegion([0@0, 2@0]) vf = VectorField('Foo', lambda pos: 1 + pos.x) ego = new Object with foo reg.distanceTo((1 @ (Range(0, 1) relative to vf).yaw)) - """ + """ ) fs = [sampleEgo(scenario).foo for i in range(60)] assert all(1 <= f <= 2 for f in fs) @@ -294,7 +294,7 @@ def bar(self, *args): return sum(args) vs = Uniform([5], [-2, -3]) ego = new Object with baz Foo().bar(Range(0, 1), *vs) - """ + """ ) bs = [sampleEgo(scenario).baz for i in range(60)] assert all(5 <= b <= 6 or -5 <= b <= -4 for b in bs) @@ -307,7 +307,7 @@ def test_attribute(): """ place = Uniform(1 @ 1, 2 @ 4, 3 @ 9) ego = new Object at place.x @ place.y - """ + """ ) xs = [sampleEgo(scenario).position.x for i in range(100)] assert all(x == 1 or x == 2 or x == 3 for x in xs) @@ -383,7 +383,7 @@ def test_list_param(): """ ego = new Object param p = [3, Uniform(1, 2)] - """ + """ ) ts = [sampleParamP(scenario) for i in range(60)] assert all(type(t) is list for t in ts) @@ -401,7 +401,7 @@ def test_list_param_lazy(): x = 0 relative to vf param p = Uniform([0, x], [0, x*2])[1] ego = new Object - """ + """ ) @@ -419,7 +419,7 @@ def test_list_sliced(): x = Uniform([1, 2, 3, 4], [5, 6, 7]) i = DiscreteRange(0, 1) ego = new Object with foo x[i:i+2] - """ + """ ) ss = [sampleEgo(scenario).foo for i in range(60)] opts = ([1, 2], [2, 3], [5, 6], [6, 7]) @@ -444,7 +444,7 @@ def test_list_nested(): """ mylist = Uniform(list(range(1000)), [1000]) ego = new Object with foo Uniform(*mylist) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(60)] assert 5 <= sum((v == 1000) for v in vs) <= 55 @@ -455,7 +455,7 @@ def test_list_nested_argument(): """ mylist = Uniform(list(range(1000)), [1, 1, 1, 1, 2000]) ego = new Object with foo max(*mylist) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(60)] assert 5 <= sum((v == 2000) for v in vs) <= 55 @@ -467,7 +467,7 @@ def test_list_filtered(): mylist = [Range(-10, -5), Range(3, 7), Range(-1, 1)] filtered = filter(lambda x: x > 0, mylist) ego = new Object with foo Uniform(*filtered) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(60)] assert all(v > 0 for v in vs) @@ -488,7 +488,7 @@ def test_list_filtered_empty_1(): mylist = [Range(-10, -5), Range(-3, 1)] filtered = filter(lambda x: x > 0, mylist) ego = new Object with foo Uniform(*filtered) - """ + """ ) vs = [sampleEgo(scenario, maxIterations=100).foo for i in range(60)] assert all(0 <= v <= 1 for v in vs) @@ -502,7 +502,7 @@ def test_list_filtered_empty_2(): mylist = [Range(-10, -5), Range(-3, 1)] filtered = filter(lambda x: x > 0, mylist) ego = new Object with foo Uniform(*filtered, 2) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(150)] assert all(0 <= v <= 1 or v == 2 for v in vs) @@ -529,7 +529,7 @@ def test_tuple_iteration(): data.append(item) ego = new Object at 2@2, with foo data require other.foo[1] == 3 - """, + """, maxIterations=60, ) assert type(ego.foo) is list @@ -541,7 +541,7 @@ def test_tuple_param(): """ ego = new Object param p = tuple([3, Uniform(1, 2)]) - """ + """ ) ts = [sampleParamP(scenario) for i in range(60)] assert all(type(t) is tuple for t in ts) @@ -557,7 +557,7 @@ def test_namedtuple(): from collections import namedtuple Data = namedtuple("Data", ["bar", "baz"]) ego = new Object with foo Data(bar=3, baz=Uniform(1, 2)) - """ + """ ) ts = [sampleEgo(scenario).foo for i in range(60)] assert all(t.bar == 3 for t in ts) @@ -589,7 +589,7 @@ def test_iter(): """ for x in Uniform([1, 2], [3, 4]): ego = new Object at x@0 - """ + """ ) @@ -601,7 +601,7 @@ def test_control_flow(): ego = new Object else: ego = new Object at 1@1 - """ + """ ) @@ -648,7 +648,7 @@ def test_reproducibility(): param foo = Uniform(1, 4, 9, 16, 25, 36) x = Range(0, 1) require x > 0.8 - """ + """ ) seeds = [random.randint(0, 100000) for i in range(10)] for seed in seeds: @@ -714,7 +714,7 @@ def test_resample(): """ x = Range(0, 1) ego = new Object at x @ resample(x) - """ + """ ) pos = sampleEgo(scenario).position assert pos.x != pos.y @@ -733,7 +733,7 @@ def test_shared_dependency(): """ x = Range(-1, 1) ego = new Object at (x * x) @ 0 - """ + """ ) xs = [sampleEgo(scenario).position.x for i in range(60)] assert all(0 <= x <= 1 for x in xs) @@ -748,7 +748,7 @@ def test_shared_dependency_lazy_1(): x = (1 relative to vf).yaw y = Uniform(0, x) ego = new Object with foo y, with bar y - """ + """ ) for i in range(60): ego = sampleEgo(scenario) @@ -763,7 +763,7 @@ def test_shared_dependency_lazy_2(): x = Range(0, 1) relative to vf ego = new Object at 1 @ 0, facing x other = new Object at -1 @ 0, facing x - """ + """ ) for i in range(60): scene = sampleScene(scenario, maxIterations=1) @@ -792,8 +792,24 @@ def test_object_expression(): v = Uniform((new Object at Range(-2,-1) @ 0), new Object at Range(1,2) @ 5).position.x ego = new Object facing v, at 0 @ 10 require abs(v) > 1.5 - """ + """ ) 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 3b4cb5c64..0bd724b03 100644 --- a/tests/syntax/test_dynamics.py +++ b/tests/syntax/test_dynamics.py @@ -33,13 +33,28 @@ def test_dynamic_property(): wait ego = new Object with behavior Foo terminate when ego.position.x >= 3 - """ + """ + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert len(actions) == 3 + + +def test_dynamic_final_property(): + scenario = compileScenic( + """ + behavior Foo(): + for i in range(3): + self.yaw = self.yaw + 0.1 + wait + ego = new Object with behavior Foo + terminate when ego.heading >= 0.25 + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert len(actions) == 3 -def test_dynamic_derived_property(): +def test_dynamic_cached_property(): scenario = compileScenic( """ behavior Foo(): @@ -48,7 +63,22 @@ def test_dynamic_derived_property(): wait ego = new Object with behavior Foo terminate when ego.left.position.y >= 3 - """ + """ + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert len(actions) == 3 + + +def test_dynamic_cached_method(): + scenario = compileScenic( + """ + behavior Foo(): + for i in range(3): + self.position = self.position + 0@1 + wait + ego = new Object with behavior Foo + terminate when ego.distanceTo(0@4) < 1 + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert len(actions) == 3 @@ -64,7 +94,7 @@ def test_current_time(): while True: take simulation().currentTime ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (0, 1, 2) @@ -87,7 +117,7 @@ def test_behavior_actions(): take 3 take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2) assert tuple(actions) == (3, 5) @@ -100,7 +130,7 @@ def test_behavior_multiple_actions(): take 1, 4, 9 take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) assert tuple(actions) == ((1, 4, 9), (5,)) @@ -113,7 +143,7 @@ def test_behavior_tuple_actions(): take (1, 4, 9) take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) assert tuple(actions) == ((1, 4, 9), (5,)) @@ -126,7 +156,7 @@ def test_behavior_list_actions(): take [1, 4, 9] take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) assert tuple(actions) == ((1, 4, 9), (5,)) @@ -142,7 +172,7 @@ def test_invalid_behavior_name(): behavior 101(): wait ego = new Object - """ + """ ) @@ -155,7 +185,7 @@ def test_behavior_no_actions(): behavior Bar(): Foo() # forgot to use 'do' ego = new Object with behavior Bar - """ + """ ) @@ -169,7 +199,7 @@ def test_behavior_stuck(monkeypatch): time.sleep(1.5) wait ego = new Object with behavior Foo - """ + """ ) monkeypatch.setattr(dynamics, "stuckBehaviorWarningTimeout", 1) with pytest.warns(dynamics.StuckBehaviorWarning): @@ -184,7 +214,7 @@ def test_behavior_create_object(): new Object at 10@10 wait ego = new Object with behavior Bar - """ + """ ) sampleResultOnce(scenario) @@ -197,7 +227,7 @@ def test_behavior_define_param(): param foo = 3 wait ego = new Object with behavior Bar - """ + """ ) sampleResultOnce(scenario) @@ -210,7 +240,7 @@ def test_behavior_illegal_yield(): yield 1 wait ego = new Object with behavior Foo - """ + """ ) with pytest.raises(ScenicSyntaxError): compileScenic( @@ -219,7 +249,7 @@ def test_behavior_illegal_yield(): yield from [] wait ego = new Object with behavior Foo - """ + """ ) @@ -232,7 +262,7 @@ def test_behavior_nested_defn(): behavior Bar(): wait ego = new Object with behavior Foo - """ + """ ) @@ -282,7 +312,7 @@ def test_behavior_object_argument(): wait other = new Object with flag 0, with behavior Bar ego = new Object at (10, 0), with behavior Foo(other) - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2) assert actions[1] == 1 @@ -299,7 +329,7 @@ def test_behavior_globals_read(): take other.position.x ego = new Object with behavior Foo other = new Object at Range(10, 20) @ 15 - """ + """ ) actions1 = sampleEgoActions(scenario, maxSteps=2) assert len(actions1) == 2 @@ -319,7 +349,7 @@ def test_behavior_globals_read_module(runLocally): while True: take helper4.foo ego = new Object with behavior Foo - """ + """ ) actions1 = sampleEgoActions(scenario, maxSteps=2) assert len(actions1) == 2 @@ -338,7 +368,7 @@ def test_behavior_globals_read_list(): take foo[1] ego = new Object with behavior Foo foo = [5, Range(10, 20)] - """ + """ ) actions1 = sampleEgoActions(scenario, maxSteps=2) assert len(actions1) == 2 @@ -363,7 +393,7 @@ def test_behavior_globals_write(): take (glob < 1) other = new Object with behavior Foo ego = new Object at 10@10, with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert len(actions) == 3 @@ -387,7 +417,7 @@ def test_behavior_namespace_interference(runLocally): behavior Foo(): take sub.subsub.myglobal ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario) assert len(actions) == 1 @@ -403,7 +433,7 @@ def test_behavior_self(): behavior Foo(): take self.bar ego = new Object with behavior Foo, with bar 3 - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (3,) @@ -416,7 +446,7 @@ def test_behavior_lazy(): behavior Foo(): take (1 relative to vf).yaw ego = new Object at 0.5@0, with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (pytest.approx(1.5),) @@ -435,7 +465,7 @@ def test_behavior_lazy_nested(): do Bar(); do Bar() new Object at -1.25@0, with behavior Baz ego = new Object at 0.5@0, with behavior Foo - """ + """ ) actions = sampleActions(scenario, maxSteps=2) assert tuple(actions) == (pytest.approx((1.5, -0.25)), pytest.approx((-0.5, -0.25))) @@ -450,7 +480,7 @@ def test_behavior_end_early(): behavior Foo(): take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (5, None, None) @@ -464,7 +494,7 @@ def test_terminate(): terminate take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (1,) @@ -481,7 +511,7 @@ def test_terminate_when(): take 2 ego = new Object with behavior Foo terminate when flag as termCond - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (1, 2) @@ -529,7 +559,7 @@ def test_behavior_ordering_default(): new Object with name 'A', with behavior Foo new Object with name 'B', at 10@0, with behavior Foo ego = new Object with name 'C', at 20@0, with behavior Foo - """ + """ ) scene = sampleScene(scenario) objsByName = {} @@ -558,7 +588,7 @@ def test_behavior_nesting(): do Foo(2) take 3 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 2, 3) @@ -574,7 +604,7 @@ def test_subbehavior_for_steps(): do Foo() for 3 steps take 2 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 1, 1, 2) @@ -590,7 +620,7 @@ def test_subbehavior_for_time(): do Foo() for 3 seconds take 2 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7, timestep=0.5) assert tuple(actions) == (1, 1, 1, 1, 1, 1, 2) @@ -606,7 +636,7 @@ def test_subbehavior_until(): do Foo() until simulation().currentTime == 2 take 2 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 1, 2, None) @@ -621,7 +651,7 @@ def test_subbehavior_incompatible_modifiers(): behavior Bar(): do Foo() for 5 steps until False ego = new Object with behavior Bar - """ + """ ) @@ -634,7 +664,7 @@ def test_subbehavior_misplaced_modifier(): behavior Bar(): do Foo() for 5 steps, Foo() ego = new Object with behavior Bar - """ + """ ) @@ -644,7 +674,7 @@ def test_behavior_invoke_mistyped(): behavior Foo(): do 12 ego = new Object with behavior Foo - """ + """ ) with pytest.raises(TypeError): sampleActions(scenario) @@ -659,7 +689,7 @@ def test_behavior_invoke_multiple(): behavior Bar(): do Foo(), Foo() ego = new Object with behavior Bar - """ + """ ) @@ -672,7 +702,7 @@ def func(a, *b, c=0, d=1, **e): behavior Foo(): take [func(4, 5, 6, blah=4, c=10)] ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == ([4, 2, 10, 1, 1],) @@ -689,7 +719,7 @@ def funcB(x): behavior Foo(): take funcA(funcB(5)) ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (11,) @@ -707,7 +737,7 @@ def func(): while True: take func() ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 3, 4) @@ -723,7 +753,7 @@ def test_behavior_precondition(): precondition: self.position.x > 0 take self.position.x ego = new Object at Range(-1, 1) @ 0, with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=1, maxIterations=1, maxScenes=50) @@ -739,7 +769,7 @@ def test_behavior_invariant(): take self.position.x self.position -= Range(0, 2) @ 0 ego = new Object at 1 @ 0, with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=3, maxIterations=50) @@ -771,7 +801,7 @@ def test_precondition_rejection(): ego = new Object at (0,0,0), with foo 0, with bar 0, with behavior MetaBehavior() - """ + """ ) scene = sampleScene(scenario) results = [sampleEgoActions(scenario, maxSteps=1) for _ in range(40)] @@ -807,7 +837,7 @@ def test_invariant_rejection(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) scene = sampleScene(scenario) result = sampleResultFromScene(scene, maxSteps=20) @@ -841,7 +871,7 @@ def test_precondition_rejection_choose(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) scene = sampleScene(scenario) result = sampleResultFromScene(scene, maxSteps=20) @@ -875,7 +905,7 @@ def test_invariant_rejection_choose(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) scene = sampleScene(scenario) result = sampleResultFromScene(scene, maxSteps=20) @@ -909,7 +939,7 @@ def test_precondition_rejection_shuffle(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) for _ in range(20): scene = sampleScene(scenario) @@ -944,7 +974,7 @@ def test_invariant_rejection_shuffle(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) for _ in range(20): scene = sampleScene(scenario) @@ -965,7 +995,7 @@ def test_choose_1(): behavior Bar(x): take x ego = new Object with behavior Foo - """ + """ ) ts = [sampleEgoActions(scenario, maxSteps=2) for i in range(40)] assert any(t[0] == 1 for t in ts) @@ -984,7 +1014,7 @@ def test_choose_2(): precondition: self.position.x == p take (self.position.x == p) ego = new Object at Uniform(1, 2) @ 0, with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=2) @@ -1000,7 +1030,7 @@ def test_choose_3(): behavior Sub(x): take x ego = new Object with behavior Foo - """ + """ ) xs = [sampleEgoActions(scenario)[0] for i in range(200)] assert all(x == 0 or x == 1 for x in xs) @@ -1016,7 +1046,7 @@ def test_choose_deadlock(): precondition: self.position.x == p wait ego = new Object at 3 @ 0, with behavior Foo - """ + """ ) result = sampleResultOnce(scenario) assert result is None @@ -1032,7 +1062,7 @@ def test_shuffle_1(): precondition: simulation().currentTime >= x take x ego = new Object with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=3) @@ -1047,7 +1077,7 @@ def test_shuffle_2(): behavior Sub(x): take x ego = new Object with behavior Foo - """ + """ ) ts = [sampleEgoActions(scenario, maxSteps=2) for i in range(30)] assert all(tuple(t) == (1, 3) or tuple(t) == (3, 1) for t in ts) @@ -1063,7 +1093,7 @@ def test_shuffle_3(): behavior Sub(x): take x ego = new Object with behavior Foo - """ + """ ) ts = [sampleEgoActions(scenario, maxSteps=2) for i in range(200)] assert all(tuple(t) == (0, 1) or tuple(t) == (1, 0) for t in ts) @@ -1079,7 +1109,7 @@ def test_shuffle_deadlock(): precondition: simulation().currentTime == 0 wait ego = new Object with behavior Foo - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -1097,7 +1127,7 @@ def test_behavior_require(): take x require x < 0 ego = new Object with behavior Foo - """ + """ ) for i in range(50): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=50, maxScenes=1) @@ -1114,7 +1144,7 @@ def test_behavior_require_scene(): take self.foo require self.foo < 0 ego = new Object with foo Range(-1, 1), with behavior Foo - """ + """ ) for i in range(50): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=1, maxScenes=50) @@ -1131,7 +1161,7 @@ def test_behavior_require_call(): require len(x) > 0 take [x] ego = new Object with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=1, maxIterations=30) @@ -1146,7 +1176,7 @@ def test_behavior_require_soft(): require[0.9] x >= 0 take x ego = new Object with behavior Foo - """ + """ ) xs = [] for i in range(350): @@ -1169,7 +1199,7 @@ def test_require_always(): self.blah += DiscreteRange(0, 1) ego = new Object with behavior Foo, with blah 0 require always ego.blah < 1 - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=50) @@ -1190,7 +1220,7 @@ def test_require_eventually(): self.blah += DiscreteRange(0, 1) ego = new Object with behavior Foo, with blah 0 require eventually ego.blah > 0 - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=50) @@ -1208,7 +1238,7 @@ def test_require_eventually_2(): require eventually ego.blah == 0 require eventually ego.blah == 1 require eventually ego.blah == 2 - """ + """ ) sampleEgoActions(scenario, maxSteps=3) @@ -1222,7 +1252,7 @@ def test_require_eventually_3(): self.blah += 1 ego = new Object with behavior Foo, with blah 0 require eventually ego.blah == -1 - """ + """ ) with pytest.raises(RejectSimulationException): sampleEgoActions(scenario, maxSteps=3) @@ -1237,7 +1267,7 @@ def test_require_next_1(): take self.blah ego = new Object with behavior Foo, with blah 0 require next ego.blah == 1 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1251,7 +1281,7 @@ def test_require_next_2(): take self.blah ego = new Object with behavior Foo, with blah 0 require next next ego.blah == 2 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1270,7 +1300,7 @@ def test_require_until(): take self.blah ego = new Object with behavior Foo, with blah 0 require ego.blah < 3 until ego.blah >= 3 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1284,7 +1314,7 @@ def test_require_until_2(): take self.blah ego = new Object with behavior Foo, with blah 0 require False until ego.blah > 3 - """ + """ ) with pytest.raises(RejectSimulationException): sampleEgoActions(scenario, maxSteps=5) @@ -1299,7 +1329,7 @@ def test_require_until_3(): take self.blah ego = new Object with behavior Foo, with blah 0 require True until False - """ + """ ) with pytest.raises(RejectSimulationException): sampleEgoActions(scenario, maxSteps=5) @@ -1314,7 +1344,7 @@ def test_require_implies_1(): take self.blah ego = new Object with behavior Foo, with blah 0 require ego.blah == 3 implies ego.blah % 2 == 1 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1328,7 +1358,7 @@ def test_require_implies_2(): take self.blah ego = new Object with behavior Foo, with blah 0 require always ego.blah % 2 == 0 implies next ego.blah % 2 == 1 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1342,7 +1372,7 @@ def test_require_implies_3(): take self.blah ego = new Object with behavior Foo, with blah 0 require always ego.blah % 2 == 0 implies ego.blah == 0 - """ + """ ) result = sampleResultOnce(scenario, maxSteps=5) assert result is None @@ -1365,7 +1395,7 @@ def test_monitor(): take self.blah self.blah += 1 ego = new Object with blah 0, with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (0, 1, 2, 3) @@ -1386,7 +1416,7 @@ def test_monitor_arguments(): take self.blah self.blah += 1 ego = new Object with blah 0, with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (0, 1, 2) @@ -1407,7 +1437,7 @@ def test_monitor_samplable_arguments(): take self.blah self.blah += 1 ego = new Object with blah 0, with behavior Foo - """ + """ ) lengths = [len(sampleEgoActions(scenario, maxSteps=5)) for i in range(60)] assert all(2 <= length <= 3 for length in lengths) @@ -1436,6 +1466,18 @@ def test_require_monitor_invalid(): sampleScene(scenario) +def test_require_monitor_error(): + with pytest.raises(ScenicSyntaxError): + compileScenic( + """ + monitor Monitor(): + wait + ego = new Object + require Monitor() + """ + ) + + def test_old_style_monitor(): with pytest.raises(ScenicSyntaxError): compileScenic( @@ -1479,7 +1521,7 @@ def test_invocable_signature(ty): {ty} Blah(foo, *bar, baz=12, **qux): wait ego = new Object with thing Blah - """ + """ ) sig = inspect.signature(ego.thing) params = tuple(sig.parameters.items()) @@ -1514,7 +1556,7 @@ def test_interrupt(): interrupt when simulation().currentTime % 3 == 2: take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=6) assert tuple(actions) == (1, 1, 2, 1, 1, 2) @@ -1530,7 +1572,7 @@ def test_interrupt_first(): interrupt when simulation().currentTime == 0: take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (2, 1, 1) @@ -1548,7 +1590,7 @@ def test_interrupt_priority(): interrupt when simulation().currentTime == 0: take 3 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (3, 2, 1) @@ -1567,7 +1609,7 @@ def test_interrupt_interrupted(): interrupt when simulation().currentTime == 1: take 4 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (2, 4, 3, 1, 1) @@ -1584,7 +1626,7 @@ def test_interrupt_actionless(): interrupt when i == 1: i = 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (1, 1, 1, None, None) @@ -1600,7 +1642,7 @@ def test_interrupt_define_local(): pass take i ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (1,) @@ -1617,7 +1659,7 @@ def test_interrupt_define_local_2(): abort take i ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (1,) @@ -1638,7 +1680,7 @@ def test_interrupt_no_handlers(): except Exception: take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (1, 2, None) @@ -1658,7 +1700,7 @@ def test_interrupt_except(): except Exception: take 4 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 4, None) @@ -1679,7 +1721,7 @@ def test_interrupt_except_else(): else: take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7) assert tuple(actions) == (1, 2, 3, 1, 1, 5, None) @@ -1703,7 +1745,7 @@ def test_interrupt_nested(): interrupt when simulation().currentTime == 1: take 3 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=6) assert tuple(actions) == (1, 3, 2, 1, 1, None) @@ -1725,7 +1767,7 @@ def test_interrupt_nested_2(): interrupt when simulation().currentTime == 2: take 4 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7) assert tuple(actions) == (1, 2, 4, 3, 1, 1, None) @@ -1744,7 +1786,7 @@ def test_interrupt_nested_3(): interrupt when simulation().currentTime == 1: take 3 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=6) assert tuple(actions) == (1, 3, 2, 1, 1, None) @@ -1766,7 +1808,7 @@ def test_interrupt_break(): break take 3 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 1, 2, None) @@ -1785,7 +1827,7 @@ def test_interrupt_break_2(): take 2 break ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 1, 1) @@ -1805,7 +1847,7 @@ def test_interrupt_continue(): continue take 3 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7) assert tuple(actions) == (4, 1, 2, 4, 1, 1, 4) @@ -1825,7 +1867,7 @@ def test_interrupt_continue_2(): continue take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (1, 2, 2, 1, 1) @@ -1845,7 +1887,7 @@ def test_interrupt_abort(): take 2 abort ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=8) assert tuple(actions) == (3, 1, 2, 3, 1, 1, 1, 3) @@ -1876,7 +1918,7 @@ def test_interrupt_return(): take 2 return ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (3, 1, 2, None) @@ -1923,7 +1965,7 @@ def test_interrupt_unassigned_local(): interrupt when i == 1: i = 2 ego = new Object with behavior Foo - """ + """ ) if sys.version_info >= (3, 10, 3): # see veneer.executeInBehavior exc_type = NameError @@ -1943,7 +1985,7 @@ def test_interrupt_guard_subbehavior(): interrupt when Foo(): wait ego = new Object with behavior Foo - """ + """ ) with pytest.raises(InvalidScenarioError): sampleEgoActions(scenario, maxSteps=1) @@ -1956,7 +1998,7 @@ def test_termination_reason_time(): scenario = compileScenic( """ ego = new Object - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.timeLimit @@ -1971,7 +2013,7 @@ def test_termination_reason_condition_1(): wait ego = new Object with behavior Foo terminate when ego.position.x >= 1 - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.scenarioComplete @@ -1986,7 +2028,7 @@ def test_termination_reason_condition_2(): wait ego = new Object with behavior Foo terminate simulation when ego.position.x >= 1 - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.simulationTerminationCondition @@ -1998,7 +2040,7 @@ def test_termination_reason_behavior(): behavior Foo(): terminate ego = new Object with behavior Foo - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.terminatedByBehavior @@ -2011,7 +2053,7 @@ def test_termination_reason_monitor(): terminate require monitor Foo() ego = new Object - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.terminatedByMonitor @@ -2032,7 +2074,7 @@ def test_record(): record initial ego.position as initial record final ego.position as final record ego.position as position - """ + """ ) result = sampleResult(scenario, maxSteps=4) assert result.records["initial"] == (0, 0, 0) @@ -2043,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 18d80bdf0..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 @@ -34,7 +47,7 @@ def test_illegal_constructor_name(): f""" class 3: pass - """ + """ ) with pytest.raises(ScenicSyntaxError): @@ -42,7 +55,7 @@ class 3: f""" class +: pass - """ + """ ) @@ -52,7 +65,7 @@ def test_illegal_constructor_superclass(): f""" class Foo(3): pass - """ + """ ) with pytest.raises(ScenicSyntaxError): @@ -60,7 +73,7 @@ class Foo(3): f""" class Foo(+): pass - """ + """ ) @@ -70,14 +83,14 @@ def test_malformed_constructor(): """ class Foo pass - """ + """ ) with pytest.raises(ScenicSyntaxError): compileScenic( """ class Foo(Bar: pass - """ + """ ) @@ -88,7 +101,7 @@ def test_new_python_class(): class PyCls(object): pass new PyCls - """ + """ ) @@ -136,7 +149,7 @@ def test_incomplete_multiline_string(): ''' x = """foobar wog - ''' + ''' ) diff --git a/tests/syntax/test_functions.py b/tests/syntax/test_functions.py index 7ba79305a..f2ea885eb 100644 --- a/tests/syntax/test_functions.py +++ b/tests/syntax/test_functions.py @@ -11,7 +11,7 @@ def test_max_min(): a = Range(0, 1) b = Range(0, 1) ego = new Object with foo max(a, b), with bar min(a, b) - """ + """ ) assert ego.foo >= ego.bar ego = sampleEgoFrom("ego = new Object with foo min([], default=Range(1,2))") @@ -56,7 +56,7 @@ def test_unpacking(): def func(*args, **kwargs): return [args, kwargs] ego = new Object with foo func(*[1,2,3], func=4) - """ + """ ) assert ego.foo == [(1, 2, 3), {"func": 4}] @@ -68,7 +68,7 @@ def func(x, y): return [y, x] pairs = Uniform([1,2], [3,4]) ego = new Object with foo func(*pairs) - """ + """ ) assert ego.foo[0] > ego.foo[1] diff --git a/tests/syntax/test_imports.py b/tests/syntax/test_imports.py index dbc2b3d72..c5b3ca0a5 100644 --- a/tests/syntax/test_imports.py +++ b/tests/syntax/test_imports.py @@ -90,7 +90,7 @@ def test_multiple_imports(runLocally): import helper ego = new Object import helper - """ + """ ) assert len(scenario.objects) == 2 scene = sampleScene(scenario) @@ -107,7 +107,7 @@ def test_import_in_try(runLocally): finally: y = 4 ego = new Caerbannog at x @ y - """ + """ ) @@ -120,7 +120,7 @@ def test_import_in_except(runLocally): except ImportError: from helper import Caerbannog ego = new Caerbannog - """ + """ ) @@ -146,7 +146,7 @@ def test_import_override_param(): param helper_file = 'foo' import tests.syntax.helper ego = new Object - """ + """ ) assert scene.params["helper_file"] != "foo" @@ -178,7 +178,7 @@ def test_model_not_override_param(): param helper_file = 'foo' model tests.syntax.helper ego = new Object - """ + """ ) assert scene.params["helper_file"] == "foo" @@ -189,7 +189,7 @@ def test_model_respects_all(): """ model tests.syntax.helper4 ego = new Object with foo bar - """ + """ ) diff --git a/tests/syntax/test_modular.py b/tests/syntax/test_modular.py index eb46de9a1..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, @@ -28,7 +29,7 @@ def test_single_scenario(): scenario Blob(): setup: ego = new Object at (1, 2, 3) - """ + """ ) assert tuple(ego.position) == (1, 2, 3) @@ -40,7 +41,7 @@ def test_simple_scenario(): behavior Foo(): wait ego = new Object at (1, 2, 3), with behavior Foo - """ + """ ) assert tuple(ego.position) == (1, 2, 3) @@ -52,7 +53,7 @@ def test_main_scenario(): ego = new Object at (10, 5) scenario Main(): ego = new Object at (1, 2) - """ + """ ) assert len(scene.objects) == 1 assert tuple(scene.egoObject.position) == (1, 2, 0) @@ -65,7 +66,7 @@ def test_requirement(): setup: ego = new Object with width Range(1, 3) require ego.width > 2 - """ + """ ) ws = [sampleEgo(scenario, maxIterations=60).width for i in range(60)] assert all(2 < w <= 3 for w in ws) @@ -78,7 +79,7 @@ def test_soft_requirement(): setup: ego = new Object with width Range(1, 3) require[0.9] ego.width >= 2 - """ + """ ) ws = [sampleEgo(scenario, maxIterations=60).width for i in range(350)] count = sum(w >= 2 for w in ws) @@ -115,7 +116,7 @@ def test_time_limit(): """ scenario Main(): ego = new Object - """ + """ ) result = sampleResult(scenario, maxSteps=3) assert len(result.trajectory) == 4 @@ -128,7 +129,7 @@ def test_terminate_when(): scenario Main(): ego = new Object terminate when simulation().currentTime > 1 - """ + """ ) result = sampleResult(scenario, maxSteps=5) assert len(result.trajectory) == 3 @@ -141,7 +142,7 @@ def test_terminate_after(): scenario Main(): ego = new Object terminate after 2 steps - """ + """ ) result = sampleResult(scenario, maxSteps=5) assert len(result.trajectory) == 3 @@ -156,7 +157,7 @@ def test_terminate_in_behavior(): wait terminate ego = new Object with behavior Foo - """ + """ ) result = sampleResult(scenario, maxSteps=5) assert len(result.trajectory) == 2 @@ -173,12 +174,12 @@ def test_top_level_precondition(): precondition: simulation().currentTime > 0 setup: ego = new Object - """ + """ ) sim = DummySimulator() scene = sampleScene(scenario) with pytest.raises(PreconditionViolation): - sim.simulate(scene, maxSteps=1, raiseGuardViolations=True) + sim.simulate(scene, maxSteps=1, raiseGuardViolations=True, verbosity=2) def test_top_level_invariant(): @@ -188,7 +189,7 @@ def test_top_level_invariant(): invariant: simulation().currentTime > 0 setup: ego = new Object - """ + """ ) sim = DummySimulator() scene = sampleScene(scenario) @@ -351,7 +352,7 @@ def test_subscenario_require_eventually(): ego = new Object require eventually simulation().currentTime == 2 terminate after 1 steps - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -373,7 +374,7 @@ def test_subscenario_require_monitor(): ego = new Object require monitor TimeLimit() terminate after 2 steps - """ + """ ) result = sampleResultOnce(scenario, maxSteps=3) assert result is not None @@ -392,7 +393,7 @@ def test_subscenario_terminate_when(): ego = new Object require eventually simulation().currentTime == 2 terminate when simulation().currentTime == 1 - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -414,7 +415,7 @@ def test_subscenario_terminate_with_parent(): do Bottom() scenario Bottom(): require eventually simulation().currentTime == 2 - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -433,7 +434,7 @@ def test_subscenario_terminate_behavior(): take 1 terminate ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2) assert tuple(actions) == (1, None) @@ -455,7 +456,7 @@ def test_subscenario_terminate_compose(): scenario Bottom(x): ego = new Object at (x, 0) terminate after 1 steps - """ + """ ) trajectory = sampleTrajectory(scenario, maxSteps=3) assert len(trajectory) == 3 @@ -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( @@ -1030,7 +1100,7 @@ def test_scenario_signature(body): scenario Blah(foo, *bar, baz=12, **qux): {body} ego = new Object with thing Blah - """ + """ ) sig = inspect.signature(ego.thing) params = tuple(sig.parameters.items()) @@ -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_operators.py b/tests/syntax/test_operators.py index a3e0b732c..7c405c789 100644 --- a/tests/syntax/test_operators.py +++ b/tests/syntax/test_operators.py @@ -23,7 +23,7 @@ def test_relative_heading(): ego = new Object facing 30 deg other = new Object facing 65 deg, at 10@10 param p = relative heading of other - """ + """ ) assert p == pytest.approx(math.radians(65 - 30)) @@ -34,7 +34,7 @@ def test_relative_heading_no_ego(): """ other = new Object ego = new Object at 2@2, facing relative heading of other - """ + """ ) @@ -50,7 +50,7 @@ def test_apparent_heading(): ego = new Object facing 30 deg other = new Object facing 65 deg, at 10@10 param p = apparent heading of other - """ + """ ) assert p == pytest.approx(math.radians(65 + 45)) @@ -61,7 +61,7 @@ def test_apparent_heading_no_ego(): """ other = new Object ego = new Object at 2@2, facing apparent heading of other - """ + """ ) @@ -70,7 +70,7 @@ def test_apparent_heading_from(): """ OP = new OrientedPoint at 10@15, facing -60 deg ego = new Object facing apparent heading of OP from 15@10 - """ + """ ) assert ego.heading == pytest.approx(math.radians(-60 - 45)) @@ -82,7 +82,7 @@ def test_angle(): ego = new Object facing 30 deg other = new Object facing 65 deg, at 10@10 param p = angle to other - """ + """ ) assert p == pytest.approx(math.radians(-45)) @@ -93,7 +93,7 @@ def test_angle_3d(): ego = new Object facing (30 deg, 0 deg, 30 deg) other = new Object facing (65 deg, 0 deg, 65 deg), at (10, 10, 10) param p = angle to other - """ + """ ) assert p == pytest.approx(math.radians(-45)) @@ -139,7 +139,7 @@ def test_distance(): ego = new Object at 5@10 other = new Object at 7@-4 param p = distance to other - """ + """ ) assert p == pytest.approx(math.hypot(7 - 5, -4 - 10)) @@ -150,7 +150,7 @@ def test_distance_3d(): ego = new Object at (5, 10, 20) other = new Object at (7, -4, 15) param p = distance to other - """ + """ ) assert p == pytest.approx(math.hypot(7 - 5, -4 - 10, 15 - 20)) @@ -183,7 +183,7 @@ def test_distance_to_region(): r = CircularRegion(10@5, 3) ego = new Object at 13@9 param p = distance to r - """ + """ ) assert p == pytest.approx(2) @@ -338,7 +338,7 @@ def test_can_see_occlusion(): with occluding False require ego can see target_obj - """ + """ ) assert p == True @@ -369,7 +369,7 @@ def test_can_see_occlusion(): with name "wall" require ego can see target_obj - """ + """ ) assert p == False @@ -386,7 +386,7 @@ def test_can_see_distance_scaling(): target_obj = new Object at (0, 100000, 0) require ego can see target_obj - """ + """ ) assert p == True @@ -405,7 +405,7 @@ def test_can_see_distance_scaling(): with width 0.75, with length 0.75, with height 0.75 require ego can see target_obj - """ + """ ) assert p == True @@ -456,8 +456,8 @@ def test_point_in_region_2d(): ptA = new Point at 11@4.5 ptB = new Point at 11@3.5 ptC = new Point at (11, 4.5, 1) - param p = tuple([9@5.5 in reg, 9@7 in reg, (11, 4.5, -1) in reg, ptA in reg, ptB in reg, ptC in reg]) - """ + param p = (9@5.5 in reg, 9@7 in reg, (11, 4.5, -1) in reg, ptA in reg, ptB in reg, ptC in reg) + """ ) assert p == (True, False, True, True, False, True) @@ -469,8 +469,8 @@ def test_object_in_region_2d(): ego = new Object at 11.5@5.5, with width 0.25, with length 0.25 other_1 = new Object at 9@4.5, with width 2.5 other_2 = new Object at (11.5, 5.5, 2), with width 0.25, with length 0.25 - param p = tuple([ego in reg, other_1 in reg, other_2 in reg]) - """ + param p = (ego in reg, other_1 in reg, other_2 in reg) + """ ) assert p == (True, False, True) @@ -482,8 +482,8 @@ def test_point_in_region_3d(): reg = BoxRegion() ptA = new Point at (0.25,0.25,0.25) ptB = new Point at (1,1,1) - param p = tuple([(0,0,0) in reg, (0.49,0.49,0.49) in reg, (0.5,0.5,0.5) in reg, (0.51,0.51,0.51) in reg, ptA in reg, ptB in reg]) - """ + param p = ((0,0,0) in reg, (0.49,0.49,0.49) in reg, (0.5,0.5,0.5) in reg, (0.51,0.51,0.51) in reg, ptA in reg, ptB in reg) + """ ) assert p == (True, True, True, False, True, False) @@ -497,12 +497,111 @@ def test_object_in_region_3d(): obj_2 = new Object at (0.49, 0.49, 0.49), with allowCollisions True obj_3 = new Object at (0.75, 0.75, 0.75), with allowCollisions True obj_4 = new Object at (3,3,3), with allowCollisions True - param p = tuple([obj_1 in reg, obj_2 in reg, obj_3 in reg, obj_4 in reg]) - """ + param p = (obj_1 in reg, obj_2 in reg, obj_3 in reg, obj_4 in reg) + """ ) assert p == (True, True, False, False) +# Intersects +def test_intersects_obj_obj(): + p = sampleParamPFrom( + """ + obj1 = new Object at (-1,0,0.1), with allowCollisions True + obj2 = new Object at (1,0,0), with allowCollisions True + obj3 = new Object with width 10, with length 10, with height 10, with allowCollisions True + param p = (obj1 intersects obj2, obj1 intersects obj3, obj2 intersects obj3) + """ + ) + assert p == (False, True, True) + + # Case where neither corners or centers intersect, but + # Objects still intersect. + p = sampleParamPFrom( + """ + obj1 = new Object at (10,0,0), with width 30, with allowCollisions True + obj2 = new Object at (0,10,0), with length 30, with allowCollisions True + param p = (obj1 intersects obj2, + obj1.position in obj2.occupiedSpace, obj2.position in obj1.occupiedSpace, + any((c in obj2.occupiedSpace) for c in obj1.corners), + any((c in obj1.occupiedSpace) for c in obj2.corners)) + """ + ) + assert p == (True, False, False, False, False) + + +def test_intersects_region_region(): + p = sampleParamPFrom( + """ + reg1 = BoxRegion(position=(-1,0,0.1)) + reg2 = BoxRegion(position=(1,0,0)) + reg3 = BoxRegion(dimensions=(10,10,10)) + param p = (reg1 intersects reg2, reg1 intersects reg3, reg2 intersects reg3) + """ + ) + assert p == (False, True, True) + + +def test_intersects_obj_region(): + p = sampleParamPFrom( + """ + reg1 = BoxRegion(position=(-1,0,0.1)) + obj2 = new Object at (1,0,0), with allowCollisions True + obj3 = new Object with width 10, with length 10, with height 10, with allowCollisions True + param p = (reg1 intersects obj2, obj2 intersects reg1, + reg1 intersects obj3, obj3 intersects reg1) + """ + ) + assert p == (False, False, True, True) + + +def test_intersects_2d(): + p = sampleParamPFrom( + """ + obj1 = new Object at (0.2,0,0), with allowCollisions True + obj2 = new Object at (-0.2,0,0), with allowCollisions True + reg = RectangularRegion((0,0,0), 0, 10, 10) + param p = (obj1 intersects obj2, obj1 intersects reg) + """ + ) + assert p == (True, True) + + +def test_intersects_non_0_z(): + p = sampleParamPFrom( + """ + obj1 = new Object at (0.2,0,1), with allowCollisions True + obj2 = new Object at (-0.2,0,1), with allowCollisions True + reg = RectangularRegion((0,0,1), 0, 10, 10) + param p = (obj1 intersects obj2, obj1 intersects reg) + """ + ) + assert p == (True, True) + + +def test_intersects_overlap(): + p = sampleParamPFrom( + """ + obj = new Object at (0,0,0), with allowCollisions True + reg = RectangularRegion((0.5,0,0), 0, 1, 1) + param p = obj intersects reg + """ + ) + assert p == True + + +def test_intersects_diff_z(): + p = sampleParamPFrom( + """ + obj1 = new Object at (0,0,0.1), with allowCollisions True + obj2 = new Object at (0,0,10), with allowCollisions True + reg = RectangularRegion((0,0,0), 0, 10, 10) + param p = (obj1 intersects reg, obj2 intersects reg, obj1 intersects obj2) + """ + ) + assert p == (True, False, False) + + ## Heading operators @@ -512,7 +611,7 @@ def test_field_at_vector(): """ vf = VectorField("Foo", lambda pos: (3 * pos.x) + pos.y) ego = new Object facing (vf at 0.02 @ -1) - """ + """ ) assert ego.heading == pytest.approx((3 * 0.02) - 1) @@ -533,7 +632,7 @@ def test_heading_relative_to_field(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0.07 @ 0, facing 0.5 relative to vf - """ + """ ) assert ego.heading == pytest.approx(0.5 + (3 * 0.07)) @@ -543,7 +642,7 @@ def test_field_relative_to_heading(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0.07 @ 0, facing vf relative to 0.5 - """ + """ ) assert ego.heading == pytest.approx(0.5 + (3 * 0.07)) @@ -553,7 +652,7 @@ def test_field_relative_to_field(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0.07 @ 0, facing vf relative to vf - """ + """ ) assert ego.heading == pytest.approx(2 * (3 * 0.07)) @@ -573,7 +672,7 @@ def test_heading_relative_to_heading_lazy(): """ vf = VectorField("Foo", lambda pos: 0.5) ego = new Object facing 0.5 relative to (0.5 relative to vf) - """ + """ ) assert ego.heading == pytest.approx(1.5) @@ -584,18 +683,33 @@ def test_orientation_relative_to_orientation(): o1 = Orientation.fromEuler(90 deg, 0, 0) o2 = Orientation.fromEuler(0, 90 deg, 0) ego = new Object facing o2 relative to o1 - """ + """ ) assert ego.orientation.approxEq(Orientation.fromEuler(math.pi / 2, math.pi / 2, 0)) +def test_orientation_relative_to_orientation2(): + ego = sampleEgoFrom( + """ + ego = new Object facing (Orientation.fromEuler(-135 deg, 45 deg, 0) + relative to Orientation.fromEuler(90 deg, 0, 0)) + """ + ) + assert ego.yaw == pytest.approx(math.radians(-45)) + assert ego.pitch == pytest.approx(math.radians(45)) + assert ego.roll == pytest.approx(0) + assert ego.orientation.approxEq( + Orientation.fromEuler(math.radians(-45), math.radians(45), 0) + ) + + def test_heading_relative_to_orientation(): ego = sampleEgoFrom( """ h1 = 90 deg o2 = Orientation.fromEuler(0, 90 deg, 0) ego = new Object facing o2 relative to h1 - """ + """ ) assert ego.orientation.approxEq(Orientation.fromEuler(math.pi / 2, math.pi / 2, 0)) @@ -606,7 +720,7 @@ def test_orientation_relative_to_heading(): o1 = Orientation.fromEuler(90 deg, 0, 0) h2 = 90 deg ego = new Object facing h2 relative to o1 - """ + """ ) assert ego.orientation.approxEq(Orientation.fromEuler(math.pi, 0, 0)) @@ -701,7 +815,7 @@ def test_offset_along_field(): """ vf = VectorField("Foo", lambda pos: 3 deg * pos.x) ego = new Object at 15@7 offset along vf by 2@-3 - """ + """ ) d = 1 / math.sqrt(2) assert tuple(ego.position) == pytest.approx( @@ -714,7 +828,7 @@ def test_offset_along_field_3d(): """ vf = VectorField("Foo", lambda pos: 3 deg * pos.x) ego = new Object at (15, 7, 5) offset along vf by (2, -3, 4) - """ + """ ) d = 1 / math.sqrt(2) assert tuple(ego.position) == pytest.approx( @@ -730,7 +844,7 @@ def test_follow(): minSteps=4, defaultStepSize=1) p = follow vf from 1@1 for 4 ego = new Object at p, facing p.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 0)) assert ego.heading == pytest.approx(math.radians(90)) @@ -743,7 +857,7 @@ def test_follow_3d(): minSteps=4, defaultStepSize=1) p = follow vf from (1, 1, 1) for 4 ego = new Object at p, facing p.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 1)) assert ego.heading == pytest.approx(math.radians(90)) @@ -757,7 +871,7 @@ def test_relative_position(): """ ego = new Object at 1@2 param p = relative position of 5@1 - """ + """ ) assert tuple(p) == (4, -1, 0) @@ -767,7 +881,7 @@ def test_relative_position_from(): """ ego = new Object at 1@2 param p = relative position of ego from 5@1 - """ + """ ) assert tuple(p) == (-4, 1, 0) @@ -783,7 +897,7 @@ def test_visible(): with visibleDistance 10, with viewAngle 90 deg reg = RectangularRegion(100@205, 0, 10, 20) param p = new Point in visible reg - """ + """ ) for i in range(30): p = sampleParamP(scenario, maxIterations=10) @@ -799,7 +913,7 @@ def test_not_visible(): with visibleDistance 30, with viewAngle 90 deg reg = RectangularRegion(100@200, 0, 10, 10) param p = new Point in not visible reg - """ + """ ) ps = [sampleParamP(scenario, maxIterations=10) for i in range(50)] assert all(p.x <= 100 or p.y <= 200 for p in ps) @@ -817,7 +931,7 @@ def test_visible_from(): with visibleDistance 10, with viewAngle 90 deg reg = RectangularRegion(100@205, 0, 10, 20) param p = new Point in reg visible from ego - """ + """ ) for i in range(30): p = sampleParamP(scenario, maxIterations=10) @@ -834,7 +948,7 @@ def test_not_visible_from(): with visibleDistance 30, with viewAngle 90 deg reg = RectangularRegion(100@200, 0, 10, 10) param p = new Point in reg not visible from ego - """ + """ ) ps = [sampleParamP(scenario, maxIterations=10) for i in range(50)] assert all(p.x <= 100 or p.y <= 200 for p in ps) @@ -872,7 +986,7 @@ def test_direction_ops(direction, loc): f""" ego = new Object facing (0, 180 deg, 0) param p = {direction} of ego - """ + """ ) oriented_loc = (loc[0], -loc[1], -loc[2]) assert tuple(p.position) == pytest.approx(oriented_loc) diff --git a/tests/syntax/test_parser.py b/tests/syntax/test_parser.py index 65f2cd940..da5f80545 100644 --- a/tests/syntax/test_parser.py +++ b/tests/syntax/test_parser.py @@ -2845,3 +2845,12 @@ def test_can_see(self): assert True case _: assert False + + def test_intersects(self): + mod = parse_string_helper("x intersects y ") + stmt = mod.body[0] + match stmt: + case Expr(IntersectsOp(Name("x"), Name("y"))): + assert True + case _: + assert False diff --git a/tests/syntax/test_properties.py b/tests/syntax/test_properties.py index f73663034..61b64a32b 100644 --- a/tests/syntax/test_properties.py +++ b/tests/syntax/test_properties.py @@ -1,8 +1,9 @@ import numpy as np import pytest +from scenic.core.distributions import Distribution, supportInterval from scenic.core.errors import SpecifierError -from tests.utils import compileScenic, sampleEgoFrom +from tests.utils import compileScenic, sampleEgo, sampleEgoFrom def test_position_wrong_type(): @@ -16,7 +17,7 @@ def test_position_oriented_point(): a = new OrientedPoint at 1@0 b = new OrientedPoint at 0@1 ego = new Object with position Uniform(a, b) - """ + """ ) @@ -25,7 +26,7 @@ def test_position_numpy_types(): """ import numpy as np ego = new Object with position np.single(3.4) @ np.single(7) - """ + """ ) assert tuple(ego.position) == pytest.approx((3.4, 7, 0)) @@ -40,7 +41,7 @@ def test_yaw_numpy_types(): """ import numpy as np ego = new Object with yaw np.single(3.1) - """ + """ ) assert ego.yaw == pytest.approx(3.1) @@ -50,7 +51,7 @@ def test_left(): """ other = new Object with width 4 ego = new Object at other.left offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((-2, 5, 0)) @@ -60,7 +61,7 @@ def test_right(): """ other = new Object with width 4 ego = new Object at other.right offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((2, 5, 0)) @@ -70,7 +71,7 @@ def test_front(): """ other = new Object with length 4 ego = new Object at other.front offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((0, 7, 0)) @@ -80,7 +81,7 @@ def test_back(): """ other = new Object with length 4 ego = new Object at other.back offset by 0@-5 - """ + """ ) assert tuple(ego.position) == pytest.approx((0, -7, 0)) @@ -90,7 +91,7 @@ def test_frontLeft(): """ other = new Object with length 4, with width 2 ego = new Object at other.frontLeft offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 7, 0)) @@ -100,7 +101,7 @@ def test_frontRight(): """ other = new Object with length 4, with width 2 ego = new Object at other.frontRight offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((1, 7, 0)) @@ -110,7 +111,7 @@ def test_backLeft(): """ other = new Object with length 4, with width 2 ego = new Object at other.backLeft offset by 0@-5 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, -7, 0)) @@ -120,7 +121,7 @@ def test_backRight(): """ other = new Object with length 4, with width 2 ego = new Object at other.backRight offset by 0@-5 - """ + """ ) assert tuple(ego.position) == pytest.approx((1, -7, 0)) @@ -128,3 +129,156 @@ def test_backRight(): def test_heading_set_directly(): with pytest.raises(SpecifierError): compileScenic("ego = new Object with heading 4") + + +def test_object_inradius(): + # Statically Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width 3, with length 3, with height 3, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].inradius == 1.5 + assert supportInterval(scenario.objects[0].inradius) == (1.5, 1.5) + assert ego.inradius == 1.5 + + # Randomly Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].inradius, Distribution) + assert supportInterval(scenario.objects[0].inradius) == (0.5, 1.5) + assert ego.inradius == pytest.approx(min(ego.width, ego.length, ego.height) / 2) + + # Hollow Static Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width 3, with length 3, with height 3, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].inradius == 0 + assert supportInterval(scenario.objects[0].inradius) == (0, 0) + assert ego.inradius == 0 + + # Hollow Random Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert supportInterval(scenario.objects[0].inradius) == (0, 0) + assert ego.inradius == 0 + + # Random Shape Example + scenario = compileScenic( + """ + import trimesh + annulus_shape = MeshShape(trimesh.creation.annulus(0.5,1,1)) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape Uniform(BoxShape(), annulus_shape) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].inradius, Distribution) + assert supportInterval(scenario.objects[0].inradius) == (0, 1.5) + + +def test_object_planarInradius(): + # Statically Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width 3, with length 3, with height 0.5, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].planarInradius == 1.5 + assert supportInterval(scenario.objects[0].planarInradius) == (1.5, 1.5) + assert ego.planarInradius == 1.5 + + # Randomly Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(0.25, 0.5), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].planarInradius, Distribution) + assert supportInterval(scenario.objects[0].planarInradius) == (0.5, 1.5) + assert ego.planarInradius == pytest.approx(min(ego.width, ego.length) / 2) + + # Hollow Static Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width 3, with length 3, with height 0.5, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].planarInradius == pytest.approx(1.5) + assert supportInterval(scenario.objects[0].planarInradius) == pytest.approx( + (1.5, 1.5) + ) + assert ego.planarInradius == pytest.approx(1.5) + + # Hollow Random Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(0.25, 0.5), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].planarInradius, Distribution) + assert supportInterval(scenario.objects[0].planarInradius) == pytest.approx( + (0.5, 1.5) + ) + assert ego.planarInradius == pytest.approx(min(ego.width, ego.length) / 2) + + # Random Shape Example + scenario = compileScenic( + """ + import trimesh + annulus_shape = MeshShape(trimesh.creation.annulus(0.5,1,1)) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape Uniform(BoxShape(), annulus_shape) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].planarInradius, Distribution) + assert supportInterval(scenario.objects[0].planarInradius) == (0, 1.5) diff --git a/tests/syntax/test_pruning.py b/tests/syntax/test_pruning.py index ed4e54b0b..6a0c693f6 100644 --- a/tests/syntax/test_pruning.py +++ b/tests/syntax/test_pruning.py @@ -4,7 +4,8 @@ import pytest from scenic.core.errors import InconsistentScenarioError -from tests.utils import compileScenic, sampleEgo +from scenic.core.vectors import Vector +from tests.utils import compileScenic, sampleEgo, sampleParamP def test_containment_in(): @@ -13,13 +14,64 @@ def test_containment_in(): """ workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) ego = new Object in workspace + """ + ) + # Sampling should only require 1 iteration after pruning + xs = [sampleEgo(scenario).position.x for i in range(60)] + assert all(0.5 <= x <= 1.5 for x in xs) + assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + + +def test_containment_2d_region(): + """Test pruning based on object containment in a 2D region. + + Specifically tests that vertical portions of baseOffset are not added + to maxDistance, and that if objects are known to be flat in the plane, + their height is not considered as part of the minRadius. """ + # Tests the effect of the vertical portion of baseOffset in a 2D region. + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) + ego = new Object on workspace + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] assert all(0.5 <= x <= 1.5 for x in xs) assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + # Test height's effect in a 2D region. + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) + ego = new Object in workspace, with height 0.1 + """ + ) + # Sampling should only require 1 iteration after pruning + xs = [sampleEgo(scenario).position.x for i in range(60)] + assert all(0.5 <= x <= 1.5 for x in xs) + assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + + # Test both combined, in a slightly more complicated case. + # Specifically, there is a non vertical component to baseOffset + # 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 100 + """ + ) + # Sampling should fail ~30.56% of the time, so + # 34 rejections are allowed to get the failure probability + # to ~1e-18. + xs = [sampleEgo(scenario, maxIterations=34).position.x for i in range(60)] + assert all(0.5 <= x <= 1.5 for x in xs) + assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + def test_containment_in_polyline(): """As above, but when the object is placed on a polyline.""" @@ -28,7 +80,7 @@ def test_containment_in_polyline(): workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) line = PolylineRegion([0@0, 1@1, 2@0]) ego = new Object in line, facing 0 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -47,7 +99,7 @@ def test_relative_heading_require_visible(): ego = new Object in union, facing vf # Objects can be in either cell other = new Object in union, facing vf, with requireVisible True require (relative heading of other) >= 60 deg # Forces ego in cell 1, other in cell 2 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -66,7 +118,7 @@ def test_relative_heading_visible_from(): ego = new Object in union, facing vf # Objects can be in either cell other = new Object in union, facing vf, visible from ego require (relative heading of other) >= 60 deg # Forces ego in cell 1, other in cell 2 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -88,7 +140,7 @@ def test_relative_heading_distance(): other = new Object in union, facing vf require (relative heading of other) >= 60 deg # Forces ego in cell 1, other cell 2/3 require (distance to other) <= 35 # Forces other in cell 2 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -106,3 +158,103 @@ def test_relative_heading_inconsistent(): require abs(relative heading of other) < -1 """ ) + + +def test_visibility_pruning(): + """Test visibility pruning in general. + + The following scenarios are equivalent except for how they specify that foo + must be visible from ego. The size of the workspace and the visibleDistance + of ego are chosen such that without pruning the chance of sampling a valid + scene over 100 tries is 1-(1-(3.14*2**2)/(1e10**2))**100 = ~1e-18. + Assuming the approximately buffered volume of the viewRegion has a 50% chance of + rejecting (i.e. it is twice as large as the true buffered viewRegion, which testing + indicates in this case has about a 10% increase in volume for this case), the chance + of not finding a sample in 100 iterations is 1e-31. + + We also want to confirm that we aren't pruning too much, i.e. placing the position + in the viewRegion instead of at any point where the object intersects the view region. + Because of this, we want to see at least one sample where the position is outside + the viewRegion but the object intersects the viewRegion. The chance of this happening + per sample is ((1 / 2)**2), so by repeating the process 30 times we have + ~1e-19 chance of not getting a single point in this zone. + """ + # requireVisible + scenario = compileScenic( + """ + workspace = Workspace(RectangularRegion(0@0, 0, 1e10, 1e10)) + ego = new Object at (0,0,0), with visibleDistance 1, with allowCollisions True + foo = new Object in workspace, + with shape SpheroidShape(dimensions=(2,2,2)), + with allowCollisions True, + with requireVisible True + param p = foo.position + """ + ) + positions = [sampleParamP(scenario, maxIterations=100) for i in range(30)] + assert all(pos.distanceTo(Vector(0, 0, 0)) <= 2 for pos in positions) + assert any(pos.distanceTo(Vector(0, 0, 0)) >= 1 for pos in positions) + + # visible + scenario = compileScenic( + """ + workspace = Workspace(RectangularRegion(0@0, 0, 1e10, 1e10)) + ego = new Object at (0,0,0), with visibleDistance 1, with allowCollisions True + foo = new Object in workspace, visible, + with shape SpheroidShape(dimensions=(2,2,2)), + with allowCollisions True + param p = foo.position + """ + ) + positions = [sampleParamP(scenario, maxIterations=100) for i in range(30)] + assert all(pos.distanceTo(Vector(0, 0, 0)) <= 2 for pos in positions) + assert any(pos.distanceTo(Vector(0, 0, 0)) >= 1 for pos in positions) + + # requireVisible with offset + baseOffsetVal = 0.0001 + scenario = compileScenic( + f""" + workspace = Workspace(RectangularRegion(0@0, 0, 1e10, 1e10)) + ego = new Object at (0,0,0), with visibleDistance 1, with allowCollisions True + foo = new Object on workspace, + with shape SpheroidShape(dimensions=(2,2,2)), + with allowCollisions True, + with requireVisible True, + with baseOffset (0,0,{baseOffsetVal}), with contactTolerance 0 + param p = foo.position + """ + ) + positions = [sampleParamP(scenario, maxIterations=100) for i in range(30)] + assert all(pos.distanceTo(Vector(0, 0, 0)) <= 2 for pos in positions) + assert any(pos.distanceTo(Vector(0, 0, 0)) >= 1 for pos in positions) + assert all(pos.z == -baseOffsetVal for pos in positions) + + # visible with offset + scenario = compileScenic( + f""" + workspace = Workspace(RectangularRegion(0@0, 0, 1e10, 1e10)) + ego = new Object at (0,0,0), with visibleDistance 1, with allowCollisions True + foo = new Object on workspace, visible, + with shape SpheroidShape(dimensions=(2,2,2)), + with allowCollisions True, + with baseOffset (0,0,{baseOffsetVal}), with contactTolerance 0 + param p = foo.position + """ + ) + positions = [sampleParamP(scenario, maxIterations=100) for i in range(30)] + assert all(pos.distanceTo(Vector(0, 0, 0)) <= 2 for pos in positions) + assert any(pos.distanceTo(Vector(0, 0, 0)) >= 1 for pos in positions) + assert all(pos.z == -baseOffsetVal for pos in positions) + + +def test_visibility_pruning_cyclical(): + """A case where a cyclical dependency could be introduced if pruning is not done carefully.""" + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 100@0, 100@100, 0@100])) + foo = new Object with requireVisible True, in workspace + ego = new Object visible from foo, in workspace + """ + ) + + sampleEgo(scenario, maxIterations=100) diff --git a/tests/syntax/test_regions.py b/tests/syntax/test_regions.py index 8c85b2183..e59542012 100644 --- a/tests/syntax/test_regions.py +++ b/tests/syntax/test_regions.py @@ -47,7 +47,7 @@ def test_circular_lazy(): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = 0 relative to vf ego = new Object at Range(0, 1) @ 0, with foo CircularRegion(0@0, x.yaw) - """ + """ ) assert ego.foo.radius == pytest.approx(2 * ego.position.x) @@ -74,7 +74,7 @@ def test_sector_lazy(): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = 0 relative to vf ego = new Object at Range(0, 1) @ 0, with foo SectorRegion(0@0, x.yaw, 0, 45 deg) - """ + """ ) assert ego.foo.radius == pytest.approx(2 * ego.position.x) @@ -103,7 +103,7 @@ def test_rectangular_lazy(): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = 0 relative to vf ego = new Object at Range(-1, 1) @ 0, with foo RectangularRegion(0@0, 0, x.yaw, 1) - """ + """ ) assert ego.foo.width == pytest.approx(2 * ego.position.x) @@ -117,7 +117,7 @@ def test_polygonal_empty_intersection(): r1 = PolygonalRegion([0@0, 10@0, 10@10, 0@10]) ego = new Object at -10@0, facing Range(-90, 0) deg, with viewAngle 60 deg new Object in visible r1, with requireVisible False - """ + """ ) for i in range(10): sampleScene(scenario, maxIterations=1000) @@ -132,7 +132,7 @@ def test_polyline_start(): r = PolylineRegion([1@1, 3@-1, 6@2]) pt = r.start ego = new Object at pt, facing pt.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((1, 1, 0)) assert ego.heading == pytest.approx(math.radians(-135)) @@ -144,7 +144,7 @@ def test_polyline_end(): r = PolylineRegion([1@1, 3@-1, 6@2]) pt = r.end ego = new Object at pt, facing pt.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((6, 2, 0)) assert ego.heading == pytest.approx(math.radians(-45)) @@ -162,11 +162,24 @@ def test_mesh_region_distribution(): region = SpheroidRegion(position=position, dimensions=dimensions, rotation=rotation) ego = new Object in region - """, + """, maxIterations=100, ) +def test_path_region_default_orientation(): + ego = sampleEgoFrom( + """ + import math + region = PathRegion(points=[(0,0,0), (1,1,math.sqrt(2)), (2,2,2*math.sqrt(2))]) + ego = new Object in region + """ + ) + assert ego.orientation.yaw == pytest.approx(math.radians(-45)) + assert ego.orientation.pitch == pytest.approx(math.radians(45)) + assert ego.orientation.roll == pytest.approx(0) + + # View Regions def test_view_region_construction(): sampleSceneFrom( @@ -244,7 +257,7 @@ def test_view_region_construction(): with visibleDistance 5, with viewAngles (200 deg, 40 deg), with requireVisible True - """, + """, maxIterations=1000, ) @@ -259,7 +272,7 @@ def test_workspace(): ego = new Object in workspace require 6@11 in workspace require ego in workspace - """ + """ ) assert 3 <= ego.position.x <= 7 assert 8 <= ego.position.y <= 12 diff --git a/tests/syntax/test_requirements.py b/tests/syntax/test_requirements.py index c72bb33be..0c7699fe0 100644 --- a/tests/syntax/test_requirements.py +++ b/tests/syntax/test_requirements.py @@ -13,7 +13,7 @@ def test_requirement(): """ ego = new Object at Range(-10, 10) @ 0 require ego.position.x >= 0 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(0 <= x <= 10 for x in xs) @@ -24,7 +24,7 @@ def test_soft_requirement(): """ ego = new Object at Range(-10, 10) @ 0 require[0.9] ego.position.x >= 0 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(350)] count = sum(x >= 0 for x in xs) @@ -37,7 +37,7 @@ def test_illegal_soft_probability(): """ ego = new Object require[1.1] ego.position.x >= 0 - """ + """ ) @@ -48,7 +48,7 @@ def test_named_requirement(): require ego.position.x >= 5 as posReq require True as 'myReq' require True as 101 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(5 <= x <= 10 for x in xs) @@ -62,7 +62,7 @@ def test_named_soft_requirement(): require[0.9] ego.position.x >= 5 as posReq require[0.8] True as 'myReq' require[0.75] True as 101 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(350)] count = sum(x >= 5 for x in xs) @@ -75,7 +75,7 @@ def test_named_requirement_invalid(): """ ego = new Object require True as + - """ + """ ) @@ -85,7 +85,7 @@ def test_unexpected_keyword_arg(): """ ego = new Object require True, line=5 - """ + """ ) @@ -96,7 +96,7 @@ def test_unexpected_unpacking(): ego = new Object a = (True,) require *a - """ + """ ) @@ -108,7 +108,7 @@ def test_distribution_in_requirement(): """ require Range(0, 1) <= 1 ego = new Object - """ + """ ) with pytest.raises(InvalidScenarioError): sampleScene(scenario) @@ -119,7 +119,7 @@ def test_object_in_requirement(): """ require new Object ego = new Object - """ + """ ) with pytest.raises(InvalidScenarioError): sampleScene(scenario) @@ -131,7 +131,7 @@ def test_param_in_requirement_1(): """ require param x = 4 ego = new Object - """ + """ ) @@ -144,7 +144,7 @@ def func(): return True require func() ego = new Object - """ + """ ) sampleScene(scenario) @@ -155,7 +155,7 @@ def test_mutate_in_requirement_1(): """ require mutate ego = new Object - """ + """ ) with pytest.raises(ScenicSyntaxError): sampleScene(scenario) @@ -170,7 +170,7 @@ def func(): return True require func() ego = new Object - """ + """ ) sampleScene(scenario) @@ -181,7 +181,7 @@ def test_require_in_requirement(): """ require (require True) ego = new Object - """ + """ ) @@ -193,7 +193,7 @@ def test_exception_in_requirement(): """ require visible 4 ego = new Object - """ + """ ) with pytest.raises(TypeError): sampleScene(scenario) @@ -206,7 +206,7 @@ def test_soft_requirement_with_temporal_operators(): """ ego = new Object require[0.2] eventually ego - """ + """ ) @@ -218,7 +218,7 @@ def test_containment_requirement(): """ foo = RectangularRegion(0@0, 0, 10, 10) ego = new Object at Range(0, 10) @ 0, with regionContainedIn foo - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(0 <= x <= 5 for x in xs) @@ -229,7 +229,7 @@ def test_containment_workspace(): """ workspace = Workspace(RectangularRegion(0@0, 0, 10, 10)) ego = new Object at Range(0, 10) @ 0 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(0 <= x <= 5 for x in xs) @@ -240,7 +240,7 @@ def test_visibility_requirement(): """ ego = new Object with visibleDistance 10, with viewAngle 90 deg, facing 45 deg other = new Object at Range(-10, 10) @ 0, with requireVisible True - """ + """ ) xs = [ sampleScene(scenario, maxIterations=60).objects[1].position.x for i in range(60) @@ -253,7 +253,7 @@ def test_visibility_requirement_disabled(): """ ego = new Object with visibleDistance 10, with viewAngle 90 deg, facing 45 deg other = new Object at Range(-10, 10) @ 0, with requireVisible False - """ + """ ) xs = [ sampleScene(scenario, maxIterations=60).objects[1].position.x for i in range(60) @@ -266,7 +266,7 @@ def test_intersection_requirement(): """ ego = new Object at Range(0, 2) @ 0 other = new Object - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(x >= 1 for x in xs) @@ -277,7 +277,7 @@ def test_intersection_requirement_disabled_1(): """ ego = new Object at Range(0, 2) @ 0, with allowCollisions True other = new Object - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert any(x < 1 for x in xs) @@ -288,7 +288,7 @@ def test_intersection_requirement_disabled_2(): """ ego = new Object at Range(0, 2) @ 0 other = new Object with allowCollisions True - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert any(x < 1 for x in xs) @@ -303,7 +303,7 @@ def test_static_containment_violation(): """ foo = RectangularRegion(0@0, 0, 5, 5) ego = new Object at 10@10, with regionContainedIn foo - """ + """ ) @@ -313,7 +313,7 @@ def test_static_containment_workspace(): """ workspace = Workspace(RectangularRegion(0@0, 0, 5, 5)) ego = new Object at 10@10 - """ + """ ) @@ -323,7 +323,7 @@ def test_static_empty_container(): """ foo = PolylineRegion([0@0, 1@1]).intersect(PolylineRegion([1@0, 2@1])) ego = new Object at Range(0, 2) @ Range(0, 1), with regionContainedIn foo - """ + """ ) @@ -333,7 +333,7 @@ def test_static_visibility_violation_enabled(): """ ego = new Object at 10@0, facing -90 deg, with viewAngle 90 deg new Object at 0@10, with requireVisible True - """ + """ ) @@ -343,7 +343,7 @@ def test_static_visibility_violation_enabled_2d(): """ ego = new Object at 10@0, facing -90 deg, with viewAngle 90 deg new Object at 0@10, with requireVisible True - """, + """, mode2D=True, ) @@ -353,7 +353,7 @@ def test_static_visibility_violation_disabled(): """ ego = new Object at 10@0, facing -90 deg, with viewAngle 90 deg new Object at 0@10, with requireVisible False - """ + """ ) @@ -363,7 +363,7 @@ def test_static_intersection_violation(): """ ego = new Object at 0@0 new Object at 0.5@0 - """ + """ ) @@ -372,7 +372,7 @@ def test_static_intersection_violation_disabled(): """ ego = new Object at 0@0 new Object at 1@0, with allowCollisions True - """ + """ ) @@ -408,7 +408,7 @@ def test_can_see_object_occlusion_enabled(): with length 0.5, with height 6, with name "wall", - """, + """, maxIterations=1, ) @@ -442,7 +442,7 @@ def test_can_see_object_occlusion_disabled(): with height 6, with name "wall", with occluding False - """, + """, maxIterations=1, ) @@ -453,7 +453,7 @@ def test_random_allowCollisions(): """ new Object with allowCollisions Uniform(True, False) new Object with allowCollisions Uniform(True, False) - """, + """, maxIterations=30, ) @@ -489,7 +489,7 @@ def test_random_occlusion(): with height 6, with name "wall", with occluding Uniform(True, False) - """, + """, maxIterations=60, ) @@ -497,3 +497,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 62be48597..274e875e1 100644 --- a/tests/syntax/test_specifiers.py +++ b/tests/syntax/test_specifiers.py @@ -10,6 +10,7 @@ compileScenic, sampleEgo, sampleEgoFrom, + sampleParamPFrom, sampleScene, sampleSceneFrom, ) @@ -33,14 +34,14 @@ def test_lazy_cyclic_dependency(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0 @ (0 relative to vf) - """ + """ ) with pytest.raises(SpecifierError): compileScenic( """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at (0, 0 relative to vf) - """ + """ ) @@ -467,7 +468,7 @@ def test_beyond_3d(): import math ego = new Object at (10, 5, 15) ego = new Object beyond (11, 6, 15 + math.sqrt(2)) by (0, 10, 0) - """ + """ ) assert tuple(ego.position) == pytest.approx( (16, 11, 15 + math.sqrt(2) + (10 / math.sqrt(2))) @@ -491,7 +492,7 @@ def test_beyond_from_3d(): """ import math ego = new Object beyond (11, 6, 15 + math.sqrt(2)) by (0, 10, 0) from (10, 5, 15) - """ + """ ) assert tuple(ego.position) == pytest.approx( (16, 11, 15 + math.sqrt(2) + (10 / math.sqrt(2))) @@ -501,9 +502,29 @@ def test_beyond_from_3d(): # Visible def test_visible(): scenario = compileScenic( - "ego = new Object at 100 @ 200, facing -45 deg,\n" - " with visibleDistance 10, with viewAngle 90 deg\n" - "ego = new Object visible" + """ + ego = new Object at 100 @ 200, facing -45 deg, + with visibleDistance 10, with viewAngle 90 deg + ego = new Object visible + """ + ) + radius = math.hypot(0.5, 0.5, 0.5) + for i in range(30): + scene = sampleScene(scenario, maxIterations=100) + ego, base = scene.objects + assert ego.position.distanceTo(base.position) <= 10 + radius + assert ego.position.x >= base.position.x - radius + assert ego.position.y >= base.position.y - radius + + +def test_visible_2d(): + scenario = compileScenic( + """ + ego = new Object at 100 @ 200, facing -45 deg, + with visibleDistance 10, with viewAngle 90 deg + ego = new Object visible + """, + mode2D=True, ) for i in range(30): scene = sampleScene(scenario, maxIterations=10) @@ -513,19 +534,41 @@ def test_visible(): assert ego.position.y >= base.position.y +def test_visible_position_underspecified(): + with pytest.raises(InvalidScenarioError): + scenario = compileScenic( + """ + from scenic.core.distributions import distributionFunction + + @distributionFunction + def dummyFunc(arg): + return arg.position[2] + + foo = new Object at (0,0,Range(1,2)) + ego = new Object visible from foo, with width dummyFunc(foo) + """, + ) + + def test_visible_no_ego(): with pytest.raises(InvalidScenarioError): 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" "ego = new Object visible from x" ) + radius = math.hypot(0.5, 0.5, 0.5) for i in range(20): - scene = sampleScene(scenario, maxIterations=10) - assert scene.egoObject.position.distanceTo(Vector(300, 200)) <= 2 + scene = sampleScene(scenario, maxIterations=100) + assert scene.egoObject.position.distanceTo(Vector(300, 200)) <= 2 + radius def test_visible_from_point_3d(): @@ -533,9 +576,10 @@ def test_visible_from_point_3d(): "x = new Point at (300, 200, 500), with visibleDistance 2\n" "ego = new Object visible from x" ) + radius = math.hypot(0.5, 0.5, 0.5) for i in range(20): - scene = sampleScene(scenario, maxIterations=10) - assert scene.egoObject.position.distanceTo(Vector(300, 200, 500)) <= 2 + scene = sampleScene(scenario, maxIterations=100) + assert scene.egoObject.position.distanceTo(Vector(300, 200, 500)) <= 2 + radius def test_visible_from_oriented_point(): @@ -546,11 +590,11 @@ def test_visible_from_oriented_point(): ) base = Vector(100, 200) for i in range(20): - scene = sampleScene(scenario, maxIterations=10) + scene = sampleScene(scenario, maxIterations=100) pos = scene.egoObject.position - assert pos.distanceTo(base) <= 5 - assert pos.x <= base.x - assert pos.y >= base.y + assert pos.distanceTo(base) <= 5 + math.hypot(0.5, 0.5, 0.5) + assert pos.x <= base.x + math.hypot(0.5, 0.5, 0.5) + assert pos.y >= base.y - math.hypot(0.5, 0.5, 0.5) @pytest.mark.slow @@ -579,7 +623,7 @@ def test_point_visible_from_object(): with height 6, with name "wall", with occluding False - """, + """, maxIterations=1, ) @@ -622,11 +666,11 @@ def test_not_visible(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object not visible - """ + """ ) base = Vector(100, 200) for i in range(20): - pos = sampleEgo(scenario, maxIterations=50).position + pos = sampleEgo(scenario, maxIterations=100).position assert pos.x < 100 or pos.y < 200 or pos.distanceTo(base) > 10 @@ -637,12 +681,12 @@ def test_not_visible_2d(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object not visible - """, + """, mode2D=True, ) base = Vector(100, 200) for i in range(20): - pos = sampleEgo(scenario, maxIterations=50).position + pos = sampleEgo(scenario, maxIterations=100).position assert pos.x < 100 or pos.y < 200 or pos.distanceTo(base) > 10 @@ -653,11 +697,11 @@ def test_not_visible_from(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object in workspace, not visible from ego - """ + """ ) base = Vector(100, 200) for i in range(20): - pos = sampleEgo(scenario, maxIterations=50).position + pos = sampleEgo(scenario, maxIterations=100).position assert pos.x < 100 or pos.y < 200 or pos.distanceTo(base) > 10 @@ -668,7 +712,7 @@ def test_not_visible_from_2d(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object not visible from ego - """, + """, mode2D=True, ) base = Vector(100, 200) @@ -734,6 +778,11 @@ def test_in_heading(): assert scene.egoObject.heading == pytest.approx(math.radians(45)) +def test_in_everywhere(): + with pytest.raises(InvalidScenarioError): + compileScenic("ego = new Object in everywhere") + + def test_in_mistyped(): with pytest.raises(TypeError): compileScenic("ego = new Object in 3@2") @@ -811,7 +860,7 @@ def test_on_object(): """ floor = new Object with shape BoxShape(dimensions=(40,40,0.1)) ego = new Object on floor - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -825,7 +874,7 @@ def test_on_position(): scenario = compileScenic( """ ego = new Object on (0,0,0) - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -858,7 +907,7 @@ def test_on_parentOrientation(): box = new Object facing (Range(0, 360 deg), Range(0, 360 deg), Range(0, 360 deg)), with width 5, with length 5, with height 5 ego = new Object on box - """ + """ ) for _ in range(30): sampleScene(scenario, maxIterations=1) @@ -884,7 +933,7 @@ def test_on_modifying_object_side(): workspace = Workspace(BoxRegion(dimensions=(10,10,10))) box = new Object with width 5, with length 5, with height 5 ego = new Object in workspace, on box.frontSurface, with shape ConeShape() - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -899,7 +948,7 @@ def test_on_modifying_object_onDirection(): box = new Object with width 5, with length 5, with height 5 ego = new Object in workspace, on box.surface, with shape ConeShape(), with onDirection (0,1,0) - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -916,7 +965,7 @@ def test_on_modifying_surface_onDirection(): box = BoxRegion(dimensions=(5,5,5), onDirection=(0,1,0)).getSurfaceRegion() ego = new Object in workspace, on box, with shape ConeShape(), with onDirection (0,1,0) - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -937,7 +986,7 @@ def test_on_incompatible(): """ box = BoxRegion() ego = new Object on box, on box - """ + """ ) @@ -949,7 +998,7 @@ def test_following(): minSteps=4, defaultStepSize=1) ego = new Object at 1@1 ego = new Object following vf for 4 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 0)) assert ego.heading == pytest.approx(math.radians(90)) @@ -961,7 +1010,7 @@ def test_following_from(): vf = VectorField("Foo", lambda pos: 90 deg * (pos.x + pos.y - 1), minSteps=4, defaultStepSize=1) ego = new Object following vf from 1@1 for 4 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 0)) assert ego.heading == pytest.approx(math.radians(90)) @@ -973,7 +1022,7 @@ def test_following_random(): vf = VectorField('Foo', lambda pos: -90 deg) x = Range(1, 2) ego = new Object following vf from 1@2 for x, facing x - """ + """ ) assert tuple(ego.position) == pytest.approx((1 + ego.heading, 2, 0)) @@ -999,7 +1048,7 @@ def test_facing_random_parentOrientation(): """ ego = new Object facing (90 deg, 90 deg, 90 deg), with parentOrientation (Range(0, 360 deg), Range(0, 360 deg), Range(0, 360 deg)) - """ + """ ) for _ in range(10): ego = sampleEgo(scenario) @@ -1026,6 +1075,20 @@ def test_facing_vf_3d(): ) +def test_facing_equivalence(): + p = sampleParamPFrom( + """ + a = new OrientedPoint facing (Orientation.fromEuler(-135 deg, 45 deg, 0) + relative to Orientation.fromEuler(90 deg, 0, 0)) + + b = new OrientedPoint with parentOrientation (90 deg, 0, 0), with yaw -135 deg, with pitch 45 deg, with roll 0 + param p = (a, b) + """ + ) + a, b = p + assert a.orientation.eulerAngles == pytest.approx(b.orientation.eulerAngles) + + # Facing Toward/Away From def test_facing_toward(): ego = sampleEgoFrom( @@ -1068,6 +1131,21 @@ def test_facing_directly_away_from(): assert ego.roll == 0 +def test_facing_directly_toward_parent_orientation(): + ego = sampleEgoFrom( + """ + ego = new Object facing directly toward (1, 1, 2**0.5), + with parentOrientation (90 deg, 0, 0) + """ + ) + assert ego.yaw == pytest.approx(-math.radians(135)) + assert ego.pitch == pytest.approx(math.radians(45)) + assert ego.roll == pytest.approx(0) + assert ego.orientation.approxEq( + Orientation.fromEuler(math.radians(-45), math.radians(45), 0) + ) + + # Apparently Facing def test_apparently_facing(): ego = sampleEgoFrom( @@ -1093,3 +1171,46 @@ def test_shape(): with pytest.raises(InvalidScenarioError): sampleEgoFrom(program, mode2D=True) + + +# Color +def test_color(): + program = """ + ego = new Object with color (0.5,0.5,0.5,0.5) + """ + ego = sampleEgoFrom(program) + assert ego.color == (0.5, 0.5, 0.5, 0.5) + + program = """ + ego = new Object with color (0.5,0.5,0.5) + """ + ego = sampleEgoFrom(program) + assert ego.color == (0.5, 0.5, 0.5) + + with pytest.raises(ValueError): + program = """ + ego = new Object with color (255,0,0) + """ + sampleEgoFrom(program) + + with pytest.raises(ValueError): + program = """ + 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 d2b5fdf88..b70f2dc53 100644 --- a/tests/syntax/test_typing.py +++ b/tests/syntax/test_typing.py @@ -10,7 +10,7 @@ def test_tuple_as_vector(): """ ego = new Object at 1 @ 2 param p = distance to (-2, -2) - """ + """ ) assert p == pytest.approx(5) @@ -20,7 +20,7 @@ def test_tuple_as_vector_2(): """ ego = new Object at 1 @ 2 param p = distance to (-2, -2, 12) - """ + """ ) assert p == pytest.approx(13) @@ -40,7 +40,7 @@ def test_list_as_vector(): """ ego = new Object at 1 @ 2 param p = distance to [-2, -2] - """ + """ ) assert p == pytest.approx(5) @@ -50,7 +50,7 @@ def test_list_as_vector_2(): """ ego = new Object at 1 @ 2 param p = distance to [-2, -2, 12] - """ + """ ) assert p == pytest.approx(13) @@ -61,5 +61,69 @@ def test_list_as_vector_3(): """ ego = new Object at 1 @ 2 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 b653f9195..27e64607f 100644 --- a/tests/syntax/test_verifai_samplers.py +++ b/tests/syntax/test_verifai_samplers.py @@ -1,5 +1,6 @@ import random +import numpy as np import pytest from tests.utils import compileScenic, sampleEgo, sampleParamP @@ -25,12 +26,45 @@ def sampleEgoWithFeedback(scenario, f, numSamples, maxIterations=1): return egos -def checkCEConvergence(scenario, rangeCheck=(lambda x: x == -1 or x == 1)): +@pytest.fixture +def checkCEConvergence(pytestconfig): + fast = pytestconfig.getoption("--fast") + + def helper(*args, **kwargs): + if "fast" not in kwargs: + kwargs["fast"] = fast + return _ceHelper(*args, **kwargs) + + return helper + + +def _ceHelper(scenario, rangeCheck=None, warmup=0, fast=False): + if not rangeCheck: + rangeCheck = lambda x: x == -1 or x == 1 f = lambda ego: -1 if ego.position.x > 0 else 1 - xs = [ego.position.x for ego in sampleEgoWithFeedback(scenario, f, 1200)] + if fast: + iterations = 120 + else: + iterations = 1250 + warmup + xs = [ego.position.x for ego in sampleEgoWithFeedback(scenario, f, iterations)] assert all(rangeCheck(x) for x in xs) - assert 22 <= sum(x < 0 for x in xs[:30]) - assert 143 <= sum(x > 0 for x in xs[200:]) + assert any(x <= 0 for x in xs[:120]) + if fast: + return + # The following parameters are for alpha=0.99, 1250 iterations, 2 buckets + # and all the counterexamples in the x > 0 bucket. With a uniform prior, no + # warmup is needed; for 9:1 odds against the right bucket, use warmup=1300. + xs = xs[warmup:] + assert 58 <= sum(x > 0 for x in xs[:250]) + assert 590 <= sum(x > 0 for x in xs[250:]) + assert 195 <= sum(x > 0 for x in xs[1000:]) + + +def checkCEStationary(scenario): + f = lambda ego: 1 + xs = [ego.position.x for ego in sampleEgoWithFeedback(scenario, f, 250)] + # These parameters assume 2 buckets with 9:1 odds against the positive bucket + assert 175 <= sum(x <= 0 for x in xs) < 250 ## Particular samplers @@ -46,61 +80,89 @@ def test_halton(): assert 29 <= sum(x < 10 for x in xs) <= 31 -def test_cross_entropy(): +def test_cross_entropy(checkCEConvergence): scenario = compileScenic( - 'param verifaiSamplerType = "ce"\n' - "from dotmap import DotMap\n" - "ce_params = DotMap()\n" - "ce_params.alpha = 0.9\n" - "ce_params.cont.buckets = 2\n" - "param verifaiSamplerParams = ce_params\n" - "ego = new Object at VerifaiRange(5, 15) @ 0" + """ + param verifaiSamplerType = "ce" + from dotmap import DotMap + ce_params = DotMap() + ce_params.alpha = 0.99 + ce_params.cont.buckets = 2 + param verifaiSamplerParams = ce_params + ego = new Object at VerifaiRange(-5, 5) @ 0 + """ ) - f = lambda ego: -1 if ego.position.x < 10 else 1 - xs = [ego.position.x for ego in sampleEgoWithFeedback(scenario, f, 120)] - assert all(5 <= x <= 15 for x in xs) - assert any(x > 10 for x in xs) - assert 66 <= sum(x < 10 for x in xs[50:]) + checkCEConvergence(scenario, rangeCheck=(lambda x: -5 <= x <= 5), fast=False) -def test_cross_entropy_inline(): +def test_cross_entropy_weights(checkCEConvergence): scenario = compileScenic( - 'param verifaiSamplerType = "ce"\n' - "from dotmap import DotMap\n" - "param verifaiSamplerParams = DotMap(alpha=0.99)\n" - "ego = new Object at VerifaiRange(-1, 1, weights=[100, 1]) @ 0" + """ + param verifaiSamplerType = "ce" + from dotmap import DotMap + param verifaiSamplerParams = DotMap(alpha=0.99) + ego = new Object at VerifaiRange(-1, 1, weights=[9, 1]) @ 0 + """ ) - checkCEConvergence(scenario, rangeCheck=(lambda x: -1 <= x <= 1)) + # Save scenario state which should not be mutated by sampling + params = scenario.params["verifaiSamplerParams"].copy() + contCESampler = scenario.externalSampler.sampler.domainSampler.cont_sampler + prior = contCESampler.dist.copy() + + # Generate samples and check convergence to the correct bucket + checkCEConvergence(scenario, rangeCheck=(lambda x: -1 <= x <= 1), warmup=1300) + + # Check the scenario state is unchanged + assert scenario.params["verifaiSamplerParams"] == params + scenario.resetExternalSampler() + contCESampler = scenario.externalSampler.sampler.domainSampler.cont_sampler + assert np.array_equal(contCESampler.dist, prior) + + # Generate new samples without feedback and check the prior distribution is used + checkCEStationary(scenario) -def test_cross_entropy_options(): + +def test_cross_entropy_options(checkCEConvergence): scenario = compileScenic( - 'param verifaiSamplerType = "ce"\n' - "from dotmap import DotMap\n" - "param verifaiSamplerParams = DotMap(alpha=0.99)\n" - "ego = new Object at VerifaiOptions({-1: 100, 1: 1}) @ 0" + """ + param verifaiSamplerType = "ce" + from dotmap import DotMap + param verifaiSamplerParams = DotMap(alpha=0.99) + ego = new Object at VerifaiOptions({-1: 9, 1: 1}) @ 0 + """ ) - checkCEConvergence(scenario) + checkCEConvergence(scenario, warmup=1300) + scenario.resetExternalSampler() + checkCEStationary(scenario) -def test_cross_entropy_prior(): +def test_cross_entropy_prior(checkCEConvergence): scenario = compileScenic( - 'param verifaiSamplerType = "ce"\n' - "from dotmap import DotMap\n" - "param verifaiSamplerParams = DotMap(alpha=0.99)\n" - "ego = new Object at VerifaiParameter.withPrior(Options({-1: 100, 1: 1})) @ 0" + """ + param verifaiSamplerType = "ce" + from dotmap import DotMap + param verifaiSamplerParams = DotMap(alpha=0.99) + ego = new Object at VerifaiParameter.withPrior(Options({-1: 9, 1: 1})) @ 0 + """ ) - checkCEConvergence(scenario) + checkCEConvergence(scenario, warmup=1300) + scenario.resetExternalSampler() + checkCEStationary(scenario) -def test_cross_entropy_prior_normal(): +def test_cross_entropy_prior_normal(checkCEConvergence): scenario = compileScenic( - 'param verifaiSamplerType = "ce"\n' - "from dotmap import DotMap\n" - "param verifaiSamplerParams = DotMap(alpha=0.99)\n" - "ego = new Object at VerifaiParameter.withPrior(Normal(-1, 0.7)) @ 0" + """ + param verifaiSamplerType = "ce" + from dotmap import DotMap + param verifaiSamplerParams = DotMap(alpha=0.99) + ego = new Object at VerifaiParameter.withPrior(Normal(-1, 0.8)) @ 0 + """ ) - checkCEConvergence(scenario, rangeCheck=(lambda x: True)) + checkCEConvergence(scenario, rangeCheck=(lambda x: True), warmup=1300) + scenario.resetExternalSampler() + checkCEStationary(scenario) ## Reproducibility and noninterference @@ -133,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 diff --git a/tox.ini b/tox.ini index 6b1910a09..e08482b81 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] -isolated_build = true -envlist = py{38,39,310,311}{,-extras} +envlist = py{38,39,310,311,312}{,-extras} +labels = + basic = py{38,39,310,311,312} [testenv] extras = diff --git a/toxfile.py b/toxfile.py new file mode 100644 index 000000000..62f2eb6df --- /dev/null +++ b/toxfile.py @@ -0,0 +1,17 @@ +import tox.plugin + + +@tox.plugin.impl +def tox_on_install(tox_env, arguments, section, of_type): + # Add extra installation stage to install Scenic *before* its dependencies, + # so that if we install VerifAI we won't pull in a different version of + # Scenic that might have different dependencies. (This is currently + # necessary since Tox installs the dependencies and Scenic in 2 different + # invocations of pip. See https://github.com/tox-dev/tox/discussions/3273 + # for a discussion.) + if section == "RunToxEnv" and of_type == "package": + assert len(arguments) == 1 + package = arguments[0] + path = package.path + install_args = ["--force-reinstall", "--no-deps", str(path)] + tox_env.installer._execute_installer(install_args, "package_early")