diff --git a/.coveragerc b/.coveragerc index b2920e991..f85af3c0a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -26,4 +26,4 @@ exclude_lines = ignore_errors = True show_missing = True -precision = 2 \ No newline at end of file +precision = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index d12f5bf46..afdb18fb5 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -5,3 +5,5 @@ c6c83f95ff370b75c3ee7130dbd8071bfe8b285a # Cleaned up test quote spacing 995cd182924dc9e3dbbc941c5b75454ea0cdaaca +# Ran black on entire codebase +cb51d08fda00df5588a418df42d9d652472f505f diff --git a/.github/scripts/run-ssm-command.sh b/.github/scripts/run-ssm-command.sh new file mode 100755 index 000000000..b9a72723e --- /dev/null +++ b/.github/scripts/run-ssm-command.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +INSTANCE_ID="${1:?Missing instance ID}" +EXECUTION_TIMEOUT="${2:-3600}" + +# Build the remote shell script as a JSON array for AWS-RunShellScript. +commands=$(jq -Rs -c 'split("\n") | if .[-1] == "" then .[:-1] else . end') + +# Run the script on the EC2 instance through SSM. +command_id=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name AWS-RunShellScript \ + --parameters "commands=$commands,executionTimeout=$EXECUTION_TIMEOUT" \ + --query 'Command.CommandId' \ + --output text) + +# Poll SSM until the remote command finishes, then print its output. +while true; do + status=$(aws ssm get-command-invocation \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text) + + case "$status" in + Success) + break + ;; + Failed|Cancelled|TimedOut|Cancelling) + aws ssm get-command-invocation --command-id "$command_id" --instance-id "$INSTANCE_ID" --query 'StandardOutputContent' --output text + aws ssm get-command-invocation --command-id "$command_id" --instance-id "$INSTANCE_ID" --query 'StandardErrorContent' --output text + exit 1 + ;; + Pending|InProgress|Delayed) + sleep 10 + ;; + esac +done + +aws ssm get-command-invocation \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query 'StandardOutputContent' \ + --output text diff --git a/.github/slack_oncall_reminder.py b/.github/slack_oncall_reminder.py deleted file mode 100644 index f62707077..000000000 --- a/.github/slack_oncall_reminder.py +++ /dev/null @@ -1,70 +0,0 @@ -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/check-formatting.yml b/.github/workflows/check-formatting.yml index 251ec326f..8ef4a843e 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -5,15 +5,24 @@ on: pull_request: workflow_call: +permissions: + contents: read + jobs: check: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + with: + persist-credentials: false - name: Run black to check formatting - uses: psf/black@stable + uses: psf/black@87928e6d6761a4a6d22250e1fee5601b3998086e + with: + version: "25.1.0" - name: Run isort to check import order - uses: isort/isort-action@v1 + uses: isort/isort-action@24d8a7a51d33ca7f36c3f23598dafa33f7071326 + with: + isort-version: "5.12.0" diff --git a/.github/workflows/on-call-reminder.yml b/.github/workflows/on-call-reminder.yml deleted file mode 100644 index 1798be663..000000000 --- a/.github/workflows/on-call-reminder.yml +++ /dev/null @@ -1,30 +0,0 @@ -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-benchmarks.yml b/.github/workflows/run-benchmarks.yml new file mode 100644 index 000000000..c9e5bb4bf --- /dev/null +++ b/.github/workflows/run-benchmarks.yml @@ -0,0 +1,130 @@ +name: Run Benchmarks + +on: + issue_comment: + types: [created] + +jobs: + check-permission: + runs-on: ubuntu-latest + permissions: + contents: none + if: > + github.event.issue.pull_request && + startsWith(github.event.comment.body, '!benchmark') + steps: + - name: Check user's permission level + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + with: + script: | + const username = context.payload.comment.user.login; + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: username, + }); + const userRole = data.role_name; + const allowedRoles = ["admin", "write", "maintain", "triage"]; + console.log(`User ${username} has role: ${userRole}`); + // Fail the job if user doesn't have allowed role + if (!allowedRoles.includes(userRole)) { + throw new Error(`User does not have sufficient permissions to run the benchmarks.`); + } + + run-benchmarks: + needs: check-permission + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Benchmarks Started Comment + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + with: + script: | + const commentId = context.payload.comment.id; + const originalBody = context.payload.comment.body; + // Append a message to indicate benchmarks have started running + const newBody = `${originalBody}\n\n🚀 Benchmarks are running...`; + // Update the comment with the new body text + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: newBody, + }); + + - name: Fetch PR Details + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + const body = context.payload.comment.body.trim(); + const requestedSha = body.split(/\s+/)[1]; + + core.exportVariable('BASE_SHA', pr.data.base.sha); + core.exportVariable('COMPARE_SHA', requestedSha); + + - name: Print PR Details + run: | + echo "COMPARE_SHA: $COMPARE_SHA" + echo "BASE_SHA: $BASE_SHA" + + - name: Checkout base branch + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + with: + ref: ${{ env.BASE_SHA }} + path: base + persist-credentials: false + + - name: Checkout compare branch + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + with: + ref: ${{ env.COMPARE_SHA }} + path: compare + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 + with: + python-version: '3.x' + + - name: Install pyperf + run: | + python -m pip install --upgrade pip + pip install pyperf + + - name: Run benchmark on base branch + working-directory: base + run: python tools/benchmarking/ci/run_benchmarks.py -o ../base.json + + - name: Run benchmark on compare branch + working-directory: compare + run: python tools/benchmarking/ci/run_benchmarks.py -o ../head.json + + - name: Compare benchmarks results + id: compare + run: | + python -m pyperf compare_to --table --table-format=md -G --min-speed=10 base.json head.json > comparison.txt + echo "Comparison results:" + cat comparison.txt + + - name: Post benchmark results + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + with: + script: | + const fs = require('fs'); + const result = fs.readFileSync('comparison.txt', 'utf8'); + const commentId = context.payload.comment.id; + const originalBody = context.payload.comment.body; + const newBody = `${originalBody}\n\n🚀 Benchmarks completed.\n\n${result}`; + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: newBody, + }); diff --git a/.github/workflows/run-coverage.yml b/.github/workflows/run-coverage.yml index 7ec93f84f..3d7d67145 100644 --- a/.github/workflows/run-coverage.yml +++ b/.github/workflows/run-coverage.yml @@ -17,31 +17,36 @@ on: description: Git ref on which to run the tests. type: string +permissions: + contents: read + jobs: coverage: strategy: fail-fast: true matrix: - python-version: ["3.11"] + python-version: ["3.12"] os: [ubuntu-latest] extras: ["test-full"] runs-on: ${{ matrix.os }} steps: - name: Checkout given ref - uses: actions/checkout@v3 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 if: inputs.ref != '' with: ref: ${{ inputs.ref }} + persist-credentials: false - name: Checkout current branch - uses: actions/checkout@v3 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 if: inputs.ref == '' with: ref: ${{ github.ref }} + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -57,9 +62,9 @@ jobs: - 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 + uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 with: token: ${{ secrets.CODECOV_TOKEN }} - slug: BerkeleyLearnVerify/Scenic \ No newline at end of file + slug: BerkeleyLearnVerify/Scenic diff --git a/.github/workflows/run-simulators.yml b/.github/workflows/run-simulators.yml index 885e386a4..9b952aec3 100644 --- a/.github/workflows/run-simulators.yml +++ b/.github/workflows/run-simulators.yml @@ -1,202 +1,313 @@ 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: +permissions: {} + +# Ensure only one simulator workflow runs at a time +concurrency: + group: sim + cancel-in-progress: false + +env: + INSTANCE_ID: ${{ secrets.AWS_EC2_INSTANCE_ID }} + 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 + start_ec2_instance: + name: start_ec2_instance + runs-on: ubuntu-latest + permissions: + id-token: write # This is required for OIDC to request the JWT + outputs: + volume_id: ${{ steps.create_volume_step.outputs.volume_id }} + steps: + # Use OIDC to get short-lived AWS credentials instead of storing long-lived AWS keys. + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + allowed-account-ids: ${{ secrets.AWS_ACCOUNT_ID }} + + - name: Create Volume from Latest Snapshot and Attach to Instance + id: create_volume_step + run: | + # Retrieve the latest snapshot ID + LATEST_SNAPSHOT_ID=$(aws ec2 describe-snapshots --owner-ids self --query 'Snapshots | sort_by(@, &StartTime) | [-1].SnapshotId' --output text) + echo "Checking availability for snapshot: $LATEST_SNAPSHOT_ID" + + # Wait for the snapshot to complete + aws ec2 wait snapshot-completed --snapshot-ids "$LATEST_SNAPSHOT_ID" + echo "Snapshot is ready." + + # Create a new volume from the latest snapshot + volume_id=$(aws ec2 create-volume --snapshot-id "$LATEST_SNAPSHOT_ID" --availability-zone us-west-1b --volume-type gp3 --size 400 --throughput 250 --query "VolumeId" --output text) + echo "Created volume with ID: $volume_id" + + # Set volume_id as output + echo "volume_id=$volume_id" >> "$GITHUB_OUTPUT" + + # Wait until the volume is available + aws ec2 wait volume-available --volume-ids "$volume_id" + echo "Volume is now available" + + # Attach the volume to the instance + aws ec2 attach-volume --volume-id "$volume_id" --instance-id "$INSTANCE_ID" --device /dev/sda1 + echo "Volume $volume_id attached to instance $INSTANCE_ID as /dev/sda1" + + - name: Start EC2 Instance + 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 instance status checks to pass + echo "Waiting for instance status checks to pass..." + aws ec2 wait instance-status-ok --instance-ids "$INSTANCE_ID" + echo "Instance is now ready for use." + + check_simulator_version_updates: + name: check_simulator_version_updates + runs-on: ubuntu-latest + needs: start_ec2_instance + permissions: + id-token: write + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + with: + persist-credentials: false + + # Use OIDC to get AWS credentials for SSM. + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + allowed-account-ids: ${{ secrets.AWS_ACCOUNT_ID }} + + - name: Check for Simulator Version Updates + env: + GH_SHA: ${{ github.sha }} + run: | + .github/scripts/run-ssm-command.sh "$INSTANCE_ID" < 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') + 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 - - # 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 + ' + EOF + + stop_ec2_instance: + name: stop_ec2_instance + runs-on: ubuntu-latest + permissions: + id-token: write + needs: + [ + start_ec2_instance, + check_simulator_version_updates, + check_nvidia_smi, + run_carla_simulators, + run_webots_simulators, + ] + if: always() + env: + VOLUME_ID: ${{ needs.start_ec2_instance.outputs.volume_id }} + steps: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + allowed-account-ids: ${{ secrets.AWS_ACCOUNT_ID }} + + - name: Stop EC2 Instance + run: | + # Get the instance state and stop it if running + instance_state=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" | jq -r '.Reservations[].Instances[].State.Name') + if [ "$instance_state" = "running" ]; then + echo "Instance is running, stopping it..." + aws ec2 stop-instances --instance-ids "$INSTANCE_ID" + aws ec2 wait instance-stopped --instance-ids "$INSTANCE_ID" + echo "Instance has stopped." + elif [ "$instance_state" = "stopped" ]; then + echo "Instance is already stopped." + else + echo "Unexpected instance state: $instance_state" + exit 1 + fi + + - name: Detach Volume + if: ${{ needs.start_ec2_instance.outputs.volume_id != '' }} + run: | + # Detach the volume + aws ec2 detach-volume --volume-id "$VOLUME_ID" + aws ec2 wait volume-available --volume-ids "$VOLUME_ID" + echo "Volume $VOLUME_ID detached." + + - name: Delete Volume + if: ${{ needs.start_ec2_instance.outputs.volume_id != '' }} + run: | + # Delete the volume after snapshot is complete + aws ec2 delete-volume --volume-id "$VOLUME_ID" + echo "Volume $VOLUME_ID deleted." diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2a5bb55d1..c93ca5f79 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,6 +25,9 @@ on: default: --no-graphics type: string +permissions: + contents: read + jobs: check-format: uses: ./.github/workflows/check-formatting.yml @@ -33,28 +36,34 @@ jobs: strategy: fail-fast: true matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - os: [ubuntu-latest, windows-latest] - extras: ["test", "test-full"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + os: [ubuntu-latest, windows-latest, macos-15-intel, macos-latest] + include: + # Only run slow tests on the latest version of Python + - python-version: "3.14" + slow: true runs-on: ${{ matrix.os }} steps: - name: Checkout given ref - uses: actions/checkout@v3 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 if: inputs.ref != '' with: ref: ${{ inputs.ref }} + persist-credentials: false - name: Checkout current branch - uses: actions/checkout@v3 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 if: inputs.ref == '' with: ref: ${{ github.ref }} + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true cache: 'pip' - name: Update pip @@ -63,8 +72,11 @@ jobs: - name: Install Scenic and dependencies run: | - python -m pip install -e ".[${{ matrix.extras }}]" + python -m pip install -e ".[test-full]" - name: Run pytest + env: + TEST_OPTIONS: ${{ inputs.options || (matrix.slow && '--no-graphics' || '--fast --no-graphics') }} + shell: sh run: | - pytest ${{ inputs.options || '--no-graphics' }} + pytest ${TEST_OPTIONS} diff --git a/.github/workflows/sync-issues-with-jira.yml b/.github/workflows/sync-issues-with-jira.yml index c07924afa..237109af5 100644 --- a/.github/workflows/sync-issues-with-jira.yml +++ b/.github/workflows/sync-issues-with-jira.yml @@ -3,13 +3,15 @@ on: issues: types: [opened] +permissions: {} + jobs: generate-issue-link: runs-on: ubuntu-latest steps: - name: Get issue details id: get_issue_details - uses: actions/github-script@v4 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea with: github-token: ${{ secrets.GH_ACCESS_TOKEN }} script: | @@ -17,9 +19,9 @@ jobs: 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}`); - + core.setOutput('issueTitle', issueTitle); + core.setOutput('issueLink', issueLink); + - name: Create Jira Ticket env: JIRA_DOMAIN: ${{ secrets.JIRA_DOMAIN }} @@ -30,7 +32,7 @@ jobs: 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" \ @@ -61,4 +63,4 @@ jobs: "key": "SCENIC" } } - }' \ No newline at end of file + }' diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index bd0c2bc5d..e0c06da30 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -8,6 +8,9 @@ on: required: true type: string +permissions: + contents: read + jobs: call-run-tests: uses: ./.github/workflows/run-tests.yml diff --git a/.github/workflows/weekly-ci-tests.yml b/.github/workflows/weekly-ci-tests.yml new file mode 100644 index 000000000..b3bf1f8f1 --- /dev/null +++ b/.github/workflows/weekly-ci-tests.yml @@ -0,0 +1,47 @@ +name: Weekly CI tests + +# Trigger every Thursday at 9:15 AM Pacific Time (16:15 UTC) +on: + schedule: + - cron: '15 16 * * 4' + +permissions: + contents: read + +jobs: + run-tests: + uses: ./.github/workflows/run-tests.yml + with: + # Use the default branch" (i.e. main) + ref: '' + + notify: + name: Notify Slack + needs: run-tests + runs-on: ubuntu-latest + if: always() + steps: + - name: Post result to Slack + uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL}} + webhook-type: incoming-webhook + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Weekly CI tests* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|run #${{ github.run_number }}> finished." + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "${{ needs.run-tests.result == 'success' && '✅ All tests passed!' || '🚨 Some tests failed!' }}" + } + } + ] + } diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 000000000..e65667276 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,37 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 +# https://woodruffw.github.io/zizmor + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +jobs: + zizmor: + name: zizmor latest via PyPI + runs-on: ubuntu-latest + permissions: + security-events: write + # required for workflows in private repositories + contents: read + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + with: + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@22695119d769bdb6f7032ad67b9bca0ef8c4a174 + + - name: Run zizmor 🌈 + run: uvx zizmor --format sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@1b549b9259bda1cb5ddde3b41741a82a2d15a841 + with: + sarif_file: results.sarif + category: zizmor diff --git a/.gitignore b/.gitignore index d2bf7f6f9..f540a7a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,143 +1,157 @@ -# Autogenerated documentation -docs/modules - -# Poetry lock file -poetry.lock - -# Scenic cache files -*.snet - -# Webots temporary files -.*.wbproj - -# OS X junk -.DS_Store - -# Sublime Text files -*.sublime-project -*.sublime-workspace - -# VSCode files -*.vscode - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ -coverage.json - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ -docs/_autosummary/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv*/ -ENV/ -env.bak/ -venv.bak/ -scenic.venv/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -*.cproject - -# generated parser -src/scenic/syntax/parser.py - -simulation.gif \ No newline at end of file +# Autogenerated documentation +docs/modules + +# Poetry lock file +poetry.lock + +# Scenic cache files +*.snet + +# Webots temporary files +.*.wbproj + +# OS X junk +.DS_Store + +# Sublime Text files +*.sublime-project +*.sublime-workspace + +# VSCode files +*.vscode + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ +coverage.json + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/_autosummary/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv*/ +ENV/ +env.bak/ +venv.bak/ +scenic.venv/ +3_10_venv/ +scenic_venv/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +*.cproject + +# generated parser +src/scenic/syntax/parser.py + +# generated media/output +simulation.gif +metadrive_gifs/ + +# Random +test.ipynb +test.sh +output.txt +pyinst* +*.txt + +*.gif +*.png \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22d93a241..4f86e1d46 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,12 @@ repos: - - repo: https://github.com/psf/black - rev: 23.3.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.1.0 hooks: - id: black - language_version: python3.11 + language_version: python3.14 - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - - id: isort \ No newline at end of file + - id: isort + language_version: python3.14 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..924ac95d5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +coc@forum.scenic-lang.org. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/README.md b/README.md index a6732338e..f804a7d98 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ For an overview of the language and some of its applications, see our [2022 jour The new syntax and features of Scenic 3 are described in our [CAV 2023 paper](https://arxiv.org/abs/2307.03325). 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. +Scenic was initially designed and implemented at UC Berkeley by Daniel J. Fremont, Tommaso Dreossi, Shromona Ghosh, Xiangyu Yue, Alberto L. Sangiovanni-Vincentelli, and Sanjit A. Seshia. +Subsequent work has been done primarily at UC Berkeley and UC Santa Cruz: in particular, 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://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) or start a conversation on our [community forum](https://forum.scenic-lang.org/). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c8b780f71 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security Policy + +## Supported Versions + +Scenic currently provides security updates for the latest stable 3.x release. + +| Version | Supported | +| ------- | --------- | +| Latest stable 3.x | ✅ | +| Older releases | ❌ | + +## Reporting a Vulnerability + +Please **do not** report security vulnerabilities through public GitHub issues or pull requests. + +### How to Report + +- **Preferred:** Use [GitHub's private vulnerability reporting](https://github.com/BerkeleyLearnVerify/Scenic/security/advisories/new) through the **Security** tab of this repository. +- **Alternative:** Email [security@forum.scenic-lang.org](mailto:security@forum.scenic-lang.org) with the subject line `SECURITY: `. + +### What to Include + +To help us investigate, please include as much of the following as possible: + +- A clear description of the issue +- Steps to reproduce the issue +- Any relevant Scenic programs, inputs, or configuration details +- The version of Scenic affected +- The potential impact +- Any suggested fix or mitigation, if available + +### What to Expect + +- We will acknowledge receipt within **1 week**. +- We will provide status updates at least every **14 days** while the issue is under investigation. +- If the report is accepted as a security vulnerability, we will prioritize a fix and coordinate disclosure with you. We are happy to credit you in the GitHub security advisory unless you prefer to remain anonymous. +- If the report is not accepted as a security vulnerability, we will let you know why. + +We ask that you follow responsible disclosure practices and avoid public disclosure until a fix has been released or **90 days** have passed since your report, whichever comes first. diff --git a/assets/maps/CARLA/Town01.net.xml b/assets/maps/CARLA/Town01.net.xml new file mode 100644 index 000000000..b390996e4 --- /dev/null +++ b/assets/maps/CARLA/Town01.net.xml @@ -0,0 +1,2331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town02.net.xml b/assets/maps/CARLA/Town02.net.xml new file mode 100644 index 000000000..ef5884646 --- /dev/null +++ b/assets/maps/CARLA/Town02.net.xml @@ -0,0 +1,1767 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town03.net.xml b/assets/maps/CARLA/Town03.net.xml new file mode 100644 index 000000000..035486e5c --- /dev/null +++ b/assets/maps/CARLA/Town03.net.xml @@ -0,0 +1,8782 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town04.net.xml b/assets/maps/CARLA/Town04.net.xml new file mode 100644 index 000000000..41fd2aab4 --- /dev/null +++ b/assets/maps/CARLA/Town04.net.xml @@ -0,0 +1,6299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town05.net.xml b/assets/maps/CARLA/Town05.net.xml new file mode 100644 index 000000000..9f12b0bea --- /dev/null +++ b/assets/maps/CARLA/Town05.net.xml @@ -0,0 +1,7463 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town06.net.xml b/assets/maps/CARLA/Town06.net.xml new file mode 100644 index 000000000..c44e57950 --- /dev/null +++ b/assets/maps/CARLA/Town06.net.xml @@ -0,0 +1,6277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town07.net.xml b/assets/maps/CARLA/Town07.net.xml new file mode 100644 index 000000000..d198924b4 --- /dev/null +++ b/assets/maps/CARLA/Town07.net.xml @@ -0,0 +1,4836 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town10.net.xml b/assets/maps/CARLA/Town10.net.xml new file mode 100644 index 000000000..3bd1df379 --- /dev/null +++ b/assets/maps/CARLA/Town10.net.xml @@ -0,0 +1,2809 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/CARLA/Town10HD.net.xml b/assets/maps/CARLA/Town10HD.net.xml new file mode 100644 index 000000000..bc3942510 --- /dev/null +++ b/assets/maps/CARLA/Town10HD.net.xml @@ -0,0 +1,1202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/misc/Issue189.xodr b/assets/maps/misc/Issue189.xodr new file mode 100644 index 000000000..c595a693c --- /dev/null +++ b/assets/maps/misc/Issue189.xodr @@ -0,0 +1,7502 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/assets/maps/misc/Issue274.xodr b/assets/maps/misc/Issue274.xodr new file mode 100644 index 000000000..e3843d33e --- /dev/null +++ b/assets/maps/misc/Issue274.xodr @@ -0,0 +1,83 @@ + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + diff --git a/assets/maps/misc/Issue295a.xodr b/assets/maps/misc/Issue295a.xodr new file mode 100644 index 000000000..01afee2bf --- /dev/null +++ b/assets/maps/misc/Issue295a.xodr @@ -0,0 +1,16734 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/misc/shoulders.xodr b/assets/maps/misc/shoulders.xodr new file mode 100644 index 000000000..4bb7ea5ae --- /dev/null +++ b/assets/maps/misc/shoulders.xodr @@ -0,0 +1,267 @@ + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + +
+ +
+ + + + + + + + + + + + + + +
+ +
+ +
+ + + + + + + + + + + + + + +
+
+
+ + + + + + + + + +
+ +
+ + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/assets/maps/misc/suspect_geometries.xodr b/assets/maps/misc/suspect_geometries.xodr new file mode 100644 index 000000000..7bf398240 --- /dev/null +++ b/assets/maps/misc/suspect_geometries.xodr @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + +
+ +
+ + + + + +
+
+
+ + + + + + + + + +
+ +
+ + + + + +
+
+
+ + + + + + + + + + + + +
+ +
+ + + + + +
+
+
+
\ No newline at end of file diff --git a/assets/maps/misc/zero_width.xodr b/assets/maps/misc/zero_width.xodr new file mode 100644 index 000000000..da2b827c6 --- /dev/null +++ b/assets/maps/misc/zero_width.xodr @@ -0,0 +1,80 @@ + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+
+
+ + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+
+
+ + + + + + + + + +
+ +
+ + + + + + + + +
+
+
+
diff --git a/codecov.yml b/codecov.yml index 9b0516a8b..3d6eb52b4 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,4 @@ -codecov: +codecov: require_ci_to_pass: true coverage: @@ -21,6 +21,7 @@ ignore: - "src/scenic/simulators/carla/" - "src/scenic/simulators/gta/" - "src/scenic/simulators/lgsvl/" + - "src/scenic/simulators/metadrive/" - "src/scenic/simulators/webots/" - "src/scenic/simulators/xplane/" - "!**/*.py" @@ -30,4 +31,4 @@ comment: cli: plugins: pycoverage: - report_type: "json" \ No newline at end of file + report_type: "json" diff --git a/docs/conf.py b/docs/conf.py index d20aee6ea..aa9714a95 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,6 +46,7 @@ paramOverrides=dict( map="../assets/maps/opendrive.org/CulDeSac.xodr", carla_map="blah", + sumo_map="blah", lgsvl_map="blah", ), ) @@ -54,6 +55,7 @@ warnings.simplefilter("ignore", SimulatorInterfaceWarning) import scenic.simulators.carla.model import scenic.simulators.lgsvl.model + import scenic.simulators.metadrive.model veneer.deactivate() # Hack to allow importing models which require 2D compatibility mode @@ -106,7 +108,7 @@ autosummary_generate = True autodoc_inherit_docstrings = False autodoc_member_order = "bysource" -autodoc_mock_imports = ["carla", "lgsvl"] +autodoc_mock_imports = ["carla", "lgsvl", "metadrive"] autodoc_typehints = "description" autodoc_type_aliases = { "Vectorlike": "`scenic.domains.driving.roads.Vectorlike`", @@ -123,15 +125,7 @@ "show-inheritance": None, } -intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "matplotlib": ("https://matplotlib.org/stable/", None), - "numpy": ("https://numpy.org/doc/stable/", None), - "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://trimesh.org/", None), -} +from intersphinx_config import intersphinx_mapping highlight_language = "scenic" pygments_style = "scenic.syntax.pygment.ScenicStyle" @@ -603,7 +597,9 @@ def add_directive_header(self, sig): orig_object_description = sphinx.util.inspect.object_description -def object_description(obj): +def object_description(obj, **kwargs): + # Accept **kwargs to support Sphinx ≥7.2, which passes the `_seen` argument + # to object_description. This ensures compatibility across Sphinx versions. if obj is scenic.core.regions.nowhere or obj is scenic.core.regions.everywhere: return str(obj) elif isinstance( @@ -620,7 +616,7 @@ def object_description(obj): elif obj is sys.stderr: return "sys.stderr" else: - return orig_object_description(obj) + return orig_object_description(obj, **kwargs) sphinx.util.inspect.object_description = object_description diff --git a/docs/credits.rst b/docs/credits.rst index b93567717..97daf99d1 100644 --- a/docs/credits.rst +++ b/docs/credits.rst @@ -26,6 +26,7 @@ The Scenic tool and example scenarios have benefitted from additional code contr * Abolfazl Karimi * Kevin Li * Guillermo López + * Lola Marrero * Shalin Mehta * Joel Moriana * Gaurav Rao diff --git a/docs/developing.rst b/docs/developing.rst index 97bdfe8e0..e1197cf6e 100644 --- a/docs/developing.rst +++ b/docs/developing.rst @@ -135,6 +135,39 @@ commonly-used features are: * the :rst:role:`term` role for glossary terms is extended so that the cross-reference will work even if the link is plural but the glossary entry is singular or vice versa. +Benchmarking and Profiling +-------------------------- + +The :file:`tools/benchmarking` folder contains tools to help measure Scenic's performance +and resource usage. In particular, the :file:`tools/benchmarking/ci` folder contains a set +of benchmarks available to run as a CI workflow to assess the performance impact of a PR. +Those with write or triage permissions in the Scenic repository can trigger benchmarking +of a commit by leaving a comment on the PR of the form ``!benchmark HASH``, specifying the +hash of the desired commit. You can also run benchmarks +locally using the :file:`run_benchmarks.py` script: use its ``-h`` option for +instructions. The script uses `pyperf `_, which runs the +benchmarks many times to improve the stability of the results, so benchmarking is slow. +You may want to use the ``--fast`` option for quick-and-dirty results. Note that +even with the slow default options, many of the Scenic benchmarks have substantial +variability, so speed differences on the order of 10% or less are probably not +significant. + +When working on optimizing a particular part of Scenic, it is much faster to focus on one +or a few suitable Scenic programs rather than constantly re-running the whole benchmark +suite. Scenic's :option:`--gather-stats` option is convenient for measuring the time and +sampling iterations required for a single Scenic program. For example: + +.. code:: console + + scenic examples/webots/vacuum/vacuum_simple.scenic -v 0 --gather-stats 100 + +This option is also ideal for running Scenic in a profiler to investigate where time is +being spent. Using `Pyinstrument `_, for example: + +.. code:: console + + pyinstrument -r html -m scenic examples/webots/vacuum/vacuum_simple.scenic -v 0 --gather-stats 100 + .. rubric:: Footnotes .. [#f1] To run the formatters on *all* files, changed or otherwise, use :command:`make format` in the root directory of the repository. But this should not be necessary if you installed the pre-commit hooks and so all files already committed are clean. diff --git a/docs/governance.rst b/docs/governance.rst new file mode 100644 index 000000000..8fef6fab8 --- /dev/null +++ b/docs/governance.rst @@ -0,0 +1,88 @@ +Project Governance +================== + +This document describes the organization and governance of the Scenic project. The key elements are as follows: + + +Steering Committee +------------------ + +The Scenic Steering Committee (SC) has responsibility for the overall governance of the Scenic project. These responsibilities include setting and revising project policies, overseeing the work of the Scenic Core Team and the Working Groups (see below), creating and phasing-out working groups, and being the final authority on changes to the Scenic language and its associated tools. + +The composition of the SC has been initially fixed based on the PIs involved in Scenic's development. In 2025-2026, we plan to develop a democratic process for choosing SC members which is inclusive of the broader Scenic community, including advisors from academia, industry, and government. + +Current SC members: + + * Parasara Sridhar Duggirala (UNC Chapel Hill) + * Daniel Fremont (UC Santa Cruz) + * Necmiye Ozay (U Michigan) + * Sanjit Seshia (UC Berkeley) + + +Core Team +--------- + +The Scenic Core Team (CT) is a trusted core group of researchers, developers, and community members who help manage and develop the Scenic project. Currently, members of the CT are chosen by the SC. + +Current CT members: + + * Kai-Chun Chang + * Parasara Sridhar Duggirala + * Daniel Fremont + * Edward Kim + * Lola Marrero + * Necmiye Ozay + * Sanjit Seshia + * Hazem Torfah + * Eric Vin + * Kai Xu + * Beyazit Yalcinkaya + + +Working Groups +-------------- + +Most of the development of Scenic is governed by specialized working groups, whose procedures are set by the Steering Committee and whose leadership comes from the Core Team. The current working groups are: + + +Language and Infrastructure WG +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This WG governs changes to Scenic's syntax, as well as the project's repository and test infrastructure. + +Current members: + + * Daniel Fremont (co-chair) + * Sanjit Seshia (co-chair) + * Edward Kim + * Hazem Torfah + * Eric Vin + + +Community WG +~~~~~~~~~~~~ + +This WG focuses on matters related to the Scenic user/contributor community. It governs workshops, bootcamps, and other outreach events, as well as development of documentation and other materials. + +Current members: + + * Edward Kim (chair) + * Daniel Fremont + * Sanjit Seshia + + +Autonomous Driving WG +~~~~~~~~~~~~~~~~~~~~~ + +This WG focuses on applications of Scenic in the autonomous driving domain, developing tools for and performing outreach to that community. + +Current members: + + * Eric Vin (chair) + * Necmiye Ozay (co-chair) + * Parasara Sridhar Duggirala (co-chair) + * Kai-Chun Chang + * Ruya Karagulle + * Dejan Ničković + * Hazem Torfah + * Beyazit Yalcinkaya diff --git a/docs/index.rst b/docs/index.rst index cc5fbcd76..d17108c42 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,8 @@ Table of Contents :maxdepth: 1 :caption: General Information + roadmap + governance publications credits diff --git a/docs/internals/compiler.rst b/docs/internals/compiler.rst index bb35fb9d3..085325e41 100644 --- a/docs/internals/compiler.rst +++ b/docs/internals/compiler.rst @@ -18,18 +18,10 @@ A Scenic AST is an abstract syntax tree for representing Scenic programs. It is a superset of Python AST and includes nodes for Scenic-specific language constructs. -The `scenic.syntax.ast` module defines all Scenic-specific AST nodes, which are instances of the `AST` class defined in the same file. - -AST nodes should include fields to store objects. To add fields, add a -parameter to the initializer and define fields by assigning values to -``self``. - -When adding fields, be sure to update the ``_fields`` and -``__match_args__`` fields. ``_fields`` lists the names of the fields in -the AST node and is used by the AST module to traverse the tree, fill in -the missing information, etc. :obj:`~object.__match_args__` is used by the test -suite to assert the structure of the AST node using Python's structural -pattern matching. +The `scenic.syntax.ast` module defines all Scenic-specific AST nodes, which +are subclasses of the `AST` class defined in the same file. AST nodes are +defined by declaring type-annotated attributes for any child nodes or values +they store; the base class handles the rest. Scenic Grammar ~~~~~~~~~~~~~~ @@ -86,39 +78,23 @@ operator using the new parser architecture. Step 1: Add AST Nodes ~~~~~~~~~~~~~~~~~~~~~ -First, we define AST nodes that represent the syntax. Since the +First, we define an AST node that represents the syntax. Since the ``implies`` operator is a binary operator, the AST node will have two -fields for each operand. +attributes for its operands: .. code-block:: python :linenos: class ImpliesOp(AST): - __match_args__ = ("hypothesis", "conclusion") - - def __init__( - self, hypothesis: ast.AST, conclusion: ast.AST, *args: Any, **kwargs: Any - ) -> None: - super().__init__(*args, **kwargs) - self.hypothesis = hypothesis - self.conclusion = conclusion - self._fields = ["hypothesis", "conclusion"] + hypothesis: ast.AST + conclusion: ast.AST * On line 1, `AST` (`scenic.syntax.ast.AST`, not :external:obj:`ast.AST`) is the base class that all Scenic AST nodes extend. -* On line 2, ``__match_args__`` is a syntax for using `structural pattern - matching `__ - on argument positions. This is to make it easier to write parser tests. - -* On line 5, the initializer takes two required arguments corresponding to the operator's operands (``hypothesis`` and ``conclusion``). Note - that their types are :external:obj:`ast.AST`, which is the base class for *all* AST nodes, - including both Scenic AST nodes and Python AST nodes. The additional arguments ``*args`` and - ``**kwargs`` should be passed to the base class’ initializer to store - extra information such as line number, offset, etc. +* The attributes ``hypothesis`` and ``conclusion`` are typed as + :external:obj:`ast.AST`, which is the base class for *all* AST nodes, + including both Scenic AST nodes and Python AST nodes. -* On line 10, ``_fields`` is a special field that specifies the child nodes. This is used by - the library functions such as ``generic_visit`` to traverse the - syntax tree. Step 2: Add Grammar ~~~~~~~~~~~~~~~~~~~ diff --git a/docs/intersphinx_config.py b/docs/intersphinx_config.py new file mode 100644 index 000000000..f1dbcc41a --- /dev/null +++ b/docs/intersphinx_config.py @@ -0,0 +1,22 @@ +"""Shared Intersphinx configuration for Scenic's documentation. + +This module defines ``intersphinx_mapping``, which is imported from +``docs/conf.py`` and reused from ``tests/test_docs.py`` to check +connectivity to all configured external documentation sites. +""" + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "matplotlib": ("https://matplotlib.org/stable/", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "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://trimesh.org/", None), +} + + +def iter_intersphinx_urls(): + """Yield the base URLs from the mapping.""" + for base_url, _ in intersphinx_mapping.values(): + yield base_url diff --git a/docs/language_reference.rst b/docs/language_reference.rst index e26903842..6a7700bef 100644 --- a/docs/language_reference.rst +++ b/docs/language_reference.rst @@ -20,6 +20,7 @@ For details on the syntax for functions, loops, etc. inherited from Python, see reference/distributions reference/statements reference/classes + reference/sensors reference/specifiers reference/operators reference/functions @@ -33,4 +34,4 @@ The pages above describe the semantics of each of Scenic's constructs individual :maxdepth: 1 reference/scene_generation - reference/dynamic_scenarios \ No newline at end of file + reference/dynamic_scenarios diff --git a/docs/new.rst b/docs/new.rst index 044760379..0d1328528 100644 --- a/docs/new.rst +++ b/docs/new.rst @@ -15,6 +15,51 @@ It also features a new parser enabling clearer error messages, greater language See :ref:`porting to Scenic 3` for tools to help migrate existing 2D scenarios. +Scenic 3.1.1 +------------ + +Minor new features: + + * Added support for Python 3.14. + + +Scenic 3.1.0 +------------ + +Major new features: + + * Added an :ref:`interface ` to the MetaDrive driving simulator. + + * Added a new experimental sensors API with support for CARLA and MetaDrive (see :ref:`Sensors Reference `). + + * Extended the :keyword:`record` statement to support saving time-series data directly to files via the ``to`` clause; see the :ref:`Language Reference ` for details. + + * Introduced :ref:`wait for ` and :ref:`wait until `, which work like :ref:`do ... for ` and :ref:`do ... until ` but advance the scenario without taking any actions. + +Minor new features: + + * Added support for Python 3.13. + + * Improved Scenic support for ``str``/``int``/``float`` conversions and type checking; these names are now reserved and can't be reassigned. + + * Added a :prop:`render` property to :class:`~scenic.core.object_types.Object` to let objects opt out of the internal visualizer, and fixed handling of face colors for object meshes. + + * Improved visual accuracy in the Newtonian physics simulator and added a ``debugRender`` visualization option (see :mod:`scenic.simulators.newtonian.driving_model`). + + * :class:`~scenic.simulators.carla.actions.SetAutopilotAction` now accepts optional CARLA Traffic Manager settings (ignoring signs/lights/walkers, automatic lane changes, target speed/route, and speed-limit offsets). + + * Added pedestrian movement support in the Newtonian physics simulator. + + * Expanded tested compatibility with CARLA to include version 0.9.16 (Scenic has been tested with CARLA 0.9.9–0.9.16). + + * Added :meth:`Network.shoulderAt ` and :meth:`Network.sidewalkAt `, updated :meth:`Network.elementAt ` to return shoulders and sidewalks, and added a ``showCurbArrows`` option to :meth:`~scenic.domains.driving.roads.Network.show` for visualizing curb orientation in 2D plots. + +Notable bug fixes: + + * Fixed :meth:`Network.roadDirection ` and :meth:`Network.nominalDirectionsAt ` so objects on shoulders or inside intersections inherit the correct maneuver heading instead of defaulting to 0. + + * Fixed the default :prop:`velocity` of :class:`~scenic.core.object_types.Object` so it now derives from :prop:`speed` and :prop:`orientation`, as previously documented, instead of always being :scenic:`(0, 0, 0)`. + Scenic 3.0.0 ------------ diff --git a/docs/options.rst b/docs/options.rst index 1df985596..956399c4c 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -134,3 +134,10 @@ Debugging Implies the :option:`-b` option. This option can be enabled from the Python API using `scenic.setDebuggingOptions`. + +.. option:: --gather-stats + + Collect timing statistics over a specified number of scenes, rather than rendering + diagrams. If the number is negative, it is considered a number of rejection sampling + iterations rather than scenes (useful to reduce variability, and if the number of + iterations required to generate a scene is very large). diff --git a/docs/reference/data.rst b/docs/reference/data.rst index 3de724599..ca717d39b 100644 --- a/docs/reference/data.rst +++ b/docs/reference/data.rst @@ -136,3 +136,11 @@ When creating a `MeshShape`, if no dimensions are provided then dimensions will :noindex: :no-show-inheritance: +.. _Sensor: + +Sensor +====== + +Sensors are mounted on objects and produce observations during a simulation. +Scenic provides several built-in sensor types. +See the :ref:`Sensors Reference ` for available types, API details, and usage examples. diff --git a/docs/reference/dynamic_scenarios.rst b/docs/reference/dynamic_scenarios.rst index ad4512912..524570350 100644 --- a/docs/reference/dynamic_scenarios.rst +++ b/docs/reference/dynamic_scenarios.rst @@ -33,12 +33,12 @@ In detail, a single time step of a dynamic simulation is executed according to t If the block executes a :keyword:`require` statement with a false condition, reject the simulation. If it executes :keyword:`terminate` or :keyword:`terminate simulation`, or finishes executing, go to step (e) below to stop the scenario. - e. If the scenario is stopping for one of the reasons above, first recursively stop any sub-scenarios it is running, then revert the effects of any :keyword:`override` statements it executed. + e. If the scenario is stopping for one of the reasons above, save the values of any :keyword:`record final` statements in the scenario, recursively stop any sub-scenarios it is running, then revert the effects of any :keyword:`override` statements it executed. Next, check if any of its :term:`temporal requirements` were not satisfied: if so, reject the simulation. Otherwise, the scenario returns to its parent scenario if it was invoked using :keyword:`do`; if it was the top-level scenario, or if it executed :keyword:`terminate simulation`, we set a flag indicating the top-level scenario has terminated. (We do not terminate immediately since we still need to check monitors in the next step.) -2. Save the values of all :keyword:`record` statements, as well as :keyword:`record initial` statements if it is time step 0. +2. Save the values of all :keyword:`record` statements in currently-running scenarios, as well as :keyword:`record initial` statements for scenarios which have just started. 3. Run each :term:`monitor` instantiated in the currently-running scenarios for one time step (i.e. resume it until it executes :keyword:`wait`). If it executes a :keyword:`require` statement with a false condition, reject the simulation. @@ -66,8 +66,7 @@ In detail, a single time step of a dynamic simulation is executed according to t 9. Update every :term:`dynamic property` of every object to its current value in the simulator. -10. If the simulation is stopping for one of the reasons above, first check if any of the :term:`temporal requirements` of any remaining scenarios were not satisfied: if so, reject the simulation. - Otherwise, save the values of any :keyword:`record final` statements. +10. If the simulation is stopping for one of the reasons above, stop any remaining scenarios as in step (1e) above (including checking :term:`temporal requirements` and saving the values of :keyword:`record final` statements). .. rubric:: Footnotes diff --git a/docs/reference/sensors.rst b/docs/reference/sensors.rst new file mode 100644 index 000000000..551783deb --- /dev/null +++ b/docs/reference/sensors.rst @@ -0,0 +1,64 @@ +.. _sensors: + +****************** +Sensors Reference +****************** + +Scenic sensors provide observations from the selected simulator during a simulation. +Use these abstract classes in :specifier:`with sensors { ... }` blocks; the selected simulator +(CARLA, MetaDrive, etc.) supplies the concrete implementation. + +.. note:: + + The sensors API is a prototype and may undergo changes. + +.. versionadded:: 3.1 + +.. contents:: :local: + +Built-in Sensors +---------------- + +.. autoclass:: scenic.core.sensors.RGBSensor + :noindex: + :no-show-inheritance: + :no-members: + +.. autoclass:: scenic.core.sensors.SSSensor + :noindex: + :no-show-inheritance: + :no-members: + +Usage +----- + +.. code-block:: scenic + + param map = localPath('../../assets/maps/CARLA/Town05.xodr') + param carla_map = 'Town05' + model scenic.domains.driving.model + + # Sample a lane at random + lane = Uniform(*network.lanes) + + spot = new OrientedPoint on lane.centerline + + attrs = {"convert": "CityScapesPalette"} # Used by CARLA + + # Spawn car on that spot with follow lane behavior and + # - an RGB Camera pointing forward + # - a semantic segmentation sensor + ego = new Car at spot, + with behavior FollowLaneBehavior(), + with sensors {"front_ss": SSSensor(offset=(1.6, 0, 1.7), width=1056, height=704, attributes=attrs), + "front_rgb": RGBSensor(offset=(1.6, 0, 1.7), width=1056, height=704, attributes=attrs) + } + + other = new Car offset by 0 @ Range(10, 30), + with behavior FollowLaneBehavior() + + param recordFolder = "out/{simulation}" + record ego.observations["front_ss"] every 0.5 seconds after 5 seconds to "frontss_{time}.jpg" + record ego.observations["front_rgb"] after 5 seconds to "frontrgb.mp4" + + terminate after 15 seconds diff --git a/docs/reference/statements.rst b/docs/reference/statements.rst index f44a0fcf8..97a8e5c02 100644 --- a/docs/reference/statements.rst +++ b/docs/reference/statements.rst @@ -41,7 +41,12 @@ Behavior Definition Defines a :term:`dynamic behavior`, which can be assigned to a Scenic object by setting its :prop:`behavior` property using the :scenic:`with behavior {behavior}` specifier; this makes the object an :term:`agent`. See our tutorial on :ref:`dynamics` for examples of how to write behaviors. -Behavior definitions have the same form as function definitions, with an argument list and a body consisting of one or more statements; the body may additionally begin with definitions of preconditions and invariants. +Behavior definitions have a similar form to function definitions, with an argument list and a body consisting of one or more statements. +A behavior may not use top-level definitional statements such as :keyword:`param`, :keyword:`mutate`, and :keyword:`terminate when`, and it cannot create Objects with :keyword:`new`. +However, the :keyword:`require` statement *is* allowed, as are ordinary control-flow constructs and function definitions. +In addition, there are many special statements allowed in behaviors to take actions, invoke sub-behaviors, etc.: see :ref:`dynamic_statements`. + +The body of a behavior may optionally begin with definitions of preconditions and invariants. Preconditions are checked when a behavior is started, and invariants are checked at every time step of the simulation while the behavior is executing (including time step zero, like preconditions, but *not* including time spent inside sub-behaviors: this allows sub-behaviors to break and restore invariants before they return). The body of a behavior executes in parallel with the simulation: in each time step, it must either :keyword:`take` specified action(s) or :keyword:`wait` and perform no actions. @@ -80,7 +85,7 @@ For examples of monitors, see our tutorial on :ref:`dynamics`. .. _setup: .. _compose: -Modular Scenario Definition +Modular Scenario Definition --------------------------- .. code-block:: scenic-grammar @@ -102,7 +107,9 @@ Defines a Scenic :term:`modular scenario`. Scenario definitions, like :ref:`behavior definitions `, may include preconditions and invariants. The body of a scenario consists of two optional parts: a ``setup`` block and a ``compose`` block. The ``setup`` block contains code that runs once when the scenario begins to execute, and is a list of statements like a top-level Scenic program (so it may create objects, define requirements, etc.). -The ``compose`` block orchestrates the execution of sub-scenarios during a dynamic scenario, and may use :keyword:`do` and any of the other statements allowed inside behaviors (except :keyword:`take`, which only makes sense for an individual :term:`agent`). +The ``compose`` block orchestrates the execution of sub-scenarios during a dynamic scenario, and may use :keyword:`do` and any of the other statements allowed inside a :ref:`behavior ` (except :keyword:`take`, which only makes sense for an individual :term:`agent`). +The constructs illegal in behaviors, such as defining global parameters and creating Objects with :keyword:`new`, are also illegal in ``compose`` blocks: they can be placed in the ``setup`` block of a sub-scenario instead. + If a modular scenario does not use preconditions, invariants, or sub-scenarios (i.e., it only needs a ``setup`` block) it may be written in the second form above, where the entire body of the ``scenario`` comprises the ``setup`` block. .. seealso:: Our tutorial on :ref:`composition` gives many examples of how to use modular scenarios. @@ -270,7 +277,7 @@ The default mutation system adds Gaussian noise to the :prop:`position` and :pro This is done by providing a value for the :prop:`mutator` property, which should be an instance of `Mutator`. Mutators inherited from superclasses (such as the default :prop:`position` and :prop:`heading` mutators from `Point` and `OrientedPoint`) will still be applied unless the new mutator disables them; see `Mutator` for details. -.. _record [initial | final] {value} as {name}: +.. _record [initial | final] {value} {...}: .. _record: .. _record initial: .. _record final: @@ -278,10 +285,39 @@ The default mutation system adds Gaussian noise to the :prop:`position` and :pro record [initial | final] *value* [as *name*] ---------------------------------------------- Record the value of an expression during each simulation. -The value can be recorded at the start of the simulation (``initial``), at the end of the simulation (``final``), or at every time step (if neither ``initial`` nor ``final`` is specified). +The value can be recorded at the start of the scenario (``initial``), at the end of the scenario (``final``), or at every time step during the scenario (if neither ``initial`` nor ``final`` is specified). The recorded values are available in the ``records`` dictionary of `SimulationResult`: its keys are the given names of the records (or synthesized names if not provided), and the corresponding values are either the value of the recorded expression or a tuple giving its value at each time step as appropriate. For debugging, the records can also be printed out using the :option:`--show-records` command-line option. +When recording an entire time series (i.e. not using ``initial`` or ``final``), additional options are available, described below. + +.. _recordFolder: + +record *value* [every *duration*] [after *duration*] [as *name*] [to *recorder*] +-------------------------------------------------------------------------------- +Record the value of an expression as a time series. +The ``every`` clause allows specifying the interval between entries of the series, either in ``steps`` or ``seconds`` (the latter being rounded down to a whole number of time steps). +Likewise, the ``after`` clause allows specifying an initial delay before recording starts. +The ``as`` clause gives the name of the record in the final `SimulationResult` as above. + +The ``to`` clause allows records to be directly saved to files in common formats. +In the most basic usage, you can pass a string giving the filename to save the time series to: the format will be determined automatically based on the file extension. +For example, the clause ``to "foo.mp4"`` will save the data as an MP4 video file (assuming the given value can be interpreted as an image). +The string can use Python :ref:`formatstrings` to refer to 3 replacement fields: + + * ``simulation``: the name of the current simulation + * ``step``: the current simulation time step + * ``time``: the current simulation time in seconds + +Thus for example you can write ``to "out/{simulation}/foo{step}.jpg"`` to save a series of images called ``foo0.jpg``, ``foo1.jpg``, ``foo2.jpg``, in a new folder for each simulation, all contained in a folder called ``out``. +To avoid having to repeat a prefix like ``out/{simulation}``, you can set the :term:`global parameter` ``recordFolder``, which will be used as the base folder for all ``record`` statements. +For a complete example, see :ref:`sensors`. + +If you need to customize the way files are saved (e.g. to specify a specific video codec), you may pass a `Recorder` object to the ``to`` clause instead of a string. + + +.. _dynamic_statements: + Dynamic Statements ================== @@ -301,6 +337,18 @@ wait ---- Take no actions this time step. +.. _wait for {scalar} (seconds | steps): + +wait for *scalar* (seconds | steps) +----------------------------------- +Take no actions for a set number of simulation seconds/time steps. + +.. _wait until {boolean}: + +wait until *boolean* +-------------------- +Take no actions until the given condition becomes true. + .. _terminate: terminate diff --git a/docs/roadmap.rst b/docs/roadmap.rst new file mode 100644 index 000000000..828db89df --- /dev/null +++ b/docs/roadmap.rst @@ -0,0 +1,64 @@ +Project Roadmap +=============== + +This document describes the core areas of development for the Scenic project, as decided by the Steering Committee (see `governance`). + +Our long-term vision is that Scenic becomes a foundational, widely-used, open-source representation and toolkit supporting the entire design lifecycle of autonomous intelligent cyber-physical systems (AI-CPS). Towards that end, we are working in three primary directions: + + 1. Facilitating applications of Scenic in both existing and new domains. + 2. Creating infrastructure to support the use and development of Scenic. + 3. Building a user and developer community through dissemination and outreach activities. + + +Application Development +----------------------- + +This thrust comprises work to facilitate the use of Scenic in specific application domains, both those where Scenic is already being successfully used and new domains that could have high impact. We are currently focusing on three domains: autonomous driving, robotics, and extended (virtual/augmented) reality. + +Autonomous Driving +~~~~~~~~~~~~~~~~~~ + +The Autonomous Driving Working Group is charged with supporting and expanding Scenic's proven use for safety testing/verification of autonomous vehicles. Planned work includes: + + * Test suite generation + * Metrics and visualization + * Improved driver modeling + * Tutorials on testing autonomous vehicles using Scenic + + +Robotics +~~~~~~~~ + +We are working to expand preliminary applications of Scenic to testing and training robotic systems, particularly those which interact with human beings. Planned work includes: + + * Interfaces to simulators including MuJoCo, Gazebo, Habitat, and Isaac Sim + * A Gym-style API to facilitate training RL agents using Scenic + * Tutorials on testing and training robots using Scenic + + +Extended Reality +~~~~~~~~~~~~~~~~ + +Extended (virtual/augmented) reality is a relatively new application domain for Scenic that we have been exploring. Planned work aims to develop personalized training and evaluation methods for sports and healthcare applications. + + +Infrastructure Development +-------------------------- + +This thrust comprises work on computational infrastructure to support Scenic's development. Planned work includes: + + * Enhancing the CI system to test all supported simulators (CARLA and Webots already completed) + * Enhancing the CI system to benchmark scene generation + * Creating a system for managing Scenic Improvement Proposals (SIPs) + * Creating an index for Scenic scenarios and libraries similar to the Python Package Index (PyPI) + + +Governance and Community Engagement +----------------------------------- + +This thrust comprises work to support and grow the community of Scenic users and developers, as well as to develop governance policies ensuring that the evolution of the project reflects the needs of all stakeholders. Planned work includes: + + * Convening working groups for each of the application areas above + * Developing governance policies, e.g. for electing Steering Committee and Core Team members and for evaluating Scenic Improvement Proposals + * Continuing and expanding the annual Scenic Workshop + * Running tutorials at academic and industry conferences diff --git a/docs/simulators.rst b/docs/simulators.rst index fd0215b81..ee5d0b0d1 100644 --- a/docs/simulators.rst +++ b/docs/simulators.rst @@ -10,12 +10,54 @@ On this page we list interfaces that we and others have developed; if you have a Note that not every interface supports all Scenic features: in particular, some interfaces do not support dynamic scenarios. See the individual entries for details on each interface's capabilities and how to set it up. +.. note:: + While Scenic aims to support multiple Python versions, some simulators may have more limited compatibility. + Be sure to check the documentation of each simulator to confirm which Python versions are supported. + .. contents:: List of Simulators :local: Currently Supported =================== +.. _metadrive_simulator: + +MetaDrive +---------------------------- + +Scenic supports integration with the `MetaDrive `_ simulator as an optional dependency, +enabling users to describe dynamic simulations of vehicles, pedestrians, and traffic scenarios. +You can install it with: + +.. code-block:: console + + python -m pip install scenic[metadrive] + +.. note:: + + MetaDrive **0.4.3** (the current PyPI release) does **not** support Python 3.12/3.13. + It also has known issues on macOS Apple Silicon (M-series) with 3D rendering and a + braking issue where vehicles may not come to a complete stop. + + To use Python 3.12+ **and** get the fixes for the macOS/braking issues, install + MetaDrive from the GitHub repo: + + .. code-block:: console + + python -m pip install "metadrive-simulator @ git+https://github.com/metadriverse/metadrive.git@main" + python -m pip install "sumolib >= 1.21.0" + +Scenic supports both 2D and 3D rendering modes for MetaDrive simulations. + +Scenic uses OpenDRIVE maps, while MetaDrive relies on SUMO maps. Scenic provides corresponding SUMO maps for OpenDRIVE maps under the :file:`assets/maps/CARLA` directory. +Additionally, you can convert your own OpenDRIVE maps to SUMO maps using the `netconvert `_ tool. +To avoid setting the SUMO map manually, name it the same as your OpenDRIVE file and place it in the same directory. +Otherwise, you can specify it explicitly using the ``sumo_map`` global parameter. + +The simulator is compatible with scenarios written using Scenic's :ref:`driving_domain`. +For more information, refer to the documentation of the `scenic.simulators.metadrive` module. + + Built-in Newtonian Simulator ---------------------------- @@ -31,7 +73,7 @@ Our interface to the `CARLA `_ simulator enables using Sceni The interface supports dynamic scenarios written using the CARLA world model (:obj:`scenic.simulators.carla.model`) as well as scenarios using the cross-platform :ref:`driving_domain`. To use the interface, please follow these instructions: -1. Install the latest version of CARLA (we've tested versions 0.9.9 through 0.9.14) from the `CARLA Release Page `_. +1. Install the latest version of CARLA (we've tested versions 0.9.9 through 0.9.16) from the `CARLA Release Page `_. Note that CARLA currently only supports Linux and Windows. 2. Install Scenic in your Python virtual environment as instructed in :ref:`quickstart`. 3. Within the same virtual environment, install CARLA's Python API. @@ -94,6 +136,8 @@ We have several interfaces to the `Webots robotics simulator ` associating an orientation to each point in space `Regions ` representing sets of points in space `Shapes ` representing shapes (regions modulo similarity) +`Sensors ` representing sensors mounted on objects ============================= ================================================================== @@ -83,8 +84,8 @@ Simple Statements - Set the scenario to terminate after a given amount of time. * - :sampref:`mutate {identifier}, {...} [by {number}]` - Enable mutation of the given list of objects. - * - :sampref:`record [initial | final] {value} as {name}` - - Save a value at every time step or only at the start/end of the simulation. + * - :sampref:`record [initial | final] {value} {...}` + - Save initial, final, or time series data from simulations. Dynamic Statements ++++++++++++++++++ @@ -100,6 +101,10 @@ These statements can only be used inside a :term:`dynamic behavior`, :term:`moni - Take the action(s) specified. * - :sampref:`wait` - Take no actions this time step. + * - :sampref:`wait until {boolean}` + - Take no actions until a condition is met. + * - :sampref:`wait for {scalar} (seconds | steps)` + - Take no actions for a specified period of time. * - :sampref:`terminate` - Immediately end the scenario. * - :sampref:`terminate simulation` @@ -168,6 +173,7 @@ Properties added by `Object`: contactTolerance 1e-4 max distance to be considered on a surface sideComponentThresholds (-0.5, 0.5) per side thresholds to determine side surfaces cameraOffset (0, 0, 0) position of camera for :keyword:`can see` + visionSensorOffset (0, self.length/2, 0) offset of default vision sensor mount point requireVisible `False` whether object must be visible from ego occluding `True` whether object occludes visibility showVisibleRegion `False` whether to display the visible region @@ -178,6 +184,7 @@ Properties added by `Object`: angularSpeed [1]_ 0 angular speed (change in :prop:`heading`/time) behavior `None` :term:`dynamic behavior`, if any lastActions `None` tuple of actions taken in last timestamp + sensors {} dict of :ref:`sensors ` the object has ======================== ======================= ================================================ .. [1] These are :term:`dynamic properties`, updated automatically every time step during diff --git a/docs/tutorials/dynamics.rst b/docs/tutorials/dynamics.rst index 20738d9fe..734ad257a 100644 --- a/docs/tutorials/dynamics.rst +++ b/docs/tutorials/dynamics.rst @@ -396,10 +396,19 @@ You can see all of the above syntax in action by running some of our examples of scenarios. We have examples written for the CARLA and LGSVL driving simulators, and those in :file:`examples/driving` in particular are designed to use Scenic's abstract :ref:`driving domain ` and so work in either of these simulators, as well -as Scenic's built-in Newtonian physics simulator. The Newtonian simulator is convenient -for testing and simple experiments; you can find details on how to install the more -realistic simulators in our :ref:`simulators` page (they should work on both Linux and -Windows, but not macOS, at the moment). +as Scenic's built-in Newtonian physics simulator and the MetaDrive simulator. While the Newtonian simulator is convenient +for testing simple experiments, we recommend using MetaDrive for more realistic driving scenarios. + +MetaDrive support is **optional**. If your system supports MetaDrive, you can install it separately using: + +.. code-block:: console + + python -m pip install scenic[metadrive] + +If MetaDrive is **not available**, we recommend using the Newtonian simulator instead. + +You can find details on these simulators and how to install them on +our :ref:`simulators` page. Let's try running :file:`examples/driving/badlyParkedCarPullingIn.scenic`, which implements the "a @@ -414,16 +423,16 @@ usual schematic diagram of the generated scenes: To run dynamic simulations, add the :option:`--simulate` option (:option:`-S` for short). Since this scenario is not written for a particular simulator, you'll need to specify which one you want by using the :option:`--model` option (:option:`-m` for short) to -select the corresponding Scenic :term:`world model`: for example, to use the Newtonian simulator we could add -``--model scenic.simulators.newtonian.driving_model``. It's also a good idea to put a time bound on -the simulations, which we can do using the :option:`--time` option. +select the corresponding Scenic :term:`world model`: for example, to use the MetaDrive simulator we could add +``--model scenic.simulators.metadrive.model``. +It's also a good idea to put a time bound on the simulations, which we can do using the :option:`--time` option. .. code-block:: console $ scenic examples/driving/badlyParkedCarPullingIn.scenic \ --2d \ --simulate \ - --model scenic.simulators.newtonian.driving_model \ + --model scenic.simulators.metadrive.model \ --time 200 Running the scenario in CARLA is exactly the same, except we use the diff --git a/examples/__init__.py b/examples/__init__.py index 2a7fb8af9..0bf5a6feb 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -1 +1 @@ -""" Scenic examples""" +"""Scenic examples""" diff --git a/examples/carla/Carla_Challenge/carlaChallenge1.scenic b/examples/carla/Carla_Challenge/carlaChallenge1.scenic index df69bfcc1..aba7b40ef 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge1.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge1.scenic @@ -3,6 +3,9 @@ Traffic Scenario 01. Control loss without previous action. The ego-vehicle loses control due to bad conditions on the road and it must recover, coming back to its original lane. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge1.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge10.scenic b/examples/carla/Carla_Challenge/carlaChallenge10.scenic index 47c9f0d3e..30e4b3b33 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge10.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge10.scenic @@ -3,6 +3,9 @@ Traffic Scenario 10. Crossing negotiation at an unsignalized intersection. The ego-vehicle needs to negotiate with other vehicles to cross an unsignalized intersection. In this situation it is assumed that the first to enter the intersection has priority. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge10.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge2.scenic b/examples/carla/Carla_Challenge/carlaChallenge2.scenic index 0641f8f55..4c423f2b1 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge2.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge2.scenic @@ -3,6 +3,9 @@ Traffic Scenario 02. Longitudinal control after leading vehicle’s brake. The leading vehicle decelerates suddenly due to an obstacle and the ego-vehicle must perform an emergency brake or an avoidance maneuver. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge2.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge3_dynamic.scenic b/examples/carla/Carla_Challenge/carlaChallenge3_dynamic.scenic index 7d1d87ad9..9fb37b60c 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge3_dynamic.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge3_dynamic.scenic @@ -3,6 +3,9 @@ Traffic Scenario 03 (dynamic). Obstacle avoidance without prior action. The ego-vehicle encounters an obstacle / unexpected entity on the road and must perform an emergency brake or an avoidance maneuver. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge3_dynamic.scenic --2d --model scenic.simulators.carla.model --simulate """ # SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge3_static.scenic b/examples/carla/Carla_Challenge/carlaChallenge3_static.scenic index f721575b7..d01f5be21 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge3_static.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge3_static.scenic @@ -3,6 +3,9 @@ Traffic Scenario 03 (static). Obstacle avoidance without prior action. The ego-vehicle encounters an obstacle / unexpected entity on the road and must perform an emergency brake or an avoidance maneuver. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge3_static.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge4.scenic b/examples/carla/Carla_Challenge/carlaChallenge4.scenic index 82e5576f5..4fe1cf019 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge4.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge4.scenic @@ -3,6 +3,9 @@ Traffic Scenario 04. Obstacle avoidance without prior action. The ego-vehicle encounters an obstacle / unexpected entity on the road and must perform an emergency brake or an avoidance maneuver. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge4.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge5.scenic b/examples/carla/Carla_Challenge/carlaChallenge5.scenic index c470b63aa..b97c64655 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge5.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge5.scenic @@ -1,6 +1,9 @@ """ Scenario Description Based on 2019 Carla Challenge Traffic Scenario 05. Ego-vehicle performs a lane changing to evade a leading vehicle, which is moving too slowly. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge5.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town05.xodr') param carla_map = 'Town05' diff --git a/examples/carla/Carla_Challenge/carlaChallenge6.scenic b/examples/carla/Carla_Challenge/carlaChallenge6.scenic index 4ea0a5130..f51b68262 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge6.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge6.scenic @@ -2,6 +2,9 @@ Based on CARLA Challenge Scenario 6: https://carlachallenge.org/challenge/nhtsa/ Ego-vehicle must go around a blocking object using the opposite lane, yielding to oncoming traffic. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge6.scenic --2d --model scenic.simulators.carla.model --simulate """ # N.B. Town07 is not included with CARLA by default; see installation instructions at diff --git a/examples/carla/Carla_Challenge/carlaChallenge7.scenic b/examples/carla/Carla_Challenge/carlaChallenge7.scenic index 676c90449..92027273b 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge7.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge7.scenic @@ -3,6 +3,9 @@ Based on 2019 Carla Challenge Traffic Scenario 07. Ego-vehicle is going straight at an intersection but a crossing vehicle runs a red light, forcing the ego-vehicle to perform a collision avoidance maneuver. Note: The traffic light control is not implemented yet, but it will soon be. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge7.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town05.xodr') param carla_map = 'Town05' diff --git a/examples/carla/Carla_Challenge/carlaChallenge8.scenic b/examples/carla/Carla_Challenge/carlaChallenge8.scenic index 97837cfcd..e1f8125df 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge8.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge8.scenic @@ -3,6 +3,9 @@ Traffic Scenario 08. Unprotected left turn at intersection with oncoming traffic. The ego-vehicle is performing an unprotected left turn at an intersection, yielding to oncoming traffic. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge8.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/carla/Carla_Challenge/carlaChallenge9.scenic b/examples/carla/Carla_Challenge/carlaChallenge9.scenic index 0b4e3e9e5..133b4e07c 100644 --- a/examples/carla/Carla_Challenge/carlaChallenge9.scenic +++ b/examples/carla/Carla_Challenge/carlaChallenge9.scenic @@ -1,6 +1,9 @@ """ Scenario Description Based on 2019 Carla Challenge Traffic Scenario 09. Ego-vehicle is performing a right turn at an intersection, yielding to crossing traffic. + +To run this file using the Carla simulator: + scenic examples/carla/Carla_Challenge/carlaChallenge9.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town05.xodr') param carla_map = 'Town05' diff --git a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_01.scenic b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_01.scenic index 1541c313b..b041174bf 100644 --- a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_01.scenic +++ b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_01.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle performs a lane change to bypass a slow adversary vehicle before returning to its original lane. SOURCE: NHSTA, #16 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/bypassing/bypassing_01.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_02.scenic b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_02.scenic index 5e72aaa04..b95269650 100644 --- a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_02.scenic +++ b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_02.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Adversary vehicle performs a lane change to bypass the slow ego vehicle before returning to its original lane. SOURCE: NHSTA, #16 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/bypassing/bypassing_02.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_03.scenic b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_03.scenic index 6493bdc1a..6ec12c61e 100644 --- a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_03.scenic +++ b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_03.scenic @@ -6,6 +6,9 @@ adversary vehicle but cannot return to its original lane because the adversary accelerates. Ego vehicle must then slow down to avoid collision with leading vehicle in new lane. SOURCE: NHSTA, #16 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/bypassing/bypassing_03.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_04.scenic b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_04.scenic index ff5feda9b..f4c42378c 100644 --- a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_04.scenic +++ b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_04.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle performs multiple lane changes to bypass two slow adversary vehicles. SOURCE: NHSTA, #16 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/bypassing/bypassing_04.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_05.scenic b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_05.scenic index 5046e3659..3001bcc92 100644 --- a/examples/carla/NHTSA_Scenarios/bypassing/bypassing_05.scenic +++ b/examples/carla/NHTSA_Scenarios/bypassing/bypassing_05.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle performs multiple lane changes to bypass three slow adversary vehicles. SOURCE: NHSTA, #16 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/bypassing/bypassing_05.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_01.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_01.scenic index 571ae9734..d17fdb7c3 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_01.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_01.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego vehicle goes straight at 4-way intersection and must suddenly stop to avoid collision when adversary vehicle from opposite lane makes a left turn. SOURCE: NHSTA, #30 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_01.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_02.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_02.scenic index 0d4063ee5..697e302b8 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_02.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_02.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego vehicle makes a left turn at 4-way intersection and must suddenly stop to avoid collision when adversary vehicle from opposite lane goes straight. SOURCE: NHSTA, #30 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_02.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_03.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_03.scenic index 6e3af7870..859898bec 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_03.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_03.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego vehicle either goes straight or makes a left turn at 4-way intersection and must suddenly stop to avoid collision when adversary vehicle from lateral lane continues straight. SOURCE: NHSTA, #28 #29 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_03.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_04.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_04.scenic index 12820cff1..4ef689d10 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_04.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_04.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego vehicle either goes straight or makes a left turn at 4-way intersection and must suddenly stop to avoid collision when adversary vehicle from lateral lane makes a left turn. SOURCE: NHSTA, #28 #29 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_04.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_05.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_05.scenic index bba6bd19c..eff0df4d9 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_05.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_05.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle makes a right turn at 4-way intersection while adversary vehicle from opposite lane makes a left turn. SOURCE: NHSTA, #25 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_05.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_06.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_06.scenic index cb749407f..5c74c5f34 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_06.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_06.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle makes a right turn at 4-way intersection while adversary vehicle from lateral lane goes straight. SOURCE: NHSTA, #25 #26 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_06.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_07.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_07.scenic index d7d84f19e..22551f68e 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_07.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_07.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego vehicle makes a left turn at 3-way intersection and must suddenly stop to avoid collision when adversary vehicle from lateral lane continues straight. SOURCE: NHSTA, #30 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_07.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_08.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_08.scenic index 5c4e9cdc2..89e1482ec 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_08.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_08.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego vehicle goes straight at 3-way intersection and must suddenly stop to avoid collision when adversary vehicle makes a left turn. SOURCE: NHSTA, #30 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_08.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_09.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_09.scenic index 776c70808..e9c81ae4b 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_09.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_09.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle makes a right turn at 3-way intersection while adversary vehicle from lateral lane goes straight. SOURCE: NHSTA, #28 #29 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_09.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/intersection/intersection_10.scenic b/examples/carla/NHTSA_Scenarios/intersection/intersection_10.scenic index 6aa8353a0..50f34b6ec 100644 --- a/examples/carla/NHTSA_Scenarios/intersection/intersection_10.scenic +++ b/examples/carla/NHTSA_Scenarios/intersection/intersection_10.scenic @@ -5,6 +5,9 @@ DESCRIPTION: Ego Vehicle waits at 4-way intersection while adversary vehicle in adjacent lane passes before performing a lane change to bypass a stationary vehicle waiting to make a left turn. SOURCE: NHSTA, #16 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/intersection/intersection_10.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_01.scenic b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_01.scenic index 7202f0e3d..c07454ccb 100644 --- a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_01.scenic +++ b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_01.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle must suddenly stop to avoid collision when pedestrian crosses the road unexpectedly. SOURCE: Carla Challenge, #03 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_01.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_02.scenic b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_02.scenic index c0a9a0109..eb4c0ea40 100644 --- a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_02.scenic +++ b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_02.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Both ego and adversary vehicles must suddenly stop to avoid collision when pedestrian crosses the road unexpectedly. SOURCE: Carla Challenge, #03 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_02.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_03.scenic b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_03.scenic index 06fadebc1..d07736569 100644 --- a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_03.scenic +++ b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_03.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle makes a left turn at an intersection and must suddenly stop to avoid collision when pedestrian crosses the crosswalk. SOURCE: Carla Challenge, #04 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_03.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_04.scenic b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_04.scenic index 7d2aa7adb..edc9ff90c 100644 --- a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_04.scenic +++ b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_04.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle makes a right turn at an intersection and must yield when pedestrian crosses the crosswalk. SOURCE: Carla Challenge, #04 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_04.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_05.scenic b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_05.scenic index 4d1776bb9..b2b6dbb4a 100644 --- a/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_05.scenic +++ b/examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_05.scenic @@ -4,6 +4,9 @@ AUTHOR: Francis Indaheng, findaheng@berkeley.edu DESCRIPTION: Ego vehicle goes straight at an intersection and must yield when pedestrian crosses the crosswalk. SOURCE: Carla Challenge, #04 + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/pedestrian/pedestrian_05.scenic --2d --model scenic.simulators.carla.model --simulate """ ################################# diff --git a/examples/carla/OAS_Scenarios/oas_scenario_05.scenic b/examples/carla/OAS_Scenarios/oas_scenario_05.scenic index f3890d5d6..4573b8376 100644 --- a/examples/carla/OAS_Scenarios/oas_scenario_05.scenic +++ b/examples/carla/OAS_Scenarios/oas_scenario_05.scenic @@ -1,6 +1,9 @@ """ Scenario Description Voyage OAS Scenario Unique ID: 2-2-XX-CF-STR-CAR:Pa>E:03 The lead car suddenly stops and then resumes moving forward + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/OAS_Scenarios/oas_scenario_05.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town01.xodr') # or other CARLA map that definitely works diff --git a/examples/carla/OAS_Scenarios/oas_scenario_06.scenic b/examples/carla/OAS_Scenarios/oas_scenario_06.scenic index dfcb3ad8e..f9c0b8aeb 100644 --- a/examples/carla/OAS_Scenarios/oas_scenario_06.scenic +++ b/examples/carla/OAS_Scenarios/oas_scenario_06.scenic @@ -2,6 +2,9 @@ Voyage OAS Scenario Unique ID: 2-2-XX-CF-STR-CAR:Pa>E:03 The car ahead of ego that is badly parked over the sidewalk cuts into ego vehicle's lane. This scenario may fail if there exists any obstacle (e.g. fences) on the sidewalk + +To run this file using the Carla simulator: + scenic examples/carla/NHTSA_Scenarios/OAS_Scenarios/oas_scenario_06.scenic --2d --model scenic.simulators.carla.model --simulate """ diff --git a/examples/carla/adjacentLanes.scenic b/examples/carla/adjacentLanes.scenic index 9015e077a..4bf11661e 100644 --- a/examples/carla/adjacentLanes.scenic +++ b/examples/carla/adjacentLanes.scenic @@ -1,3 +1,8 @@ +''' +To run this file using the Carla simulator: + scenic examples/carla/adjacentLanes.scenic --2d --model scenic.simulators.carla.model +''' + param map = localPath('../../assets/maps/CARLA/Town03.xodr') model scenic.simulators.carla.model diff --git a/examples/carla/adjacentOpposingPair.scenic b/examples/carla/adjacentOpposingPair.scenic index 8f6c0ab80..f28044289 100644 --- a/examples/carla/adjacentOpposingPair.scenic +++ b/examples/carla/adjacentOpposingPair.scenic @@ -1,3 +1,7 @@ +''' +To run this file using the Carla simulator: + scenic examples/carla/adjacentOpposingPair.scenic --2d --model scenic.simulators.carla.model +''' param map = localPath('../../assets/maps/CARLA/Town01.xodr') model scenic.simulators.carla.model diff --git a/examples/carla/backgroundActivity.scenic b/examples/carla/backgroundActivity.scenic index f2cfb906b..8b29e1f01 100644 --- a/examples/carla/backgroundActivity.scenic +++ b/examples/carla/backgroundActivity.scenic @@ -2,6 +2,9 @@ Background Activity The simulation is filled with vehicles that freely roam around the town. This simulates normal driving conditions, without any abnormal behaviors + +To run this file using the Carla simulator: + scenic examples/carla/backgroundActivity.scenic --2d --model scenic.simulators.carla.model --simulate """ # SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) param map = localPath('../../assets/maps/CARLA/Town05.xodr') # or other CARLA map that definitely works @@ -18,6 +21,10 @@ behavior EgoBehavior(speed=10): interrupt when withinDistanceToObjsInLane(self, 10): take SetBrakeAction(1.0) +# PEDESTRIAN BEHAVIOR: cross the street +behavior PedestrianBehavior(min_speed=1, threshold=10): + do CrossingBehavior(ego, min_speed, threshold) + ## DEFINING SPATIAL RELATIONS # Please refer to scenic/domains/driving/roads.py how to access detailed road infrastructure # 'network' is the 'class Network' object in roads.py @@ -36,7 +43,7 @@ background_walkers = [] for _ in range(10): sideWalk = Uniform(*network.sidewalks) background_walker = new Pedestrian in sideWalk, - with behavior WalkBehavior() + with behavior PedestrianBehavior() background_walkers.append(background_walker) diff --git a/examples/carla/car.scenic b/examples/carla/car.scenic index edf60b5a9..1f4c68cb7 100644 --- a/examples/carla/car.scenic +++ b/examples/carla/car.scenic @@ -1,3 +1,7 @@ +''' +To run this file using the Carla simulator: + scenic examples/carla/car.scenic --2d --model scenic.simulators.carla.model --simulate +''' param map = localPath('../../assets/maps/CARLA/Town01.xodr') model scenic.simulators.carla.model diff --git a/examples/carla/manual_control/carlaChallenge1.scenic b/examples/carla/manual_control/carlaChallenge1.scenic index 763385dfe..bb024a335 100644 --- a/examples/carla/manual_control/carlaChallenge1.scenic +++ b/examples/carla/manual_control/carlaChallenge1.scenic @@ -3,12 +3,15 @@ Traffic Scenario 01. Control loss without previous action. The ego-vehicle loses control due to bad conditions on the road and it must recover, coming back to its original lane. + +To run this file using the Carla simulator: + scenic examples/carla/manual_control/carlaChallenge1.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) param map = localPath('../../../assets/maps/CARLA/Town01.xodr') # or other CARLA map that definitely works param carla_map = 'Town01' -param render = '0' +param render = 0 model scenic.simulators.carla.model ## CONSTANTS diff --git a/examples/carla/manual_control/carlaChallenge3_dynamic.scenic b/examples/carla/manual_control/carlaChallenge3_dynamic.scenic index 3410d5cae..1ae36f3f4 100644 --- a/examples/carla/manual_control/carlaChallenge3_dynamic.scenic +++ b/examples/carla/manual_control/carlaChallenge3_dynamic.scenic @@ -3,12 +3,15 @@ Traffic Scenario 03 (dynamic). Obstacle avoidance without prior action. The ego-vehicle encounters an obstacle / unexpected entity on the road and must perform an emergency brake or an avoidance maneuver. + +To run this file using the Carla simulator: + scenic examples/carla/manual_control/carlaChallenge3_dynamic.scenic --2d --model scenic.simulators.carla.model --simulate """ # SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) param map = localPath('../../../assets/maps/CARLA/Town05.xodr') # or other CARLA map that definitely works param carla_map = 'Town05' -param render = '0' +param render = 0 model scenic.simulators.carla.model # CONSTANTS diff --git a/examples/carla/manual_control/carlaChallenge4.scenic b/examples/carla/manual_control/carlaChallenge4.scenic index 100d091bf..4b3eb48e5 100644 --- a/examples/carla/manual_control/carlaChallenge4.scenic +++ b/examples/carla/manual_control/carlaChallenge4.scenic @@ -3,12 +3,15 @@ Traffic Scenario 04. Obstacle avoidance without prior action. The ego-vehicle encounters an obstacle / unexpected entity on the road and must perform an emergency brake or an avoidance maneuver. + +To run this file using the Carla simulator: + scenic examples/carla/manual_control/carlaChallenge4.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) param map = localPath('../../../assets/maps/CARLA/Town01.xodr') # or other CARLA map that definitely works param carla_map = 'Town01' -param render = '0' +param render = 0 model scenic.simulators.carla.model ## CONSTANTS diff --git a/examples/carla/manual_control/carlaChallenge7.scenic b/examples/carla/manual_control/carlaChallenge7.scenic index 68f123f67..49477f9c7 100644 --- a/examples/carla/manual_control/carlaChallenge7.scenic +++ b/examples/carla/manual_control/carlaChallenge7.scenic @@ -3,12 +3,15 @@ Traffic Scenario 07. Crossing traffic running a red light at an intersection. The ego-vehicle is going straight at an intersection but a crossing vehicle runs a red light, forcing the ego-vehicle to avoid the collision. + +To run this file using the Carla simulator: + scenic examples/carla/manual_control/carlaChallenge7.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) param map = localPath('../../../assets/maps/CARLA/Town05.xodr') # or other CARLA map that definitely works param carla_map = 'Town05' -param render = '0' +param render = 0 model scenic.simulators.carla.model ## CONSTANTS diff --git a/examples/carla/pedestrian.scenic b/examples/carla/pedestrian.scenic index 9559a1d04..863b1ff51 100644 --- a/examples/carla/pedestrian.scenic +++ b/examples/carla/pedestrian.scenic @@ -1,6 +1,11 @@ +''' +To run this file using the Carla simulator: + scenic examples/carla/pedestrian.scenic --2d --model scenic.simulators.carla.model --simulate +''' param map = localPath('../../assets/maps/CARLA/Town03.xodr') param carla_map = 'Town03' +param address = "10.0.0.122" model scenic.simulators.carla.model ego = new Car -new Pedestrian on visible sidewalk +new Pedestrian on visible sidewalk \ No newline at end of file diff --git a/examples/carla/trafficLights.scenic b/examples/carla/trafficLights.scenic index 8bfc12b0f..17013211f 100644 --- a/examples/carla/trafficLights.scenic +++ b/examples/carla/trafficLights.scenic @@ -1,5 +1,8 @@ """ Scenario Description Example scenario of traffic lights management. + +To run this file using the Carla simulator: + scenic examples/carla/trafficLights.scenic --2d --model scenic.simulators.carla.model --simulate """ ## SET MAP AND MODEL (i.e. definitions of all referenceable vehicle types, road library, etc) diff --git a/examples/cosim/readme.md b/examples/cosim/readme.md new file mode 100644 index 000000000..d77638c2c --- /dev/null +++ b/examples/cosim/readme.md @@ -0,0 +1,28 @@ +# To run the CoSimulator + Start by opening and running both CARLA and METS-R + +# Setting up METSR: +1. Create a virtual env `python -m venv metsr_venv` +2. Run `git clone clone https://github.com/umnilab/METS-R_HPC` +3. Create an instance of METSR sim. An example template for this is provided in `run_blank.py`. Please note this template should be run inside the top level METS-R folder. + +For METSR specific details please review its documentation: https://umnilab.github.io/METS-R_doc/`. + +# Setting up CARLA: +1. Ensure CARLA is open an running on your desktop + +For CARLA specific details please review the CARLA examples folder or its documentation: https://carla.readthedocs.io/en/latest/python_api/. + +# Finally to run the scenic program first ensure +- The `globalParameter address` set at the top of your Scenic program matches your current IP address to allow Scenic to connect to CARLA +- The `globalParameter map` set at the top of your Scenic program provides the path to the corresponding `xodr` map you would like to run +- Ensure that this parameter matches the configuration file used to spin up METSR (this can be viewed inside `run_blank.py`. +- The `globalParameter xml_map` matches provides the path to the corresponding `xml` map. + `xml` map files can be generated from the provided sumo file found in `assets\maps\CARLA` using the command noted at the end of this file + +Now the simulator can be started running the following command: `scenic [your_file.scenic] --simulate --2d` + +In order to run cosimulation using both METSR and Carla the user needs to supply both a SUMO and openDrive file. +To generate the associated SUMO file from an opendrive file run the following command: + +```netconvert --opendrive-files example.xodr --output-file example.net.xml --geometry.min-radius.fix --geometry.remove --opendrive.curve-resolution 1 --opendrive.import-all-lanes --output.original-names --tls.guess-signals --tls.discard-simple --tls.join``` diff --git a/examples/cosim/test.scenic b/examples/cosim/test.scenic index d0dfa8d48..b4b3b9eb9 100644 --- a/examples/cosim/test.scenic +++ b/examples/cosim/test.scenic @@ -1,4 +1,15 @@ -param carla_host = "walle" - -model scenic.simulators.cosim.model - +# param startTime = 0 +param map = localPath('../../assets/maps/CARLA/Town05.xodr') # OpenDrive file +param xml_map = localPath("../../assets/maps/CARLA/Town05.net.xml") # Sumo file +param address = "10.139.168.114" +# param address = "10.0.0.122" +# param verbose = True +model scenic.simulators.cosim.model + +ego = new EgoCar with name "ego", with behavior DriveAvoidingCollisions(target_speed=15, avoidance_threshold=12) + +for i in range(100): + title = f"npccar_{i}" # allow me to debug more easily + vehicle = new NPCCar with name title + +terminate after 500 steps \ No newline at end of file diff --git a/examples/cosim/test_flows.scenic b/examples/cosim/test_flows.scenic new file mode 100644 index 000000000..21cdf7003 --- /dev/null +++ b/examples/cosim/test_flows.scenic @@ -0,0 +1,30 @@ +# param startTime = 0 +param map = localPath('../../assets/maps/CARLA/Town05.xodr') # OpenDrive file +param xml_map = localPath("../../assets/maps/CARLA/Town05.net.xml") # Sumo file +param address = "10.139.168.114" +# param address = "10.0.0.122" +# param verbose = True +model scenic.simulators.cosim.model + +scenario CustomCommuterTrafficStream(origin, destination): + setup: + num_commuters = Range(100, 200) + morning_peak_time = 1*60*60 # Normal(9*60*60, 30*60) + evening_peak_time = 2*60*60 # Normal(17*60*60, 30*60) + traffic_stddev = 15*60 # Normal(1*60*60, 10*60) + + compose: + do CommuterTrafficStream(origin, destination, num_commuters, + morning_peak_time, evening_peak_time, traffic_stddev) + +scenario Main(): + setup: + ego = new EgoCar with name "ego", with behavior DriveAvoidingCollisions(target_speed=15, avoidance_threshold=12) + compose: + ts_2_21 = CustomCommuterTrafficStream(2, 21) + ts_3_21 = CustomCommuterTrafficStream(3, 21) + ts_4_21 = CustomCommuterTrafficStream(4, 21) + ts_7_21 = CustomCommuterTrafficStream(7, 21) + ts_11_21 = CustomCommuterTrafficStream(11, 21) + + do ts_2_21, ts_3_21, ts_4_21, ts_7_21, ts_11_21 for 3*60*60 seconds # 16*60*60 seconds diff --git a/examples/driving/Carla_Challenge/carlaChallenge2.scenic b/examples/driving/Carla_Challenge/carlaChallenge2.scenic index 61c689f77..cf244683e 100644 --- a/examples/driving/Carla_Challenge/carlaChallenge2.scenic +++ b/examples/driving/Carla_Challenge/carlaChallenge2.scenic @@ -1,8 +1,14 @@ """ Scenario Description Based on 2019 Carla Challenge Traffic Scenario 02. -Leading vehicle decelerates suddently due to an obstacle and +Leading vehicle decelerates suddently due to an obstacle and ego-vehicle must react, performing an emergency brake or an avoidance maneuver. Note: The scenario may fail if the leadCar or the ego get past the intersection while following the roadDirection + +To run this file using the MetaDrive simulator: + scenic examples/driving/Carla_Challenge/carlaChallenge2.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/Carla_Challenge/carlaChallenge2.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town07.xodr') # or other CARLA map that definitely works param carla_map = 'Town07' @@ -51,5 +57,3 @@ leadCar = new Car following roadDirection from obstacle for LEADCAR_TO_OBSTACLE, ego = new Car following roadDirection from leadCar for EGO_TO_LEADCAR, with behavior EgoBehavior(EGO_SPEED) - - diff --git a/examples/driving/Carla_Challenge/carlaChallenge3.scenic b/examples/driving/Carla_Challenge/carlaChallenge3.scenic index 37256b334..275593932 100644 --- a/examples/driving/Carla_Challenge/carlaChallenge3.scenic +++ b/examples/driving/Carla_Challenge/carlaChallenge3.scenic @@ -1,7 +1,13 @@ """ Scenario Description Based on 2019 Carla Challenge Traffic Scenario 03. -Leading vehicle decelerates suddenly due to an obstacle and +Leading vehicle decelerates suddenly due to an obstacle and ego-vehicle must react, performing an emergency brake or an avoidance maneuver. + +To run this file using the MetaDrive simulator: + scenic examples/driving/Carla_Challenge/carlaChallenge3.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/Carla_Challenge/carlaChallenge3.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town01.xodr') # or other CARLA map that definitely works param carla_map = 'Town01' diff --git a/examples/driving/OAS_Scenarios/oas_scenario_03.scenic b/examples/driving/OAS_Scenarios/oas_scenario_03.scenic index 8c103dfcd..bed577db0 100644 --- a/examples/driving/OAS_Scenarios/oas_scenario_03.scenic +++ b/examples/driving/OAS_Scenarios/oas_scenario_03.scenic @@ -1,6 +1,12 @@ """ Scenario Description Voyage OAS Scenario Unique ID: 2-2-XX-CF-STR-CAR The ego vehicle follows the lead car + +To run this file using the MetaDrive simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_03.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_03.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town04.xodr') # or other CARLA map that definitely works @@ -33,4 +39,3 @@ leadCar = new Car on select_lane.centerline, ego = new Car following roadDirection from leadCar for INITIAL_DISTANCE_APART, with behavior FollowLeadCarBehavior() - diff --git a/examples/driving/OAS_Scenarios/oas_scenario_04.scenic b/examples/driving/OAS_Scenarios/oas_scenario_04.scenic index 0b497214c..859fa6c01 100644 --- a/examples/driving/OAS_Scenarios/oas_scenario_04.scenic +++ b/examples/driving/OAS_Scenarios/oas_scenario_04.scenic @@ -1,6 +1,12 @@ """ Scenario Description Voyage OAS Scenario Unique ID: 2-2-XX-CF-STR-CAR:01 The ego vehicle follows the lead car which suddenly stops + +To run this file using the MetaDrive simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_04.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_04.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town07.xodr') # or other CARLA map that definitely works @@ -40,4 +46,4 @@ other = new Car on select_lane.centerline, with behavior LeadCarBehavior() ego = new Car following roadDirection from other for INITIAL_DISTANCE_APART, - with behavior FollowLeadCarBehavior() \ No newline at end of file + with behavior FollowLeadCarBehavior() diff --git a/examples/driving/OAS_Scenarios/oas_scenario_28.scenic b/examples/driving/OAS_Scenarios/oas_scenario_28.scenic index 72fa8dc2f..ea787cc3e 100644 --- a/examples/driving/OAS_Scenarios/oas_scenario_28.scenic +++ b/examples/driving/OAS_Scenarios/oas_scenario_28.scenic @@ -1,8 +1,14 @@ """ Scenario Description Voyage OAS Scenario Unique ID: 3-2-ESW-I-STR-CAR:S>W:02 -At three-way intersection. The ego vehicle goes straight. -The other car, on the other leg of the intersection, takes a left turn first +At three-way intersection. The ego vehicle goes straight. +The other car, on the other leg of the intersection, takes a left turn first because it is closer to the intersection. + +To run this file using the MetaDrive simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_28.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_28.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town05.xodr') # or other CARLA map that definitely works diff --git a/examples/driving/OAS_Scenarios/oas_scenario_29.scenic b/examples/driving/OAS_Scenarios/oas_scenario_29.scenic index 212b92ac6..2ef507ceb 100644 --- a/examples/driving/OAS_Scenarios/oas_scenario_29.scenic +++ b/examples/driving/OAS_Scenarios/oas_scenario_29.scenic @@ -1,8 +1,14 @@ """ Scenario Description Voyage OAS Scenario Unique ID: 3-2-NSW-I-L-CAR:S>W:02 -At 3 way intersection. The ego car turns left. -The other car, on a different leg of the intersection, +At 3 way intersection. The ego car turns left. +The other car, on a different leg of the intersection, has the right of the way and makes a left turn first because it is closer to the intersection. + +To run this file using the MetaDrive simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_29.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_29.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town05.xodr') # or other CARLA map that definitely works param carla_map = 'Town05' diff --git a/examples/driving/OAS_Scenarios/oas_scenario_30.scenic b/examples/driving/OAS_Scenarios/oas_scenario_30.scenic index 4ddb712df..d94a00693 100644 --- a/examples/driving/OAS_Scenarios/oas_scenario_30.scenic +++ b/examples/driving/OAS_Scenarios/oas_scenario_30.scenic @@ -1,8 +1,14 @@ """ Scenario Description Voyage OAS Scenario Unique ID: 3-2-NWS-I-L-CAR:S>W:01 -At 3 way intersection. The ego car turns left. +At 3 way intersection. The ego car turns left. The other car approaches from a different leg of the intersection to make a left turn, but ego has the right of the way because it is closer to the intersection. + +To run this file using the MetaDrive simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_30.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_30.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town10HD.xodr') # or other CARLA map that definitely works @@ -55,4 +61,3 @@ ego = new Car following roadDirection from egoStart for EGO_OFFSET, other = new Car following roadDirection from actorStart for OTHERCAR_OFFSET, with behavior SafeBehavior(target_speed=SPEED, trajectory=actor_centerlines, \ thresholdDistance = SAFE_DIST) - diff --git a/examples/driving/OAS_Scenarios/oas_scenario_32.scenic b/examples/driving/OAS_Scenarios/oas_scenario_32.scenic index 9a63373ab..6ccef865f 100644 --- a/examples/driving/OAS_Scenarios/oas_scenario_32.scenic +++ b/examples/driving/OAS_Scenarios/oas_scenario_32.scenic @@ -2,6 +2,12 @@ Voyage OAS Scenario Unique ID: 3-2-W-I-L-CAR:N>S At 3-way intersection, ego turns left and the other car on a different leg of the intersection goes straight. There is no requirement on which vehicle has the right of the way. + +To run this file using the MetaDrive simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_32.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/OAS_Scenarios/oas_scenario_32.scenic --2d --model scenic.simulators.carla.model --simulate """ param map = localPath('../../../assets/maps/CARLA/Town10HD.xodr') # or other CARLA map that definitely works @@ -50,4 +56,3 @@ ego = new Car on ego_L_startLane.centerline, other = new Car on startLane.centerline, with behavior FollowTrafficBehavior(target_speed=10, trajectory=centerlines) - diff --git a/examples/driving/README.md b/examples/driving/README.md index 753169d5a..23979db54 100644 --- a/examples/driving/README.md +++ b/examples/driving/README.md @@ -6,5 +6,5 @@ For example: ``` scenic --2d badlyParkedCarPullingIn.scenic -scenic --2d -S --model scenic.simulators.newtonian.driving_model badlyParkedCarPullingIn.scenic +scenic --2d -S --model scenic.simulators.metadrive.model badlyParkedCarPullingIn.scenic ``` diff --git a/examples/driving/badlyParkedCarPullingIn.scenic b/examples/driving/badlyParkedCarPullingIn.scenic index dd36ede2b..8e27e3314 100644 --- a/examples/driving/badlyParkedCarPullingIn.scenic +++ b/examples/driving/badlyParkedCarPullingIn.scenic @@ -1,3 +1,11 @@ +''' +To run this file using the MetaDrive simulator: + scenic examples/driving/badlyParkedCarPullingIn.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/badlyParkedCarPullingIn.scenic --2d --model scenic.simulators.carla.model --simulate +''' + param map = localPath('../../assets/maps/CARLA/Town05.xodr') param carla_map = 'Town05' param time_step = 1.0/10 diff --git a/examples/driving/car.scenic b/examples/driving/car.scenic index 91743ac53..49c421125 100644 --- a/examples/driving/car.scenic +++ b/examples/driving/car.scenic @@ -1,6 +1,13 @@ +''' +To run this file using the MetaDrive simulator: + scenic examples/driving/car.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/car.scenic --2d --model scenic.simulators.carla.model --simulate +''' param map = localPath('../../assets/maps/CARLA/Town01.xodr') model scenic.domains.driving.model -ego = new Car \ No newline at end of file +ego = new Car diff --git a/examples/driving/pedestrian.scenic b/examples/driving/pedestrian.scenic index e564107b4..d1c088539 100644 --- a/examples/driving/pedestrian.scenic +++ b/examples/driving/pedestrian.scenic @@ -1,3 +1,10 @@ +''' +To run this file using the MetaDrive simulator: + scenic examples/driving/pedestrian.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/pedestrian.scenic --2d --model scenic.simulators.carla.model --simulate +''' param map = localPath('../../assets/maps/CARLA/Town01.xodr') param carla_map = 'Town01' diff --git a/examples/driving/pedestrianAcrossRoad.scenic b/examples/driving/pedestrianAcrossRoad.scenic index 2bc6f9329..4cde3a294 100644 --- a/examples/driving/pedestrianAcrossRoad.scenic +++ b/examples/driving/pedestrianAcrossRoad.scenic @@ -1,3 +1,10 @@ +''' +To run this file using the MetaDrive simulator: + scenic examples/driving/pedestrianAcrossRoad.scenic --2d --model scenic.simulators.metadrive.model --simulate + +To run this file using the Carla simulator: + scenic examples/driving/pedestrianAcrossRoad.scenic --2d --model scenic.simulators.carla.model --simulate +''' param map = localPath('../../assets/maps/CARLA/Town01.xodr') diff --git a/examples/driving/pedestrianJaywalking.scenic b/examples/driving/pedestrianJaywalking.scenic new file mode 100644 index 000000000..73e232afb --- /dev/null +++ b/examples/driving/pedestrianJaywalking.scenic @@ -0,0 +1,45 @@ +""" Scenario Description +A parked car is placed off the curb. When the ego vehicle approaches, a pedestrian steps out from in front of the parked car and crosses the road. +The ego is expected to detect the pedestrian and brake before reaching them. + +To run this file using the MetaDrive simulator: + scenic examples/driving/pedestrianJaywalking.scenic --2d --model scenic.simulators.metadrive.model --simulate +""" +param map = localPath('../../assets/maps/CARLA/Town01.xodr') +model scenic.domains.driving.model + +#CONSTANTS +PEDESTRIAN_TRIGGER_DISTANCE = 15 # Distance at which pedestrian begins to cross +BRAKE_TRIGGER_DISTANCE = 10 # Distance at which ego begins braking +EGO_TO_PARKED_CAR_MIN_DIST = 30 # Ensure ego starts far enough away +PEDESTRIAN_OFFSET = 3 # Offset for pedestrian placement ahead of parked car +PARKED_CAR_OFFSET = 1 # Offset for parked car from the curb + +#EGO BEHAVIOR: Ego drives by following lanes, but brakes if a pedestrian is close +behavior DriveAndBrakeForPedestrians(): + try: + do FollowLaneBehavior() + interrupt when withinDistanceToAnyPedestrians(self, BRAKE_TRIGGER_DISTANCE): + take SetThrottleAction(0), SetBrakeAction(1) + +#PEDESTRIAN BEHAVIOR: Pedestrian crosses road when ego is near +behavior CrossRoad(): + while distance from self to ego > PEDESTRIAN_TRIGGER_DISTANCE: + wait + take SetWalkingDirectionAction(self.heading), SetWalkingSpeedAction(1) + +#SCENE SETUP +ego = new Car with behavior DriveAndBrakeForPedestrians() + +rightCurb = ego.laneGroup.curb +spot = new OrientedPoint on visible rightCurb + +parkedCar = new Car right of spot by PARKED_CAR_OFFSET, with regionContainedIn None + +require distance from ego to parkedCar > EGO_TO_PARKED_CAR_MIN_DIST + +new Pedestrian ahead of parkedCar by PEDESTRIAN_OFFSET, + facing 90 deg relative to parkedCar, + with behavior CrossRoad() + +terminate after 30 seconds diff --git a/examples/metsr/test.scenic b/examples/metsr/test.scenic index 58ba400db..829d2f093 100644 --- a/examples/metsr/test.scenic +++ b/examples/metsr/test.scenic @@ -12,3 +12,4 @@ scenario Main(): compose: foo = Test() do foo for 500 seconds + diff --git a/examples/sensors/conftest.py b/examples/sensors/conftest.py new file mode 100644 index 000000000..d40f9442c --- /dev/null +++ b/examples/sensors/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def options(): + return dict(mode2D=True) diff --git a/examples/sensors/data_generation.scenic b/examples/sensors/data_generation.scenic new file mode 100644 index 000000000..a960d1b90 --- /dev/null +++ b/examples/sensors/data_generation.scenic @@ -0,0 +1,28 @@ +param map = localPath('../../assets/maps/CARLA/Town05.xodr') +param carla_map = 'Town05' +model scenic.domains.driving.model + +# Sample a lane at random +lane = Uniform(*network.lanes) + +spot = new OrientedPoint on lane.centerline + +attrs = {"convert": "CityScapesPalette"} # Used by CARLA + +# Spawn car on that spot with follow lane behavior and +# - an RGB Camera pointing forward +# - a semantic segmentation sensor +ego = new Car at spot, + with behavior FollowLaneBehavior(), + with sensors {"front_ss": SSSensor(offset=(1.6, 0, 1.7), width=1056, height=704, attributes=attrs), + "front_rgb": RGBSensor(offset=(1.6, 0, 1.7), width=1056, height=704, attributes=attrs) + } + +other = new Car offset by 0 @ Range(10, 30), + with behavior FollowLaneBehavior() + +param recordFolder = "out/{simulation}" +record ego.observations["front_ss"] every 0.5 seconds after 5 seconds to "frontss_{time}.jpg" +record ego.observations["front_rgb"] after 5 seconds to "frontrgb.mp4" + +terminate after 15 seconds diff --git a/examples/webots/city_intersection/README.md b/examples/webots/city_intersection/README.md index e811ff043..3ef601491 100644 --- a/examples/webots/city_intersection/README.md +++ b/examples/webots/city_intersection/README.md @@ -2,6 +2,8 @@ An example showing how to use Scenic to generate training data for an autonomous car. In this example the ego car is approaching an intersection where it has an obligation to yield, with another car crossing the intersection. At regular intervals the ego car's camera output will be saved, and tagged with whether or not the crossing car is visible or not visible. -First navigate to `controllers/create_avoid_obstacles` and run `make` (you may need to first set the Webots environment variable as shown [here](https://cyberbotics.com/doc/guide/compiling-controllers-in-a-terminal)). Then run the scenario using the `worlds/city_intersection.wbt` file in webots and play the simulation by pressing one of the buttons at the top (we recommend "Run the simulation as fast as possible" to maximize speed). After Webots closes (indicating the simulations has completed), look in the `images` directory for the tagged images. +First ensure that you have your `WEBOTS_HOME` environment variable set to the root of your Webots directory by running: `export WEBOTS_HOME=/path/to/webots`. + +Then navigate to `controllers/autonomous_vehicle` and run `make` (you may need to first set the Webots environment variable as shown [here](https://cyberbotics.com/doc/guide/compiling-controllers-in-a-terminal)). Then run the scenario using the `worlds/city_intersection.wbt` file in webots and play the simulation by pressing one of the buttons at the top (we recommend "Run the simulation as fast as possible" to maximize speed). After Webots closes (indicating the simulations has completed), look in the `images` directory for the tagged images. These examples are intended to be run **without** the ``--2d`` flag. diff --git a/examples/webots/vacuum/README.md b/examples/webots/vacuum/README.md index 7ada0e8f0..77d224b3c 100644 --- a/examples/webots/vacuum/README.md +++ b/examples/webots/vacuum/README.md @@ -2,6 +2,8 @@ An example showing how to use Scenic to evaluate the coverage of a robot vacuum. -First navigate to `controllers/create_avoid_obstacles` and run `make` (you may need to first set the Webots environment variable as shown [here](https://cyberbotics.com/doc/guide/compiling-controllers-in-a-terminal)). Then run the scenario using the `worlds/create.wbt` file in webots and play the simulation by pressing one of the buttons at the top (we recommend "Run the simulation as fast as possible" to maximize speed). After Webots closes (indicating all simulations have run), run `python summary.py` to get a summary of the output. +First ensure that you have your `WEBOTS_HOME` environment variable set to the root of your Webots directory by running: `export WEBOTS_HOME=/path/to/webots`. + +Then navigate to `controllers/create_avoid_obstacles` and run `make` (you may need to first set the Webots environment variable as shown [here](https://cyberbotics.com/doc/guide/compiling-controllers-in-a-terminal)). Then run the scenario using the `worlds/create.wbt` file in webots and play the simulation by pressing one of the buttons at the top (we recommend "Run the simulation as fast as possible" to maximize speed). After Webots closes (indicating all simulations have run), run `python summary.py` to get a summary of the output. These examples are intended to be run **without** the ``--2d`` flag. diff --git a/pyproject.toml b/pyproject.toml index 960bc4724..dc667ba7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scenic" -version = "3.0.0" +version = "3.1.1" description = "The Scenic scenario description language." authors = [ { name = "Daniel Fremont" }, @@ -32,22 +32,23 @@ dependencies = [ "dotmap ~= 1.3", "mapbox_earcut >= 0.12.10", "matplotlib ~= 3.2", - "manifold3d == 2.3.0", + "manifold3d >= 2.5.1", "networkx >= 2.6", - "numpy ~= 1.24", + "numpy >= 1.24", "opencv-python ~= 4.5", "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", + 'pygame-ce >= 2.5.7, <3; python_version >= "3.10"', + 'pygame ~= 2.0; python_version < "3.10"', + "pyglet >= 1.5, <= 1.5.26", "python-fcl >= 0.7", "Rtree ~= 1.0", "rv-ltl ~= 0.1", "scikit-image ~= 0.21", - "scipy ~= 1.7", + 'scipy >= 1.17.0; python_version >= "3.11"', + 'scipy ~= 1.7; python_version < "3.11"', "shapely ~= 2.0", - "trimesh >=4.0.9, <5", + "trimesh >=4.6.0, <5", ] [project.optional-dependencies] @@ -55,8 +56,12 @@ guideways = [ 'pyproj ~= 3.0; python_version < "3.10"', 'pyproj ~= 3.3; python_version >= "3.10"', ] +metadrive = [ + "metadrive-simulator >= 0.4.3", + "sumolib >= 1.21.0", +] test = [ # minimum dependencies for running tests (used for tox virtualenvs) - "pytest >= 7.0.0, <8", + "pytest >= 7.0.0", "pytest-cov >= 3.0.0", "pytest-randomly ~= 3.2", ] @@ -64,20 +69,22 @@ 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.10" and (platform_system == "Linux" or platform_system == "Windows")', + 'carla >= 0.9.12; python_version <= "3.12" and (platform_system == "Linux" or platform_system == "Windows")', "dill", "exceptiongroup", "inflect ~= 5.5", + 'metadrive-simulator >= 0.4.3; python_version <= "3.11"', # listed directly (not scenic[metadrive]) to work around tox issue that ignored the python_version<=3.11 guard "pygments ~= 2.11", - "sphinx >= 5.0.0, <6", + "sphinx >= 6.2.0, <8", "sphinx_rtd_theme >= 0.5.2", "sphinx-tabs ~= 3.4.1", - "verifai >= 2.1.0b1", + 'sumolib >= 1.21.0; python_version <= "3.11"', # required for MetaDrive + "verifai >= 2.2.0", ] dev = [ "scenic[test-full]", - "black ~= 24.0", - "isort ~= 5.11", + "black ~= 25.1.0", + "isort ~= 5.12.0", "pre-commit ~= 3.0", "pytest-cov >= 3.0.0", "tox ~= 4.0", @@ -128,4 +135,4 @@ extend_skip_glob = [ norecursedirs = ["examples"] [tool.coverage.run] -source = ["src"] \ No newline at end of file +source = ["src"] diff --git a/src/scenic/__main__.py b/src/scenic/__main__.py index dd72bf8b4..05f527fba 100644 --- a/src/scenic/__main__.py +++ b/src/scenic/__main__.py @@ -223,7 +223,7 @@ def generateScene(maxIterations=2000): return scene, iterations -def runSimulation(scene): +def runSimulation(scene, sceneCount): startTime = time.time() if args.verbosity >= 1: print(f" Beginning simulation of {scene.dynamicScenario}...") @@ -232,6 +232,7 @@ def runSimulation(scene): lambda: simulator.simulate( scene, maxSteps=args.time, + name=str(sceneCount), verbosity=args.verbosity, maxIterations=args.max_sims_per_scene, ) @@ -267,11 +268,13 @@ def runSimulation(scene): "(try installing python3-tk)" ) + sceneCount = 0 successCount = 0 while True: scene, _ = generateScene() + sceneCount += 1 if args.simulate: - success = runSimulation(scene) + success = runSimulation(scene, sceneCount) if success: successCount += 1 else: diff --git a/src/scenic/core/dynamics/invocables.py b/src/scenic/core/dynamics/invocables.py index 7f3eb1a8d..7a671dac3 100644 --- a/src/scenic/core/dynamics/invocables.py +++ b/src/scenic/core/dynamics/invocables.py @@ -100,7 +100,11 @@ def scheduler(): if not scheduler: def scheduler(): - yield from self._invokeInner(agent, subs) + if not subs: + while True: + yield () + else: + yield from self._invokeInner(agent, subs) if modifier: if modifier.name == "for": # do X for Y [seconds | steps] diff --git a/src/scenic/core/dynamics/scenarios.py b/src/scenic/core/dynamics/scenarios.py index 7765eb9ee..d931e9587 100644 --- a/src/scenic/core/dynamics/scenarios.py +++ b/src/scenic/core/dynamics/scenarios.py @@ -6,12 +6,14 @@ import functools import inspect import sys +import types import warnings import weakref import rv_ltl import scenic +from scenic.core.distributions import Samplable import scenic.core.dynamics as dynamics from scenic.core.errors import InvalidScenarioError, ScenicSyntaxError from scenic.core.lazy_eval import DelayedArgument, needsLazyEvaluation @@ -60,7 +62,7 @@ def __init__(self, *args, **kwargs): self._objects = [] # ordered for reproducibility self._sampledObjects = self._objects self._externalParameters = [] - self._pendingRequirements = defaultdict(list) + self._pendingRequirements = [] self._requirements = [] # things needing to be sampled to evaluate the requirements self._requirementDeps = set() @@ -86,6 +88,7 @@ def __init__(self, *args, **kwargs): self._timeLimitInSteps = None # computed at simulation time self._elapsedTime = 0 + self._recordedTime = None self._eventuallySatisfied = None self._overrides = {} @@ -185,8 +188,9 @@ def _start(self): # Compute time limit now that we know the simulation timestep self._elapsedTime = 0 self._timeLimitInSteps = self._timeLimit + timestep = veneer.currentSimulation.timestep if self._timeLimitIsInSeconds: - self._timeLimitInSteps /= veneer.currentSimulation.timestep + self._timeLimitInSteps /= timestep # create monitors for each requirement used for this simulation self._requirementMonitors = [r.toMonitor() for r in self._temporalRequirements] @@ -196,10 +200,8 @@ def _start(self): # Start compose block if self._compose is not None: if not inspect.isgeneratorfunction(self._compose): - from scenic.syntax.translator import composeBlock - raise InvalidScenarioError( - f'"{composeBlock}" does not invoke any scenarios' + '"compose" block does not invoke any scenarios' ) self._runningIterator = self._compose(None, *self._args, **self._kwargs) @@ -212,6 +214,16 @@ def _start(self): for monitor in self._monitors: monitor._start() + # Prepare recorders + simName = veneer.currentSimulation.name + currentTime = veneer.currentSimulation.currentTime + globalParams = types.MappingProxyType(veneer._globalParameters) + for req in self._recordedExprs: + if (recConfig := req.recConfig) and (recorder := recConfig.recorder): + recorder.beginRecording( + recConfig, simName, timestep, globalParams, currentTime + ) + def _step(self): """Execute the (already-started) scenario for one time step. @@ -244,7 +256,8 @@ def _step(self): else: def alarmHandler(signum, frame): - if sys.gettrace(): + # NOTE: if using pytest-cov, sys.gettrace() may be set by coverage, 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 compose block of scenario {self} is taking a long time; " @@ -285,6 +298,15 @@ def _stop(self, reason, quiet=False): assert self._isRunning + if not quiet: + # Record finally-recorded values. + sim = veneer.currentSimulation + for rec in self._recordedFinalExprs: + sim._record(rec.name, rec.evaluate()) + + # Record ordinary `record` statements too if they haven't been already. + self._recordTimeSeries() + # Stop monitors and subscenarios. for monitor in self._monitors: if monitor._isRunning: @@ -303,13 +325,25 @@ def _stop(self, reason, quiet=False): veneer.endScenario(self, reason, quiet=quiet) super()._stop(reason) - # Reject if a temporal requirement was not satisfied. + # Check if a temporal requirement was not satisfied. + rejection = None if not quiet: for req in self._requirementMonitors: if req.lastValue.is_falsy: - raise RejectSimulationException(str(req)) + rejection = str(req) + break self._requirementMonitors = None + # Stop recorders. + cancelRecordings = quiet or rejection is not None + for req in self._recordedExprs: + if (recConfig := req.recConfig) and (recorder := recConfig.recorder): + recorder.endRecording(canceled=cancelRecordings) + + # If a temporal requirement was violated, reject (now that we're cleaned up). + if rejection is not None: + raise RejectSimulationException(rejection) + return reason def _invokeInner(self, agent, subs): @@ -335,25 +369,33 @@ def _invokeInner(self, agent, subs): # Check if any sub-scenarios stopped during action execution self._subScenarios = [sub for sub in self._subScenarios if sub._isRunning] - def _evaluateRecordedExprs(self, ty): - if ty is RequirementType.record: - place = "_recordedExprs" - elif ty is RequirementType.recordInitial: - place = "_recordedInitialExprs" - elif ty is RequirementType.recordFinal: - place = "_recordedFinalExprs" - else: - assert False, "invalid record type requested" - return self._evaluateRecordedExprsAt(place) + def _updateRecords(self): + from scenic.syntax.veneer import currentSimulation + + # _step() was called earlier this time step, so at time step 0 we will + # already have _elapsedTime == 1 + assert self._elapsedTime >= 1 + if self._elapsedTime == 1: + for rec in self._recordedInitialExprs: + currentSimulation._record(rec.name, rec.evaluate()) + + self._recordTimeSeries() - def _evaluateRecordedExprsAt(self, place): - values = {} - for rec in getattr(self, place): - values[rec.name] = rec.evaluate() for sub in self._subScenarios: - subvals = sub._evaluateRecordedExprsAt(place) - values.update(subvals) - return values + sub._updateRecords() + + def _recordTimeSeries(self): + from scenic.syntax.veneer import currentSimulation + + if self._recordedTime == currentSimulation.currentTime: + # This time step was already recorded (e.g. the scenario was terminated + # by a behavior after the current state was recorded). + return + + for rec in self._recordedExprs: + currentSimulation._recordTimeSeries(rec.name, rec.evaluate()) + + self._recordedTime = currentSimulation.currentTime def _runMonitors(self): terminationReason = None @@ -388,6 +430,8 @@ def _allAgents(self): return agents def _inherit(self, other): + if not self._ego: + self._ego = other._ego if not self._workspace: self._workspace = other._workspace self._instances.extend(other._instances) @@ -409,11 +453,10 @@ def _registerObject(self, obj): obj._parentScenario = weakref.ref(self) - def _addRequirement(self, ty, reqID, req, line, name, prob): + def _addRequirement(self, ty, reqID, req, line, name, prob, recConfig=None): """Save a requirement defined at compile-time for later processing.""" - assert reqID not in self._pendingRequirements - preq = PendingRequirement(ty, req, line, prob, name, self._ego) - self._pendingRequirements[reqID] = preq + preq = PendingRequirement(ty, req, line, prob, name, self._ego, recConfig, self) + self._pendingRequirements.append((reqID, preq)) def _addDynamicRequirement(self, ty, req, line, name): """Add a requirement defined during a dynamic simulation.""" @@ -431,7 +474,7 @@ def _compileRequirements(self): namespace = self._dummyNamespace if self._dummyNamespace else self.__dict__ requirementSyntax = self._requirementSyntax assert requirementSyntax is not None - for reqID, requirement in self._pendingRequirements.items(): + for reqID, requirement in self._pendingRequirements: syntax = requirementSyntax[reqID] if requirementSyntax else None # Catch the simple case where someone has most likely forgotten the "monitor" @@ -512,6 +555,13 @@ def _toScenario(self, namespace): ) # TODO unify these! return scenario + def _makeLocalsSnapshot(self): + locs = {} + for local in self._locals: + if local in self.__dict__: + locs[local] = self.__dict__[local] + return LocalsSnapshot(locs) + def __getattr__(self, name): if name in self._locals: return DelayedArgument( @@ -525,3 +575,13 @@ def __str__(self): else: args = argsToString(self._args, self._kwargs) return f"{self.__class__.__name__}({args})" + + +class LocalsSnapshot(Samplable): + def __init__(self, locs): + self._locs = locs + self.__dict__.update(locs) + super().__init__(locs.values()) + + def sampleGiven(self, value): + return LocalsSnapshot({name: value[val] for name, val in self._locs.items()}) diff --git a/src/scenic/core/external_params.py b/src/scenic/core/external_params.py index 88848db4b..6fa464a45 100644 --- a/src/scenic/core/external_params.py +++ b/src/scenic/core/external_params.py @@ -86,6 +86,15 @@ in general may be approximated so that VerifAI can handle them -- see `VerifaiParameter.withPrior` for details. +To set a time bound when using VerifAI's dynamic sampling, set the ``timeBound`` +global parameter to value representing the upper bound on the number of timesteps +the sampler should account for. For example:: + + param timeBound = 250 + +This value can also be set directly in VerifAI via the ``maxSteps`` parameter to the +``ScenicSampler``. + For more information on how to customize the sampler, see `VerifaiSampler`. .. _VerifAI: https://github.com/BerkeleyLearnVerify/VerifAI @@ -96,6 +105,10 @@ """ +from abc import ABC, abstractmethod +from importlib import metadata +import warnings + from dotmap import DotMap import numpy @@ -181,7 +194,10 @@ def __init__(self, params, globalParams): import verifai.features import verifai.server + self._verifaiDynamic = int(metadata.version("verifai").split(".")[0]) > 2 + # construct FeatureSpace + timeBound = globalParams.get("timeBound", 0) usingProbs = False self.params = tuple(params) for index, param in enumerate(self.params): @@ -193,11 +209,30 @@ def __init__(self, params, globalParams): param.index = index if param.probs is not None: usingProbs = True + + if not self._verifaiDynamic and any(param.isTimeSeries for param in self.params): + raise RuntimeError("TimeSeries not supported for VerifAI versions < 3.0") + + if timeBound == 0 and any(param.isTimeSeries for param in self.params): + warnings.warn( + "TimeSeries external parameter used but no global parameter `timeBound` is specified. " + "(If using VerifAI’s ScenicSampler, set its maxSteps option)." + ) + + fs_kwargs = {} + if self._verifaiDynamic: + fs_kwargs["timeBound"] = timeBound + space = verifai.features.FeatureSpace( { - self.nameForParam(index): verifai.features.Feature(param.domain) + self.nameForParam(index): ( + verifai.features.Feature(param.domain) + if not param.isTimeSeries + else verifai.features.TimeSeriesFeature(param.domain) + ) for index, param in enumerate(self.params) - } + }, + **fs_kwargs, ) # set up VerifAI sampler @@ -252,17 +287,61 @@ def __init__(self, params, globalParams): self.rejectionFeedback = 1 self.cachedSample = None + self._lastSample = None + self._lastInfo = None + self._lastDynamicSample = None + self._lastSimulation = None + self._lastTime = -1 + def nextSample(self, feedback): - return self.sampler.nextSample(feedback) + if feedback is not None: + assert self._lastSample is not None + if self._verifaiDynamic: + self._lastSample.complete(feedback) + else: + self.sampler.update(self._lastSample, self._lastInfo, feedback) - def update(self, sample, info, rho): - self.sampler.update(sample, info, rho) + if self._verifaiDynamic: + self._lastSample = self.sampler.getSample() + else: + lastSample = self.sampler.getSample() + self._lastSample = lastSample[0] + self._lastInfo = lastSample[1] + return self._lastSample - def getSample(self): - return self.sampler.getSample() + def nextDynamicSample(self): + import scenic.syntax.veneer as veneer + + assert veneer.currentSimulation is not None + + if self._lastSimulation is not veneer.currentSimulation: + self._lastSimulation = veneer.currentSimulation + self._lastTime = -1 + + if veneer.currentSimulation.currentTime > self._lastTime: + feedback = veneer.currentSimulation + self._lastDynamicSample = self.cachedSample.getDynamicSample(feedback) + self._lastTime = veneer.currentSimulation.currentTime + + return self._lastDynamicSample def valueFor(self, param): - return getattr(self.cachedSample, self.nameForParam(param.index)) + if not param.isTimeSeries: + if self._verifaiDynamic: + sampleTarget = self.cachedSample.staticSample + else: + sampleTarget = self.cachedSample + return param.extractOutput( + getattr(sampleTarget, self.nameForParam(param.index)) + ) + else: + callback = lambda: param.extractOutput( + getattr( + self.nextDynamicSample(), + self.nameForParam(param.index), + ) + ) + return TimeSeriesParameter(callback) @staticmethod def nameForParam(i): @@ -276,6 +355,7 @@ class ExternalParameter(Distribution): def __init__(self): super().__init__() self.sampler = None + self.isTimeSeries = False import scenic.syntax.veneer as veneer # TODO improve? veneer.registerExternalParameter(self) @@ -289,6 +369,41 @@ def sampleGiven(self, value): assert self.sampler is not None return self.sampler.valueFor(self) + def extractOutput(self, value): + """ + Given a raw sampled value for a parameter, optionally extract the actual desired value. + + By default just passes the value through unchanged. + """ + return value + + +class TimeSeriesParameter: + def __init__(self, callback): + self._callback = callback + self._lastTime = -1 + + def getSample(self): + import scenic.syntax.veneer as veneer + + assert veneer.currentSimulation is not None + + if veneer.currentSimulation.currentTime <= self._lastTime: + raise RuntimeError( + "Attempted `getSample` for a TimeSeries external parameter twice in one timestep." + ) + + self._lastTime = veneer.currentSimulation.currentTime + return self._callback() + + +def TimeSeries(param): + if not isinstance(param, ExternalParameter): + raise TypeError("Cannot turn a non `ExternalParameter` into a time series") + + param.isTimeSeries = True + return param + class VerifaiParameter(ExternalParameter): """An external parameter sampled using one of VerifAI's samplers.""" @@ -341,8 +456,7 @@ def __init__(self, low, high, buckets=None, weights=None): total = sum(weights) self.probs = tuple(wt / total for wt in weights) - def sampleGiven(self, value): - value = super().sampleGiven(value) + def extractOutput(self, value): assert len(value) == 1 return value[0] @@ -367,8 +481,7 @@ def __init__(self, low, high, weights=None): else: self.probs = None - def sampleGiven(self, value): - value = super().sampleGiven(value) + def extractOutput(self, value): assert len(value) == 1 return value[0] diff --git a/src/scenic/core/geometry.py b/src/scenic/core/geometry.py index dba37887a..b60a68b2d 100644 --- a/src/scenic/core/geometry.py +++ b/src/scenic/core/geometry.py @@ -110,7 +110,7 @@ def distanceToLine(point, a, b): # Fastest known way to make a Shapely Point from a list/tuple/Vector -makeShapelyPoint = shapely.lib.points +makeShapelyPoint = shapely.points def polygonUnion(polys, buf=0, tolerance=0, holeTolerance=0.002): diff --git a/src/scenic/core/object_types.py b/src/scenic/core/object_types.py index 5230f0c83..11ff01004 100644 --- a/src/scenic/core/object_types.py +++ b/src/scenic/core/object_types.py @@ -16,6 +16,8 @@ from abc import ABC, abstractmethod import collections +import functools +import inspect import math import random import typing @@ -77,7 +79,7 @@ toVector, underlyingType, ) -from scenic.core.utils import DefaultIdentityDict, cached_method, cached_property +from scenic.core.utils import DefaultIdentityDict, cached, cached_method, cached_property from scenic.core.vectors import ( Orientation, Vector, @@ -214,6 +216,7 @@ def __init__(self, properties, constProps=frozenset(), _internal=False): self.properties = tuple(sorted(properties.keys())) self._propertiesSet = set(self.properties) self._constProps = constProps + self._sampleParent = None @classmethod def _withProperties(cls, properties, constProps=None): @@ -544,7 +547,9 @@ def sampleGiven(self, value): if not needsSampling(self): return self props = {prop: value[getattr(self, prop)] for prop in self.properties} - return type(self)(props, constProps=self._constProps, _internal=True) + obj = type(self)(props, constProps=self._constProps, _internal=True) + obj._sampleParent = self + return obj def _allProperties(self): return {prop: getattr(self, prop) for prop in self.properties} @@ -599,6 +604,35 @@ def __repr__(self): return f"{type(self).__name__}({allProps})" +def precomputed_property(func): + """A @property which can be precomputed if its dependencies are not random. + + Converts a function inside a subclass of `Constructible` into a method; the + function's arguments must correspond to the properties of the object needed + to compute this property. If any of those dependencies have random values, + this property will evaluate to `None`; otherwise it will be computed once + the first time it is needed and then reused across samples. + """ + deps = tuple(inspect.signature(func).parameters) + + @cached + @functools.wraps(func) + def method(self): + args = [getattr(self, prop) for prop in deps] + if any(needsSampling(arg) for arg in args): + return None + return func(*args) + + @functools.wraps(func) + def wrapper(self): + parent = self._sampleParent or self + return method(parent) + + wrapper._scenic_cache_clearer = method._scenic_cache_clearer + + return property(wrapper) + + ## Mutators @@ -998,6 +1032,9 @@ class Object(OrientedPoint): Default value :scenic:`((-0.5, 0.5), (-0.5, 0.5), (-0.5, 0.5))`. cameraOffset (`Vector`): Position of the camera for the :keyword:`can see` operator, relative to the object's :prop:`position`. Default :scenic:`(0, 0, 0)`. + visionSensorOffset (`Vector`): Offset of the default vision sensor mount point, + relative to the object's :prop:`position`. Defaults to the front-center of the + bounding box, :scenic:`(0, self.length/2, 0)`. requireVisible (bool): Whether the object is required to be visible from the ``ego`` object. Default value ``False``. occluding (bool): Whether or not this object can occlude other objects. Default @@ -1008,6 +1045,8 @@ class Object(OrientedPoint): An optional color (with optional alpha) property that is used by the internal visualizer, or possibly simulators. All values should be between 0 and 1. Default value ``None`` + render (bool): Whether this object is drawn in Scenic's internal visualizer. + Default value ``True``. Set to ``False`` to hide the object. velocity (`Vector`; *dynamic*): Velocity in dynamic simulations. Default value is the velocity determined by :prop:`speed` and :prop:`orientation`. speed (float; dynamic): Speed in dynamic simulations. Default value 0. @@ -1018,6 +1057,9 @@ class Object(OrientedPoint): value ``None``. lastActions: Tuple of :term:`actions` taken by this agent in the last time step (an empty tuple if the object is not an agent or this is the first time step). + sensors: Dict of ("name": sensor) that populate the observations field every time step + observations: Dict of ("name": observation) storing the latest observation of the sensor + with the same name """ _scenic_properties = { @@ -1033,12 +1075,19 @@ class Object(OrientedPoint): "contactTolerance": 1e-4, "sideComponentThresholds": ((-0.5, 0.5), (-0.5, 0.5), (-0.5, 0.5)), "cameraOffset": Vector(0, 0, 0), + "visionSensorOffset": PropertyDefault( + ("length",), {}, lambda self: Vector(0, self.length / 2, 0) + ), "requireVisible": False, "occluding": True, "showVisibleRegion": False, "color": None, "render": True, - "velocity": PropertyDefault((), {"dynamic"}, lambda self: Vector(0, 0, 0)), + "velocity": PropertyDefault( + ("speed", "orientation"), + {"dynamic"}, + lambda self: Vector(0, self.speed, 0).rotatedBy(self.orientation), + ), "speed": PropertyDefault((), {"dynamic"}, lambda self: 0), "angularVelocity": PropertyDefault((), {"dynamic"}, lambda self: Vector(0, 0, 0)), "angularSpeed": PropertyDefault((), {"dynamic"}, lambda self: 0), @@ -1046,6 +1095,9 @@ class Object(OrientedPoint): "lastActions": tuple(), # weakref to scenario which created this object, for internal use "_parentScenario": None, + # Sensor properties + "sensors": PropertyDefault((), {}, lambda self: {}), + "observations": PropertyDefault((), {"final"}, lambda self: {}), } def __new__(cls, *args, **kwargs): @@ -1297,14 +1349,38 @@ def _corners2D(self): @cached_property def occupiedSpace(self): """A region representing the space this object occupies""" + if self._sampleParent and self._sampleParent._hasStaticBounds: + return self._sampleParent.occupiedSpace + shape = self.shape + scaledShape = self._scaledShape + if scaledShape: + mesh = scaledShape.mesh + dimensions = None # mesh does not need to be scaled + convex = scaledShape.isConvex + else: + mesh = shape.mesh + dimensions = (self.width, self.length, self.height) + convex = shape.isConvex return MeshVolumeRegion( - mesh=shape.mesh, - dimensions=(self.width, self.length, self.height), + mesh=mesh, + dimensions=dimensions, position=self.position, rotation=self.orientation, centerMesh=False, _internal=True, + _isConvex=convex, + _shape=shape, + _scaledShape=scaledShape, + ) + + @precomputed_property + def _scaledShape(shape, width, length, height): + return MeshVolumeRegion( + mesh=shape.mesh, + dimensions=(width, length, height), + centerMesh=False, + _internal=True, _isConvex=shape.isConvex, ) @@ -1322,8 +1398,9 @@ def _hasStaticBounds(self): self.width, self.length, self.height, + self.mutationScale, ) - return not any(needsSampling(v) for v in deps) + return not any(needsSampling(v) for v in deps) and self.mutationScale == 0 @cached_property def boundingBox(self): diff --git a/src/scenic/core/pruning.py b/src/scenic/core/pruning.py index 9d03ecb52..4e4360f76 100644 --- a/src/scenic/core/pruning.py +++ b/src/scenic/core/pruning.py @@ -10,8 +10,8 @@ import time import numpy +import shapely.errors import shapely.geometry -import shapely.geos from trimesh.transformations import translation_matrix from scenic.core.distributions import ( @@ -372,7 +372,7 @@ def pruneRelativeHeading(scenario, verbosity): continue try: pruned = newBasePoly & feasible - except shapely.geos.TopologicalError: # TODO how can we prevent these?? + except shapely.errors.TopologicalError: # TODO how can we prevent these?? pruned = newBasePoly & feasible.buffer(0.1, cap_style=2) if verbosity >= 1: percent = 100 * (1.0 - (pruned.area / newBasePoly.area)) diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 07f4c58b7..13afc1348 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -13,6 +13,7 @@ import random import warnings +import fcl import numpy import scipy import shapely @@ -24,6 +25,7 @@ compose_matrix, identity_matrix, quaternion_matrix, + transform_points, translation_matrix, ) import trimesh.voxel @@ -56,7 +58,13 @@ ) 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, unifyMesh +from scenic.core.utils import ( + cached, + cached_method, + cached_property, + findMeshInteriorPoint, + unifyMesh, +) from scenic.core.vectors import ( Orientation, OrientedVector, @@ -208,10 +216,15 @@ def containsRegion(self, reg, tolerance=0): if self.dimensionality < reg.dimensionality: return False - if self.size is not None and reg.size is not None: + if tolerance == 0 and self.size is not None and reg.size is not None: # A smaller region cannot contain a larger region of the - # same dimensionality. - if self.dimensionality == reg.dimensionality and self.size < reg.size: + # same dimensionality. We add a small factor to account for numerical error. + # NOTE: This path is ignored when tolerance is non-zero as evaluating it correctly + # in that case is non-trivial. + if ( + self.dimensionality == reg.dimensionality + and self.size * 1.01 < reg.size + ): return False return self.containsRegionInner(reg, tolerance) @@ -806,7 +819,7 @@ def __init__( # Copy parameters self._mesh = mesh self.dimensions = None if dimensions is None else toVector(dimensions) - self.position = None if position is None else toVector(position) + self.position = Vector(0, 0, 0) if position is None else toVector(position) self.rotation = None if rotation is None else toOrientation(rotation) self.orientation = None if orientation is None else toDistribution(orientation) self.tolerance = tolerance @@ -828,31 +841,17 @@ def __init__( if isLazy(self): return - # Convert extract mesh - if isinstance(mesh, trimesh.primitives.Primitive): - self._mesh = mesh.to_mesh() - elif isinstance(mesh, trimesh.base.Trimesh): - self._mesh = mesh.copy() - else: + if not isinstance(mesh, (trimesh.primitives.Primitive, trimesh.base.Trimesh)): raise TypeError( f"Got unexpected mesh parameter of type {type(mesh).__name__}" ) - # Center mesh unless disabled - if centerMesh: - self.mesh.vertices -= self.mesh.bounding_box.center_mass - # Apply scaling, rotation, and translation, if any - if self.dimensions is not None: - scale = numpy.array(self.dimensions) / self.mesh.extents - else: - scale = None if self.rotation is not None: angles = self.rotation._trimeshEulerAngles() else: angles = None - matrix = compose_matrix(scale=scale, angles=angles, translate=self.position) - self.mesh.apply_transform(matrix) + self._rigidTransform = compose_matrix(angles=angles, translate=self.position) self.orientation = orientation @@ -937,10 +936,29 @@ def evaluateInner(self, context): ) ## API Methods ## - @property + @cached_property @distributionFunction def mesh(self): - return self._mesh + mesh = self._mesh + + # Convert/extract mesh + if isinstance(mesh, trimesh.primitives.Primitive): + mesh = mesh.to_mesh() + elif isinstance(mesh, trimesh.base.Trimesh): + mesh = mesh.copy(include_visual=False) + else: + assert False, f"mesh of invalid type {type(mesh).__name__}" + + # Center mesh unless disabled + if self.centerMesh: + mesh.vertices -= mesh.bounding_box.center_mass + + # Apply scaling, rotation, and translation, if any. + # N.B. Avoid using Trimesh.apply_transform since it generates random numbers (!) + # to check if the transform flips windings; ours are rigid motions, so don't. + mesh.vertices = transform_points(mesh.vertices, matrix=self._transform) + + return mesh @distributionFunction def projectVector(self, point, onDirection): @@ -1003,9 +1021,50 @@ def AABB(self): tuple(self.mesh.bounds[1]), ) + @cached_property + def _transform(self): + """Transform from input mesh to final mesh. + + :meta private: + """ + if self.dimensions is not None: + scale = numpy.array(self.dimensions) / self._mesh.extents + else: + scale = None + if self.rotation is not None: + angles = self.rotation._trimeshEulerAngles() + else: + angles = None + transform = compose_matrix(scale=scale, angles=angles, translate=self.position) + return transform + + @cached_property + def _shapeTransform(self): + """Transform from Shape mesh (scaled to unit dimensions) to final mesh. + + :meta private: + """ + if self.dimensions is not None: + scale = numpy.array(self.dimensions) + else: + scale = self._mesh.extents + if self.rotation is not None: + angles = self.rotation._trimeshEulerAngles() + else: + angles = None + transform = compose_matrix(scale=scale, angles=angles, translate=self.position) + return transform + @cached_property def _boundingPolygonHull(self): assert not isLazy(self) + if self._shape: + raw = self._shape._multipoint + tr = self._shapeTransform + matrix = numpy.concatenate((tr[0:3, 0:3].flatten(), tr[0:3, 3])) + transformed = shapely.affinity.affine_transform(raw, matrix) + return transformed.convex_hull + return shapely.multipoints(self.mesh.vertices).convex_hull @cached_property @@ -1019,7 +1078,7 @@ def _boundingPolygon(self): # Generic case for arbitrary shapes if self.mesh.is_watertight: projection = trimesh.path.polygons.projected( - self.mesh, normal=(0, 0, 1), rpad=1e-4 + self.mesh, normal=(0, 0, 1), rpad=1e-4, precise=True ) else: # Special parameters to use all faces if mesh is not watertight. @@ -1029,6 +1088,7 @@ def _boundingPolygon(self): rpad=1e-4, ignore_sign=False, tol_dot=-float("inf"), + precise=True, ) return projection @@ -1075,9 +1135,20 @@ class MeshVolumeRegion(MeshRegion): onDirection: The direction to use if an object being placed on this region doesn't specify one. """ - def __init__(self, *args, _internal=False, _isConvex=None, **kwargs): + def __init__( + self, + *args, + _internal=False, + _isConvex=None, + _shape=None, + _scaledShape=None, + **kwargs, + ): super().__init__(*args, **kwargs) self._isConvex = _isConvex + self._shape = _shape + self._scaledShape = _scaledShape + self._num_samples = None if isLazy(self): return @@ -1095,18 +1166,6 @@ def __init__(self, *args, _internal=False, _isConvex=None, **kwargs): " Consider using scenic.core.utils.repairMesh." ) - # Compute how many samples are necessary to achieve 99% probability - # of success when rejection sampling volume. - p_volume = self._mesh.volume / self._mesh.bounding_box.volume - - if p_volume > 0.99: - self.num_samples = 1 - else: - self.num_samples = min(1e6, max(1, math.ceil(math.log(0.01, 1 - p_volume)))) - - # Always try to take at least 8 samples to avoid surface point total rejections - self.num_samples = max(self.num_samples, 8) - # Property testing methods # @distributionFunction def intersects(self, other, triedReversed=False): @@ -1119,73 +1178,23 @@ def intersects(self, other, triedReversed=False): """ if isinstance(other, MeshVolumeRegion): # PASS 1 - # 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. - 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: + # Check if the centers of the regions are far enough apart that the regions + # cannot overlap. This check only requires the circumradius of each region, + # which we can often precompute without explicitly constructing the mesh. + center_distance = numpy.linalg.norm(self.position - other.position) + if center_distance > self._circumradius + other._circumradius: return False - # PASS 2 - # Compute inradius and circumradius for a candidate point in each region, - # and compute the inradius and circumradius of each point. If the candidate - # points are closer than the sum of the inradius values, they must intersect. - # If the candidate points are farther apart than the sum of the circumradius - # values, they can't intersect. - - # Get a candidate point from each mesh. If the center of the object is in the mesh use that. - # Otherwise try to sample a point as a candidate, skipping this pass if the sample fails. - if self.containsPoint(Vector(*self.mesh.bounding_box.center_mass)): - s_candidate_point = Vector(*self.mesh.bounding_box.center_mass) - elif ( - len(samples := trimesh.sample.volume_mesh(self.mesh, self.num_samples)) - > 0 - ): - s_candidate_point = Vector(*samples[0]) - else: - s_candidate_point = None - - if other.containsPoint(Vector(*other.mesh.bounding_box.center_mass)): - o_candidate_point = Vector(*other.mesh.bounding_box.center_mass) - elif ( - len(samples := trimesh.sample.volume_mesh(other.mesh, other.num_samples)) - > 0 - ): - o_candidate_point = Vector(*samples[0]) - else: - o_candidate_point = None - - if s_candidate_point is not None and o_candidate_point is not None: - # Compute the inradius of each object from its candidate point. - s_inradius = abs( - trimesh.proximity.ProximityQuery(self.mesh).signed_distance( - [s_candidate_point] - )[0] - ) - o_inradius = abs( - trimesh.proximity.ProximityQuery(other.mesh).signed_distance( - [o_candidate_point] - )[0] - ) - - # Compute the circumradius of each object from its candidate point. - s_circumradius = numpy.max( - numpy.linalg.norm(self.mesh.vertices - s_candidate_point, axis=1) - ) - o_circumradius = numpy.max( - numpy.linalg.norm(other.mesh.vertices - o_candidate_point, axis=1) - ) + # PASS 2A + # If precomputed inradii are available, check if the volumes are close enough + # to ensure a collision. (While we're at it, check circumradii too.) + if self._scaledShape and other._scaledShape: + s_point = self._interiorPoint + s_inradius, s_circumradius = self._interiorPointRadii + o_point = other._interiorPoint + o_inradius, o_circumradius = other._interiorPointRadii - # Get the distance between the two points and check for mandatory or impossible collision. - point_distance = s_candidate_point.distanceTo(o_candidate_point) + point_distance = numpy.linalg.norm(s_point - o_point) if point_distance < s_inradius + o_inradius: return True @@ -1193,38 +1202,53 @@ def intersects(self, other, triedReversed=False): if point_distance > s_circumradius + o_circumradius: return False + # PASS 2B + # If precomputed geometry is not available, compute the bounding boxes + # (requiring that we construct the meshes, if they were previously lazy; + # hence we only do this check if we'll be constructing meshes anyway). + # For bounding boxes to intersect there must be overlap of the bounds + # in all 3 dimensions. + else: + 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: + return False + # PASS 3 - # Use Trimesh's collision manager to check for intersection. + # Use FCL to check for intersection between the surfaces. # If the surfaces collide, that implies a collision of the volumes. # Cheaper than computing volumes immediately. - collision_manager = trimesh.collision.CollisionManager() + # (N.B. Does not require explicitly building the mesh, if we have a + # precomputed _scaledShape available.) - collision_manager.add_object("SelfRegion", self.mesh) - collision_manager.add_object("OtherRegion", other.mesh) - - surface_collision = collision_manager.in_collision_internal() + selfObj = fcl.CollisionObject(*self._fclData) + otherObj = fcl.CollisionObject(*other._fclData) + surface_collision = fcl.collide(selfObj, otherObj) if surface_collision: return True - if self.mesh.is_convex and other.mesh.is_convex: - # For convex shapes, the manager detects containment as well as + if self.isConvex and other.isConvex: + # For convex shapes, FCL detects containment as well as # surface intersections, so we can just return the result return surface_collision # PASS 4 - # If we have 2 candidate points and both regions have only one body, - # we can just check if either region contains the candidate point of the - # other. (This is because we previously ruled out surface intersections) - if ( - s_candidate_point is not None - and o_candidate_point is not None - and self.mesh.body_count == 1 - and other.mesh.body_count == 1 - ): - return self.containsPoint(o_candidate_point) or other.containsPoint( - s_candidate_point - ) + # If both regions have only one body, we can just check if either region + # contains an arbitrary interior point of the other. (This is because we + # previously ruled out surface intersections) + if self._bodyCount == 1 and other._bodyCount == 1: + overlap = self._containsPointExact( + other._interiorPoint + ) or other._containsPointExact(self._interiorPoint) + return overlap # PASS 5 # Compute intersection and check if it's empty. Expensive but guaranteed @@ -1291,6 +1315,9 @@ def containsPoint(self, point): """Check if this region's volume contains a point.""" return self.distanceTo(point) <= self.tolerance + def _containsPointExact(self, point): + return self.mesh.contains([point])[0] + @distributionFunction def containsObject(self, obj): """Check if this region's volume contains an :obj:`~scenic.core.object_types.Object`.""" @@ -1476,7 +1503,7 @@ def intersect(self, other, triedReversed=False): if slice_3d is None: return nowhere - slice_2d, _ = slice_3d.to_planar(to_2D=numpy.eye(4)) + slice_2d, _ = slice_3d.to_2D(to_2D=numpy.eye(4)) polygons = MultiPolygon(slice_2d.polygons_full) & other.polygons if polygons.is_empty: @@ -1836,6 +1863,97 @@ def getVolumeRegion(self): """Returns this object, as it is already a MeshVolumeRegion""" return self + @property + def num_samples(self): + if self._num_samples is not None: + return self._num_samples + + # Compute how many samples are necessary to achieve 99% probability + # of success when rejection sampling volume. + volume = self._scaledShape.mesh.volume if self._scaledShape else self.mesh.volume + p_volume = volume / self.mesh.bounding_box.volume + + if p_volume > 0.99: + num_samples = 1 + else: + num_samples = math.ceil(min(1e6, max(1, math.log(0.01, 1 - p_volume)))) + + # Always try to take at least 8 samples to avoid surface point total rejections + self._num_samples = max(num_samples, 8) + return self._num_samples + + @cached_property + def _circumradius(self): + if self._scaledShape: + return self._scaledShape._circumradius + if self._shape: + dims = self.dimensions or self._mesh.extents + scale = max(dims) + return scale * self._shape._circumradius + + return numpy.max(numpy.linalg.norm(self.mesh.vertices, axis=1)) + + @cached_property + def _interiorPoint(self): + # Use precomputed point if available (transformed appropriately) + if self._scaledShape: + raw = self._scaledShape._interiorPoint + homog = numpy.append(raw, [1]) + return numpy.dot(self._rigidTransform, homog)[:3] + if self._shape: + raw = self._shape._interiorPoint + homog = numpy.append(raw, [1]) + return numpy.dot(self._shapeTransform, homog)[:3] + + return findMeshInteriorPoint(self.mesh, num_samples=self.num_samples) + + @cached_property + def _interiorPointRadii(self): + # Use precomputed radii if available + if self._scaledShape: + return self._scaledShape._interiorPointRadii + + # Compute inradius and circumradius w.r.t. the point + point = self._interiorPoint + pq = trimesh.proximity.ProximityQuery(self.mesh) + inradius = abs(pq.signed_distance([point])[0]) + circumradius = numpy.max(numpy.linalg.norm(self.mesh.vertices - point, axis=1)) + return inradius, circumradius + + @cached_property + def _bodyCount(self): + # Use precomputed geometry if available + if self._scaledShape: + return self._scaledShape._bodyCount + + return self.mesh.body_count + + @cached_property + def _fclData(self): + # Use precomputed geometry if available + if self._scaledShape: + geom = self._scaledShape._fclData[0] + trans = fcl.Transform(self.rotation.r.as_matrix(), numpy.array(self.position)) + return geom, trans + + mesh = self.mesh + if self.isConvex: + vertCounts = 3 * numpy.ones((len(mesh.faces), 1), dtype=numpy.int64) + faces = numpy.concatenate((vertCounts, mesh.faces), axis=1) + geom = fcl.Convex(mesh.vertices, len(faces), faces.flatten()) + else: + geom = fcl.BVHModel() + geom.beginModel(num_tris_=len(mesh.faces), num_vertices_=len(mesh.vertices)) + geom.addSubModel(mesh.vertices, mesh.faces) + geom.endModel() + trans = fcl.Transform() + return geom, trans + + def __getstate__(self): + state = self.__dict__.copy() + state.pop("_cached__fclData", None) # remove non-picklable FCL objects + return state + class MeshSurfaceRegion(MeshRegion): """A region representing the surface of a mesh. @@ -2612,6 +2730,10 @@ def __init__( # Extract vertex cast_pt = toVector(pt) + # Filter out zero distance segments + if last_pt == cast_pt: + continue + if cast_pt not in self.vec_to_vert: self.vec_to_vert[cast_pt] = vertex_iter vertex_iter += 1 @@ -3090,7 +3212,7 @@ def __init__(self, center, radius, resolution=32, name=None): @distributionFunction def _makePolygons(center, radius, resolution): ctr = makeShapelyPoint(center) - return ctr.buffer(radius, resolution=resolution) + return ctr.buffer(radius, quad_segs=resolution) ## Lazy Construction Methods ## def sampleGiven(self, value): @@ -3184,7 +3306,7 @@ def __init__(self, center, radius, heading, angle, resolution=32, name=None): @distributionFunction def _makePolygons(center, radius, heading, angle, resolution): ctr = makeShapelyPoint(center) - circle = ctr.buffer(radius, resolution=resolution) + circle = ctr.buffer(radius, quad_segs=resolution) if angle >= math.tau - 0.001: polygon = circle else: @@ -3275,7 +3397,9 @@ def __init__(self, position, heading, width, length, name=None): self.circumcircle = (self.position, self.radius) super().__init__( - polygon=self._makePolygons(position, heading, width, length), + polygon=self._makePolygons( + self.position, self.heading, self.width, self.length + ), z=self.position.z, name=name, additionalDeps=deps, diff --git a/src/scenic/core/requirements.py b/src/scenic/core/requirements.py index f143ae552..f816d429b 100644 --- a/src/scenic/core/requirements.py +++ b/src/scenic/core/requirements.py @@ -6,6 +6,8 @@ import inspect import itertools +import fcl +import numpy import rv_ltl import trimesh @@ -13,6 +15,7 @@ from scenic.core.errors import InvalidScenarioError from scenic.core.lazy_eval import needsLazyEvaluation from scenic.core.propositions import Atomic, PropositionNode +from scenic.core.utils import DefaultIdentityDict import scenic.syntax.relations as relations @@ -37,12 +40,13 @@ def constrainsSampling(self): class PendingRequirement: - def __init__(self, ty, condition, line, prob, name, ego): + def __init__(self, ty, condition, line, prob, name, ego, recConfig, scenario): self.ty = ty self.condition = condition self.line = line self.prob = prob self.name = name + self.recConfig = recConfig # the translator wrapped the requirement in a lambda to prevent evaluation, # so we need to save the current values of all referenced names; we save @@ -50,16 +54,25 @@ def __init__(self, ty, condition, line, prob, name, ego): # condition is an instance of Proposition. Flatten to get a list of atomic propositions. atoms = condition.atomics() - bindings = {} + self.globalBindings = {} # bindings to global/builtin names + self.closureBindings = {} # bindings to top-level closure variables + self.cells = [] # cells used in referenced closures atomGlobals = None for atom in atoms: - bindings.update(getAllGlobals(atom.closure)) + gbindings, cbindings, closures = getNameBindings(atom.closure) + self.globalBindings.update(gbindings) + self.closureBindings.update(cbindings) + for closure in closures: + self.cells.extend(closure.__closure__) globs = atom.closure.__globals__ if atomGlobals is not None: assert globs is atomGlobals else: atomGlobals = globs - self.bindings = bindings + for cell in self.cells: + # save current values of scenario locals (only needed for top-level scenario) + if cell.cell_contents is scenario: + cell.cell_contents = scenario._makeLocalsSnapshot() self.egoObject = ego def compile(self, namespace, scenario, syntax=None): @@ -68,21 +81,28 @@ def compile(self, namespace, scenario, syntax=None): While we're at it, determine whether the requirement implies any relations we can use for pruning, and gather all of its dependencies. """ - bindings, ego, line = self.bindings, self.egoObject, self.line + globalBindings, closureBindings = self.globalBindings, self.closureBindings + cells, ego, line = self.cells, self.egoObject, self.line condition, ty = self.condition, self.ty # Convert bound values to distributions as needed - for name, value in bindings.items(): - bindings[name] = toDistribution(value) + for name, value in globalBindings.items(): + globalBindings[name] = toDistribution(value) + for name, value in closureBindings.items(): + closureBindings[name] = toDistribution(value) + cells = tuple((cell, toDistribution(cell.cell_contents)) for cell in cells) + allBindings = dict(globalBindings) + allBindings.update(closureBindings) # Check whether requirement implies any relations used for pruning canPrune = condition.check_constrains_sampling() if canPrune: - relations.inferRelationsFrom(syntax, bindings, ego, line) + relations.inferRelationsFrom(syntax, allBindings, ego, line) # Gather dependencies of the requirement deps = set() - for value in bindings.values(): + cellVals = (value for cell, value in cells) + for value in itertools.chain(allBindings.values(), cellVals): if needsSampling(value): deps.add(value) if needsLazyEvaluation(value): @@ -93,7 +113,7 @@ def compile(self, namespace, scenario, syntax=None): # If this requirement contains the CanSee specifier, we will need to sample all objects # to meet the dependencies. - if "CanSee" in bindings: + if "CanSee" in globalBindings: deps.update(scenario.objects) if ego is not None: @@ -102,13 +122,18 @@ def compile(self, namespace, scenario, syntax=None): # Construct closure def closure(values, monitor=None): - # rebind any names referring to sampled objects + # rebind any names referring to sampled objects (for require statements, + # rebind all names, since we want their values at the time the requirement + # was created) # note: need to extract namespace here rather than close over value # from above because of https://github.com/uqfoundation/dill/issues/532 namespace = condition.atomics()[0].closure.__globals__ - for name, value in bindings.items(): - if value in values: + for name, value in globalBindings.items(): + if ty == RequirementType.require or value in values: namespace[name] = values[value] + for cell, value in cells: + cell.cell_contents = values[value] + # rebind ego object, which can be referred to implicitly boundEgo = None if ego is None else values[ego] # evaluate requirement condition, reporting errors on the correct line @@ -132,24 +157,34 @@ def closure(values, monitor=None): return CompiledRequirement(self, closure, deps, condition) -def getAllGlobals(req, restrictTo=None): +def getNameBindings(req, restrictTo=None): """Find all names the given lambda depends on, along with their current bindings.""" namespace = req.__globals__ if restrictTo is not None and restrictTo is not namespace: - return {} + return {}, {}, () externals = inspect.getclosurevars(req) - assert not externals.nonlocals # TODO handle these - globs = dict(externals.builtins) - for name, value in externals.globals.items(): - globs[name] = value - if inspect.isfunction(value): - subglobs = getAllGlobals(value, restrictTo=namespace) - for name, value in subglobs.items(): - if name in globs: - assert value is globs[name] - else: - globs[name] = value - return globs + globalBindings = externals.builtins + + closures = set() + if externals.nonlocals: + closures.add(req) + + def handleFunctions(bindings): + for value in bindings.values(): + if inspect.isfunction(value): + if value.__closure__ is not None: + closures.add(value) + subglobs, _, _ = getNameBindings(value, restrictTo=namespace) + for name, value in subglobs.items(): + if name in globalBindings: + assert value is globalBindings[name] + else: + globalBindings[name] = value + + globalBindings.update(externals.globals) + handleFunctions(externals.globals) + handleFunctions(externals.nonlocals) + return globalBindings, externals.nonlocals, closures class BoundRequirement: @@ -158,6 +193,7 @@ def __init__(self, compiledReq, sample, proposition): self.closure = compiledReq.closure self.line = compiledReq.line self.name = compiledReq.name + self.recConfig = compiledReq.recConfig self.sample = sample self.compiledReq = compiledReq self.proposition = proposition @@ -292,6 +328,8 @@ def violationMsg(self): class IntersectionRequirement(SamplingRequirement): + """Requirement that a pair of objects do not intersect.""" + def __init__(self, objA, objB, optional=False): super().__init__(optional=optional) self.objA = objA @@ -310,6 +348,16 @@ def violationMsg(self): class BlanketCollisionRequirement(SamplingRequirement): + """Requirement that the surfaces of a given set of objects do not intersect. + + We can check for such intersections more quickly than full objects using FCL, + but since FCL checks for surface intersections rather than volume intersections + (in general), this requirement being satisfied does not imply that the objects + do not intersect (one might still be contained in another). Therefore, this + requirement is intended to quickly check for intersections in the common case + rather than completely determine whether any objects collide. + """ + def __init__(self, objects, optional=True): super().__init__(optional=optional) self.objects = objects @@ -317,23 +365,32 @@ def __init__(self, objects, optional=True): def falsifiedByInner(self, sample): objects = tuple(sample[obj] for obj in self.objects) - cm = trimesh.collision.CollisionManager() + manager = fcl.DynamicAABBTreeCollisionManager() + objForGeom = {} for i, obj in enumerate(objects): - if not obj.allowCollisions: - cm.add_object(str(i), obj.occupiedSpace.mesh) - collision, names = cm.in_collision_internal(return_names=True) + if obj.allowCollisions: + continue + geom, trans = obj.occupiedSpace._fclData + collisionObject = fcl.CollisionObject(geom, trans) + objForGeom[geom] = obj + manager.registerObject(collisionObject) + + manager.setup() + cdata = fcl.CollisionData() + manager.collide(cdata, fcl.defaultCollisionCallback) + collision = cdata.result.is_collision if collision: - self._collidingObjects = tuple(sorted(names)) + contact = cdata.result.contacts[0] + self._collidingObjects = (objForGeom[contact.o1], objForGeom[contact.o2]) return collision @property def violationMsg(self): assert self._collidingObjects is not None - objA_index, objB_index = map(int, self._collidingObjects[0]) - objA, objB = self.objects[objA_index], self.objects[objB_index] - return f"Intersection violation: {objA} intersects {objB}" + objA, objB = self._collidingObjects + return f"Blanket Intersection violation: {objA} intersects {objB}" class ContainmentRequirement(SamplingRequirement): @@ -390,6 +447,7 @@ def __init__(self, pendingReq, closure, dependencies, proposition): self.line = pendingReq.line self.name = pendingReq.name self.prob = pendingReq.prob + self.recConfig = pendingReq.recConfig self.dependencies = dependencies self.proposition = proposition @@ -401,6 +459,10 @@ def falsifiedByInner(self, sample): one_time_monitor = self.proposition.create_monitor() return self.closure(sample, one_time_monitor) == rv_ltl.B4.FALSE + def evaluate(self): + # Used only for `terminate when`, etc. defined in setup blocks of subscenarios + return self.closure(DefaultIdentityDict()) + def __str__(self): if self.name: return self.name diff --git a/src/scenic/core/sample_checking.py b/src/scenic/core/sample_checking.py index 3a96826df..040d9a23a 100644 --- a/src/scenic/core/sample_checking.py +++ b/src/scenic/core/sample_checking.py @@ -106,7 +106,7 @@ def sortedRequirements(self): """Return the list of requirements in sorted order""" # Extract and sort active requirements reqs = [req for req in self.requirements if req.active] - reqs.sort(key=self.getWeightedAcceptanceProb) + reqs.sort(key=self.getRequirementCost) # Remove any optional requirements at the end of the list, since they're useless while reqs and reqs[-1].optional: @@ -131,6 +131,13 @@ def updateMetrics(self, req, new_metrics): sum_time += new_time - old_time self.bufferSums[req] = (sum_acc, sum_time) - def getWeightedAcceptanceProb(self, req): + def getRequirementCost(self, req): + # Expected cost of a requirement is average runtime divided by rejection probability; + # if estimated rejection probability is zero, break ties using runtime. sum_acc, sum_time = self.bufferSums[req] - return (sum_acc / self.bufferSize) * (sum_time / self.bufferSize) + runtime = sum_time / self.bufferSize + rej_prob = 1 - (sum_acc / self.bufferSize) + if rej_prob > 0: + return (runtime / rej_prob, 0) + else: + return (float("inf"), runtime) diff --git a/src/scenic/core/scenarios.py b/src/scenic/core/scenarios.py index 133911013..fa93d454b 100644 --- a/src/scenic/core/scenarios.py +++ b/src/scenic/core/scenarios.py @@ -495,8 +495,9 @@ def generateDefaultRequirements(self): requirements = [] ## Optional Requirements ## - # Any collision indicates an intersection - requirements.append(BlanketCollisionRequirement(self.objects)) + # Any collision between object surfaces indicates an intersection + if INITIAL_COLLISION_CHECK: + requirements.append(BlanketCollisionRequirement(self.objects)) ## Mandatory Requirements ## # Pairwise object intersection diff --git a/src/scenic/core/sensors.py b/src/scenic/core/sensors.py new file mode 100644 index 000000000..c4805ea10 --- /dev/null +++ b/src/scenic/core/sensors.py @@ -0,0 +1,333 @@ +"""Sensors which can gather and save data from simulations.""" + +import abc +from dataclasses import dataclass +import math +import os.path +import pickle +import sys +from typing import Literal, Tuple + +import PIL.Image +import cv2 +import numpy as np + + +class Sensor(abc.ABC): + @abc.abstractmethod + def getObservation(self): + raise NotImplementedError + + +class RGBSensor(Sensor, abc.ABC): + """Abstract RGB camera sensor. + + Args: + offset: Sensor position offset relative to the attached object (x, y, z). + rotation: Sensor rotation relative to the attached object (yaw, pitch, roll). + width: Output image width. + height: Output image height. + attributes: Simulator-specific options (dict). + + """ + + pass + + +class SSSensor(Sensor, abc.ABC): + """Abstract semantic segmentation camera sensor. + + Args: + offset: Sensor position offset relative to the attached object (x, y, z). + rotation: Sensor rotation relative to the attached object (yaw, pitch, roll). + width: Output image width. + height: Output image height. + attributes: Simulator-specific options (dict). + + """ + + pass + + +NO_OBSERVATION = object() + + +class CallbackSensor(Sensor): + def __init__(self, defaultValue=NO_OBSERVATION): + self._lastObservation = defaultValue + + def getObservation(self): + if self._lastObservation is NO_OBSERVATION: + raise RuntimeError( + f"{type(self).__name__} callback not called before first observation" + ) + + return self._lastObservation + + def onData(self, data): + self._lastObservation = self.process(data) + + @abc.abstractmethod + def process(self, data): + raise NotImplementedError + + +class GroundTruthSensor(Sensor): + def __init__(self, value): + self._value = value + + def getObservation(self): + return self._value() + + +# Recorders + + +@dataclass +class RecordingConfiguration: + name: str + period: Tuple[float, Literal["seconds", "steps"]] + delay: Tuple[float, Literal["seconds", "steps"]] + + recorder: "Optional[Recorder]" = None + + +class Recorder: + """A method of saving a :ref:`record ` to a file. + + Detailed documentation forthcoming. + """ + + def __init__(self): + self._recording = False + + def beginRecording(self, config, simulationName, timestep, globalParams, currentTime): + assert not self._recording + self._recording = True + self.simulationName = simulationName + self.recordName = config.name + self.timestep = timestep + self.globalParams = globalParams + + val, unit = config.period + if unit == "steps": + assert isinstance(val, int) and val >= 1, val + self._period = val + else: # unit == "seconds" + assert val > 0, val + self._period = max(1, math.floor(val / timestep)) + + val, unit = config.delay + assert val >= 0, val + if unit == "steps": + assert isinstance(val, int), val + delay = val + else: # unit == "seconds" + delay = max(0, math.floor(val / timestep)) + self._startTime = currentTime + delay + + def recordValue(self, value, step): + raise NotImplementedError + + def endRecording(self, canceled): + assert self._recording + self._recording = False + + def _record(self, value, step): + relativeTime = step - self._startTime + if relativeTime >= 0 and relativeTime % self._period == 0: + self.recordValue(np.asarray(value), step) + + @staticmethod + def _forPattern(pattern): + p0 = Recorder._formatPattern(pattern, "s", 0, 0) + p1 = Recorder._formatPattern(pattern, "s", 1, 1) + if p0 != p1: # pattern depends on step or time + return Files(pattern) + else: + return File(pattern) + + @staticmethod + def _formatPattern(pattern, simName, step, time): + return pattern.format( + simName, step, time, simulation=simName, step=step, time=time + ) + + def makePath(self, step, time): + path = self.pattern + if recordFolder := self.globalParams.get("recordFolder"): + path = os.path.join(recordFolder, path) + path = self._formatPattern(path, self.simulationName, step, time) + return path + + +class AccumulatingRecorder(Recorder): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._series = [] + + def recordTimeSeries(self, series): + raise NotImplementedError + + def recordValue(self, value, step): + self._series.append((step, value)) + + def endRecording(self, canceled): + if not canceled: + self.recordTimeSeries(self._series) + self._series.clear() + super().endRecording(canceled) + + +valueExtHandlers = {} #: Handlers for saving individual values to files, by extension +seriesExtHandlers = {} #: Handlers for saving time series to files, by extension + + +def prepareImageData(data, intOnly=False): + shape = data.shape + noColor = False + if len(shape) == 2: + noColor = True + elif len(shape) != 3 or shape[2] != 3: + raise TypeError(f"image data must be a 2D array of intensities or RGB triples") + dtype = data.dtype + kind = dtype.kind + if kind == "u" or kind == "i": + if np.min(data) < 0 or np.max(data) > 255: + raise ValueError(f"integer pixel data must be in the range 0-255") + return data.astype("uint8", copy=False) + elif kind == "f": + if np.min(data) < 0 or np.max(data) > 1: + raise ValueError(f"floating-point pixel data must be in the range 0-1") + if noColor and not intOnly: + return data.astype("float32", copy=False) + else: + # PIL doesn't support RGB floating-point, so round to integers + return np.round(data * 255).astype("uint8") + elif kind == "b": + if intOnly: + return (data * 255).astype("uint8") + else: + return data + else: + raise TypeError(f"cannot create image from data with dtype {dtype.name}") + + +def pilHandler(path, value, options): + image = PIL.Image.fromarray(prepareImageData(value)) + image.save(path, **options) + + +for ext, name in PIL.Image.registered_extensions().items(): + if name in PIL.Image.SAVE: + valueExtHandlers[ext[1:]] = pilHandler + + +def npyHandler(path, value, options): + np.save(path, value, **options) + + +valueExtHandlers["npy"] = npyHandler + + +def fileHandler(*exts): + def decorator(handler): + for ext in exts: + assert ext and not ext[0] == ".", ext + seriesExtHandlers[ext] = handler + return handler + + return decorator + + +@fileHandler("mkv", "mov", "mp4") +def videoHandler(path, values, timestep, options): + if not values: + return # empty time series + codec = options.get("codec") + if codec is None: + if sys.platform == "darwin": + # Pragmatic choice for wider playability; may switch to always using AV1 later + codec = "avc1" if path.endswith((".mp4", ".mov")) else "AV01" + else: + codec = "mp4v" + elif len(codec) != 4: + raise ValueError("video codec must be a 4-character string (FourCC)") + fourcc = cv2.VideoWriter_fourcc(*codec) + height, width = values[0][1].shape[:2] + writer = cv2.VideoWriter(path, fourcc, 1.0 / timestep, (width, height)) + try: + for step, value in values: + frame = cv2.cvtColor(prepareImageData(value, intOnly=True), cv2.COLOR_RGB2BGR) + writer.write(frame) + finally: + writer.release() + + +@fileHandler("npz") +def npzHandler(path, values, timestep, options): + timesteps, values = zip(*values) + np.savez_compressed(path, timesteps=timesteps, values=values) + + +@fileHandler("pickle") +def pickleHandler(path, values, timestep, options): + with open(path, "wb") as outFile: + pickle.dump(values, outFile, **options) + + +class File(AccumulatingRecorder): + def __init__(self, pattern, /, **options): + super().__init__() + self.pattern = pattern + _, ext = os.path.splitext(pattern) + handler = seriesExtHandlers.get(ext[1:]) + if handler is None: + if ext: + raise ValueError( + f'unknown file extension "{ext}" for recording a time series' + ) + handler = npzHandler + self.handler = handler + self.options = options + + def recordTimeSeries(self, series): + path = self.makePath("series", "series") + if dirname := os.path.dirname(path): + os.makedirs(dirname, exist_ok=True) + self.handler(path, series, self.timestep, self.options) + + +class Files(Recorder): + def __init__(self, pattern, /, **options): + super().__init__() + self.pattern = pattern + _, ext = os.path.splitext(pattern) + if not ext: + ext = ".npy" + self.pattern += ".npy" + self.handler = valueExtHandlers.get(ext[1:]) + if self.handler is None: + raise ValueError( + f'unknown file extension "{ext}" for recording a single value' + ) + self.options = options + self._paths = [] + + def recordValue(self, value, step): + time = step * self.timestep + path = self.makePath(step, time) + self._paths.append(path) + if dirname := os.path.dirname(path): + os.makedirs(dirname, exist_ok=True) + self.handler(path, value, self.options) + + def endRecording(self, canceled): + if canceled: + for path in self._paths: + try: + os.remove(path) + except FileNotFoundError: + pass + self._paths.clear() + super().endRecording(canceled) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index a7c52367a..580ffb32b 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -5,6 +5,7 @@ `Scenario.simulationToBytes`, and `Scene.dumpAsScenicCode`. """ +import hashlib import io import math import pickle @@ -14,6 +15,33 @@ from scenic.core.distributions import Samplable, needsSampling from scenic.core.utils import DefaultIdentityDict + +def deterministicHash(mapping, *, digest_size=8): + """Compute a deterministic hash for a mapping of options. + + Keys are sorted (by their string representation) and encoded with explicit + separators so that different key/value combinations do not collide under + simple concatenation. + + Only int/float/str (and bool, as a subclass of int) values are encoded directly; any other value is replaced + by a generic placeholder byte, to avoid nondeterminism from reprs containing + memory addresses or other run-specific data. + """ + hasher = hashlib.blake2b(digest_size=digest_size) + # Sort by stringified key so we can handle non-string keys deterministically. + for key in sorted(mapping.keys(), key=str): + hasher.update(b"\0K") + hasher.update(str(key).encode()) + hasher.update(b"\0V") + value = mapping[key] + if isinstance(value, (int, float, str)): + hasher.update(str(value).encode()) + else: + # Unsupported types just contribute a placeholder byte. + hasher.update(b"\0") + return hasher.digest() + + ## JSON @@ -126,7 +154,7 @@ def sceneFormatVersion(cls): Must be incremented if the `writeScene` method or any of its helper methods (e.g. `writeValue`) change, or if a new codec is added. """ - return 2 + return 3 @classmethod def replayFormatVersion(cls): diff --git a/src/scenic/core/shapes.py b/src/scenic/core/shapes.py index 2a57c1f37..0c50a22e4 100644 --- a/src/scenic/core/shapes.py +++ b/src/scenic/core/shapes.py @@ -1,8 +1,9 @@ -""" Module containing the Shape class and its subclasses, which represent shapes of Objects""" +"""Module containing the Shape class and its subclasses, which represent shapes of Objects""" from abc import ABC, abstractmethod import numpy +import shapely import trimesh from trimesh.transformations import ( concatenate_matrices, @@ -11,7 +12,7 @@ ) from scenic.core.type_support import toOrientation -from scenic.core.utils import cached_property, unifyMesh +from scenic.core.utils import cached_property, findMeshInteriorPoint, unifyMesh from scenic.core.vectors import Orientation ################################################################################################### @@ -64,6 +65,18 @@ def mesh(self): def isConvex(self): pass + @cached_property + def _circumradius(self): + return numpy.max(numpy.linalg.norm(self.mesh.vertices, axis=1)) + + @cached_property + def _interiorPoint(self): + return findMeshInteriorPoint(self.mesh) + + @cached_property + def _multipoint(self): + return shapely.multipoints(self.mesh.vertices) + ################################################################################################### # 3D Shape Classes diff --git a/src/scenic/core/simulators.py b/src/scenic/core/simulators.py index 8c0d51844..65ee0348d 100644 --- a/src/scenic/core/simulators.py +++ b/src/scenic/core/simulators.py @@ -34,6 +34,7 @@ from scenic.core.vectors import Vector + class SimulatorInterfaceWarning(UserWarning): """Warning indicating an issue with the interface to an external simulator.""" @@ -54,6 +55,11 @@ class DivergenceError(Exception): pass +class ObjectMissingInSimulation(Exception): + """Exception indicating that the corresponding object is not accesible as expected in the simulator instance""" + + pass + class Simulator(abc.ABC): """A simulator which can execute dynamic simulations from Scenic scenes. @@ -80,6 +86,7 @@ def simulate( maxIterations=1, *, timestep=None, + name=None, verbosity=None, raiseGuardViolations=False, replay=None, @@ -103,6 +110,8 @@ def simulate( default provided by the simulator interface. Some interfaces may not allow arbitrary time step lengths or may require the timestep to be set when creating the `Simulator` and not customized per-simulation. + name (str): Name of the simulation, if any. Used to identify the simulation + when saving records to files and in debugging messages. verbosity (int): If not `None`, override Scenic's global verbosity level (from the :option:`--verbosity` option or `scenic.setDebuggingOptions`). raiseGuardViolations (bool): Whether violations of preconditions/invariants @@ -175,10 +184,14 @@ def simulate( simulation = None while not simulation and (maxIterations is None or iterations < maxIterations): iterations += 1 + if maxIterations == 1: + simName = str(name) if name else "1" + else: + simName = f"{name}.{iterations}" if name else str(iterations) simulation = self._runSingleSimulation( scene, maxSteps, - name=iterations, + name=simName, verbosity=verbosity, timestep=timestep, raiseGuardViolations=raiseGuardViolations, @@ -331,6 +344,7 @@ def __init__( continueAfterDivergence=False, verbosity=0, ): + self.screen = None self.result = None self.scene = scene self.objects = [] @@ -371,16 +385,11 @@ def __init__( # Run the simulation. terminationType, terminationReason = self._run(dynamicScenario, maxSteps) - # Stop all remaining scenarios. - # (and reject if some 'require eventually' condition was never satisfied) + # Stop all remaining scenarios (and handle their `record final` statements; + # also reject if some `require eventually` condition was never satisfied). for scenario in tuple(reversed(veneer.runningScenarios)): scenario._stop("simulation terminated") - # Record finally-recorded values. - values = dynamicScenario._evaluateRecordedExprs(RequirementType.recordFinal) - for name, val in values.items(): - self.records[name] = val - # Package up simulation results into a compact object. result = SimulationResult( self.trajectory, @@ -423,8 +432,16 @@ def _run(self, dynamicScenario, maxSteps): terminationReason = dynamicScenario._step() terminationType = TerminationType.scenarioComplete + # Update observations of objects with sensors + for obj in self.objects: + if not obj.sensors: + continue + obj.observations.update( + {key: sensor.getObservation() for key, sensor in obj.sensors.items()} + ) + # Record current state of the simulation - self.recordCurrentState() + self._recordCurrentState() # Run monitors newReason = dynamicScenario._runMonitors() @@ -432,6 +449,13 @@ def _run(self, dynamicScenario, maxSteps): terminationReason = newReason terminationType = TerminationType.terminatedByMonitor + # Check if users manually closed out display for simulator + if "Dead" in str(self.screen): + return ( + TerminationType.terminatedByUser, + "user manually terminated simulation", + ) + # "Always" and scenario-level requirements have been checked; # now safe to terminate if the top-level scenario has finished, # a monitor requested termination, or we've hit the timeout @@ -571,22 +595,18 @@ def createObjectInSimulator(self, obj): """ raise NotImplementedError - def recordCurrentState(self): - dynamicScenario = self.scene.dynamicScenario - records = self.records + def _recordCurrentState(self): + # Record values of `record initial` and `record` statements. + # (calls _record and _recordTimeSeries below) + self.scene.dynamicScenario._updateRecords() - # Record initially-recorded values - if self.currentTime == 0: - values = dynamicScenario._evaluateRecordedExprs(RequirementType.recordInitial) - for name, val in values.items(): - records[name] = val + self.trajectory.append(self.currentState()) - # Record time-series values - values = dynamicScenario._evaluateRecordedExprs(RequirementType.record) - for name, val in values.items(): - records[name].append((self.currentTime, val)) + def _record(self, name, value): + self.records[name] = value - self.trajectory.append(self.currentState()) + def _recordTimeSeries(self, name, value): + self.records[name].append((self.currentTime, value)) def replayCanContinue(self): if not self.replaying: @@ -887,6 +907,9 @@ class TerminationType(enum.Enum): #: A :term:`dynamic behavior` used :keyword:`terminate simulation` to end the simulation. terminatedByBehavior = "a behavior terminated the simulation" + #: A user manually intervenes and closes display window + terminatedByUser = "manually terminated by user" + class SimulationResult: """Result of running a simulation. diff --git a/src/scenic/core/utils.py b/src/scenic/core/utils.py index 4549afdad..d2a4c1a72 100644 --- a/src/scenic/core/utils.py +++ b/src/scenic/core/utils.py @@ -13,6 +13,7 @@ import typing import warnings import weakref +import random import numpy import trimesh @@ -307,6 +308,48 @@ def repairMesh(mesh, pitch=(1 / 2) ** 6, verbose=True): raise ValueError("Mesh could not be repaired.") +def findMeshInteriorPoint(mesh, num_samples=None): + # Use center of mass if it's contained + com = mesh.bounding_box.center_mass + if mesh.contains([com])[0]: + return com + + # Try sampling a point inside the volume + if num_samples is None: + p_volume = mesh.volume / mesh.bounding_box.volume + if p_volume > 0.99: + num_samples = 1 + else: + num_samples = math.ceil(min(1e6, max(1, math.log(0.01, 1 - p_volume)))) + + # Do the "random" number generation ourselves so that it's deterministic + # (this helps debugging and reproducibility) + rng = numpy.random.default_rng(49493130352093220597973654454967996892) + pts = (rng.random((num_samples, 3)) * mesh.extents) + mesh.bounds[0] + samples = pts[mesh.contains(pts)] + if samples.size > 0: + return samples[0] + + # If all else fails, take a point from the surface and move inward + surfacePt, index = list(zip(*mesh.sample(1, return_index=True)))[0] + inward = -mesh.face_normals[index] + startPt = surfacePt + 1e-6 * inward + hits, _, _ = mesh.ray.intersects_location( + ray_origins=[startPt], + ray_directions=[inward], + multiple_hits=False, + ) + if hits.size > 0: + endPt = hits[0] + midPt = (surfacePt + endPt) / 2.0 + if mesh.contains([midPt])[0]: + return midPt + + # Should never get here with reasonable geometry, but we return a surface + # point just in case. + return surfacePt # pragma: no cover + + class DefaultIdentityDict: """Dictionary which is the identity map by default. @@ -351,3 +394,9 @@ def get_type_hints(obj, globalns=None, localns=None): wrapped = wrapped.__wrapped__ globalns = getattr(wrapped, "__globals__", {}) return typing.get_type_hints(obj, globalns, localns) + + +def setSeed(seed): + """Set the random seed used by Scenic""" + random.seed(seed) + numpy.random.seed(seed) \ No newline at end of file diff --git a/src/scenic/core/vectors.py b/src/scenic/core/vectors.py index 0ec044df8..c4ae11f48 100644 --- a/src/scenic/core/vectors.py +++ b/src/scenic/core/vectors.py @@ -10,6 +10,7 @@ import numbers import random import struct +import sys import typing import warnings @@ -43,15 +44,6 @@ ) from scenic.core.utils import argsToString, cached_property -# Suppress gimbal lock warning from scipy.spatial.transform.Rotation. -# N.B. due to the way the pytest works, the warning will still appear when -# running the test suite. -warnings.filterwarnings( - "ignore", - message="Gimbal lock detected. Setting third angle to zero", - module="scenic.core.vectors", -) - class VectorDistribution(Distribution): """A distribution over Vectors.""" @@ -309,10 +301,10 @@ def _canCoerceType(ty): @cached_property def eulerAngles(self) -> typing.Tuple[float, float, float]: """Global intrinsic Euler angles yaw, pitch, roll.""" - return self.r.as_euler("ZXY", degrees=False) + return _getEulerAngles(self.r, "ZXY") def _trimeshEulerAngles(self): - return self.r.as_euler("xyz", degrees=False) + return _getEulerAngles(self.r, "xyz") def getRotation(self): return self.r @@ -408,6 +400,17 @@ def decodeFrom(cls, stream): return cls(rotation) +if sys.version_info >= (3, 11): + + def _getEulerAngles(rotation, axes, degrees=False): + return rotation.as_euler(axes, degrees=degrees, suppress_warnings=True) + +else: + + def _getEulerAngles(rotation, axes, degrees=False): + return rotation.as_euler(axes, degrees=degrees) + + globalOrientation = Orientation.fromEuler(0, 0, 0) Orientation.__mul__ = distributionMethod(Orientation.__mul__, identity=globalOrientation) diff --git a/src/scenic/domains/driving/__init__.py b/src/scenic/domains/driving/__init__.py index bff86a5b9..b89c1c54c 100644 --- a/src/scenic/domains/driving/__init__.py +++ b/src/scenic/domains/driving/__init__.py @@ -12,6 +12,7 @@ Scenarios written for the driving domain should work without changes [#f1]_ in any of the following simulators: + * MetaDrive, using the model :doc:`scenic.simulators.metadrive.model` * CARLA, using the model :doc:`scenic.simulators.carla.model` * LGSVL, using the model :doc:`scenic.simulators.lgsvl.model` * the built-in Newtonian simulator, using the model @@ -34,6 +35,14 @@ $ scenic -S --model scenic.simulators.newtonian.driving_model \\ examples/driving/badlyParkedCarPullingIn.scenic + * MetaDrive, using the corresponding SUMO map (.net.xml) from the default map specified in + the scenario: + + .. code-block:: console + + $ scenic -S --2d --model scenic.simulators.metadrive.model \\ + examples/driving/badlyParkedCarPullingIn.scenic + * CARLA, using the default map specified in the scenario: .. code-block:: console diff --git a/src/scenic/domains/driving/actions.py b/src/scenic/domains/driving/actions.py index 6649fe79a..7f99cce1a 100644 --- a/src/scenic/domains/driving/actions.py +++ b/src/scenic/domains/driving/actions.py @@ -122,6 +122,8 @@ def canBeTakenBy(self, agent): class SetThrottleAction(SteeringAction): """Set the throttle. + The throttle setting will remain constant until this action is taken again. + Arguments: throttle: Throttle value between 0 and 1. """ @@ -138,8 +140,10 @@ def applyTo(self, obj, sim): class SetSteerAction(SteeringAction): """Set the steering 'angle'. + The steering setting will remain constant until this action is taken again. + Arguments: - steer: Steering 'angle' between -1 and 1. + steer: Steering 'angle' between -1 and 1. Positive values steer to the right. """ def __init__(self, steer: float): @@ -154,6 +158,8 @@ def applyTo(self, obj, sim): class SetBrakeAction(SteeringAction): """Set the amount of brake. + The brake setting will remain constant until this action is taken again. + Arguments: brake: Amount of braking between 0 and 1. """ @@ -170,6 +176,8 @@ def applyTo(self, obj, sim): class SetHandBrakeAction(SteeringAction): """Set or release the hand brake. + The handbrake setting will remain constant until this action is taken again. + Arguments: handBrake: Whether or not the hand brake is set. """ @@ -186,6 +194,8 @@ def applyTo(self, obj, sim): class SetReverseAction(SteeringAction): """Engage or release reverse gear. + The reverse setting will remain constant until this action is taken again. + Arguments: reverse: Whether or not the car is in reverse. """ diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index 1abac6f95..173500190 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -40,11 +40,11 @@ behavior ConstantThrottleBehavior(x): take SetThrottleAction(x) behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTraffic=False): - """ + """ Follow's the lane on which the vehicle is at, unless the laneToFollow is specified. Once the vehicle reaches an intersection, by default, the vehicle will take the straight route. - If straight route is not available, then any availble turn route will be taken, uniformly randomly. - If turning at the intersection, the vehicle will slow down to make the turn, safely. + If straight route is not available, then any availble turn route will be taken, uniformly randomly. + If turning at the intersection, the vehicle will slow down to make the turn, safely. This behavior does not terminate. A recommended use of the behavior is to accompany it with condition, e.g. do FollowLaneBehavior() until ... @@ -52,7 +52,7 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTra :param target_speed: Its unit is in m/s. By default, it is set to 10 m/s :param laneToFollow: If the lane to follow is different from the lane that the vehicle is on, this parameter can be used to specify that lane. By default, this variable will be set to None, which means that the vehicle will follow the lane that it is currently on. """ - + past_steer_angle = 0 past_speed = 0 # making an assumption here that the agent starts from zero speed if laneToFollow is None: @@ -75,7 +75,7 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTra nearby_intersection = current_lane.centerline[-1] else: nearby_intersection = current_lane.centerline[-1] - + # instantiate longitudinal and lateral controllers _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) @@ -127,7 +127,7 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTra if (end_lane is not None) and (self.position in end_lane) and not intersection_passed: intersection_passed = True in_turning_lane = False - entering_intersection = False + entering_intersection = False target_speed = original_target_speed _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) @@ -151,11 +151,11 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTra behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_speed=None): - """ + """ Follows the given trajectory. The behavior terminates once the end of the trajectory is reached. :param target_speed: Its unit is in m/s. By default, it is set to 10 m/s - :param trajectory: It is a list of sequential lanes to track, from the lane that the vehicle is initially on to the lane it should end up on. + :param trajectory: It is a list of sequential lanes to track, from the lane that the vehicle is initially on to the lane it should end up on. """ assert trajectory is not None @@ -172,7 +172,7 @@ behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_spe # instantiate longitudinal and lateral controllers _lon_controller,_lat_controller = simulation().getLaneFollowingControllers(self) past_steer_angle = 0 - + if trajectory[-1].maneuvers: end_intersection = trajectory[-1].maneuvers[0].intersection if end_intersection == None: @@ -209,8 +209,8 @@ behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_spe behavior TurnBehavior(trajectory, target_speed=6): """ This behavior uses a controller specifically tuned for turning at an intersection. - This behavior is only operational within an intersection, - it will terminate if the vehicle is outside of an intersection. + This behavior is only operational within an intersection, + it will terminate if the vehicle is outside of an intersection. """ if isinstance(trajectory, PolylineRegion): @@ -276,7 +276,7 @@ behavior LaneChangeBehavior(laneSectionToSwitch, is_oppositeTraffic=False, targe while True: if abs(trajectory_centerline.signedDistanceTo(self.position)) < 0.1: - break + break if (distance from self to nearby_intersection) < distanceToEndpoint: straight_manuevers = filter(lambda i: i.type == ManeuverType.STRAIGHT, current_lane.maneuvers) @@ -304,7 +304,7 @@ behavior LaneChangeBehavior(laneSectionToSwitch, is_oppositeTraffic=False, targe current_speed = 0 cte = trajectory_centerline.signedDistanceTo(self.position) - if is_oppositeTraffic: # [bypass] when crossing over the yellowline to opposite traffic lane + if is_oppositeTraffic: # [bypass] when crossing over the yellowline to opposite traffic lane cte = -cte speed_error = target_speed - current_speed diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index 988d03072..40191b14b 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -43,6 +43,7 @@ from scenic.domains.driving.roads import (ManeuverType, Network, Road, Lane, Lan from scenic.domains.driving.actions import * from scenic.domains.driving.behaviors import * +from scenic.core.sensors import Sensor from scenic.core.distributions import RejectionException from scenic.simulators.utils.colors import Color @@ -325,6 +326,22 @@ class Pedestrian(DrivingObject): length: 0.75 color: [0, 0.5, 1] +## Stub sensor implementations + +class SSSensor(Sensor): + def __init__(self, *args, **kwargs): + pass + + def getObservation(self): + raise RuntimeError("This simulator does not support SSSensor") + +class RGBSensor(Sensor): + def __init__(self, *args, **kwargs): + pass + + def getObservation(self): + raise RuntimeError("This simulator does not support RGBSensor") + ## Utility functions def withinDistanceToAnyCars(car, thresholdDistance): @@ -351,6 +368,18 @@ def withinDistanceToAnyObjs(vehicle, thresholdDistance): return True return False +def withinDistanceToAnyPedestrians(vehicle, thresholdDistance): + """Returns True if any visible pedestrian is within thresholdDistance of the given vehicle.""" + objects = simulation().objects + for obj in objects: + if obj is vehicle or not isinstance(obj, Pedestrian): + continue + if not (vehicle can see obj): + continue + if (distance from obj to front of vehicle) < thresholdDistance: + return True + return False + def withinDistanceToObjsInLane(vehicle, thresholdDistance): """ checks whether there exists any obj (1) in front of the vehicle, (2) on the same lane, (3) within thresholdDistance """ diff --git a/src/scenic/domains/driving/roads.py b/src/scenic/domains/driving/roads.py index 48289f0c8..dba85d79e 100644 --- a/src/scenic/domains/driving/roads.py +++ b/src/scenic/domains/driving/roads.py @@ -37,6 +37,7 @@ import scenic.core.geometry as geometry from scenic.core.object_types import Point from scenic.core.regions import PolygonalRegion, PolylineRegion +from scenic.core.serialization import deterministicHash import scenic.core.type_support as type_support import scenic.core.utils as utils from scenic.core.vectors import Orientation, Vector, VectorField @@ -586,7 +587,7 @@ class RoadSection(LinearElement): forwardLanes: Tuple[LaneSection] = () # as above backwardLanes: Tuple[LaneSection] = () # as above - lanesByOpenDriveID: Dict[LaneSection] + lanesByOpenDriveID: dict[LaneSection] def __attrs_post_init__(self): super().__attrs_post_init__() @@ -844,7 +845,7 @@ class Network: """ #: All network elements, indexed by unique ID. - elements: Dict[str, NetworkElement] + elements: dict[str, NetworkElement] #: All ordinary roads in the network (i.e. those not part of an intersection). roads: Tuple[Road] @@ -956,6 +957,11 @@ def __attrs_post_init__(self): # Build R-tree for faster lookup of roads, etc. at given points self._uidForIndex = tuple(self.elements) self._rtree = shapely.STRtree([elem.polygons for elem in self.elements.values()]) + self._nominalDirElems = self.intersections + self.roads + self.shoulders + self._topLevelElements = ( + self.intersections + self.roads + self.shoulders + self.sidewalks + ) + # NOTE: Order matters here; elementAt resolves in this sequence. def _defaultRoadDirection(self, point): """Default value for the `roadDirection` vector field. @@ -963,8 +969,8 @@ def _defaultRoadDirection(self, point): :meta private: """ point = _toVector(point) - road = self.roadAt(point) - return 0 if road is None else road.orientation[point] + elem = self.findPointIn(point, self._nominalDirElems, reject=False) + return elem.orientation[point] if elem is not None else 0 #: File extension for cached versions of processed networks. pickledExt = ".snet" @@ -981,7 +987,7 @@ def _currentFormatVersion(cls): :meta private: """ - return 33 + return 35 class DigestMismatchError(Exception): """Exception raised when loading a cached map not matching the original file.""" @@ -1049,21 +1055,34 @@ def fromFile(cls, path, useCache: bool = True, writeCache: bool = True, **kwargs data = f.read() digest = hashlib.blake2b(data).digest() + # Hash the map options as well so changing them invalidates the cache. + optionsDigest = deterministicHash(kwargs, digest_size=8) + # By default, use the pickled version if it exists and is not outdated pickledPath = path.with_suffix(cls.pickledExt) if useCache and pickledPath.exists(): try: - return cls.fromPickle(pickledPath, originalDigest=digest) + return cls.fromPickle( + pickledPath, + originalDigest=digest, + optionsDigest=optionsDigest, + ) except pickle.UnpicklingError: verbosePrint("Unable to load cached network (old format or corrupted).") except cls.DigestMismatchError: - verbosePrint("Cached network does not match original file; ignoring it.") + verbosePrint( + "Cached network does not match original file or map options; ignoring it." + ) # Not using the pickled version; parse the original file based on its extension network = handlers[ext](path, **kwargs) if writeCache: verbosePrint(f"Caching road network in {cls.pickledExt} file.") - network.dumpPickle(path.with_suffix(cls.pickledExt), digest) + network.dumpPickle( + path.with_suffix(cls.pickledExt), + digest, + optionsDigest=optionsDigest, + ) return network @classmethod @@ -1107,11 +1126,12 @@ def fromOpenDrive( return network @classmethod - def fromPickle(cls, path, originalDigest=None): + def fromPickle(cls, path, originalDigest=None, optionsDigest=None): startTime = time.time() verbosePrint("Loading cached version of road network...") with open(path, "rb") as f: + # Version field versionField = f.read(4) if len(versionField) != 4: raise pickle.UnpicklingError(f"{cls.pickledExt} file is corrupted") @@ -1121,6 +1141,8 @@ def fromPickle(cls, path, originalDigest=None): f"{cls.pickledExt} file is too old; " "regenerate it from the original map" ) + + # Digest of the original map file digest = f.read(64) if len(digest) != 64: raise pickle.UnpicklingError(f"{cls.pickledExt} file is corrupted") @@ -1129,6 +1151,18 @@ def fromPickle(cls, path, originalDigest=None): f"{cls.pickledExt} file does not correspond to the original map; " " regenerate it" ) + + # Digest of the map options used to generate this cache + cachedOptionsDigest = f.read(8) + if len(cachedOptionsDigest) != 8: + raise pickle.UnpicklingError(f"{cls.pickledExt} file is corrupted") + if optionsDigest and optionsDigest != cachedOptionsDigest: + raise cls.DigestMismatchError( + f"{cls.pickledExt} file does not correspond to the current " + "map options; regenerate it" + ) + + # Remaining bytes are the compressed pickle of the Network with gzip.open(f) as gf: try: network = pickle.load(gf) # invokes __setstate__ below @@ -1162,15 +1196,19 @@ def reconnect(thing): for maneuver in elem.maneuvers: reconnect(maneuver) - def dumpPickle(self, path, digest): + def dumpPickle(self, path, digest, optionsDigest): path = pathlib.Path(path) if not path.suffix: path = path.with_suffix(self.pickledExt) version = struct.pack(" Union[NetworkElement, None]: """Get the highest-level `NetworkElement` at a given point, if any. - If the point lies in an `Intersection`, we return that; otherwise if the point - lies in a `Road`, we return that; otherwise we return :obj:`None`, or reject the - simulation if **reject** is true (default false). + If the point lies in an element, return it. + Otherwise, if the point lies within ``self.tolerance`` of an element, return the first + match using this priority order: Intersection → Road → Shoulder → Sidewalk. + If nothing matches, return `None` (or reject if ``reject=True``). """ point = _toVector(point) - intersection = self.intersectionAt(point) - if intersection is not None: - return intersection - return self.roadAt(point, reject=reject) + return self.findPointIn(point, self._topLevelElements, reject) @distributionMethod def roadAt(self, point: Vectorlike, reject=False) -> Union[Road, None]: @@ -1280,6 +1316,16 @@ def intersectionAt( """Get the `Intersection` at a given point.""" return self.findPointIn(point, self.intersections, reject) + @distributionMethod + def sidewalkAt(self, point: Vectorlike, reject=False) -> Union[Sidewalk, None]: + """Get the `Sidewalk` at a given point.""" + return self.findPointIn(point, self.sidewalks, reject) + + @distributionMethod + def shoulderAt(self, point: Vectorlike, reject=False) -> Union[Shoulder, None]: + """Get the `Shoulder` at a given point.""" + return self.findPointIn(point, self.shoulders, reject) + @distributionMethod def nominalDirectionsAt(self, point: Vectorlike, reject=False) -> Tuple[Orientation]: """Get the nominal traffic direction(s) at a given point, if any. @@ -1287,15 +1333,10 @@ def nominalDirectionsAt(self, point: Vectorlike, reject=False) -> Tuple[Orientat There can be more than one such direction in an intersection, for example: a car at a given point could be going straight, turning left, etc. """ - inter = self.intersectionAt(point) - if inter is not None: - return inter.nominalDirectionsAt(point) - road = self.roadAt(point, reject=reject) - if road is not None: - return road.nominalDirectionsAt(point) - return () + elem = self.findPointIn(point, self._nominalDirElems, reject) + return elem.nominalDirectionsAt(point) if elem is not None else () - def show(self, labelIncomingLanes=False): + def show(self, labelIncomingLanes=False, showCurbArrows=False, bubble_roads=None, ego_position=None, metsr_road_lines=None): """Render a schematic of the road network for debugging. If you call this function directly, you'll need to subsequently call @@ -1304,13 +1345,19 @@ def show(self, labelIncomingLanes=False): Args: labelIncomingLanes (bool): Whether to label the incoming lanes of intersections with their indices in ``incomingLanes``. + showCurbArrows (bool): Whether to draw arrows along the curb to show orientation """ import matplotlib.pyplot as plt + from matplotlib.patches import Circle self.walkableRegion.show(plt, style="-", color="#00A0FF") self.shoulderRegion.show(plt, style="-", color="#606060") for road in self.roads: road.show(plt, style="r-") + if road in bubble_roads: + color="black" + else: + color="yellow" for lane in road.lanes: # will loop only over lanes of main roads lane.leftEdge.show(plt, style="r--") lane.rightEdge.show(plt, style="r--") @@ -1333,10 +1380,35 @@ def show(self, labelIncomingLanes=False): headlength=4.5, scale=0.06, units="dots", - color="#A0A0A0", + color=color, ) + + if showCurbArrows: + for lg in road.laneGroups: + curb = lg.curb + if curb.length >= 40: + cpts = curb.pointsSeparatedBy(20) + else: + cpts = [curb.pointAlongBy(0.5, normalized=True)] + chs = [curb.orientation[pt].yaw for pt in cpts] + cx, cy, _ = zip(*cpts) + cu = [math.cos(h + (math.pi / 2)) for h in chs] + cv = [math.sin(h + (math.pi / 2)) for h in chs] + plt.quiver( + cx, + cy, + cu, + cv, + pivot="middle", + headlength=4.5, + scale=0.06, + units="dots", + color="#606060", + ) + for lane in self.lanes: # draw centerlines of all lanes (including connecting) - lane.centerline.show(plt, style=":", color="#A0A0A0") + color = "#A0A0A0" if lane.road not in bubble_roads else "m" + lane.centerline.show(plt, style=":", color=color) self.intersectionRegion.show(plt, style="g") if labelIncomingLanes: for intersection in self.intersections: @@ -1344,3 +1416,37 @@ def show(self, labelIncomingLanes=False): x, y, _ = lane.centerline[-1] plt.plot([x], [y], "*b") plt.annotate(str(i), (x, y)) + + ax = plt.gca() + for attr in ["ego_circle", "ego_point"]: + if hasattr(self, attr): + getattr(self, attr).remove() + + if hasattr(self, "road_points"): + [p.remove() for p in self.road_points] + + if ego_position: + x, y = ego_position[:2] + + self.ego_point, = plt.plot( + x, y, + marker='o', + color='#ff00ff', + markersize=10 + ) + + self.ego_circle = Circle((x, y), 100, fill=False, color='#ff00ff') + ax.add_patch(self.ego_circle) + + self.road_points = [ + plt.plot( + line[0], line[1], + marker='o', + color='#dfff00', + markersize=6 + )[0] + for road in metsr_road_lines or [] + for line in road + ] # point + + diff --git a/src/scenic/formats/opendrive/xodr_parser.py b/src/scenic/formats/opendrive/xodr_parser.py index e01d9c49d..f7d7628de 100644 --- a/src/scenic/formats/opendrive/xodr_parser.py +++ b/src/scenic/formats/opendrive/xodr_parser.py @@ -38,6 +38,20 @@ def buffer_union(polys, tolerance=0.01): return polygonUnion(polys, buf=tolerance, tolerance=tolerance) +def separate(polyA, polyB): + if not polyA.overlaps(polyB): + return polyA, polyB + + # Shrink the larger polygon to try to avoid losing fiddly bits + if polyA.area >= polyB.area: + polyA = polyA.difference(polyB).buffer(-1e-6) + else: + polyB = polyB.difference(polyA).buffer(-1e-6) + + assert not polyA.overlaps(polyB) + return polyA, polyB + + class Poly3: """Cubic polynomial.""" @@ -152,7 +166,10 @@ def arclength(self, p): def point_at(self, s): root_func = lambda x: self.arclength(x) - s - p = float(brentq(root_func, 0, self.p_range)) + # we expand the upper bound slightly to ensure a zero is bracketed even + # with numerical error + p = float(brentq(root_func, 0, self.p_range + 1e-9)) + p = min(p, self.p_range) pt = (self.u_poly.eval_at(p), self.v_poly.eval_at(p), s) return self.rel_to_abs(pt) @@ -166,7 +183,7 @@ def __init__(self, x0, y0, hdg, length, curv0, curv1): # Initial and final curvature. self.curv0 = curv0 self.curv1 = curv1 - self.curve_rate = (curv1 - curv0) / length + self.curve_rate = 0 if length == 0 else (curv1 - curv0) / length self.a = abs(curv0) self.r = 1 / self.a if curv0 != 0 else 1 # value not used if curv0 == 0 self.ode_init = np.array([x0, y0, hdg]) @@ -212,6 +229,59 @@ def point_at(self, s): return self.rel_to_abs((s, 0, s)) +def makeCurve(x0, y0, hdg, length, curve_elem): + """Create a reference-line curve from an OpenDRIVE geometry element. + + The given length is the effective length Scenic should use, which may + differ from the length declared in the map if the planView is inconsistent. + """ + suspicion = None + if curve_elem.tag == "line": + curve = Line(x0, y0, hdg, length) + elif curve_elem.tag == "arc": + # Arc is clothoid of constant curvature. + curv = float(curve_elem.get("curvature")) + curve = Clothoid(x0, y0, hdg, length, curv, curv) + elif curve_elem.tag == "spiral": + curv0 = float(curve_elem.get("curvStart")) + curv1 = float(curve_elem.get("curvEnd")) + if length and abs(curv0 - curv1) / length > 50: + suspicion = f"spiral with very high curvature rate (length {length:.3g})" + curve = Clothoid(x0, y0, hdg, length, curv0, curv1) + elif curve_elem.tag == "poly3": + a, b, c, d = ( + float(curve_elem.get("a")), + float(curve_elem.get("b")), + float(curve_elem.get("c")), + float(curve_elem.get("d")), + ) + curve = Cubic(x0, y0, hdg, length, a, b, c, d) + elif curve_elem.tag == "paramPoly3": + au, bu, cu, du, av, bv, cv, dv = ( + float(curve_elem.get("aU")), + float(curve_elem.get("bU")), + float(curve_elem.get("cU")), + float(curve_elem.get("dU")), + float(curve_elem.get("aV")), + float(curve_elem.get("bV")), + float(curve_elem.get("cV")), + float(curve_elem.get("dV")), + ) + p_range = curve_elem.get("pRange") + if p_range and p_range != "normalized": + # TODO support arcLength + raise NotImplementedError("unsupported pRange for paramPoly3") + else: + p_range = 1 + curve = ParamCubic(x0, y0, hdg, length, au, bu, cu, du, av, bv, cv, dv, p_range) + else: + raise NotImplementedError(f'unhandled OpenDRIVE geometry type "{curve_elem.tag}"') + + if length < 1e-6: + suspicion = f"geometry of length {length:.3g}" + return curve, suspicion + + class Lane: def __init__(self, id_, type_, pred=None, succ=None): self.id_ = id_ @@ -222,6 +292,7 @@ def __init__(self, id_, type_, pred=None, succ=None): self.left_bounds = [] # to be filled in later self.right_bounds = [] self.centerline = [] + self.poly = None self.parent_lane_poly = None def width_at(self, s): @@ -232,8 +303,8 @@ def width_at(self, s): assert self.width[ind][1] <= s, "No matching width entry found." w_poly, s_off = self.width[ind] w = w_poly.eval_at(s - s_off) - if w < -1e-6: # allow for numerical error - raise RuntimeError("OpenDRIVE lane has negative width") + if w < -1e-3: # allow for numerical error + warn("OpenDRIVE lane has negative width; clamping to zero") return max(w, 0) @@ -337,7 +408,6 @@ def __init__(self, name, id_, length, junction, drive_on_right=True): # List of lane polygons. Not a dict b/c lane id is not unique along road. self.lane_polys = [] # Each polygon in lane_polys is the union of connected lane section polygons. - # lane_polys is currently not used. # Reference line offset: self.offset = [] # List of tuple (Poly3, s-coordinate). self.drive_on_right = drive_on_right @@ -368,13 +438,15 @@ def get_ref_points(self, num): transition_points = [sec.s0 for sec in self.lane_secs[1:]] last_s = 0 for piece in self.ref_line: - piece_points = piece.to_points(num, extra_points=transition_points) + target_transition_points = [ + s - last_s for s in transition_points if s >= last_s + ] + piece_points = piece.to_points(num, extra_points=target_transition_points) assert piece_points, "Failed to get piece points" - if ref_points: - last_s = ref_points[-1][-1][2] - piece_points = [(p[0], p[1], p[2] + last_s) for p in piece_points] + piece_points = [(p[0], p[1], p[2] + last_s) for p in piece_points] ref_points.append(piece_points) - transition_points = [s - last_s for s in transition_points if s > last_s] + last_s = piece_points[-1][2] + return ref_points def heading_at(self, point): @@ -463,21 +535,29 @@ def calc_geometry_for_type(self, lane_types, num, tolerance, calc_gap=False): bounds = left + right if len(bounds) < 3: + warn( + f"road {self.id_} section {i} lane {id_} is " + "entirely zero-width; skipping it" + ) continue - poly = cleanPolygon(Polygon(bounds), tolerance) - if not poly.is_empty: - if poly.geom_type == "MultiPolygon": - poly = MultiPolygon( - [ - p - for p in poly.geoms - if not p.is_empty and p.exterior - ] + poly = Polygon(bounds) + if not poly.is_valid: + poly = cleanPolygon(poly, tolerance) + if poly.is_empty: + warn( + f"could not generate polygon for road {self.id_} " + f"section {i} lane {id_}; skipping it" ) - cur_sec_polys.extend(poly.geoms) - else: - cur_sec_polys.append(poly) - cur_sec_lane_polys[id_].append(poly) + continue + assert not poly.is_empty + if poly.geom_type == "MultiPolygon": + poly = MultiPolygon( + [p for p in poly.geoms if not p.is_empty and p.exterior] + ) + cur_sec_polys.extend(poly.geoms) + else: + cur_sec_polys.append(poly) + cur_sec_lane_polys[id_].append(poly) cur_last_lefts[id_] = left_bounds[id_][-1] cur_last_rights[id_] = right_bounds[id_][-1] if i == 0 or not self.start_bounds_left: @@ -530,6 +610,23 @@ def calc_geometry_for_type(self, lane_types, num, tolerance, calc_gap=False): prev_id = id_ - 1 else: prev_id = id_ + 1 + + if ( + offsets[id_] == offsets[prev_id] + and id_ in left_bounds + and left_bounds[id_][-1] == right_bounds[id_][-1] + ): + # Both the previous and current segments of this lane had + # zero width; drop the former to avoid invalid polygons + warn( + f"road {self.id_} section {i} lane {id_} has a " + "zero-width segment; skipping it" + ) + left_bounds[id_].pop() + right_bounds[id_].pop() + lane.left_bounds.pop() + lane.right_bounds.pop() + lane.centerline.pop() left_bound = [ cur_p[0] + normal_vec[0] * offsets[id_], cur_p[1] + normal_vec[1] * offsets[id_], @@ -583,22 +680,20 @@ def calc_geometry_for_type(self, lane_types, num, tolerance, calc_gap=False): # Difference and slightly erode all overlapping polygons for i in range(len(sec_polys) - 1): - if sec_polys[i].overlaps(sec_polys[i + 1]): - sec_polys[i] = sec_polys[i].difference(sec_polys[i + 1]).buffer(-1e-6) - assert not sec_polys[i].overlaps(sec_polys[i + 1]) + sec_polys[i], sec_polys[i + 1] = separate(sec_polys[i], sec_polys[i + 1]) for polys in sec_lane_polys: ids = sorted(polys) # order adjacent lanes consecutively for i in range(len(ids) - 1): - polyA, polyB = polys[ids[i]], polys[ids[i + 1]] - if polyA.overlaps(polyB): - polys[ids[i]] = polyA.difference(polyB).buffer(-1e-6) - assert not polys[ids[i]].overlaps(polyB) + polys[ids[i]], polys[ids[i + 1]] = separate( + polys[ids[i]], polys[ids[i + 1]] + ) - for i in range(len(lane_polys) - 1): - if lane_polys[i].overlaps(lane_polys[i + 1]): - lane_polys[i] = lane_polys[i].difference(lane_polys[i + 1]).buffer(-1e-6) - assert not lane_polys[i].overlaps(lane_polys[i + 1]) + for i in range(len(lane_polys)): + # TODO do this later when we have lane adjacency information to avoid + # considering all possible pairs + for j in range(i): + lane_polys[i], lane_polys[j] = separate(lane_polys[i], lane_polys[j]) # Set parent lane polygon references to corrected polygons for sec in self.lane_secs: @@ -676,8 +771,8 @@ def toScenicRoad(self, tolerance): # Create lane and road sections roadSections = [] last_section = None - sidewalkSections = defaultdict(list) - shoulderSections = defaultdict(list) + forwardSidewalks, backwardSidewalks = [], [] + forwardShoulders, backwardShoulders = [], [] for sec, pts, sec_poly, lane_polys in zip( self.lane_secs, self.sec_points, self.sec_polys, self.sec_lane_polys ): @@ -702,7 +797,7 @@ def toScenicRoad(self, tolerance): left, center, right = right[::-1], center[::-1], left[::-1] succ, pred = pred, succ section = roadDomain.LaneSection( - id=f"road{self.id_}_sec{len(roadSections)}_lane{id_}", + uid=f"road{self.id_}_sec{len(roadSections)}_lane{id_}", polygon=lane_polys[id_], centerline=PolylineRegion(cleanChain(center)), leftEdge=PolylineRegion(cleanChain(left)), @@ -712,6 +807,7 @@ def toScenicRoad(self, tolerance): lane=None, # will set these later group=None, road=None, + id=id_, openDriveID=id_, isForward=id_ < 0, ) @@ -719,7 +815,7 @@ def toScenicRoad(self, tolerance): laneSections[id_] = section allElements.append(section) section = roadDomain.RoadSection( - id=f"road{self.id_}_sec{len(roadSections)}", + uid=f"road{self.id_}_sec{len(roadSections)}", polygon=sec_poly, centerline=PolylineRegion(cleanChain(pts)), leftEdge=PolylineRegion(cleanChain(sec.left_edge)), @@ -733,42 +829,77 @@ def toScenicRoad(self, tolerance): allElements.append(section) last_section = section + fss, bss = {}, {} for id_, lane in sec.sidewalk_lanes.items(): - sidewalkSections[id_].append(lane) + if lane.poly is None: # skip if we could not generate polygon + continue + (fss if id_ < 0 else bss)[id_] = lane + forwardSidewalks.append(fss) + backwardSidewalks.append(bss) + fss, bss = {}, {} for id_, lane in sec.shoulder_lanes.items(): - shoulderSections[id_].append(lane) + if lane.poly is None: + continue + (fss if id_ < 0 else bss)[id_] = lane + forwardShoulders.append(fss) + backwardShoulders.append(bss) # Build sidewalks and shoulders # TODO improve this! - forwardSidewalks, backwardSidewalks = [], [] - forwardShoulders, backwardShoulders = [], [] - for id_ in sidewalkSections: - (forwardSidewalks if id_ < 0 else backwardSidewalks).append(id_) - for id_ in shoulderSections: - (forwardShoulders if id_ < 0 else backwardShoulders).append(id_) - - def combineSections(laneIDs, sections, name): - leftmost, rightmost = max(laneIDs), min(laneIDs) - if len(laneIDs) != leftmost - rightmost + 1: - warn(f"ignoring {name} in the middle of road {self.id_}") + def combineSections(sections, name, backward): leftPoints, rightPoints = [], [] - if leftmost < 0: - leftmost = rightmost - while leftmost + 1 in laneIDs: - leftmost = leftmost + 1 - leftSecs, rightSecs = sections[leftmost], sections[rightmost] - for leftSec, rightSec in zip(leftSecs, rightSecs): + allPolys = [] + if backward: + sections = reversed(sections) + leftSec, rightSec = None, None + for section in sections: + if leftSec is None: + if not section: + continue + leftmost, rightmost = max(section), min(section) + elif backward: + leftmost, rightmost = leftSec.pred, rightSec.pred + else: + leftmost, rightmost = leftSec.succ, rightSec.succ + if not leftmost or not rightmost: + break + if len(section) != leftmost - rightmost + 1: + warn(f"ignoring {name} in the middle of road {self.id_}") + if leftmost not in section or rightmost not in section: + # can happen if successor has a different lane type + break + + def hasGap(edge1, edge2): + gap = np.linalg.norm(np.asarray(edge1) - edge2) + if gap < max(tolerance, 0.01): + return False + warn(f"road {self.id_} has discontinuous {name}; truncating it") + return True + + if leftmost < 0: + leftmost = rightmost + secPolys = [section[leftmost].poly] + while leftmost + 1 in section: + leftmost = leftmost + 1 + secPolys.append(section[leftmost].poly) + leftSec, rightSec = section[leftmost], section[rightmost] + if leftPoints and hasGap(leftPoints[-1], leftSec.left_bounds[0]): + break leftPoints.extend(leftSec.left_bounds) rightPoints.extend(rightSec.right_bounds) - else: - rightmost = leftmost - while rightmost - 1 in laneIDs: - rightmost = rightmost - 1 - leftSecs = reversed(sections[leftmost]) - rightSecs = reversed(sections[rightmost]) - for leftSec, rightSec in zip(leftSecs, rightSecs): + else: + rightmost = leftmost + secPolys = [section[rightmost].poly] + while rightmost - 1 in section: + rightmost = rightmost - 1 + secPolys.append(section[rightmost].poly) + leftSec, rightSec = section[leftmost], section[rightmost] + if leftPoints and hasGap(leftPoints[-1], rightSec.right_bounds[-1]): + break leftPoints.extend(reversed(rightSec.right_bounds)) rightPoints.extend(reversed(leftSec.left_bounds)) + allPolys.extend(secPolys) + assert allPolys leftEdge = PolylineRegion(cleanChain(leftPoints)) rightEdge = PolylineRegion(cleanChain(rightPoints)) @@ -785,20 +916,17 @@ def combineSections(laneIDs, sections, name): r = rightEdge.lineString.interpolate(d, normalized=True) centerPoints.append(averageVectors(l.coords[0], r.coords[0])) centerline = PolylineRegion(cleanChain(centerPoints)) - allPolys = ( - sec.poly - for id_ in range(rightmost, leftmost + 1) - for sec in sections[id_] - ) union = buffer_union(allPolys, tolerance=tolerance) - id_ = f"road{self.id_}_{name}({leftmost},{rightmost})" + direction = "Backward" if backward else "Forward" + id_ = f"road{self.id_}_{name}{direction}" return id_, union, centerline, leftEdge, rightEdge - def makeSidewalk(laneIDs): - if not laneIDs: + def makeSidewalk(sections, backward=False): + sections = tuple(sections) + if not any(sections): return None id_, union, centerline, leftEdge, rightEdge = combineSections( - laneIDs, sidewalkSections, "sidewalk" + sections, "sidewalk", backward ) sidewalk = roadDomain.Sidewalk( id=id_, @@ -813,13 +941,14 @@ def makeSidewalk(laneIDs): return sidewalk forwardSidewalk = makeSidewalk(forwardSidewalks) - backwardSidewalk = makeSidewalk(backwardSidewalks) + backwardSidewalk = makeSidewalk(backwardSidewalks, backward=True) - def makeShoulder(laneIDs): - if not laneIDs: + def makeShoulder(sections, backward=False): + sections = tuple(sections) + if not any(sections): return None id_, union, centerline, leftEdge, rightEdge = combineSections( - laneIDs, shoulderSections, "shoulder" + sections, "shoulder", backward ) shoulder = roadDomain.Shoulder( id=id_, @@ -833,7 +962,7 @@ def makeShoulder(laneIDs): return shoulder forwardShoulder = makeShoulder(forwardShoulders) - backwardShoulder = makeShoulder(backwardShoulders) + backwardShoulder = makeShoulder(backwardShoulders, backward=True) # Connect sections to their successors next_section = None @@ -931,7 +1060,8 @@ def makeShoulder(laneIDs): rightEdge = PolylineRegion(cleanChain(rightPoints)) centerline = PolylineRegion(cleanChain(centerPoints)) lane = roadDomain.Lane( - id=f"road{self.id_}_lane{nextID}", + uid=f"road{self.id_}_lane{nextID}", + id = laneSection.openDriveID, polygon=ls.parent_lane_poly, centerline=centerline, leftEdge=leftEdge, @@ -986,7 +1116,8 @@ def getEdges(forward): if forwardLanes: leftEdge, centerline, rightEdge = getEdges(forward=True) forwardGroup = roadDomain.LaneGroup( - id=f"road{self.id_}_forward", + uid=f"road{self.id_}_forward", + id = self.id_, polygon=buffer_union( (lane.polygon for lane in forwardLanes), tolerance=tolerance ), @@ -1007,7 +1138,8 @@ def getEdges(forward): if backwardLanes: leftEdge, centerline, rightEdge = getEdges(forward=False) backwardGroup = roadDomain.LaneGroup( - id=f"road{self.id_}_backward", + uid=f"road{self.id_}_backward", + id = self.id_, polygon=buffer_union( (lane.polygon for lane in backwardLanes), tolerance=tolerance ), @@ -1228,8 +1360,7 @@ def calculate_geometry(self, num, calc_gap=False, calc_intersect=True): b_bounds_right[other_id], ] ).convex_hull - if not gap_poly.is_valid: - continue + assert gap_poly.is_valid if gap_poly.geom_type == "Polygon" and not gap_poly.is_empty: if lane.type_ in self.drivable_lane_types: drivable_polys.append(gap_poly) @@ -1426,7 +1557,7 @@ def parse(self, path): # Parse planView: plan_view = r.find("planView") - curves = [] + curveData = [] for geom in plan_view.iter("geometry"): x0 = float(geom.get("x")) y0 = float(geom.get("y")) @@ -1434,82 +1565,50 @@ def parse(self, path): hdg = float(geom.get("hdg")) length = float(geom.get("length")) curve_elem = geom[0] - curve = None - if curve_elem.tag == "line": - curve = Line(x0, y0, hdg, length) - elif curve_elem.tag == "arc": - # Arc is clothoid of constant curvature. - curv = float(curve_elem.get("curvature")) - curve = Clothoid(x0, y0, hdg, length, curv, curv) - elif curve_elem.tag == "spiral": - curv0 = float(curve_elem.get("curvStart")) - curv1 = float(curve_elem.get("curvEnd")) - curve = Clothoid(x0, y0, hdg, length, curv0, curv1) - elif curve_elem.tag == "poly3": - a, b, c, d = ( - cubic_elem.get("a"), - float(curve_elem.get("b")), - float(curve_elem.get("c")), - float(curve_elem.get("d")), - ) - curve = Cubic(x0, y0, hdg, length, a, b, c, d) - elif curve_elem.tag == "paramPoly3": - au, bu, cu, du, av, bv, cv, dv = ( - float(curve_elem.get("aU")), - float(curve_elem.get("bU")), - float(curve_elem.get("cU")), - float(curve_elem.get("dU")), - float(curve_elem.get("aV")), - float(curve_elem.get("bV")), - float(curve_elem.get("cV")), - float(curve_elem.get("dV")), - ) - p_range = curve_elem.get("pRange") - if p_range and p_range != "normalized": - # TODO support arcLength - raise NotImplementedError("unsupported pRange for paramPoly3") - else: - p_range = 1 - curve = ParamCubic( - x0, y0, hdg, length, au, bu, cu, du, av, bv, cv, dv, p_range - ) - curves.append((s0, curve)) - if not curves: + curveData.append((s0, x0, y0, hdg, length, curve_elem)) + + if not curveData: raise ValueError(f"road {road.id_} has an empty planView") - if not curves[0][0] == 0: + if not curveData[0][0] == 0: raise ValueError( f"reference line of road {road.id_} does not start at s=0" ) - lastS = 0 - lastCurve = curves[0][1] - refLine = [] - for s0, curve in curves[1:]: - l = s0 - lastS - if abs(lastCurve.length - l) > 1e-4: - raise ValueError( - f"planView of road {road.id_} has inconsistent length" - ) - if l < 0: - raise ValueError(f"planView of road {road.id_} is not in order") - elif l < 1e-6: - warn( - f"road {road.id_} reference line has a geometry of " - f"length {l}; skipping it" - ) + + refLine, suspectGeometries = [], [] + for i, (s0, x0, y0, hdg, length, curve_elem) in enumerate(curveData): + if i + 1 < len(curveData): + nextS0 = curveData[i + 1][0] + l = nextS0 - s0 + + if l < 0: + raise ValueError(f"planView of road {road.id_} is not in order") + + if abs(length - l) > 1e-3: + warn( + f"planView of road {road.id_} has inconsistent length: " + f"geometry at s={s0} has declared length {length}, " + f"but the next geometry starts at s={nextS0}; " + f"using length {l}" + ) + length = l + + curve, suspicion = makeCurve(x0, y0, hdg, length, curve_elem) + if suspicion: + suspectGeometries.append((curve, suspicion, length)) else: - refLine.append(lastCurve) - lastS = s0 - lastCurve = curve - if refLine and lastCurve.length < 1e-6: - warn( - f"road {road.id_} reference line has a geometry of " - f"length {lastCurve.length}; skipping it" - ) - else: - # even if the last curve is shorter than the threshold, we'll keep it if - # it is the only curve; getting rid of the road entirely is handled by - # road elision above - refLine.append(lastCurve) + refLine.append(curve) + + exonerated = None + if not refLine: + # All geometries were suspect, so we need to keep at least one of them. + # We'll heuristically keep whichever is longest and drop the rest. + # (Extremely short roads can be removed entirely by road elision above.) + exonerated = max(suspectGeometries, key=lambda t: t[2])[0] + refLine.append(exonerated) + for curve, suspicion, _ in suspectGeometries: + note = "" if curve is exonerated else "; skipping it" + warn(f"road {road.id_} reference line has a {suspicion}{note}") + assert refLine road.ref_line = refLine @@ -1850,14 +1949,14 @@ def cyclicOrder(elements, contactStart=None): if rid not in roads: continue # road does not have any drivable lanes, so we skipped it newRoad = roads[rid] - if oldRoad.predecessor: - intersection = intersections[oldRoad.predecessor] + if (pred := oldRoad.predecessor) and pred in intersections: + intersection = intersections[pred] newRoad._predecessor = intersection newRoad.sections[0]._predecessor = intersection if newRoad.backwardLanes: newRoad.backwardLanes._successor = intersection - if oldRoad.successor: - intersection = intersections[oldRoad.successor] + if (succ := oldRoad.successor) and succ in intersections: + intersection = intersections[succ] newRoad._successor = intersection newRoad.sections[-1]._successor = intersection if newRoad.forwardLanes: diff --git a/src/scenic/simulators/carla/_blueprintData.py b/src/scenic/simulators/carla/_blueprintData.py new file mode 100644 index 000000000..e0ef29f77 --- /dev/null +++ b/src/scenic/simulators/carla/_blueprintData.py @@ -0,0 +1,4167 @@ +# AUTO-GENERATED. Do not edit by hand. +# Built from tools/carla/make_blueprints.py + +_IDS = { + "0.10.0": { + "advertisementModels": [ + "static.prop.advertisement", + "static.prop.streetsign", + "static.prop.streetsign01", + "static.prop.streetsign04", + ], + "atmModels": ["static.prop.atm"], + "barrelModels": ["static.prop.barrel"], + "barrierModels": [ + "static.prop.chainbarrier", + "static.prop.chainbarrierend", + "static.prop.streetbarrier", + ], + "benchModels": ["static.prop.bench01", "static.prop.bench02"], + "bicycleModels": [], + "boxModels": [], + "busModels": ["vehicle.fuso.mitsubishi"], + "busStopModels": ["static.prop.busstop"], + "carModels": [ + "vehicle.dodge.charger", + "vehicle.dodgecop.charger", + "vehicle.lincoln.mkz", + "vehicle.mini.cooper", + "vehicle.nissan.patrol", + "vehicle.taxi.ford", + ], + "caseModels": [ + "static.prop.briefcase", + "static.prop.guitarcase", + "static.prop.travelcase", + ], + "chairModels": ["static.prop.plasticchair"], + "coneModels": [ + "static.prop.constructioncone", + "static.prop.trafficcone01", + "static.prop.trafficcone02", + ], + "containerModels": ["static.prop.container"], + "creasedboxModels": ["static.prop.creasedbox01"], + "debrisModels": [ + "static.prop.dirtdebris01", + "static.prop.dirtdebris02", + "static.prop.dirtdebris03", + ], + "garbageModels": [ + "static.prop.colacan", + "static.prop.garbage01", + "static.prop.garbage02", + "static.prop.garbage03", + "static.prop.garbage04", + "static.prop.garbage05", + "static.prop.garbage06", + "static.prop.plasticbag", + "static.prop.platformgarbage01", + "static.prop.trashbag", + ], + "gnomeModels": ["static.prop.gnome"], + "ironplateModels": [], + "kioskModels": ["static.prop.kiosk_01"], + "mailboxModels": ["static.prop.mailbox"], + "motorcycleModels": [], + "plantpotModels": [ + "static.prop.plantpot01", + "static.prop.plantpot02", + "static.prop.plantpot03", + "static.prop.plantpot04", + "static.prop.plantpot05", + "static.prop.plantpot06", + "static.prop.plantpot07", + ], + "tableModels": ["static.prop.maptable", "static.prop.plastictable"], + "trafficwarningModels": ["static.prop.trafficwarning"], + "trashModels": [ + "static.prop.trashcan01", + "static.prop.trashcan02", + "static.prop.trashcan03", + "static.prop.trashcan04", + ], + "truckModels": [ + "vehicle.ambulance.ford", + "vehicle.carlacola.actors", + "vehicle.firetruck.actors", + ], + "vanModels": ["vehicle.sprinter.mercedes"], + "vendingMachineModels": ["static.prop.vendingmachine"], + "walkerModels": [ + "walker.pedestrian.0015", + "walker.pedestrian.0016", + "walker.pedestrian.0017", + "walker.pedestrian.0018", + "walker.pedestrian.0019", + "walker.pedestrian.0020", + "walker.pedestrian.0021", + "walker.pedestrian.0022", + "walker.pedestrian.0023", + "walker.pedestrian.0024", + "walker.pedestrian.0025", + "walker.pedestrian.0026", + "walker.pedestrian.0027", + "walker.pedestrian.0028", + "walker.pedestrian.0029", + "walker.pedestrian.0030", + "walker.pedestrian.0031", + "walker.pedestrian.0032", + "walker.pedestrian.0033", + "walker.pedestrian.0034", + "walker.pedestrian.0035", + "walker.pedestrian.0036", + "walker.pedestrian.0037", + "walker.pedestrian.0038", + "walker.pedestrian.0039", + "walker.pedestrian.0040", + "walker.pedestrian.0041", + "walker.pedestrian.0042", + "walker.pedestrian.0043", + "walker.pedestrian.0044", + "walker.pedestrian.0045", + "walker.pedestrian.0046", + "walker.pedestrian.0047", + "walker.pedestrian.0048", + "walker.pedestrian.0049", + "walker.pedestrian.0050", + "walker.pedestrian.0051", + ], + }, + "0.9.16": { + "advertisementModels": [ + "static.prop.advertisement", + "static.prop.streetsign", + "static.prop.streetsign01", + "static.prop.streetsign04", + ], + "atmModels": ["static.prop.atm"], + "barrelModels": ["static.prop.barrel"], + "barrierModels": [ + "static.prop.chainbarrier", + "static.prop.chainbarrierend", + "static.prop.streetbarrier", + ], + "benchModels": [ + "static.prop.bench01", + "static.prop.bench02", + "static.prop.bench03", + ], + "bicycleModels": [ + "vehicle.bh.crossbike", + "vehicle.diamondback.century", + "vehicle.gazelle.omafiets", + ], + "boxModels": ["static.prop.box01", "static.prop.box02", "static.prop.box03"], + "busModels": ["vehicle.mitsubishi.fusorosa"], + "busStopModels": ["static.prop.busstop", "static.prop.busstoplb"], + "carModels": [ + "vehicle.audi.a2", + "vehicle.audi.etron", + "vehicle.audi.tt", + "vehicle.bmw.grandtourer", + "vehicle.chevrolet.impala", + "vehicle.citroen.c3", + "vehicle.dodge.charger_2020", + "vehicle.dodge.charger_police", + "vehicle.dodge.charger_police_2020", + "vehicle.ford.crown", + "vehicle.ford.mustang", + "vehicle.jeep.wrangler_rubicon", + "vehicle.lincoln.mkz_2017", + "vehicle.lincoln.mkz_2020", + "vehicle.mercedes.coupe", + "vehicle.mercedes.coupe_2020", + "vehicle.micro.microlino", + "vehicle.mini.cooper_s", + "vehicle.mini.cooper_s_2021", + "vehicle.nissan.micra", + "vehicle.nissan.patrol", + "vehicle.nissan.patrol_2021", + "vehicle.seat.leon", + "vehicle.tesla.model3", + "vehicle.toyota.prius", + ], + "caseModels": [ + "static.prop.briefcase", + "static.prop.guitarcase", + "static.prop.travelcase", + ], + "chairModels": ["static.prop.plasticchair"], + "coneModels": [ + "static.prop.constructioncone", + "static.prop.trafficcone01", + "static.prop.trafficcone02", + ], + "containerModels": [ + "static.prop.clothcontainer", + "static.prop.container", + "static.prop.glasscontainer", + ], + "creasedboxModels": [ + "static.prop.creasedbox01", + "static.prop.creasedbox02", + "static.prop.creasedbox03", + ], + "debrisModels": [ + "static.prop.dirtdebris01", + "static.prop.dirtdebris02", + "static.prop.dirtdebris03", + ], + "garbageModels": [ + "static.prop.colacan", + "static.prop.garbage01", + "static.prop.garbage02", + "static.prop.garbage03", + "static.prop.garbage04", + "static.prop.garbage05", + "static.prop.garbage06", + "static.prop.plasticbag", + "static.prop.platformgarbage01", + "static.prop.trashbag", + ], + "gnomeModels": ["static.prop.gnome"], + "ironplateModels": ["static.prop.ironplank"], + "kioskModels": ["static.prop.kiosk_01"], + "mailboxModels": ["static.prop.mailbox"], + "motorcycleModels": [ + "vehicle.harley-davidson.low_rider", + "vehicle.kawasaki.ninja", + "vehicle.vespa.zx125", + "vehicle.yamaha.yzf", + ], + "plantpotModels": [ + "static.prop.plantpot01", + "static.prop.plantpot02", + "static.prop.plantpot03", + "static.prop.plantpot04", + "static.prop.plantpot05", + "static.prop.plantpot06", + "static.prop.plantpot07", + "static.prop.plantpot08", + ], + "tableModels": [ + "static.prop.maptable", + "static.prop.plastictable", + "static.prop.table", + ], + "trafficwarningModels": ["static.prop.trafficwarning"], + "trashModels": [ + "static.prop.bin", + "static.prop.trashcan01", + "static.prop.trashcan02", + "static.prop.trashcan03", + "static.prop.trashcan04", + "static.prop.trashcan05", + ], + "truckModels": [ + "vehicle.carlamotors.carlacola", + "vehicle.carlamotors.european_hgv", + "vehicle.carlamotors.firetruck", + "vehicle.tesla.cybertruck", + ], + "vanModels": [ + "vehicle.ford.ambulance", + "vehicle.mercedes.sprinter", + "vehicle.volkswagen.t2", + "vehicle.volkswagen.t2_2021", + ], + "vendingMachineModels": ["static.prop.vendingmachine"], + "walkerModels": [ + "walker.pedestrian.0001", + "walker.pedestrian.0002", + "walker.pedestrian.0003", + "walker.pedestrian.0004", + "walker.pedestrian.0005", + "walker.pedestrian.0006", + "walker.pedestrian.0007", + "walker.pedestrian.0008", + "walker.pedestrian.0009", + "walker.pedestrian.0010", + "walker.pedestrian.0011", + "walker.pedestrian.0012", + "walker.pedestrian.0013", + "walker.pedestrian.0014", + "walker.pedestrian.0015", + "walker.pedestrian.0016", + "walker.pedestrian.0017", + "walker.pedestrian.0018", + "walker.pedestrian.0019", + "walker.pedestrian.0020", + "walker.pedestrian.0021", + "walker.pedestrian.0022", + "walker.pedestrian.0023", + "walker.pedestrian.0024", + "walker.pedestrian.0025", + "walker.pedestrian.0026", + "walker.pedestrian.0027", + "walker.pedestrian.0028", + "walker.pedestrian.0029", + "walker.pedestrian.0030", + "walker.pedestrian.0031", + "walker.pedestrian.0032", + "walker.pedestrian.0033", + "walker.pedestrian.0034", + "walker.pedestrian.0035", + "walker.pedestrian.0036", + "walker.pedestrian.0037", + "walker.pedestrian.0038", + "walker.pedestrian.0039", + "walker.pedestrian.0040", + "walker.pedestrian.0041", + "walker.pedestrian.0042", + "walker.pedestrian.0043", + "walker.pedestrian.0044", + "walker.pedestrian.0045", + "walker.pedestrian.0046", + "walker.pedestrian.0047", + "walker.pedestrian.0048", + "walker.pedestrian.0049", + "walker.pedestrian.0050", + "walker.pedestrian.0051", + "walker.pedestrian.0052", + ], + }, + "0.9.15": { + "advertisementModels": [ + "static.prop.advertisement", + "static.prop.streetsign", + "static.prop.streetsign01", + "static.prop.streetsign04", + ], + "atmModels": ["static.prop.atm"], + "barrelModels": ["static.prop.barrel"], + "barrierModels": [ + "static.prop.chainbarrier", + "static.prop.chainbarrierend", + "static.prop.streetbarrier", + ], + "benchModels": [ + "static.prop.bench01", + "static.prop.bench02", + "static.prop.bench03", + ], + "bicycleModels": [ + "vehicle.bh.crossbike", + "vehicle.diamondback.century", + "vehicle.gazelle.omafiets", + ], + "boxModels": ["static.prop.box01", "static.prop.box02", "static.prop.box03"], + "busModels": ["vehicle.mitsubishi.fusorosa"], + "busStopModels": ["static.prop.busstop", "static.prop.busstoplb"], + "carModels": [ + "vehicle.audi.a2", + "vehicle.audi.etron", + "vehicle.audi.tt", + "vehicle.bmw.grandtourer", + "vehicle.chevrolet.impala", + "vehicle.citroen.c3", + "vehicle.dodge.charger_2020", + "vehicle.dodge.charger_police", + "vehicle.dodge.charger_police_2020", + "vehicle.ford.crown", + "vehicle.ford.mustang", + "vehicle.jeep.wrangler_rubicon", + "vehicle.lincoln.mkz_2017", + "vehicle.lincoln.mkz_2020", + "vehicle.mercedes.coupe", + "vehicle.mercedes.coupe_2020", + "vehicle.micro.microlino", + "vehicle.mini.cooper_s", + "vehicle.mini.cooper_s_2021", + "vehicle.nissan.micra", + "vehicle.nissan.patrol", + "vehicle.nissan.patrol_2021", + "vehicle.seat.leon", + "vehicle.tesla.model3", + "vehicle.toyota.prius", + ], + "caseModels": [ + "static.prop.briefcase", + "static.prop.guitarcase", + "static.prop.travelcase", + ], + "chairModels": ["static.prop.plasticchair"], + "coneModels": [ + "static.prop.constructioncone", + "static.prop.trafficcone01", + "static.prop.trafficcone02", + ], + "containerModels": [ + "static.prop.clothcontainer", + "static.prop.container", + "static.prop.glasscontainer", + ], + "creasedboxModels": [ + "static.prop.creasedbox01", + "static.prop.creasedbox02", + "static.prop.creasedbox03", + ], + "debrisModels": [ + "static.prop.dirtdebris01", + "static.prop.dirtdebris02", + "static.prop.dirtdebris03", + ], + "garbageModels": [ + "static.prop.colacan", + "static.prop.garbage01", + "static.prop.garbage02", + "static.prop.garbage03", + "static.prop.garbage04", + "static.prop.garbage05", + "static.prop.garbage06", + "static.prop.plasticbag", + "static.prop.platformgarbage01", + "static.prop.trashbag", + ], + "gnomeModels": ["static.prop.gnome"], + "ironplateModels": ["static.prop.ironplank"], + "kioskModels": ["static.prop.kiosk_01"], + "mailboxModels": ["static.prop.mailbox"], + "motorcycleModels": [ + "vehicle.harley-davidson.low_rider", + "vehicle.kawasaki.ninja", + "vehicle.vespa.zx125", + "vehicle.yamaha.yzf", + ], + "plantpotModels": [ + "static.prop.plantpot01", + "static.prop.plantpot02", + "static.prop.plantpot03", + "static.prop.plantpot04", + "static.prop.plantpot05", + "static.prop.plantpot06", + "static.prop.plantpot07", + "static.prop.plantpot08", + ], + "tableModels": [ + "static.prop.maptable", + "static.prop.plastictable", + "static.prop.table", + ], + "trafficwarningModels": ["static.prop.trafficwarning"], + "trashModels": [ + "static.prop.bin", + "static.prop.trashcan01", + "static.prop.trashcan02", + "static.prop.trashcan03", + "static.prop.trashcan04", + "static.prop.trashcan05", + ], + "truckModels": [ + "vehicle.carlamotors.carlacola", + "vehicle.carlamotors.european_hgv", + "vehicle.carlamotors.firetruck", + "vehicle.tesla.cybertruck", + ], + "vanModels": [ + "vehicle.ford.ambulance", + "vehicle.mercedes.sprinter", + "vehicle.volkswagen.t2", + "vehicle.volkswagen.t2_2021", + ], + "vendingMachineModels": ["static.prop.vendingmachine"], + "walkerModels": [ + "walker.pedestrian.0001", + "walker.pedestrian.0002", + "walker.pedestrian.0003", + "walker.pedestrian.0004", + "walker.pedestrian.0005", + "walker.pedestrian.0006", + "walker.pedestrian.0007", + "walker.pedestrian.0008", + "walker.pedestrian.0009", + "walker.pedestrian.0010", + "walker.pedestrian.0011", + "walker.pedestrian.0012", + "walker.pedestrian.0013", + "walker.pedestrian.0014", + "walker.pedestrian.0015", + "walker.pedestrian.0016", + "walker.pedestrian.0017", + "walker.pedestrian.0018", + "walker.pedestrian.0019", + "walker.pedestrian.0020", + "walker.pedestrian.0021", + "walker.pedestrian.0022", + "walker.pedestrian.0023", + "walker.pedestrian.0024", + "walker.pedestrian.0025", + "walker.pedestrian.0026", + "walker.pedestrian.0027", + "walker.pedestrian.0028", + "walker.pedestrian.0029", + "walker.pedestrian.0030", + "walker.pedestrian.0031", + "walker.pedestrian.0032", + "walker.pedestrian.0033", + "walker.pedestrian.0034", + "walker.pedestrian.0035", + "walker.pedestrian.0036", + "walker.pedestrian.0037", + "walker.pedestrian.0038", + "walker.pedestrian.0039", + "walker.pedestrian.0040", + "walker.pedestrian.0041", + "walker.pedestrian.0042", + "walker.pedestrian.0043", + "walker.pedestrian.0044", + "walker.pedestrian.0045", + "walker.pedestrian.0046", + "walker.pedestrian.0047", + "walker.pedestrian.0048", + "walker.pedestrian.0049", + "walker.pedestrian.0050", + "walker.pedestrian.0051", + ], + }, + "0.9.14": { + "advertisementModels": [ + "static.prop.advertisement", + "static.prop.streetsign", + "static.prop.streetsign01", + "static.prop.streetsign04", + ], + "atmModels": ["static.prop.atm"], + "barrelModels": ["static.prop.barrel"], + "barrierModels": [ + "static.prop.chainbarrier", + "static.prop.chainbarrierend", + "static.prop.streetbarrier", + ], + "benchModels": [ + "static.prop.bench01", + "static.prop.bench02", + "static.prop.bench03", + ], + "bicycleModels": [ + "vehicle.bh.crossbike", + "vehicle.diamondback.century", + "vehicle.gazelle.omafiets", + ], + "boxModels": ["static.prop.box01", "static.prop.box02", "static.prop.box03"], + "busModels": ["vehicle.mitsubishi.fusorosa"], + "busStopModels": ["static.prop.busstop", "static.prop.busstoplb"], + "carModels": [ + "vehicle.audi.a2", + "vehicle.audi.etron", + "vehicle.audi.tt", + "vehicle.bmw.grandtourer", + "vehicle.chevrolet.impala", + "vehicle.citroen.c3", + "vehicle.dodge.charger_2020", + "vehicle.dodge.charger_police", + "vehicle.dodge.charger_police_2020", + "vehicle.ford.crown", + "vehicle.ford.mustang", + "vehicle.jeep.wrangler_rubicon", + "vehicle.lincoln.mkz_2017", + "vehicle.lincoln.mkz_2020", + "vehicle.mercedes.coupe", + "vehicle.mercedes.coupe_2020", + "vehicle.micro.microlino", + "vehicle.mini.cooper_s", + "vehicle.mini.cooper_s_2021", + "vehicle.nissan.micra", + "vehicle.nissan.patrol", + "vehicle.nissan.patrol_2021", + "vehicle.seat.leon", + "vehicle.tesla.model3", + "vehicle.toyota.prius", + ], + "caseModels": [ + "static.prop.briefcase", + "static.prop.guitarcase", + "static.prop.travelcase", + ], + "chairModels": ["static.prop.plasticchair"], + "coneModels": [ + "static.prop.constructioncone", + "static.prop.trafficcone01", + "static.prop.trafficcone02", + ], + "containerModels": [ + "static.prop.clothcontainer", + "static.prop.container", + "static.prop.glasscontainer", + ], + "creasedboxModels": [ + "static.prop.creasedbox01", + "static.prop.creasedbox02", + "static.prop.creasedbox03", + ], + "debrisModels": [ + "static.prop.dirtdebris01", + "static.prop.dirtdebris02", + "static.prop.dirtdebris03", + ], + "garbageModels": [ + "static.prop.colacan", + "static.prop.garbage01", + "static.prop.garbage02", + "static.prop.garbage03", + "static.prop.garbage04", + "static.prop.garbage05", + "static.prop.garbage06", + "static.prop.plasticbag", + "static.prop.platformgarbage01", + "static.prop.trashbag", + ], + "gnomeModels": ["static.prop.gnome"], + "ironplateModels": ["static.prop.ironplank"], + "kioskModels": ["static.prop.kiosk_01"], + "mailboxModels": ["static.prop.mailbox"], + "motorcycleModels": [ + "vehicle.harley-davidson.low_rider", + "vehicle.kawasaki.ninja", + "vehicle.vespa.zx125", + "vehicle.yamaha.yzf", + ], + "plantpotModels": [ + "static.prop.plantpot01", + "static.prop.plantpot02", + "static.prop.plantpot03", + "static.prop.plantpot04", + "static.prop.plantpot05", + "static.prop.plantpot06", + "static.prop.plantpot07", + "static.prop.plantpot08", + ], + "tableModels": [ + "static.prop.maptable", + "static.prop.plastictable", + "static.prop.table", + ], + "trafficwarningModels": ["static.prop.trafficwarning"], + "trashModels": [ + "static.prop.bin", + "static.prop.trashcan01", + "static.prop.trashcan02", + "static.prop.trashcan03", + "static.prop.trashcan04", + "static.prop.trashcan05", + ], + "truckModels": [ + "vehicle.carlamotors.carlacola", + "vehicle.carlamotors.firetruck", + "vehicle.tesla.cybertruck", + ], + "vanModels": [ + "vehicle.ford.ambulance", + "vehicle.mercedes.sprinter", + "vehicle.volkswagen.t2", + "vehicle.volkswagen.t2_2021", + ], + "vendingMachineModels": ["static.prop.vendingmachine"], + "walkerModels": [ + "walker.pedestrian.0001", + "walker.pedestrian.0002", + "walker.pedestrian.0003", + "walker.pedestrian.0004", + "walker.pedestrian.0005", + "walker.pedestrian.0006", + "walker.pedestrian.0007", + "walker.pedestrian.0008", + "walker.pedestrian.0009", + "walker.pedestrian.0010", + "walker.pedestrian.0011", + "walker.pedestrian.0012", + "walker.pedestrian.0013", + "walker.pedestrian.0014", + "walker.pedestrian.0015", + "walker.pedestrian.0016", + "walker.pedestrian.0017", + "walker.pedestrian.0018", + "walker.pedestrian.0019", + "walker.pedestrian.0020", + "walker.pedestrian.0021", + "walker.pedestrian.0022", + "walker.pedestrian.0023", + "walker.pedestrian.0024", + "walker.pedestrian.0025", + "walker.pedestrian.0026", + "walker.pedestrian.0027", + "walker.pedestrian.0028", + "walker.pedestrian.0029", + "walker.pedestrian.0030", + "walker.pedestrian.0031", + "walker.pedestrian.0032", + "walker.pedestrian.0033", + "walker.pedestrian.0034", + "walker.pedestrian.0035", + "walker.pedestrian.0036", + "walker.pedestrian.0037", + "walker.pedestrian.0038", + "walker.pedestrian.0039", + "walker.pedestrian.0040", + "walker.pedestrian.0041", + "walker.pedestrian.0042", + "walker.pedestrian.0043", + "walker.pedestrian.0044", + "walker.pedestrian.0045", + "walker.pedestrian.0046", + "walker.pedestrian.0047", + "walker.pedestrian.0048", + "walker.pedestrian.0049", + ], + }, +} + +_DIMS = { + "0.10.0": { + "static.prop.advertisement": { + "width": 0.28684958815574646, + "length": 1.549233317375183, + "height": 2.4428441524505615, + }, + "static.prop.atm": { + "width": 0.8325381278991699, + "length": 0.5721203684806824, + "height": 2.194814443588257, + }, + "static.prop.barbeque": { + "width": 0.49884626269340515, + "length": 0.9456468820571899, + "height": 1.2640891075134277, + }, + "static.prop.barrel": { + "width": 0.47143638134002686, + "length": 0.4596165418624878, + "height": 0.8067459464073181, + }, + "static.prop.bench01": { + "width": 0.6381909847259521, + "length": 1.7933329343795776, + "height": 1.0038363933563232, + }, + "static.prop.bench02": { + "width": 0.5126523971557617, + "length": 1.58349609375, + "height": 0.5126523971557617, + }, + "static.prop.bike helmet": { + "width": 0.2770470678806305, + "length": 0.18554961681365967, + "height": 0.1712976098060608, + }, + "static.prop.briefcase": { + "width": 0.18117640912532806, + "length": 0.543353796005249, + "height": 0.43285050988197327, + }, + "static.prop.brokentile01": { + "width": 0.1760428547859192, + "length": 0.13444176316261292, + "height": 0.006563859060406685, + }, + "static.prop.brokentile02": { + "width": 0.15229617059230804, + "length": 0.20967701077461243, + "height": 0.007594939786940813, + }, + "static.prop.brokentile03": { + "width": 0.1449233442544937, + "length": 0.1544525921344757, + "height": 0.008505801670253277, + }, + "static.prop.brokentile04": { + "width": 0.12647496163845062, + "length": 0.12972931563854218, + "height": 0.01014416478574276, + }, + "static.prop.busstop": { + "width": 3.8760786056518555, + "length": 1.8936100006103516, + "height": 2.738938331604004, + }, + "static.prop.calibrator": { + "width": 0.690200924873352, + "length": 1.5475953817367554, + "height": 0.20195052027702332, + }, + "static.prop.chainbarrier": { + "width": 0.24313338100910187, + "length": 1.6833475828170776, + "height": 0.6920976042747498, + }, + "static.prop.chainbarrierend": { + "width": 0.24313338100910187, + "length": 0.24313347041606903, + "height": 0.6920976042747498, + }, + "static.prop.colacan": { + "width": 0.07167824357748032, + "length": 0.06817007064819336, + "height": 0.10954885929822922, + }, + "static.prop.constructioncone": { + "width": 0.4784207046031952, + "length": 0.478452205657959, + "height": 0.7117974162101746, + }, + "static.prop.container": { + "width": 1.6529669761657715, + "length": 5.399704933166504, + "height": 1.3477081060409546, + }, + "static.prop.creasedbox01": { + "width": 0.9068000316619873, + "length": 1.0287128686904907, + "height": 0.061725616455078125, + }, + "static.prop.dirtdebris01": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.14900343120098114, + }, + "static.prop.dirtdebris02": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.14900343120098114, + }, + "static.prop.dirtdebris03": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1883699893951416, + }, + "static.prop.doghouse": { + "width": 1.2721054553985596, + "length": 1.0791168212890625, + "height": 1.0936739444732666, + }, + "static.prop.dumpster": { + "width": 1.3943947553634644, + "length": 2.8662402629852295, + "height": 2.2673521041870117, + }, + "static.prop.foodcart": { + "width": 4.638397693634033, + "length": 2.2846763134002686, + "height": 3.573108673095703, + }, + "static.prop.fountain": { + "width": 8.401816368103027, + "length": 8.401811599731445, + "height": 6.924410343170166, + }, + "static.prop.garbage01": { + "width": 0.05342775210738182, + "length": 0.053427763283252716, + "height": 0.08676455914974213, + }, + "static.prop.garbage02": { + "width": 0.05342775210738182, + "length": 0.05342775210738182, + "height": 0.009213896468281746, + }, + "static.prop.garbage03": { + "width": 0.05944278463721275, + "length": 0.059658296406269073, + "height": 0.07927705347537994, + }, + "static.prop.garbage04": { + "width": 0.05568109452724457, + "length": 0.05727477744221687, + "height": 0.06884017586708069, + }, + "static.prop.garbage05": { + "width": 0.23253268003463745, + "length": 0.23187801241874695, + "height": 0.05761278420686722, + }, + "static.prop.garbage06": { + "width": 0.2133311778306961, + "length": 0.3661772906780243, + "height": 0.07683420181274414, + }, + "static.prop.gardenlamp": { + "width": 0.28122183680534363, + "length": 0.2972412407398224, + "height": 0.6478500366210938, + }, + "static.prop.gnome": { + "width": 0.37559059262275696, + "length": 0.36661627888679504, + "height": 0.8829975724220276, + }, + "static.prop.guitarcase": { + "width": 0.1251685619354248, + "length": 1.242393970489502, + "height": 0.4944811165332794, + }, + "static.prop.haybale": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949762105941772, + }, + "static.prop.kiosk_01": { + "width": 1.6540685892105103, + "length": 1.6252474784851074, + "height": 3.2453691959381104, + }, + "static.prop.mailbox": { + "width": 0.43842461705207825, + "length": 0.526629626750946, + "height": 1.1572810411453247, + }, + "static.prop.maptable": { + "width": 0.6170762777328491, + "length": 1.5732090473175049, + "height": 1.1810814142227173, + }, + "static.prop.mobile": { + "width": 0.007261085323989391, + "length": 0.08048340678215027, + "height": 0.16641773283481598, + }, + "static.prop.motorhelmet": { + "width": 0.2592744827270508, + "length": 0.20041197538375854, + "height": 0.25808918476104736, + }, + "static.prop.pergola": { + "width": 5.085033893585205, + "length": 5.029393672943115, + "height": 3.541531562805176, + }, + "static.prop.plantpot01": { + "width": 0.4757719337940216, + "length": 1.08914053440094, + "height": 0.5573238730430603, + }, + "static.prop.plantpot02": { + "width": 0.43072694540023804, + "length": 1.0511938333511353, + "height": 0.48692914843559265, + }, + "static.prop.plantpot03": { + "width": 0.43072694540023804, + "length": 1.0511937141418457, + "height": 0.4869290888309479, + }, + "static.prop.plantpot04": { + "width": 0.7366006970405579, + "length": 4.967305660247803, + "height": 0.12478026747703552, + }, + "static.prop.plantpot05": { + "width": 0.4757719337940216, + "length": 0.4946027398109436, + "height": 0.5573238730430603, + }, + "static.prop.plantpot06": { + "width": 1.5932812690734863, + "length": 1.5932812690734863, + "height": 0.84954833984375, + }, + "static.prop.plantpot07": { + "width": 0.5304248929023743, + "length": 0.5304248929023743, + "height": 0.4888741672039032, + }, + "static.prop.plasticbag": { + "width": 0.4025624990463257, + "length": 0.3109833598136902, + "height": 0.5523712635040283, + }, + "static.prop.plasticchair": { + "width": 0.7504488825798035, + "length": 0.7304753661155701, + "height": 1.2713558673858643, + }, + "static.prop.plastictable": { + "width": 2.4822070598602295, + "length": 2.4822070598602295, + "height": 2.479797840118408, + }, + "static.prop.platformgarbage01": { + "width": 1.7114051580429077, + "length": 3.6332411766052246, + "height": 0.33158016204833984, + }, + "static.prop.purse": { + "width": 0.3326959013938904, + "length": 0.15921518206596375, + "height": 0.5852110385894775, + }, + "static.prop.recyclecardboard": { + "width": 2.3846962451934814, + "length": 2.5890233516693115, + "height": 2.0096852779388428, + }, + "static.prop.recycleglass": { + "width": 1.7764321565628052, + "length": 1.0195367336273193, + "height": 1.943043828010559, + }, + "static.prop.recycleorganic": { + "width": 1.7764321565628052, + "length": 1.0195363759994507, + "height": 1.943043828010559, + }, + "static.prop.recycleplastic": { + "width": 1.9872466325759888, + "length": 2.5890233516693115, + "height": 2.188204765319824, + }, + "static.prop.shoppingbag": { + "width": 0.2432928830385208, + "length": 0.47446563839912415, + "height": 0.6006307601928711, + }, + "static.prop.shoppingcart": { + "width": 1.207547903060913, + "length": 0.6694015264511108, + "height": 1.0793083906173706, + }, + "static.prop.shoppingtrolley": { + "width": 0.33110183477401733, + "length": 0.5055894255638123, + "height": 1.0936459302902222, + }, + "static.prop.streetbarrier": { + "width": 0.3716789782047272, + "length": 1.2149077653884888, + "height": 1.069164514541626, + }, + "static.prop.streetfountain": { + "width": 0.9839538335800171, + "length": 0.405456006526947, + "height": 1.0484803915023804, + }, + "static.prop.streetsign": { + "width": 0.1252390295267105, + "length": 1.0643447637557983, + "height": 2.154392957687378, + }, + "static.prop.streetsign01": { + "width": 0.3029319941997528, + "length": 2.470391273498535, + "height": 3.8779332637786865, + }, + "static.prop.streetsign04": { + "width": 0.12483911216259003, + "length": 1.1708152294158936, + "height": 2.7728850841522217, + }, + "static.prop.swing": { + "width": 1.5768225193023682, + "length": 4.105227947235107, + "height": 2.5724740028381348, + }, + "static.prop.swingcouch": { + "width": 2.362380266189575, + "length": 1.357409954071045, + "height": 2.2493832111358643, + }, + "static.prop.trafficcone01": { + "width": 0.9552291035652161, + "length": 0.955228865146637, + "height": 1.2336182594299316, + }, + "static.prop.trafficcone02": { + "width": 0.3945564925670624, + "length": 0.455596923828125, + "height": 1.1829206943511963, + }, + "static.prop.trafficwarning": { + "width": 2.8705859184265137, + "length": 2.373429536819458, + "height": 3.569523334503174, + }, + "static.prop.trampoline": { + "width": 4.131584644317627, + "length": 4.131584644317627, + "height": 2.801903247833252, + }, + "static.prop.trashbag": { + "width": 0.42955997586250305, + "length": 0.3779701590538025, + "height": 0.702082097530365, + }, + "static.prop.trashcan01": { + "width": 0.37456512451171875, + "length": 0.5437172055244446, + "height": 1.2140138149261475, + }, + "static.prop.trashcan02": { + "width": 0.6696233749389648, + "length": 0.7931143045425415, + "height": 1.0590858459472656, + }, + "static.prop.trashcan03": { + "width": 0.7573251724243164, + "length": 0.7565627098083496, + "height": 0.9889887571334839, + }, + "static.prop.trashcan04": { + "width": 0.5135547518730164, + "length": 0.5238340497016907, + "height": 1.0454455614089966, + }, + "static.prop.travelcase": { + "width": 0.3276098966598511, + "length": 0.5673347115516663, + "height": 1.262577772140503, + }, + "static.prop.vendingmachine": { + "width": 1.102043867111206, + "length": 0.8728882670402527, + "height": 2.1082382202148438, + }, + "static.prop.warningaccident": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602019548416138, + }, + "static.prop.warningconstruction": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602019548416138, + }, + "vehicle.ambulance.ford": { + "width": 2.3511743545532227, + "length": 6.356935024261475, + "height": 2.431001663208008, + }, + "vehicle.carlacola.actors": { + "width": 2.9117815494537354, + "length": 8.003673553466797, + "height": 4.054566383361816, + }, + "vehicle.dodge.charger": { + "width": 1.8809516429901123, + "length": 5.006043434143066, + "height": 1.540313720703125, + }, + "vehicle.dodgecop.charger": { + "width": 1.9244433641433716, + "length": 5.236761569976807, + "height": 1.6439720392227173, + }, + "vehicle.firetruck.actors": { + "width": 2.90128493309021, + "length": 8.579916000366211, + "height": 3.8267812728881836, + }, + "vehicle.fuso.mitsubishi": { + "width": 3.927680492401123, + "length": 10.174287796020508, + "height": 4.241000652313232, + }, + "vehicle.lincoln.mkz": { + "width": 1.8356460332870483, + "length": 4.891970157623291, + "height": 1.5241189002990723, + }, + "vehicle.mini.cooper": { + "width": 2.0955777168273926, + "length": 4.55255126953125, + "height": 1.7724559307098389, + }, + "vehicle.nissan.patrol": { + "width": 2.1466238498687744, + "length": 5.591163635253906, + "height": 2.05902099609375, + }, + "vehicle.sprinter.mercedes": { + "width": 1.988406777381897, + "length": 5.9151387214660645, + "height": 2.7260189056396484, + }, + "vehicle.taxi.ford": { + "width": 1.7890609502792358, + "length": 5.354491233825684, + "height": 1.575230598449707, + }, + "walker.pedestrian.0015": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0016": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0017": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0018": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0019": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0020": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0021": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0022": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0023": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0024": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0025": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0026": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0027": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0028": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0029": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0030": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0031": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0032": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0033": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0034": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8700000047683716, + }, + "walker.pedestrian.0035": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.9149999618530273, + }, + "walker.pedestrian.0036": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.9199999570846558, + }, + "walker.pedestrian.0037": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.9199999570846558, + }, + "walker.pedestrian.0038": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8700000047683716, + }, + "walker.pedestrian.0039": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0040": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0041": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8399999141693115, + }, + "walker.pedestrian.0042": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8299999237060547, + }, + "walker.pedestrian.0043": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8299999237060547, + }, + "walker.pedestrian.0044": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8299999237060547, + }, + "walker.pedestrian.0045": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0046": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0047": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8499999046325684, + }, + "walker.pedestrian.0048": { + "width": 0.5, + "length": 0.5, + "height": 1.1100000143051147, + }, + "walker.pedestrian.0049": { + "width": 0.5, + "length": 0.5, + "height": 1.1100000143051147, + }, + "walker.pedestrian.0050": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.9049999713897705, + }, + "walker.pedestrian.0051": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.9049999713897705, + }, + }, + "0.9.16": { + "static.prop.advertisement": { + "width": 0.28684958815574646, + "length": 1.549233317375183, + "height": 2.442812442779541, + }, + "static.prop.aporosatree": { + "width": 12.584906578063965, + "length": 12.958136558532715, + "height": 15.63304615020752, + }, + "static.prop.atm": { + "width": 0.8738368153572083, + "length": 0.7131599187850952, + "height": 2.2757811546325684, + }, + "static.prop.barbeque": { + "width": 0.49884626269340515, + "length": 0.9456468820571899, + "height": 1.264062523841858, + }, + "static.prop.barrel": { + "width": 0.47143638134002686, + "length": 0.4596165418624878, + "height": 0.8067187070846558, + }, + "static.prop.bench01": { + "width": 0.735850989818573, + "length": 1.5440508127212524, + "height": 0.9697656035423279, + }, + "static.prop.bench02": { + "width": 0.5445890426635742, + "length": 1.5636827945709229, + "height": 0.5090624690055847, + }, + "static.prop.bench03": { + "width": 0.5126523971557617, + "length": 1.58349609375, + "height": 0.5126562118530273, + }, + "static.prop.bike helmet": { + "width": 0.2770470678806305, + "length": 0.18554961681365967, + "height": 0.1713281273841858, + }, + "static.prop.bin": { + "width": 0.6381588578224182, + "length": 0.548203706741333, + "height": 1.0591405630111694, + }, + "static.prop.box01": { + "width": 0.6521967053413391, + "length": 0.6521967649459839, + "height": 0.6915624737739563, + }, + "static.prop.box02": { + "width": 0.648569643497467, + "length": 0.648569643497467, + "height": 0.6485937237739563, + }, + "static.prop.box03": { + "width": 0.6700571775436401, + "length": 0.6608889102935791, + "height": 0.6485937237739563, + }, + "static.prop.briefcase": { + "width": 0.18117640912532806, + "length": 0.543353796005249, + "height": 0.43281248211860657, + }, + "static.prop.brokentile01": { + "width": 0.1760428547859192, + "length": 0.13444174826145172, + "height": 0.006562499795109034, + }, + "static.prop.brokentile02": { + "width": 0.15229617059230804, + "length": 0.20967696607112885, + "height": 0.007578124757856131, + }, + "static.prop.brokentile03": { + "width": 0.1449233442544937, + "length": 0.15445251762866974, + "height": 0.008515625260770321, + }, + "static.prop.brokentile04": { + "width": 0.12647496163845062, + "length": 0.12972931563854218, + "height": 0.01015624962747097, + }, + "static.prop.busstop": { + "width": 1.893609881401062, + "length": 3.8760783672332764, + "height": 2.738906145095825, + }, + "static.prop.busstoplb": { + "width": 3.8760783672332764, + "length": 1.893609881401062, + "height": 2.738906145095825, + }, + "static.prop.calibrator": { + "width": 0.690200924873352, + "length": 1.5475953817367554, + "height": 0.20195311307907104, + }, + "static.prop.chainbarrier": { + "width": 0.23859326541423798, + "length": 1.4941967725753784, + "height": 0.8887499570846558, + }, + "static.prop.chainbarrierend": { + "width": 0.23859326541423798, + "length": 0.23859328031539917, + "height": 0.8887499570846558, + }, + "static.prop.clothcontainer": { + "width": 1.8865405321121216, + "length": 1.2394330501556396, + "height": 1.810156226158142, + }, + "static.prop.clothesline": { + "width": 1.1180102825164795, + "length": 6.652331352233887, + "height": 2.0610156059265137, + }, + "static.prop.coconutpalm": { + "width": 8.885843276977539, + "length": 8.603760719299316, + "height": 20.31476593017578, + }, + "static.prop.colacan": { + "width": 0.07167824357748032, + "length": 0.06817007064819336, + "height": 0.10953124612569809, + }, + "static.prop.constructioncone": { + "width": 0.3440696597099304, + "length": 0.3440696597099304, + "height": 0.5857812166213989, + }, + "static.prop.container": { + "width": 1.0124343633651733, + "length": 1.9318325519561768, + "height": 1.7142187356948853, + }, + "static.prop.creasedbox01": { + "width": 0.7129369974136353, + "length": 0.8087886571884155, + "height": 0.11781249940395355, + }, + "static.prop.creasedbox02": { + "width": 0.7129369974136353, + "length": 1.6332061290740967, + "height": 0.2116406261920929, + }, + "static.prop.creasedbox03": { + "width": 0.7129369974136353, + "length": 1.6687225103378296, + "height": 0.021718749776482582, + }, + "static.prop.cypresstree": { + "width": 4.628193378448486, + "length": 4.6099138259887695, + "height": 15.678828239440918, + }, + "static.prop.dirtdebris01": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1489843726158142, + }, + "static.prop.dirtdebris02": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1489843726158142, + }, + "static.prop.dirtdebris03": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1883593648672104, + }, + "static.prop.doghouse": { + "width": 1.2721054553985596, + "length": 1.0791168212890625, + "height": 1.0936717987060547, + }, + "static.prop.foodcart": { + "width": 4.638397693634033, + "length": 2.2846763134002686, + "height": 3.573124885559082, + }, + "static.prop.fountain": { + "width": 8.401816368103027, + "length": 8.401811599731445, + "height": 6.924375057220459, + }, + "static.prop.garbage01": { + "width": 0.05342773348093033, + "length": 0.05342777073383331, + "height": 0.08679687231779099, + }, + "static.prop.garbage02": { + "width": 0.05342775210738182, + "length": 0.05342775210738182, + "height": 0.009218749590218067, + }, + "static.prop.garbage03": { + "width": 0.05944278463721275, + "length": 0.05965827777981758, + "height": 0.07929687201976776, + }, + "static.prop.garbage04": { + "width": 0.055681075900793076, + "length": 0.05727477744221687, + "height": 0.06882812082767487, + }, + "static.prop.garbage05": { + "width": 0.23253268003463745, + "length": 0.23187801241874695, + "height": 0.05757812410593033, + }, + "static.prop.garbage06": { + "width": 0.21333114802837372, + "length": 0.3661772906780243, + "height": 0.07679687440395355, + }, + "static.prop.gardenlamp": { + "width": 0.28122183680534363, + "length": 0.2972412407398224, + "height": 0.6478124856948853, + }, + "static.prop.glasscontainer": { + "width": 2.0087928771972656, + "length": 1.9547909498214722, + "height": 1.7884374856948853, + }, + "static.prop.gnome": { + "width": 0.37559059262275696, + "length": 0.36661627888679504, + "height": 0.8829687237739563, + }, + "static.prop.guitarcase": { + "width": 0.1251685619354248, + "length": 1.242393970489502, + "height": 0.494453102350235, + }, + "static.prop.haybale": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949999332427979, + }, + "static.prop.haybalelb": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949999332427979, + }, + "static.prop.ironplank": { + "width": 1.1743810176849365, + "length": 1.45187246799469, + "height": 0.020546874031424522, + }, + "static.prop.kiosk_01": { + "width": 1.6540685892105103, + "length": 1.6252474784851074, + "height": 3.2453906536102295, + }, + "static.prop.mailbox": { + "width": 0.43842464685440063, + "length": 0.526629626750946, + "height": 1.157265543937683, + }, + "static.prop.maptable": { + "width": 0.6170762777328491, + "length": 1.5732090473175049, + "height": 1.181093692779541, + }, + "static.prop.mobile": { + "width": 0.007261085323989391, + "length": 0.08048340678215027, + "height": 0.16640624403953552, + }, + "static.prop.motorhelmet": { + "width": 0.2592744827270508, + "length": 0.20041197538375854, + "height": 0.2581250071525574, + }, + "static.prop.pergola": { + "width": 5.085033893585205, + "length": 5.029393672943115, + "height": 3.54156231880188, + }, + "static.prop.plantpot01": { + "width": 0.4757719337940216, + "length": 1.08914053440094, + "height": 0.5573437213897705, + }, + "static.prop.plantpot02": { + "width": 0.43072694540023804, + "length": 1.0511938333511353, + "height": 0.48695310950279236, + }, + "static.prop.plantpot03": { + "width": 0.43072694540023804, + "length": 1.0511937141418457, + "height": 0.48695310950279236, + }, + "static.prop.plantpot04": { + "width": 0.7366006970405579, + "length": 4.967305660247803, + "height": 0.12476561963558197, + }, + "static.prop.plantpot05": { + "width": 0.40022480487823486, + "length": 0.40022480487823486, + "height": 0.2800000011920929, + }, + "static.prop.plantpot06": { + "width": 1.5932812690734863, + "length": 1.5932812690734863, + "height": 0.8495312333106995, + }, + "static.prop.plantpot07": { + "width": 0.4757719337940216, + "length": 0.4946027398109436, + "height": 0.5573437213897705, + }, + "static.prop.plantpot08": { + "width": 0.5304248929023743, + "length": 0.5304248929023743, + "height": 0.48890623450279236, + }, + "static.prop.plasticbag": { + "width": 0.4025624990463257, + "length": 0.3109833598136902, + "height": 0.5523437261581421, + }, + "static.prop.plasticchair": { + "width": 0.7504488825798035, + "length": 0.7304753661155701, + "height": 1.271328091621399, + }, + "static.prop.plastictable": { + "width": 2.482203245162964, + "length": 2.482203245162964, + "height": 2.4797656536102295, + }, + "static.prop.platformgarbage01": { + "width": 1.7114051580429077, + "length": 3.6332411766052246, + "height": 0.33156248927116394, + }, + "static.prop.purse": { + "width": 0.3326959013938904, + "length": 0.15921516716480255, + "height": 0.5852343440055847, + }, + "static.prop.shoppingbag": { + "width": 0.2432928830385208, + "length": 0.47446563839912415, + "height": 0.6006249785423279, + }, + "static.prop.shoppingcart": { + "width": 1.207547903060913, + "length": 0.6694015264511108, + "height": 1.0792968273162842, + }, + "static.prop.shoppingtrolley": { + "width": 0.33110183477401733, + "length": 0.5055894255638123, + "height": 1.0936717987060547, + }, + "static.prop.slide": { + "width": 4.122486591339111, + "length": 0.9425268173217773, + "height": 1.9766405820846558, + }, + "static.prop.streetbarrier": { + "width": 0.3716789782047272, + "length": 1.2149077653884888, + "height": 1.0691405534744263, + }, + "static.prop.streetfountain": { + "width": 0.6702813506126404, + "length": 0.2869437038898468, + "height": 1.259374976158142, + }, + "static.prop.streetsign": { + "width": 0.1252390295267105, + "length": 1.0643447637557983, + "height": 2.154374837875366, + }, + "static.prop.streetsign01": { + "width": 0.3029319941997528, + "length": 2.470391273498535, + "height": 3.8779685497283936, + }, + "static.prop.streetsign04": { + "width": 0.12483911216259003, + "length": 1.1708152294158936, + "height": 2.772890567779541, + }, + "static.prop.swing": { + "width": 1.5768225193023682, + "length": 4.105227947235107, + "height": 2.572499990463257, + }, + "static.prop.swingcouch": { + "width": 2.362380266189575, + "length": 1.357409954071045, + "height": 2.2493748664855957, + }, + "static.prop.table": { + "width": 2.1518361568450928, + "length": 2.1562821865081787, + "height": 0.846484363079071, + }, + "static.prop.trafficcone01": { + "width": 0.8821874856948853, + "length": 0.8828125, + "height": 1.1332812309265137, + }, + "static.prop.trafficcone02": { + "width": 0.3945564925670624, + "length": 0.455596923828125, + "height": 1.1828906536102295, + }, + "static.prop.trafficwarning": { + "width": 2.8705859184265137, + "length": 2.373429536819458, + "height": 3.569531202316284, + }, + "static.prop.trampoline": { + "width": 4.131584644317627, + "length": 4.131584644317627, + "height": 2.801874876022339, + }, + "static.prop.trashbag": { + "width": 0.42955997586250305, + "length": 0.3779701590538025, + "height": 0.7021093368530273, + }, + "static.prop.trashcan01": { + "width": 0.5678514838218689, + "length": 0.6352845430374146, + "height": 0.8482031226158142, + }, + "static.prop.trashcan02": { + "width": 0.5135547518730164, + "length": 0.5238340497016907, + "height": 1.0454686880111694, + }, + "static.prop.trashcan03": { + "width": 0.5453212857246399, + "length": 0.6293092370033264, + "height": 0.93359375, + }, + "static.prop.trashcan04": { + "width": 0.5803966522216797, + "length": 0.7887265086174011, + "height": 1.0163280963897705, + }, + "static.prop.trashcan05": { + "width": 0.5803966522216797, + "length": 0.7887265086174011, + "height": 1.0163280963897705, + }, + "static.prop.travelcase": { + "width": 0.3276098966598511, + "length": 0.5673347115516663, + "height": 1.2625781297683716, + }, + "static.prop.vendingmachine": { + "width": 1.102043867111206, + "length": 0.8728882670402527, + "height": 2.108203172683716, + }, + "static.prop.warningaccident": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602343797683716, + }, + "static.prop.warningconstruction": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602343797683716, + }, + "static.prop.wateringcan": { + "width": 0.19403739273548126, + "length": 0.07035235315561295, + "height": 0.13484375178813934, + }, + "vehicle.audi.a2": { + "width": 1.788678526878357, + "length": 3.705369472503662, + "height": 1.549050211906433, + }, + "vehicle.audi.etron": { + "width": 2.0327565670013428, + "length": 4.855708599090576, + "height": 1.6493593454360962, + }, + "vehicle.audi.tt": { + "width": 1.9941171407699585, + "length": 4.181210041046143, + "height": 1.385296106338501, + }, + "vehicle.bh.crossbike": { + "width": 0.8659406304359436, + "length": 1.5093227624893188, + "height": 1.6123536825180054, + }, + "vehicle.bmw.grandtourer": { + "width": 2.241713285446167, + "length": 4.611005783081055, + "height": 1.6672759056091309, + }, + "vehicle.carlamotors.carlacola": { + "width": 2.6269896030426025, + "length": 5.203838348388672, + "height": 2.467444658279419, + }, + "vehicle.carlamotors.european_hgv": { + "width": 2.8910882472991943, + "length": 7.935710430145264, + "height": 3.4619433879852295, + }, + "vehicle.carlamotors.firetruck": { + "width": 2.8910882472991943, + "length": 8.46804141998291, + "height": 3.8274123668670654, + }, + "vehicle.chevrolet.impala": { + "width": 2.033202886581421, + "length": 5.357479572296143, + "height": 1.4106587171554565, + }, + "vehicle.citroen.c3": { + "width": 1.8508483171463013, + "length": 3.987684965133667, + "height": 1.6171095371246338, + }, + "vehicle.diamondback.century": { + "width": 0.5824381709098816, + "length": 1.6562436819076538, + "height": 1.6197669506072998, + }, + "vehicle.dodge.charger_2020": { + "width": 1.8816219568252563, + "length": 5.0078253746032715, + "height": 1.5347249507904053, + }, + "vehicle.dodge.charger_police": { + "width": 2.0384011268615723, + "length": 4.974244117736816, + "height": 1.5421181917190552, + }, + "vehicle.dodge.charger_police_2020": { + "width": 1.9297595024108887, + "length": 5.237514495849609, + "height": 1.638383150100708, + }, + "vehicle.ford.ambulance": { + "width": 2.3511743545532227, + "length": 6.36564302444458, + "height": 2.431375741958618, + }, + "vehicle.ford.crown": { + "width": 1.8007241487503052, + "length": 5.365678787231445, + "height": 1.5749659538269043, + }, + "vehicle.ford.mustang": { + "width": 1.894826889038086, + "length": 4.717525005340576, + "height": 1.300939917564392, + }, + "vehicle.gazelle.omafiets": { + "width": 0.6590427160263062, + "length": 1.843441367149353, + "height": 1.7758288383483887, + }, + "vehicle.harley-davidson.low_rider": { + "width": 0.7662330269813538, + "length": 2.350175619125366, + "height": 1.6494941711425781, + }, + "vehicle.jeep.wrangler_rubicon": { + "width": 1.9051965475082397, + "length": 3.866220712661743, + "height": 1.8779358863830566, + }, + "vehicle.kawasaki.ninja": { + "width": 0.7969123125076294, + "length": 2.043684244155884, + "height": 1.523191213607788, + }, + "vehicle.lincoln.mkz_2017": { + "width": 2.128324270248413, + "length": 4.901683330535889, + "height": 1.5107464790344238, + }, + "vehicle.lincoln.mkz_2020": { + "width": 1.8367133140563965, + "length": 4.89238166809082, + "height": 1.490277647972107, + }, + "vehicle.mercedes.coupe": { + "width": 2.1515462398529053, + "length": 5.0267767906188965, + "height": 1.6506516933441162, + }, + "vehicle.mercedes.coupe_2020": { + "width": 1.8118125200271606, + "length": 4.673638820648193, + "height": 1.441947340965271, + }, + "vehicle.mercedes.sprinter": { + "width": 1.9884328842163086, + "length": 5.91519021987915, + "height": 2.560655355453491, + }, + "vehicle.micro.microlino": { + "width": 1.4809197187423706, + "length": 2.2072951793670654, + "height": 1.3760247230529785, + }, + "vehicle.mini.cooper_s": { + "width": 1.970275640487671, + "length": 3.805800199508667, + "height": 1.4750301837921143, + }, + "vehicle.mini.cooper_s_2021": { + "width": 2.097072124481201, + "length": 4.552699089050293, + "height": 1.7671663761138916, + }, + "vehicle.mitsubishi.fusorosa": { + "width": 3.9441518783569336, + "length": 10.272685050964355, + "height": 4.252848148345947, + }, + "vehicle.nissan.micra": { + "width": 1.845113754272461, + "length": 3.633375883102417, + "height": 1.5012825727462769, + }, + "vehicle.nissan.patrol": { + "width": 1.9315929412841797, + "length": 4.6045098304748535, + "height": 1.8548461198806763, + }, + "vehicle.nissan.patrol_2021": { + "width": 2.1499669551849365, + "length": 5.565828800201416, + "height": 2.045147180557251, + }, + "vehicle.seat.leon": { + "width": 1.8161858320236206, + "length": 4.1928300857543945, + "height": 1.4738311767578125, + }, + "vehicle.tesla.cybertruck": { + "width": 2.3895740509033203, + "length": 6.273553371429443, + "height": 2.098191261291504, + }, + "vehicle.tesla.model3": { + "width": 2.163450002670288, + "length": 4.791779518127441, + "height": 1.4876600503921509, + }, + "vehicle.toyota.prius": { + "width": 2.006814479827881, + "length": 4.513522624969482, + "height": 1.5248334407806396, + }, + "vehicle.vespa.zx125": { + "width": 0.8659406304359436, + "length": 1.8171066045761108, + "height": 1.5900908708572388, + }, + "vehicle.volkswagen.t2": { + "width": 2.069315195083618, + "length": 4.4804368019104, + "height": 2.0377919673919678, + }, + "vehicle.volkswagen.t2_2021": { + "width": 1.77456533908844, + "length": 4.442183971405029, + "height": 1.9872066974639893, + }, + "vehicle.yamaha.yzf": { + "width": 0.8659172654151917, + "length": 2.1907684803009033, + "height": 1.530381441116333, + }, + "walker.pedestrian.0001": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0002": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0003": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0004": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0005": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0006": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0007": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0008": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0009": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0010": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0011": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0012": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0013": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0014": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0015": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0016": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0017": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0018": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0019": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0020": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0021": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0022": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0023": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0024": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0025": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0026": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0027": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0028": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0029": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0030": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0031": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0032": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0033": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0034": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0035": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0036": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0037": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0038": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0039": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0040": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0041": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0042": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0043": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0044": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0045": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0046": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0047": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0048": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0049": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0050": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0051": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0052": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + }, + "0.9.15": { + "static.prop.advertisement": { + "width": 0.28684958815574646, + "length": 1.549233317375183, + "height": 2.442812442779541, + }, + "static.prop.atm": { + "width": 0.8738368153572083, + "length": 0.7131599187850952, + "height": 2.2757811546325684, + }, + "static.prop.barbeque": { + "width": 0.49884626269340515, + "length": 0.9456468820571899, + "height": 1.264062523841858, + }, + "static.prop.barrel": { + "width": 0.47143638134002686, + "length": 0.4596165418624878, + "height": 0.8067187070846558, + }, + "static.prop.bench01": { + "width": 0.735850989818573, + "length": 1.5440508127212524, + "height": 0.9697656035423279, + }, + "static.prop.bench02": { + "width": 0.5445890426635742, + "length": 1.5636827945709229, + "height": 0.5090624690055847, + }, + "static.prop.bench03": { + "width": 0.5126523971557617, + "length": 1.58349609375, + "height": 0.5126562118530273, + }, + "static.prop.bike helmet": { + "width": 0.2770470678806305, + "length": 0.18554961681365967, + "height": 0.1713281273841858, + }, + "static.prop.bin": { + "width": 0.6381588578224182, + "length": 0.548203706741333, + "height": 1.0591405630111694, + }, + "static.prop.box01": { + "width": 0.6521967053413391, + "length": 0.6521967649459839, + "height": 0.6915624737739563, + }, + "static.prop.box02": { + "width": 0.648569643497467, + "length": 0.648569643497467, + "height": 0.6485937237739563, + }, + "static.prop.box03": { + "width": 0.6700571775436401, + "length": 0.6608889102935791, + "height": 0.6485937237739563, + }, + "static.prop.briefcase": { + "width": 0.18117640912532806, + "length": 0.543353796005249, + "height": 0.43281248211860657, + }, + "static.prop.brokentile01": { + "width": 0.1760428547859192, + "length": 0.13444174826145172, + "height": 0.006562499795109034, + }, + "static.prop.brokentile02": { + "width": 0.15229617059230804, + "length": 0.20967696607112885, + "height": 0.007578124757856131, + }, + "static.prop.brokentile03": { + "width": 0.1449233442544937, + "length": 0.15445251762866974, + "height": 0.008515625260770321, + }, + "static.prop.brokentile04": { + "width": 0.12647496163845062, + "length": 0.12972931563854218, + "height": 0.01015624962747097, + }, + "static.prop.busstop": { + "width": 1.893609881401062, + "length": 3.8760783672332764, + "height": 2.738906145095825, + }, + "static.prop.busstoplb": { + "width": 3.8760783672332764, + "length": 1.893609881401062, + "height": 2.738906145095825, + }, + "static.prop.calibrator": { + "width": 0.690200924873352, + "length": 1.5475953817367554, + "height": 0.20195311307907104, + }, + "static.prop.chainbarrier": { + "width": 0.23859326541423798, + "length": 1.4941967725753784, + "height": 0.8887499570846558, + }, + "static.prop.chainbarrierend": { + "width": 0.23859326541423798, + "length": 0.23859328031539917, + "height": 0.8887499570846558, + }, + "static.prop.clothcontainer": { + "width": 1.8865405321121216, + "length": 1.2394330501556396, + "height": 1.810156226158142, + }, + "static.prop.clothesline": { + "width": 1.1180102825164795, + "length": 6.652331352233887, + "height": 2.0610156059265137, + }, + "static.prop.colacan": { + "width": 0.07167824357748032, + "length": 0.06817007064819336, + "height": 0.10953124612569809, + }, + "static.prop.constructioncone": { + "width": 0.3440696597099304, + "length": 0.3440696597099304, + "height": 0.5857812166213989, + }, + "static.prop.container": { + "width": 1.0124343633651733, + "length": 1.9318325519561768, + "height": 1.7142187356948853, + }, + "static.prop.creasedbox01": { + "width": 0.7129369974136353, + "length": 0.8087886571884155, + "height": 0.11781249940395355, + }, + "static.prop.creasedbox02": { + "width": 0.7129369974136353, + "length": 1.6332061290740967, + "height": 0.2116406261920929, + }, + "static.prop.creasedbox03": { + "width": 0.7129369974136353, + "length": 1.6687225103378296, + "height": 0.021718749776482582, + }, + "static.prop.dirtdebris01": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1489843726158142, + }, + "static.prop.dirtdebris02": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1489843726158142, + }, + "static.prop.dirtdebris03": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1883593648672104, + }, + "static.prop.doghouse": { + "width": 1.2721054553985596, + "length": 1.0791168212890625, + "height": 1.0936717987060547, + }, + "static.prop.foodcart": { + "width": 4.638397693634033, + "length": 2.2846763134002686, + "height": 3.573124885559082, + }, + "static.prop.fountain": { + "width": 8.401816368103027, + "length": 8.401811599731445, + "height": 6.924375057220459, + }, + "static.prop.garbage01": { + "width": 0.05342773348093033, + "length": 0.05342777073383331, + "height": 0.08679687231779099, + }, + "static.prop.garbage02": { + "width": 0.05342775210738182, + "length": 0.05342775210738182, + "height": 0.009218749590218067, + }, + "static.prop.garbage03": { + "width": 0.05944278463721275, + "length": 0.05965827777981758, + "height": 0.07929687201976776, + }, + "static.prop.garbage04": { + "width": 0.055681075900793076, + "length": 0.05727477744221687, + "height": 0.06882812082767487, + }, + "static.prop.garbage05": { + "width": 0.23253268003463745, + "length": 0.23187801241874695, + "height": 0.05757812410593033, + }, + "static.prop.garbage06": { + "width": 0.21333114802837372, + "length": 0.3661772906780243, + "height": 0.07679687440395355, + }, + "static.prop.gardenlamp": { + "width": 0.28122183680534363, + "length": 0.2972412407398224, + "height": 0.6478124856948853, + }, + "static.prop.glasscontainer": { + "width": 2.0087928771972656, + "length": 1.9547909498214722, + "height": 1.7884374856948853, + }, + "static.prop.gnome": { + "width": 0.37559059262275696, + "length": 0.36661627888679504, + "height": 0.8829687237739563, + }, + "static.prop.guitarcase": { + "width": 0.1251685619354248, + "length": 1.242393970489502, + "height": 0.494453102350235, + }, + "static.prop.haybale": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949999332427979, + }, + "static.prop.haybalelb": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949999332427979, + }, + "static.prop.ironplank": { + "width": 1.1743810176849365, + "length": 1.45187246799469, + "height": 0.020546874031424522, + }, + "static.prop.kiosk_01": { + "width": 1.6540685892105103, + "length": 1.6252474784851074, + "height": 3.2453906536102295, + }, + "static.prop.mailbox": { + "width": 0.43842464685440063, + "length": 0.526629626750946, + "height": 1.157265543937683, + }, + "static.prop.maptable": { + "width": 0.6170762777328491, + "length": 1.5732090473175049, + "height": 1.181093692779541, + }, + "static.prop.mobile": { + "width": 0.007261085323989391, + "length": 0.08048340678215027, + "height": 0.16640624403953552, + }, + "static.prop.motorhelmet": { + "width": 0.2592744827270508, + "length": 0.20041197538375854, + "height": 0.2581250071525574, + }, + "static.prop.pergola": { + "width": 5.085033893585205, + "length": 5.029393672943115, + "height": 3.54156231880188, + }, + "static.prop.plantpot01": { + "width": 0.4757719337940216, + "length": 1.08914053440094, + "height": 0.5573437213897705, + }, + "static.prop.plantpot02": { + "width": 0.43072694540023804, + "length": 1.0511938333511353, + "height": 0.48695310950279236, + }, + "static.prop.plantpot03": { + "width": 0.43072694540023804, + "length": 1.0511937141418457, + "height": 0.48695310950279236, + }, + "static.prop.plantpot04": { + "width": 0.7366006970405579, + "length": 4.967305660247803, + "height": 0.12476561963558197, + }, + "static.prop.plantpot05": { + "width": 0.40022480487823486, + "length": 0.40022480487823486, + "height": 0.2800000011920929, + }, + "static.prop.plantpot06": { + "width": 1.5932812690734863, + "length": 1.5932812690734863, + "height": 0.8495312333106995, + }, + "static.prop.plantpot07": { + "width": 0.4757719337940216, + "length": 0.4946027398109436, + "height": 0.5573437213897705, + }, + "static.prop.plantpot08": { + "width": 0.5304248929023743, + "length": 0.5304248929023743, + "height": 0.48890623450279236, + }, + "static.prop.plasticbag": { + "width": 0.4025624990463257, + "length": 0.3109833598136902, + "height": 0.5523437261581421, + }, + "static.prop.plasticchair": { + "width": 0.7504488825798035, + "length": 0.7304753661155701, + "height": 1.271328091621399, + }, + "static.prop.plastictable": { + "width": 2.482203245162964, + "length": 2.482203245162964, + "height": 2.4797656536102295, + }, + "static.prop.platformgarbage01": { + "width": 1.7114051580429077, + "length": 3.6332411766052246, + "height": 0.33156248927116394, + }, + "static.prop.purse": { + "width": 0.3326959013938904, + "length": 0.15921516716480255, + "height": 0.5852343440055847, + }, + "static.prop.shoppingbag": { + "width": 0.2432928830385208, + "length": 0.47446563839912415, + "height": 0.6006249785423279, + }, + "static.prop.shoppingcart": { + "width": 1.207547903060913, + "length": 0.6694015264511108, + "height": 1.0792968273162842, + }, + "static.prop.shoppingtrolley": { + "width": 0.33110183477401733, + "length": 0.5055894255638123, + "height": 1.0936717987060547, + }, + "static.prop.slide": { + "width": 4.122486591339111, + "length": 0.9425268173217773, + "height": 1.9766405820846558, + }, + "static.prop.streetbarrier": { + "width": 0.3716789782047272, + "length": 1.2149077653884888, + "height": 1.0691405534744263, + }, + "static.prop.streetfountain": { + "width": 0.6702813506126404, + "length": 0.2869437038898468, + "height": 1.259374976158142, + }, + "static.prop.streetsign": { + "width": 0.1252390295267105, + "length": 1.0643447637557983, + "height": 2.154374837875366, + }, + "static.prop.streetsign01": { + "width": 0.3029319941997528, + "length": 2.470391273498535, + "height": 3.8779685497283936, + }, + "static.prop.streetsign04": { + "width": 0.12483911216259003, + "length": 1.1708152294158936, + "height": 2.772890567779541, + }, + "static.prop.swing": { + "width": 1.5768225193023682, + "length": 4.105227947235107, + "height": 2.572499990463257, + }, + "static.prop.swingcouch": { + "width": 2.362380266189575, + "length": 1.357409954071045, + "height": 2.2493748664855957, + }, + "static.prop.table": { + "width": 2.1518361568450928, + "length": 2.1562821865081787, + "height": 0.846484363079071, + }, + "static.prop.trafficcone01": { + "width": 0.8821874856948853, + "length": 0.8828125, + "height": 1.1332812309265137, + }, + "static.prop.trafficcone02": { + "width": 0.3945564925670624, + "length": 0.455596923828125, + "height": 1.1828906536102295, + }, + "static.prop.trafficwarning": { + "width": 2.8705859184265137, + "length": 2.373429536819458, + "height": 3.569531202316284, + }, + "static.prop.trampoline": { + "width": 4.131584644317627, + "length": 4.131584644317627, + "height": 2.801874876022339, + }, + "static.prop.trashbag": { + "width": 0.42955997586250305, + "length": 0.3779701590538025, + "height": 0.7021093368530273, + }, + "static.prop.trashcan01": { + "width": 0.5678514838218689, + "length": 0.6352845430374146, + "height": 0.8482031226158142, + }, + "static.prop.trashcan02": { + "width": 0.5135547518730164, + "length": 0.5238340497016907, + "height": 1.0454686880111694, + }, + "static.prop.trashcan03": { + "width": 0.5453212857246399, + "length": 0.6293092370033264, + "height": 0.93359375, + }, + "static.prop.trashcan04": { + "width": 0.5803966522216797, + "length": 0.7887265086174011, + "height": 1.0163280963897705, + }, + "static.prop.trashcan05": { + "width": 0.5803966522216797, + "length": 0.7887265086174011, + "height": 1.0163280963897705, + }, + "static.prop.travelcase": { + "width": 0.3276098966598511, + "length": 0.5673347115516663, + "height": 1.2625781297683716, + }, + "static.prop.vendingmachine": { + "width": 1.102043867111206, + "length": 0.8728882670402527, + "height": 2.108203172683716, + }, + "static.prop.warningaccident": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602343797683716, + }, + "static.prop.warningconstruction": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602343797683716, + }, + "static.prop.wateringcan": { + "width": 0.19403739273548126, + "length": 0.07035235315561295, + "height": 0.13484375178813934, + }, + "vehicle.audi.a2": { + "width": 1.788678526878357, + "length": 3.705369472503662, + "height": 1.549050211906433, + }, + "vehicle.audi.etron": { + "width": 2.0327565670013428, + "length": 4.855708599090576, + "height": 1.6493593454360962, + }, + "vehicle.audi.tt": { + "width": 1.9941171407699585, + "length": 4.181210041046143, + "height": 1.385296106338501, + }, + "vehicle.bh.crossbike": { + "width": 0.8659406304359436, + "length": 1.5093227624893188, + "height": 1.6123536825180054, + }, + "vehicle.bmw.grandtourer": { + "width": 2.241713285446167, + "length": 4.611005783081055, + "height": 1.6672759056091309, + }, + "vehicle.carlamotors.carlacola": { + "width": 2.6269896030426025, + "length": 5.203838348388672, + "height": 2.467444658279419, + }, + "vehicle.carlamotors.european_hgv": { + "width": 2.8910882472991943, + "length": 7.935710430145264, + "height": 3.4619433879852295, + }, + "vehicle.carlamotors.firetruck": { + "width": 2.8910882472991943, + "length": 8.46804141998291, + "height": 3.8274123668670654, + }, + "vehicle.chevrolet.impala": { + "width": 2.033202886581421, + "length": 5.357479572296143, + "height": 1.4106587171554565, + }, + "vehicle.citroen.c3": { + "width": 1.8508483171463013, + "length": 3.987684965133667, + "height": 1.6171095371246338, + }, + "vehicle.diamondback.century": { + "width": 0.5824381709098816, + "length": 1.6562436819076538, + "height": 1.6197669506072998, + }, + "vehicle.dodge.charger_2020": { + "width": 1.8816219568252563, + "length": 5.0078253746032715, + "height": 1.5347249507904053, + }, + "vehicle.dodge.charger_police": { + "width": 2.0384011268615723, + "length": 4.974244117736816, + "height": 1.5421181917190552, + }, + "vehicle.dodge.charger_police_2020": { + "width": 1.9297595024108887, + "length": 5.237514495849609, + "height": 1.638383150100708, + }, + "vehicle.ford.ambulance": { + "width": 2.3511743545532227, + "length": 6.36564302444458, + "height": 2.431375741958618, + }, + "vehicle.ford.crown": { + "width": 1.8007241487503052, + "length": 5.365678787231445, + "height": 1.5749659538269043, + }, + "vehicle.ford.mustang": { + "width": 1.894826889038086, + "length": 4.717525005340576, + "height": 1.300939917564392, + }, + "vehicle.gazelle.omafiets": { + "width": 0.6590427160263062, + "length": 1.843441367149353, + "height": 1.7758288383483887, + }, + "vehicle.harley-davidson.low_rider": { + "width": 0.7662330269813538, + "length": 2.350175619125366, + "height": 1.6494941711425781, + }, + "vehicle.jeep.wrangler_rubicon": { + "width": 1.9051965475082397, + "length": 3.866220712661743, + "height": 1.8779358863830566, + }, + "vehicle.kawasaki.ninja": { + "width": 0.7969123125076294, + "length": 2.043684244155884, + "height": 1.523191213607788, + }, + "vehicle.lincoln.mkz_2017": { + "width": 2.128324270248413, + "length": 4.901683330535889, + "height": 1.5107464790344238, + }, + "vehicle.lincoln.mkz_2020": { + "width": 1.8367133140563965, + "length": 4.89238166809082, + "height": 1.490277647972107, + }, + "vehicle.mercedes.coupe": { + "width": 2.1515462398529053, + "length": 5.0267767906188965, + "height": 1.6506516933441162, + }, + "vehicle.mercedes.coupe_2020": { + "width": 1.8118125200271606, + "length": 4.673638820648193, + "height": 1.441947340965271, + }, + "vehicle.mercedes.sprinter": { + "width": 1.9884328842163086, + "length": 5.91519021987915, + "height": 2.560655355453491, + }, + "vehicle.micro.microlino": { + "width": 1.4809197187423706, + "length": 2.2072951793670654, + "height": 1.3760247230529785, + }, + "vehicle.mini.cooper_s": { + "width": 1.970275640487671, + "length": 3.805800199508667, + "height": 1.4750301837921143, + }, + "vehicle.mini.cooper_s_2021": { + "width": 2.097072124481201, + "length": 4.552699089050293, + "height": 1.7671663761138916, + }, + "vehicle.mitsubishi.fusorosa": { + "width": 3.9441518783569336, + "length": 10.272685050964355, + "height": 4.252848148345947, + }, + "vehicle.nissan.micra": { + "width": 1.845113754272461, + "length": 3.633375883102417, + "height": 1.5012825727462769, + }, + "vehicle.nissan.patrol": { + "width": 1.9315929412841797, + "length": 4.6045098304748535, + "height": 1.8548461198806763, + }, + "vehicle.nissan.patrol_2021": { + "width": 2.1499669551849365, + "length": 5.565828800201416, + "height": 2.045147180557251, + }, + "vehicle.seat.leon": { + "width": 1.8161858320236206, + "length": 4.1928300857543945, + "height": 1.4738311767578125, + }, + "vehicle.tesla.cybertruck": { + "width": 2.3895740509033203, + "length": 6.273553371429443, + "height": 2.098191261291504, + }, + "vehicle.tesla.model3": { + "width": 2.163450002670288, + "length": 4.791779518127441, + "height": 1.4876600503921509, + }, + "vehicle.toyota.prius": { + "width": 2.006814479827881, + "length": 4.513522624969482, + "height": 1.5248334407806396, + }, + "vehicle.vespa.zx125": { + "width": 0.8659406304359436, + "length": 1.8171066045761108, + "height": 1.5900908708572388, + }, + "vehicle.volkswagen.t2": { + "width": 2.069315195083618, + "length": 4.4804368019104, + "height": 2.0377919673919678, + }, + "vehicle.volkswagen.t2_2021": { + "width": 1.77456533908844, + "length": 4.442183971405029, + "height": 1.9872066974639893, + }, + "vehicle.yamaha.yzf": { + "width": 0.8659172654151917, + "length": 2.1907684803009033, + "height": 1.530381441116333, + }, + "walker.pedestrian.0001": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0002": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0003": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0004": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0005": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0006": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0007": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0008": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0009": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0010": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0011": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0012": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0013": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0014": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0015": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0016": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0017": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0018": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0019": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0020": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0021": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0022": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0023": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0024": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0025": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0026": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0027": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0028": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0029": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0030": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0031": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0032": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0033": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0034": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0035": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0036": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0037": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0038": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0039": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0040": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0041": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0042": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0043": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0044": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0045": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0046": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0047": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0048": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0049": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0050": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0051": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + }, + "0.9.14": { + "static.prop.advertisement": { + "width": 0.28684958815574646, + "length": 1.549233317375183, + "height": 2.442812442779541, + }, + "static.prop.atm": { + "width": 0.8738368153572083, + "length": 0.7131599187850952, + "height": 2.2757811546325684, + }, + "static.prop.barbeque": { + "width": 0.49884626269340515, + "length": 0.9456468820571899, + "height": 1.264062523841858, + }, + "static.prop.barrel": { + "width": 0.47143638134002686, + "length": 0.4596165418624878, + "height": 0.8067187070846558, + }, + "static.prop.bench01": { + "width": 0.735850989818573, + "length": 1.5440508127212524, + "height": 0.9697656035423279, + }, + "static.prop.bench02": { + "width": 0.5445890426635742, + "length": 1.5636827945709229, + "height": 0.5090624690055847, + }, + "static.prop.bench03": { + "width": 0.5126523971557617, + "length": 1.58349609375, + "height": 0.5126562118530273, + }, + "static.prop.bike helmet": { + "width": 0.2770470678806305, + "length": 0.18554961681365967, + "height": 0.1713281273841858, + }, + "static.prop.bin": { + "width": 0.6381588578224182, + "length": 0.548203706741333, + "height": 1.0591405630111694, + }, + "static.prop.box01": { + "width": 0.6521967053413391, + "length": 0.6521967649459839, + "height": 0.6915624737739563, + }, + "static.prop.box02": { + "width": 0.648569643497467, + "length": 0.648569643497467, + "height": 0.6485937237739563, + }, + "static.prop.box03": { + "width": 0.6700571775436401, + "length": 0.6608889102935791, + "height": 0.6485937237739563, + }, + "static.prop.briefcase": { + "width": 0.18117640912532806, + "length": 0.543353796005249, + "height": 0.43281248211860657, + }, + "static.prop.brokentile01": { + "width": 0.1760428547859192, + "length": 0.13444174826145172, + "height": 0.006562499795109034, + }, + "static.prop.brokentile02": { + "width": 0.15229617059230804, + "length": 0.20967696607112885, + "height": 0.007578124757856131, + }, + "static.prop.brokentile03": { + "width": 0.1449233442544937, + "length": 0.15445251762866974, + "height": 0.008515625260770321, + }, + "static.prop.brokentile04": { + "width": 0.12647496163845062, + "length": 0.12972931563854218, + "height": 0.01015624962747097, + }, + "static.prop.busstop": { + "width": 1.893609881401062, + "length": 3.8760783672332764, + "height": 2.738906145095825, + }, + "static.prop.busstoplb": { + "width": 3.8760783672332764, + "length": 1.893609881401062, + "height": 2.738906145095825, + }, + "static.prop.calibrator": { + "width": 0.690200924873352, + "length": 1.5475953817367554, + "height": 0.20195311307907104, + }, + "static.prop.chainbarrier": { + "width": 0.23859326541423798, + "length": 1.4941967725753784, + "height": 0.8887499570846558, + }, + "static.prop.chainbarrierend": { + "width": 0.23859326541423798, + "length": 0.23859328031539917, + "height": 0.8887499570846558, + }, + "static.prop.clothcontainer": { + "width": 1.8865405321121216, + "length": 1.2394330501556396, + "height": 1.810156226158142, + }, + "static.prop.clothesline": { + "width": 1.1180102825164795, + "length": 6.652331352233887, + "height": 2.0610156059265137, + }, + "static.prop.colacan": { + "width": 0.07167824357748032, + "length": 0.06817007064819336, + "height": 0.10953124612569809, + }, + "static.prop.constructioncone": { + "width": 0.3440696597099304, + "length": 0.3440696597099304, + "height": 0.5857812166213989, + }, + "static.prop.container": { + "width": 1.0124343633651733, + "length": 1.9318325519561768, + "height": 1.7142187356948853, + }, + "static.prop.creasedbox01": { + "width": 0.7129369974136353, + "length": 0.8087886571884155, + "height": 0.11781249940395355, + }, + "static.prop.creasedbox02": { + "width": 0.7129369974136353, + "length": 1.6332061290740967, + "height": 0.2116406261920929, + }, + "static.prop.creasedbox03": { + "width": 0.7129369974136353, + "length": 1.6687225103378296, + "height": 0.021718749776482582, + }, + "static.prop.dirtdebris01": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1489843726158142, + }, + "static.prop.dirtdebris02": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1489843726158142, + }, + "static.prop.dirtdebris03": { + "width": 1.5139321088790894, + "length": 1.8574368953704834, + "height": 0.1883593648672104, + }, + "static.prop.doghouse": { + "width": 1.2721054553985596, + "length": 1.0791168212890625, + "height": 1.0936717987060547, + }, + "static.prop.foodcart": { + "width": 4.638397693634033, + "length": 2.2846763134002686, + "height": 3.573124885559082, + }, + "static.prop.fountain": { + "width": 8.401816368103027, + "length": 8.401811599731445, + "height": 6.924375057220459, + }, + "static.prop.garbage01": { + "width": 0.05342773348093033, + "length": 0.05342777073383331, + "height": 0.08679687231779099, + }, + "static.prop.garbage02": { + "width": 0.05342775210738182, + "length": 0.05342775210738182, + "height": 0.009218749590218067, + }, + "static.prop.garbage03": { + "width": 0.05944278463721275, + "length": 0.05965827777981758, + "height": 0.07929687201976776, + }, + "static.prop.garbage04": { + "width": 0.055681075900793076, + "length": 0.05727477744221687, + "height": 0.06882812082767487, + }, + "static.prop.garbage05": { + "width": 0.23253268003463745, + "length": 0.23187801241874695, + "height": 0.05757812410593033, + }, + "static.prop.garbage06": { + "width": 0.21333114802837372, + "length": 0.3661772906780243, + "height": 0.07679687440395355, + }, + "static.prop.gardenlamp": { + "width": 0.28122183680534363, + "length": 0.2972412407398224, + "height": 0.6478124856948853, + }, + "static.prop.glasscontainer": { + "width": 2.0087928771972656, + "length": 1.9547909498214722, + "height": 1.7884374856948853, + }, + "static.prop.gnome": { + "width": 0.37559059262275696, + "length": 0.36661627888679504, + "height": 0.8829687237739563, + }, + "static.prop.guitarcase": { + "width": 0.1251685619354248, + "length": 1.242393970489502, + "height": 0.494453102350235, + }, + "static.prop.haybale": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949999332427979, + }, + "static.prop.haybalelb": { + "width": 1.1933469772338867, + "length": 1.247710943222046, + "height": 1.1949999332427979, + }, + "static.prop.ironplank": { + "width": 1.1743810176849365, + "length": 1.45187246799469, + "height": 0.020546874031424522, + }, + "static.prop.kiosk_01": { + "width": 1.6540685892105103, + "length": 1.6252474784851074, + "height": 3.2453906536102295, + }, + "static.prop.mailbox": { + "width": 0.43842464685440063, + "length": 0.526629626750946, + "height": 1.157265543937683, + }, + "static.prop.maptable": { + "width": 0.6170762777328491, + "length": 1.5732090473175049, + "height": 1.181093692779541, + }, + "static.prop.mobile": { + "width": 0.007261085323989391, + "length": 0.08048340678215027, + "height": 0.16640624403953552, + }, + "static.prop.motorhelmet": { + "width": 0.2592744827270508, + "length": 0.20041197538375854, + "height": 0.2581250071525574, + }, + "static.prop.pergola": { + "width": 5.085033893585205, + "length": 5.029393672943115, + "height": 3.54156231880188, + }, + "static.prop.plantpot01": { + "width": 0.4757719337940216, + "length": 1.08914053440094, + "height": 0.5573437213897705, + }, + "static.prop.plantpot02": { + "width": 0.43072694540023804, + "length": 1.0511938333511353, + "height": 0.48695310950279236, + }, + "static.prop.plantpot03": { + "width": 0.43072694540023804, + "length": 1.0511937141418457, + "height": 0.48695310950279236, + }, + "static.prop.plantpot04": { + "width": 0.7366006970405579, + "length": 4.967305660247803, + "height": 0.12476561963558197, + }, + "static.prop.plantpot05": { + "width": 0.40022480487823486, + "length": 0.40022480487823486, + "height": 0.2800000011920929, + }, + "static.prop.plantpot06": { + "width": 1.5932812690734863, + "length": 1.5932812690734863, + "height": 0.8495312333106995, + }, + "static.prop.plantpot07": { + "width": 0.4757719337940216, + "length": 0.4946027398109436, + "height": 0.5573437213897705, + }, + "static.prop.plantpot08": { + "width": 0.5304248929023743, + "length": 0.5304248929023743, + "height": 0.48890623450279236, + }, + "static.prop.plasticbag": { + "width": 0.4025624990463257, + "length": 0.3109833598136902, + "height": 0.5523437261581421, + }, + "static.prop.plasticchair": { + "width": 0.7504488825798035, + "length": 0.7304753661155701, + "height": 1.271328091621399, + }, + "static.prop.plastictable": { + "width": 2.482203245162964, + "length": 2.482203245162964, + "height": 2.4797656536102295, + }, + "static.prop.platformgarbage01": { + "width": 1.7114051580429077, + "length": 3.6332411766052246, + "height": 0.33156248927116394, + }, + "static.prop.purse": { + "width": 0.3326959013938904, + "length": 0.15921516716480255, + "height": 0.5852343440055847, + }, + "static.prop.shoppingbag": { + "width": 0.2432928830385208, + "length": 0.47446563839912415, + "height": 0.6006249785423279, + }, + "static.prop.shoppingcart": { + "width": 1.207547903060913, + "length": 0.6694015264511108, + "height": 1.0792968273162842, + }, + "static.prop.shoppingtrolley": { + "width": 0.33110183477401733, + "length": 0.5055894255638123, + "height": 1.0936717987060547, + }, + "static.prop.slide": { + "width": 4.122486591339111, + "length": 0.9425268173217773, + "height": 1.9766405820846558, + }, + "static.prop.streetbarrier": { + "width": 0.3716789782047272, + "length": 1.2149077653884888, + "height": 1.0691405534744263, + }, + "static.prop.streetfountain": { + "width": 0.6702813506126404, + "length": 0.2869437038898468, + "height": 1.259374976158142, + }, + "static.prop.streetsign": { + "width": 0.1252390295267105, + "length": 1.0643447637557983, + "height": 2.154374837875366, + }, + "static.prop.streetsign01": { + "width": 0.3029319941997528, + "length": 2.470391273498535, + "height": 3.8779685497283936, + }, + "static.prop.streetsign04": { + "width": 0.12483911216259003, + "length": 1.1708152294158936, + "height": 2.772890567779541, + }, + "static.prop.swing": { + "width": 1.5768225193023682, + "length": 4.105227947235107, + "height": 2.572499990463257, + }, + "static.prop.swingcouch": { + "width": 2.362380266189575, + "length": 1.357409954071045, + "height": 2.2493748664855957, + }, + "static.prop.table": { + "width": 2.1518361568450928, + "length": 2.1562821865081787, + "height": 0.846484363079071, + }, + "static.prop.trafficcone01": { + "width": 0.8821874856948853, + "length": 0.8828125, + "height": 1.1332812309265137, + }, + "static.prop.trafficcone02": { + "width": 0.3945564925670624, + "length": 0.455596923828125, + "height": 1.1828906536102295, + }, + "static.prop.trafficwarning": { + "width": 2.8705859184265137, + "length": 2.373429536819458, + "height": 3.569531202316284, + }, + "static.prop.trampoline": { + "width": 4.131584644317627, + "length": 4.131584644317627, + "height": 2.801874876022339, + }, + "static.prop.trashbag": { + "width": 0.42955997586250305, + "length": 0.3779701590538025, + "height": 0.7021093368530273, + }, + "static.prop.trashcan01": { + "width": 0.5678514838218689, + "length": 0.6352845430374146, + "height": 0.8482031226158142, + }, + "static.prop.trashcan02": { + "width": 0.5135547518730164, + "length": 0.5238340497016907, + "height": 1.0454686880111694, + }, + "static.prop.trashcan03": { + "width": 0.5453212857246399, + "length": 0.6293092370033264, + "height": 0.93359375, + }, + "static.prop.trashcan04": { + "width": 0.5803966522216797, + "length": 0.7887265086174011, + "height": 1.0163280963897705, + }, + "static.prop.trashcan05": { + "width": 0.5803966522216797, + "length": 0.7887265086174011, + "height": 1.0163280963897705, + }, + "static.prop.travelcase": { + "width": 0.3276098966598511, + "length": 0.5673347115516663, + "height": 1.2625781297683716, + }, + "static.prop.vendingmachine": { + "width": 1.102043867111206, + "length": 0.8728882670402527, + "height": 2.108203172683716, + }, + "static.prop.warningaccident": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602343797683716, + }, + "static.prop.warningconstruction": { + "width": 1.055053472518921, + "length": 1.3050163984298706, + "height": 1.8602343797683716, + }, + "static.prop.wateringcan": { + "width": 0.19403739273548126, + "length": 0.07035235315561295, + "height": 0.13484375178813934, + }, + "vehicle.audi.a2": { + "width": 1.788678526878357, + "length": 3.705369472503662, + "height": 1.549050211906433, + }, + "vehicle.audi.etron": { + "width": 2.0327565670013428, + "length": 4.855708599090576, + "height": 1.6493593454360962, + }, + "vehicle.audi.tt": { + "width": 1.9941171407699585, + "length": 4.181210041046143, + "height": 1.385296106338501, + }, + "vehicle.bh.crossbike": { + "width": 0.7165295481681824, + "length": 0.0, + "height": 0.0, + }, + "vehicle.bmw.grandtourer": { + "width": 2.241713285446167, + "length": 4.611005783081055, + "height": 1.6672759056091309, + }, + "vehicle.carlamotors.carlacola": { + "width": 2.6269896030426025, + "length": 5.203838348388672, + "height": 2.467444658279419, + }, + "vehicle.carlamotors.firetruck": { + "width": 2.8910882472991943, + "length": 8.46804141998291, + "height": 3.8274123668670654, + }, + "vehicle.chevrolet.impala": { + "width": 2.033202886581421, + "length": 5.357479572296143, + "height": 1.4106587171554565, + }, + "vehicle.citroen.c3": { + "width": 1.8508483171463013, + "length": 3.987684965133667, + "height": 1.6171095371246338, + }, + "vehicle.diamondback.century": {"width": 0.0, "length": 0.0, "height": 0.0}, + "vehicle.dodge.charger_2020": { + "width": 1.8816219568252563, + "length": 5.0078253746032715, + "height": 1.5347249507904053, + }, + "vehicle.dodge.charger_police": { + "width": 2.0384011268615723, + "length": 4.974244117736816, + "height": 1.5421181917190552, + }, + "vehicle.dodge.charger_police_2020": { + "width": 1.9297595024108887, + "length": 5.237514495849609, + "height": 1.638383150100708, + }, + "vehicle.ford.ambulance": { + "width": 2.3511743545532227, + "length": 6.36564302444458, + "height": 2.431375741958618, + }, + "vehicle.ford.crown": { + "width": 1.8007241487503052, + "length": 5.365678787231445, + "height": 1.5749659538269043, + }, + "vehicle.ford.mustang": { + "width": 1.894826889038086, + "length": 4.717525005340576, + "height": 1.300939917564392, + }, + "vehicle.gazelle.omafiets": {"width": 0.0, "length": 0.0, "height": 0.0}, + "vehicle.harley-davidson.low_rider": { + "width": 0.7457675933837891, + "length": 0.0, + "height": 0.0, + }, + "vehicle.jeep.wrangler_rubicon": { + "width": 1.9051965475082397, + "length": 3.866220712661743, + "height": 1.8779358863830566, + }, + "vehicle.kawasaki.ninja": { + "width": 0.6402643918991089, + "length": 0.0, + "height": 0.0, + }, + "vehicle.lincoln.mkz_2017": { + "width": 2.128324270248413, + "length": 4.901683330535889, + "height": 1.5107464790344238, + }, + "vehicle.lincoln.mkz_2020": { + "width": 1.8367133140563965, + "length": 4.89238166809082, + "height": 1.490277647972107, + }, + "vehicle.mercedes.coupe": { + "width": 2.1515462398529053, + "length": 5.0267767906188965, + "height": 1.6506516933441162, + }, + "vehicle.mercedes.coupe_2020": { + "width": 1.8118125200271606, + "length": 4.673638820648193, + "height": 1.441947340965271, + }, + "vehicle.mercedes.sprinter": { + "width": 1.9884328842163086, + "length": 5.91519021987915, + "height": 2.560655355453491, + }, + "vehicle.micro.microlino": { + "width": 1.4809197187423706, + "length": 2.2072951793670654, + "height": 1.3760247230529785, + }, + "vehicle.mini.cooper_s": { + "width": 1.970275640487671, + "length": 3.805800199508667, + "height": 1.4750301837921143, + }, + "vehicle.mini.cooper_s_2021": { + "width": 2.097072124481201, + "length": 4.552699089050293, + "height": 1.7671663761138916, + }, + "vehicle.mitsubishi.fusorosa": { + "width": 3.9441518783569336, + "length": 10.272685050964355, + "height": 4.252848148345947, + }, + "vehicle.nissan.micra": { + "width": 1.845113754272461, + "length": 3.633375883102417, + "height": 1.5012825727462769, + }, + "vehicle.nissan.patrol": { + "width": 1.9315929412841797, + "length": 4.6045098304748535, + "height": 1.8548461198806763, + }, + "vehicle.nissan.patrol_2021": { + "width": 2.1499669551849365, + "length": 5.565828800201416, + "height": 2.045147180557251, + }, + "vehicle.seat.leon": { + "width": 1.8161858320236206, + "length": 4.1928300857543945, + "height": 1.4738311767578125, + }, + "vehicle.tesla.cybertruck": { + "width": 2.3895740509033203, + "length": 6.273553371429443, + "height": 2.098191261291504, + }, + "vehicle.tesla.model3": { + "width": 2.163450002670288, + "length": 4.791779518127441, + "height": 1.4876600503921509, + }, + "vehicle.toyota.prius": { + "width": 2.006814479827881, + "length": 4.513522624969482, + "height": 1.5248334407806396, + }, + "vehicle.vespa.zx125": { + "width": 0.7517611384391785, + "length": 0.0, + "height": 0.0, + }, + "vehicle.volkswagen.t2": { + "width": 2.069315195083618, + "length": 4.4804368019104, + "height": 2.0377919673919678, + }, + "vehicle.volkswagen.t2_2021": { + "width": 1.77456533908844, + "length": 4.442183971405029, + "height": 1.9872066974639893, + }, + "vehicle.yamaha.yzf": {"width": 0.652134358882904, "length": 0.0, "height": 0.0}, + "walker.pedestrian.0001": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0002": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0003": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0004": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0005": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0006": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0007": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0008": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0009": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0010": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0011": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0012": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0013": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0014": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.2999999523162842, + }, + "walker.pedestrian.0015": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0016": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0017": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0018": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0019": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0020": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0021": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0022": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0023": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0024": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0025": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0026": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0027": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0028": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0029": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0030": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0031": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0032": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0033": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0034": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0035": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0036": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0037": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0038": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0039": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0040": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0041": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0042": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0043": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0044": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0045": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0046": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0047": { + "width": 0.3753577768802643, + "length": 0.3753577768802643, + "height": 1.8600000143051147, + }, + "walker.pedestrian.0048": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + "walker.pedestrian.0049": { + "width": 0.5, + "length": 0.5, + "height": 1.100000023841858, + }, + }, +} diff --git a/src/scenic/simulators/carla/actions.py b/src/scenic/simulators/carla/actions.py index 8aba75e48..cd0ef3882 100644 --- a/src/scenic/simulators/carla/actions.py +++ b/src/scenic/simulators/carla/actions.py @@ -3,6 +3,8 @@ import math as _math import carla as _carla +import numpy as np +from scipy import linalg from scenic.domains.driving.actions import * import scenic.simulators.carla.utils.utils as _utils @@ -57,7 +59,7 @@ def canBeTakenBy(self, agent): class SetManualGearShiftAction(VehicleAction): def __init__(self, manualGearShift): if not isinstance(manualGearShift, bool): - raise RuntimeError("Manual gear shift must be a boolean.") + raise TypeError("Manual gear shift must be a boolean.") self.manualGearShift = manualGearShift def applyTo(self, obj, sim): @@ -70,7 +72,7 @@ def applyTo(self, obj, sim): class SetGearAction(VehicleAction): def __init__(self, gear): if not isinstance(gear, int): - raise RuntimeError("Gear must be an int.") + raise TypeError("Gear must be an int.") self.gear = gear def applyTo(self, obj, sim): @@ -98,7 +100,7 @@ class SetTrafficLightAction(VehicleAction): def __init__(self, color, distance=100, group=False): self.color = _utils.scenicToCarlaTrafficLightStatus(color) if color is None: - raise RuntimeError("Color must be red/yellow/green/off/unknown.") + raise ValueError("Color must be red/yellow/green/off/unknown.") self.distance = distance def applyTo(self, obj, sim): @@ -108,15 +110,67 @@ def applyTo(self, obj, sim): class SetAutopilotAction(VehicleAction): - def __init__(self, enabled): + """Enable or disable CARLA autopilot with optional Traffic Manager settings. + + Arguments: + enabled: Enable or disable autopilot (bool) + kwargs: Additional autopilot options such as: + + * ``speed``: Target speed of the car in m/s (default: None). Mutually exclusive with ``vehicle_percentage_speed_difference``. + * ``vehicle_percentage_speed_difference``: Percentage difference between intended speed and the current speed limit. Can be negative to exceed the speed limit. + * ``path``: Route for the vehicle to follow (default: None) + * ``ignore_signs_percentage``: Percentage of ignored traffic signs (default: 0) + * ``ignore_lights_percentage``: Percentage of ignored traffic lights (default: 0) + * ``ignore_walkers_percentage``: Percentage of ignored pedestrians (default: 0) + * ``auto_lane_change``: Whether to allow automatic lane changes (default: False) + """ + + def __init__(self, enabled, **kwargs): if not isinstance(enabled, bool): - raise RuntimeError("Enabled must be a boolean.") + raise TypeError("Enabled must be a boolean.") + self.enabled = enabled + # Default values for optional parameters + self.speed = kwargs.get("speed", None) + self.vehicle_percentage_speed_difference = kwargs.get( + "vehicle_percentage_speed_difference", None + ) + self.path = kwargs.get("path", None) + self.ignore_signs_percentage = kwargs.get("ignore_signs_percentage", 0) + self.ignore_lights_percentage = kwargs.get("ignore_lights_percentage", 0) + self.ignore_walkers_percentage = kwargs.get("ignore_walkers_percentage", 0) + self.auto_lane_change = kwargs.get("auto_lane_change", False) # Default: False + + if (self.speed is not None) and ( + self.vehicle_percentage_speed_difference is not None + ): + raise ValueError( + "Provide either 'speed' or 'vehicle_percentage_speed_difference', not both." + ) + def applyTo(self, obj, sim): vehicle = obj.carlaActor vehicle.set_autopilot(self.enabled, sim.tm.get_port()) + # Apply auto lane change setting + sim.tm.auto_lane_change(vehicle, self.auto_lane_change) + + if self.path: + sim.tm.set_route(vehicle, self.path) + if self.speed is not None: + sim.tm.set_desired_speed(vehicle, 3.6 * self.speed) + if self.vehicle_percentage_speed_difference is not None: + sim.tm.vehicle_percentage_speed_difference( + vehicle, self.vehicle_percentage_speed_difference + ) + + # Apply traffic management settings + sim.tm.update_vehicle_lights(vehicle, True) + sim.tm.ignore_signs_percentage(vehicle, self.ignore_signs_percentage) + sim.tm.ignore_lights_percentage(vehicle, self.ignore_lights_percentage) + sim.tm.ignore_walkers_percentage(vehicle, self.ignore_walkers_percentage) + class SetVehicleLightStateAction(VehicleAction): """Set the vehicle lights' states. @@ -150,7 +204,7 @@ def canBeTakenBy(self, agent): class SetJumpAction(PedestrianAction): def __init__(self, jump): if not isinstance(jump, bool): - raise RuntimeError("Jump must be a boolean.") + raise TypeError("Jump must be a boolean.") self.jump = jump def applyTo(self, obj, sim): @@ -163,7 +217,7 @@ def applyTo(self, obj, sim): class SetWalkAction(PedestrianAction): def __init__(self, enabled, maxSpeed=1.4): if not isinstance(enabled, bool): - raise RuntimeError("Enabled must be a boolean.") + raise TypeError("Enabled must be a boolean.") self.enabled = enabled self.maxSpeed = maxSpeed @@ -177,89 +231,104 @@ def applyTo(self, obj, sim): controller.stop() -class TrackWaypointsAction(Action): - def __init__(self, waypoints, cruising_speed=10): - self.waypoints = np.array(waypoints) - self.curr_index = 1 - self.cruising_speed = cruising_speed +class TrackWaypointsAction(VehicleAction): + """Track (x, y) waypoints in Scenic coordinates at a target speed.""" - def canBeTakenBy(self, agent): - # return agent.lgsvlAgentType is lgsvl.AgentType.EGO - return True + PREDICTIVE_LENGTH = 3.0 + MIN_SPEED = 1.0 + WHEEL_BASE = 3.0 + EPS = 1e-9 + + Q = np.array([[1.0, 0.0], [0.0, 3.0]]) + R = np.array([[10.0]]) + + def __init__(self, waypoints, cruising_speed=10.0): + self.waypoints = waypoints + self.cruising_speed = float(cruising_speed) + self._route_key = id(waypoints) + + @staticmethod + def _wrap_to_pi(angle): + return (angle + _math.pi) % (2 * _math.pi) - _math.pi + + @classmethod + def _lqr_gain(cls, speed): + A = np.array([[0.0, speed], [0.0, 0.0]]) + B = np.array([[0.0], [speed / cls.WHEEL_BASE]]) + V = linalg.solve_continuous_are(A, B, cls.Q, cls.R) + return np.linalg.solve(cls.R, B.T @ V) + + def _ensure_state(self, obj): + if getattr(obj, "_tw_route_key", None) == self._route_key: + return + + waypoints = np.asarray(self.waypoints, dtype=float) + if waypoints.ndim != 2 or waypoints.shape[1] < 2 or len(waypoints) < 3: + raise ValueError( + "TrackWaypointsAction expects waypoints as an array of shape (N, 2) with N >= 3." + ) - def LQR(v_target, wheelbase, Q, R): - A = np.matrix([[0, v_target * (5.0 / 18.0)], [0, 0]]) - B = np.matrix([[0], [(v_target / wheelbase) * (5.0 / 18.0)]]) - V = np.matrix(linalg.solve_continuous_are(A, B, Q, R)) - K = np.matrix(linalg.inv(R) * (B.T * V)) - return K + obj._tw_waypoints = waypoints[:, :2] + obj._tw_index = 1 + obj._tw_route_key = self._route_key def applyTo(self, obj, sim): - carlaObj = obj.carlaActor - transform = carlaObj.get_transform() - pos = transform.location - rot = transform.rotation - velocity = carlaObj.get_velocity() - th, x, y, v = ( - rot.y / 180.0 * np.pi, - pos.x, - pos.z, - (velocity.x**2 + velocity.z**2) ** 0.5, - ) - # print('state:', th, x, y, v) - PREDICTIVE_LENGTH = 3 - MIN_SPEED = 1 - WHEEL_BASE = 3 - v = max(MIN_SPEED, v) - - x = x + PREDICTIVE_LENGTH * np.cos(-th + np.pi / 2) - y = y + PREDICTIVE_LENGTH * np.sin(-th + np.pi / 2) - # print('car front:', x, y) - dists = np.linalg.norm(self.waypoints - np.array([x, y]), axis=1) - dist_pos = np.argpartition(dists, 1) - index = dist_pos[0] - if index > self.curr_index and index < len(self.waypoints) - 1: - self.curr_index = index - p1, p2, p3 = ( - self.waypoints[self.curr_index - 1], - self.waypoints[self.curr_index], - self.waypoints[self.curr_index + 1], - ) + self._ensure_state(obj) + waypoints = obj._tw_waypoints + index = obj._tw_index - p1_a = np.linalg.norm(p1 - np.array([x, y])) - p3_a = np.linalg.norm(p3 - np.array([x, y])) - p1_p2 = np.linalg.norm(p1 - p2) - p3_p2 = np.linalg.norm(p3 - p2) - - if p1_a - p1_p2 > p3_a - p3_p2: - p1 = p2 - p2 = p3 - - # print('points:',p1, p2) - x1, y1, x2, y2 = p1[0], p1[1], p2[0], p2[1] - th_n = -math.atan2(y2 - y1, x2 - x1) + np.pi / 2 - d_th = (th - th_n + 3 * np.pi) % (2 * np.pi) - np.pi - d_x = (x2 - x1) * y - (y2 - y1) * x + y2 * x1 - y1 * x2 - d_x /= np.linalg.norm(np.array([x1, y1]) - np.array([x2, y2])) - # print('d_th, d_x:',d_th, d_x) - - K = TrackWaypoints.LQR( - v, WHEEL_BASE, np.array([[1, 0], [0, 3]]), np.array([[10]]) - ) - u = -K * np.matrix([[-d_x], [d_th]]) - u = np.double(u) - u_steering = min(max(u, -1), 1) - - K = 1 - u = -K * (v - self.cruising_speed) - u_thrust = min(max(u, -1), 1) - - # print('u:', u_thrust, u_steering) - - ctrl = carlaObj.get_control() - ctrl.steering = u_steering - if u_thrust > 0: - ctrl.throttle = u_thrust - elif u_thrust < 0.1: - ctrl.braking = -u_thrust - carlaObj.apply_control(ctrl) + actor = obj.carlaActor + transform = actor.get_transform() + + pos = _utils.carlaToScenicPosition(transform.location) + vel = _utils.carlaToScenicPosition(actor.get_velocity()) + + x, y = pos.x, pos.y + speed = max(self.MIN_SPEED, _math.hypot(vel.x, vel.y)) + + forward = transform.get_forward_vector() + fx, fy = forward.x, -forward.y + heading = self._wrap_to_pi(_math.atan2(fx, fy)) + + x_f = x + self.PREDICTIVE_LENGTH * fx + y_f = y + self.PREDICTIVE_LENGTH * fy + lookahead = np.array([x_f, y_f]) + + nearest = int(np.argmin(np.linalg.norm(waypoints - lookahead, axis=1))) + if index < nearest < (len(waypoints) - 1): + index = nearest + index = max(1, min(index, len(waypoints) - 2)) + obj._tw_index = index + + p1 = waypoints[index - 1] + p2 = waypoints[index] + p3 = waypoints[index + 1] + + if (np.linalg.norm(p1 - lookahead) - np.linalg.norm(p1 - p2)) > ( + np.linalg.norm(p3 - lookahead) - np.linalg.norm(p3 - p2) + ): + p1, p2 = p2, p3 + + x1, y1 = p1 + x2, y2 = p2 + dx = x2 - x1 + dy = y2 - y1 + + desired_heading = _math.atan2(dx, dy) + heading_error = self._wrap_to_pi(heading - desired_heading) + + denom = _math.hypot(dx, dy) + self.EPS + cross_track_error = (dx * y_f - dy * x_f + y2 * x1 - y1 * x2) / denom + + K = self._lqr_gain(speed) + err = np.array([[-cross_track_error], [heading_error]]) + steer = float(np.clip((-(K @ err)).item(), -1.0, 1.0)) + + u = float(np.clip(self.cruising_speed - speed, -1.0, 1.0)) + throttle = max(0.0, u) + brake = max(0.0, -u) + + ctrl = obj.control + ctrl.steer = steer + ctrl.throttle = throttle + ctrl.brake = brake diff --git a/src/scenic/simulators/carla/behaviors.scenic b/src/scenic/simulators/carla/behaviors.scenic index 9ed39942b..1af819a4a 100644 --- a/src/scenic/simulators/carla/behaviors.scenic +++ b/src/scenic/simulators/carla/behaviors.scenic @@ -7,9 +7,9 @@ try: except ModuleNotFoundError: pass # ignore; error will be caught later if user attempts to run a simulation -behavior AutopilotBehavior(): +behavior AutopilotBehavior(enabled = True, **kwargs): """Behavior causing a vehicle to use CARLA's built-in autopilot.""" - take SetAutopilotAction(True) + take SetAutopilotAction(enabled=enabled, **kwargs) behavior WalkForwardBehavior(speed=0.5): take SetWalkingDirectionAction(self.heading), SetWalkingSpeedAction(speed) diff --git a/src/scenic/simulators/carla/blueprints.py b/src/scenic/simulators/carla/blueprints.py index 1293b1b4e..6228a097b 100644 --- a/src/scenic/simulators/carla/blueprints.py +++ b/src/scenic/simulators/carla/blueprints.py @@ -1,9 +1,50 @@ """CARLA blueprints for cars, pedestrians, etc.""" -#: Mapping from current names of blueprints to ones in old CARLA versions. -#: -#: We provide a tuple of old names in case they change more than once. +from importlib.metadata import PackageNotFoundError, version as _pkg_version +import warnings + +from scenic.core.distributions import distributionFunction +from scenic.core.errors import InvalidScenarioError + +# Import auto-generated blueprint data for all CARLA versions +from ._blueprintData import _DIMS, _IDS + +try: + _CARLA_VER = _pkg_version("carla") +except PackageNotFoundError: + _CARLA_VER = "0.0.0" # no carla package; default to newest known blueprints + + +def _verkey(s: str): + # Handle '0.9.15', '0.10.0', '1.2' -> (major, minor, patch) + parts = [int(p) for p in s.split(".")] + parts += [0] * (3 - len(parts)) + return tuple(parts[:3]) + + +def _pick(vermap, ver): + """Choose the blueprint map for CARLA version""" + # 1) Exact match + if ver in vermap: + return vermap[ver] + # 2) If same major.minor, use the newest patch. + mm = ".".join(ver.split(".")[:2]) + cands = [v for v in vermap if v.startswith(mm + ".")] + if cands: + best = max(cands, key=_verkey) + return vermap[best] + # 3) Otherwise, use the newest version. + best = max(vermap.keys(), key=_verkey) + if ver != "0.0.0": + warnings.warn( + f"Unknown CARLA version {ver}; using blueprints for {best}. " + "Scenic may not have the correct set of blueprints." + ) + return vermap[best] + + oldBlueprintNames = { + # Map current names to legacy names "vehicle.dodge.charger_police": ("vehicle.dodge_charger.police",), "vehicle.lincoln.mkz_2017": ("vehicle.lincoln.mkz2017",), "vehicle.mercedes.coupe": ("vehicle.mercedes-benz.coupe",), @@ -11,223 +52,81 @@ "vehicle.ford.mustang": ("vehicle.mustang.mustang",), } -## Vehicle blueprints - -#: blueprints for cars -carModels = [ - "vehicle.audi.a2", - "vehicle.audi.etron", - "vehicle.audi.tt", - "vehicle.bmw.grandtourer", - "vehicle.chevrolet.impala", - "vehicle.citroen.c3", - "vehicle.dodge.charger_police", - "vehicle.jeep.wrangler_rubicon", - "vehicle.lincoln.mkz_2017", - "vehicle.mercedes.coupe", - "vehicle.mini.cooper_s", - "vehicle.ford.mustang", - "vehicle.nissan.micra", - "vehicle.nissan.patrol", - "vehicle.seat.leon", - "vehicle.tesla.model3", - "vehicle.toyota.prius", - "vehicle.volkswagen.t2", -] - -#: blueprints for bicycles -bicycleModels = [ - "vehicle.bh.crossbike", - "vehicle.diamondback.century", - "vehicle.gazelle.omafiets", -] - -#: blueprints for motorcycles -motorcycleModels = [ - "vehicle.harley-davidson.low_rider", - "vehicle.kawasaki.ninja", - "vehicle.yamaha.yzf", -] - -#: blueprints for trucks -truckModels = [ - "vehicle.carlamotors.carlacola", - "vehicle.tesla.cybertruck", -] - -## Prop blueprints - -#: blueprints for trash cans -trashModels = [ - "static.prop.trashcan01", - "static.prop.trashcan02", - "static.prop.trashcan03", - "static.prop.trashcan04", - "static.prop.trashcan05", - "static.prop.bin", -] - -#: blueprints for traffic cones -coneModels = [ - "static.prop.constructioncone", - "static.prop.trafficcone01", - "static.prop.trafficcone02", -] - -#: blueprints for road debris -debrisModels = [ - "static.prop.dirtdebris01", - "static.prop.dirtdebris02", - "static.prop.dirtdebris03", -] - -#: blueprints for vending machines -vendingMachineModels = [ - "static.prop.vendingmachine", -] - -#: blueprints for chairs -chairModels = [ - "static.prop.plasticchair", -] - -#: blueprints for bus stops -busStopModels = [ - "static.prop.busstop", -] - -#: blueprints for roadside billboards -advertisementModels = [ - "static.prop.advertisement", - "static.prop.streetsign", - "static.prop.streetsign01", - "static.prop.streetsign04", -] - -#: blueprints for pieces of trash -garbageModels = [ - "static.prop.colacan", - "static.prop.garbage01", - "static.prop.garbage02", - "static.prop.garbage03", - "static.prop.garbage04", - "static.prop.garbage05", - "static.prop.garbage06", - "static.prop.plasticbag", - "static.prop.trashbag", -] - -#: blueprints for containers -containerModels = [ - "static.prop.container", - "static.prop.clothcontainer", - "static.prop.glasscontainer", -] - -#: blueprints for tables -tableModels = [ - "static.prop.table", - "static.prop.plastictable", -] - -#: blueprints for traffic barriers -barrierModels = [ - "static.prop.streetbarrier", - "static.prop.chainbarrier", - "static.prop.chainbarrierend", -] - -#: blueprints for flowerpots -plantpotModels = [ - "static.prop.plantpot01", - "static.prop.plantpot02", - "static.prop.plantpot03", - "static.prop.plantpot04", - "static.prop.plantpot05", - "static.prop.plantpot06", - "static.prop.plantpot07", - "static.prop.plantpot08", -] - -#: blueprints for mailboxes -mailboxModels = [ - "static.prop.mailbox", -] - -#: blueprints for garden gnomes -gnomeModels = [ - "static.prop.gnome", -] - -#: blueprints for creased boxes -creasedboxModels = [ - "static.prop.creasedbox01", - "static.prop.creasedbox02", - "static.prop.creasedbox03", -] - -#: blueprints for briefcases, suitcases, etc. -caseModels = [ - "static.prop.travelcase", - "static.prop.briefcase", - "static.prop.guitarcase", -] - -#: blueprints for boxes -boxModels = [ - "static.prop.box01", - "static.prop.box02", - "static.prop.box03", -] - -#: blueprints for benches -benchModels = [ - "static.prop.bench01", - "static.prop.bench02", - "static.prop.bench03", -] - -#: blueprints for barrels -barrelModels = [ - "static.prop.barrel", -] - -#: blueprints for ATMs -atmModels = [ - "static.prop.atm", -] - -#: blueprints for kiosks -kioskModels = [ - "static.prop.kiosk_01", -] - -#: blueprints for iron plates -ironplateModels = [ - "static.prop.ironplank", -] - -#: blueprints for traffic warning signs -trafficwarningModels = [ - "static.prop.trafficwarning", -] - -## Walker blueprints - -#: blueprints for pedestrians -walkerModels = [ - "walker.pedestrian.0001", - "walker.pedestrian.0002", - "walker.pedestrian.0003", - "walker.pedestrian.0004", - "walker.pedestrian.0005", - "walker.pedestrian.0006", - "walker.pedestrian.0007", - "walker.pedestrian.0008", - "walker.pedestrian.0009", - "walker.pedestrian.0010", - "walker.pedestrian.0011", - "walker.pedestrian.0012", - "walker.pedestrian.0013", - "walker.pedestrian.0014", -] + +# Pick blueprint data for current CARLA version +ids = _pick(_IDS, _CARLA_VER) +dims = _pick(_DIMS, _CARLA_VER) + + +# Backwards-compatible model lists +# Vehicles +carModels = ids["carModels"] +bicycleModels = ids["bicycleModels"] +motorcycleModels = ids["motorcycleModels"] +truckModels = ids["truckModels"] +vanModels = ids["vanModels"] +busModels = ids["busModels"] +# Walkers +walkerModels = ids["walkerModels"] +# Props +trashModels = ids["trashModels"] +coneModels = ids["coneModels"] +debrisModels = ids["debrisModels"] +vendingMachineModels = ids["vendingMachineModels"] +chairModels = ids["chairModels"] +busStopModels = ids["busStopModels"] +advertisementModels = ids["advertisementModels"] +garbageModels = ids["garbageModels"] +containerModels = ids["containerModels"] +tableModels = ids["tableModels"] +barrierModels = ids["barrierModels"] +plantpotModels = ids["plantpotModels"] +mailboxModels = ids["mailboxModels"] +gnomeModels = ids["gnomeModels"] +creasedboxModels = ids["creasedboxModels"] +caseModels = ids["caseModels"] +boxModels = ids["boxModels"] +benchModels = ids["benchModels"] +barrelModels = ids["barrelModels"] +atmModels = ids["atmModels"] +kioskModels = ids["kioskModels"] +ironplateModels = ids["ironplateModels"] +trafficwarningModels = ids["trafficwarningModels"] + + +def blueprintsInCategory(category): + """Return all blueprint IDs for a category; raise if none recorded.""" + model = category + "Models" + models = ids.get(model) + if models: + return models + raise InvalidScenarioError( + f"Scenic has no '{category}' blueprints recorded for CARLA {_CARLA_VER}." + ) + + +def _get_dim(bp_id, key, default): + """Return recorded dimension or ``default`` if missing/0. + + Note: CARLA 0.9.14 bbox returns 0 for some blueprints (see CARLA issue #5841). + """ + val = dims.get(bp_id, {}).get(key) + return val if val else default + + +@distributionFunction +def width(bp_id, default): + """Get width for ``bp_id``; return ``default`` if unknown.""" + return _get_dim(bp_id, "width", default) + + +@distributionFunction +def length(bp_id, default): + """Get length for ``bp_id``; return ``default`` if unknown.""" + return _get_dim(bp_id, "length", default) + + +@distributionFunction +def height(bp_id, default): + """Get height for ``bp_id``; return ``default`` if unknown.""" + return _get_dim(bp_id, "height", default) diff --git a/src/scenic/simulators/carla/misc.py b/src/scenic/simulators/carla/misc.py index 116f2cd0a..cd9b97497 100644 --- a/src/scenic/simulators/carla/misc.py +++ b/src/scenic/simulators/carla/misc.py @@ -6,7 +6,7 @@ # This work is licensed under the terms of the MIT license. # For a copy, see . -""" Module with auxiliary functions. """ +"""Module with auxiliary functions.""" import math diff --git a/src/scenic/simulators/carla/model.scenic b/src/scenic/simulators/carla/model.scenic index 66433de53..ba1015b82 100644 --- a/src/scenic/simulators/carla/model.scenic +++ b/src/scenic/simulators/carla/model.scenic @@ -48,6 +48,10 @@ import scenic.simulators.carla.blueprints as blueprints from scenic.simulators.carla.behaviors import * from scenic.simulators.utils.colors import Color +# Sensor imports +from scenic.simulators.carla.sensors import CarlaRGBSensor as RGBSensor +from scenic.simulators.carla.sensors import CarlaSSSensor as SSSensor + try: from scenic.simulators.carla.simulator import CarlaSimulator # for use in scenarios from scenic.simulators.carla.actions import * @@ -99,6 +103,7 @@ param weather = Uniform( 'HardRainSunset' ) param snapToGroundDefault = is2DMode() +param bubble_size = None simulator CarlaSimulator( carla_map=globalParameters.carla_map, @@ -108,7 +113,8 @@ simulator CarlaSimulator( timeout=int(globalParameters.timeout), render=bool(globalParameters.render), record=globalParameters.record, - timestep=float(globalParameters.timestep) + timestep=float(globalParameters.timestep), + bubble_size=globalParameters.bubble_size, ) class CarlaActor(DrivingObject): @@ -118,6 +124,18 @@ class CarlaActor(DrivingObject): carlaActor (dynamic): Set during simulations to the ``carla.Actor`` representing this object. blueprint (str): Identifier of the CARLA blueprint specifying the type of object. + defaultWidth (float): Default width to use if Scenic has no recorded dimensions for + this blueprint. + defaultLength (float): Default length to use if Scenic has no recorded dimensions for + this blueprint. + defaultHeight (float): Default height to use if Scenic has no recorded dimensions for + this blueprint. + width (float): Width for this object; uses the value from ``blueprint`` when available, + otherwise ``defaultWidth``. + length (float): Length for this object; uses the value from ``blueprint`` when available, + otherwise ``defaultLength``. + height (float): Height for this object; uses the value from ``blueprint`` when available, + otherwise ``defaultHeight``. 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. @@ -126,8 +144,13 @@ class CarlaActor(DrivingObject): """ carlaActor: None blueprint: None + defaultWidth: 1 + defaultLength: 1 + defaultHeight: 1 + width: blueprints.width(self.blueprint, self.defaultWidth) + length: blueprints.length(self.blueprint, self.defaultLength) + height: blueprints.height(self.blueprint, self.defaultHeight) rolename: None - color: None physics: True snapToGround: globalParameters.snapToGroundDefault @@ -151,7 +174,7 @@ class CarlaActor(DrivingObject): else: self.carlaActor.set_velocity(cvel) -class Vehicle(Vehicle, CarlaActor, Steers, _CarlaVehicle): +class Vehicle(CarlaActor, Vehicle, Steers, _CarlaVehicle): """Abstract class for steerable vehicles.""" def setThrottle(self, throttle): @@ -175,10 +198,16 @@ class Vehicle(Vehicle, CarlaActor, Steers, _CarlaVehicle): class Car(Vehicle): """A car. - The default ``blueprint`` (see `CarlaActor`) is a uniform distribution over the - blueprints listed in :obj:`scenic.simulators.carla.blueprints.carModels`. + The default ``blueprint`` (see `CarlaActor`) is a uniform distribution over the + car blueprints recorded for the installed CARLA version (or the closest recorded + version), obtained via + :func:`scenic.simulators.carla.blueprints.blueprintsInCategory` with category + ``"car"``. """ - blueprint: Uniform(*blueprints.carModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("car")) + defaultWidth: 2 + defaultLength: 4.5 + defaultHeight: 1.5 @property def isCar(self): @@ -188,32 +217,48 @@ class NPCCar(Car): # no distinction between these in CARLA pass class Bicycle(Vehicle): - width: 1 - length: 2 - blueprint: Uniform(*blueprints.bicycleModels) - + blueprint: Uniform(*blueprints.blueprintsInCategory("bicycle")) + defaultWidth: 1 + defaultLength: 2 + defaultHeight: 1.5 class Motorcycle(Vehicle): - width: 1 - length:2 - blueprint: Uniform(*blueprints.motorcycleModels) - + blueprint: Uniform(*blueprints.blueprintsInCategory("motorcycle")) + defaultWidth: 1 + defaultLength: 2 + defaultHeight: 1.5 class Truck(Vehicle): - width: 3 - length: 7 - blueprint: Uniform(*blueprints.truckModels) - - -class Pedestrian(Pedestrian, CarlaActor, Walks, _CarlaPedestrian): + blueprint: Uniform(*blueprints.blueprintsInCategory("truck")) + defaultWidth: 2.5 + defaultLength: 7.5 + defaultHeight: 3 + +class Van(Vehicle): + blueprint: Uniform(*blueprints.blueprintsInCategory("van")) + defaultWidth: 2 + defaultLength: 5 + defaultHeight: 2 + +class Bus(Vehicle): + blueprint: Uniform(*blueprints.blueprintsInCategory("bus")) + defaultWidth: 4 + defaultLength: 10 + defaultHeight: 4 + +class Pedestrian(CarlaActor, Pedestrian, Walks, _CarlaPedestrian): """A pedestrian. The default ``blueprint`` (see `CarlaActor`) is a uniform distribution over the - blueprints listed in :obj:`scenic.simulators.carla.blueprints.walkerModels`. + pedestrian blueprints recorded for the installed CARLA version (or the closest + recorded version), obtained via + :func:`scenic.simulators.carla.blueprints.blueprintsInCategory` with category + ``"walker"``. """ - width: 0.5 - length: 0.5 - blueprint: Uniform(*blueprints.walkerModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("walker")) + defaultWidth: 0.5 + defaultLength: 0.5 + defaultHeight: 1.5 carlaController: None def setWalkingDirection(self, heading): @@ -234,100 +279,99 @@ class Prop(CarlaActor): regionContainedIn: road position: new Point on road parentOrientation: Range(0, 360) deg - width: 0.5 - length: 0.5 + defaultWidth: 0.5 + defaultLength: 0.5 + defaultHeight: 0.5 physics: False class Trash(Prop): - blueprint: Uniform(*blueprints.trashModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("trash")) class Cone(Prop): - blueprint: Uniform(*blueprints.coneModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("cone")) class Debris(Prop): - blueprint: Uniform(*blueprints.debrisModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("debris")) class VendingMachine(Prop): - blueprint: Uniform(*blueprints.vendingMachineModels) - + blueprint: Uniform(*blueprints.blueprintsInCategory("vendingMachine")) + class Chair(Prop): - blueprint: Uniform(*blueprints.chairModels) - + blueprint: Uniform(*blueprints.blueprintsInCategory("chair")) + class BusStop(Prop): - blueprint: Uniform(*blueprints.busStopModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("busStop")) class Advertisement(Prop): - blueprint: Uniform(*blueprints.advertisementModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("advertisement")) class Garbage(Prop): - blueprint: Uniform(*blueprints.garbageModels) - + blueprint: Uniform(*blueprints.blueprintsInCategory("garbage")) class Container(Prop): - blueprint: Uniform(*blueprints.containerModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("container")) class Table(Prop): - blueprint: Uniform(*blueprints.tableModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("table")) class Barrier(Prop): - blueprint: Uniform(*blueprints.barrierModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("barrier")) class PlantPot(Prop): - blueprint: Uniform(*blueprints.plantpotModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("plantpot")) class Mailbox(Prop): - blueprint: Uniform(*blueprints.mailboxModels) - + blueprint: Uniform(*blueprints.blueprintsInCategory("mailbox")) class Gnome(Prop): - blueprint: Uniform(*blueprints.gnomeModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("gnome")) class CreasedBox(Prop): - blueprint: Uniform(*blueprints.creasedboxModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("creasedbox")) class Case(Prop): - blueprint: Uniform(*blueprints.caseModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("case")) class Box(Prop): - blueprint: Uniform(*blueprints.boxModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("box")) class Bench(Prop): - blueprint: Uniform(*blueprints.benchModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("bench")) class Barrel(Prop): - blueprint: Uniform(*blueprints.barrelModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("barrel")) class ATM(Prop): - blueprint: Uniform(*blueprints.atmModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("atm")) class Kiosk(Prop): - blueprint: Uniform(*blueprints.kioskModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("kiosk")) class IronPlate(Prop): - blueprint: Uniform(*blueprints.ironplateModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("ironplate")) class TrafficWarning(Prop): - blueprint: Uniform(*blueprints.trafficwarningModels) + blueprint: Uniform(*blueprints.blueprintsInCategory("trafficwarning")) ## Utility functions diff --git a/src/scenic/simulators/carla/sensors.py b/src/scenic/simulators/carla/sensors.py new file mode 100644 index 000000000..f5d64e15d --- /dev/null +++ b/src/scenic/simulators/carla/sensors.py @@ -0,0 +1,74 @@ +import numpy as np + +from scenic.core.sensors import CallbackSensor + + +class CarlaVisionSensor(CallbackSensor): + def __init__( + self, + offset=(0, 0, 0), + rotation=(0, 0, 0), + width=None, + height=None, + attributes=None, + ): + super().__init__() + self.offset = offset + self.rotation = rotation + + if isinstance(attributes, str): + raise NotImplementedError( + "String parsing for attributes is not yet implemented. Feel free to do so." + ) + elif isinstance(attributes, dict): + self.attributes = attributes + else: + self.attributes = {} + + if width is not None: + self.attributes["image_size_x"] = int(width) + if height is not None: + self.attributes["image_size_y"] = int(height) + + self.convert = None + convert = self.attributes.get("convert") + if convert is not None and not isinstance(convert, (str, int)): + raise TypeError("'convert' has to be int or string.") + self.convert = convert + + self.frame = 0 + + blueprint = "sensor.camera.rgb" + + def onData(self, data): + super().onData(data) + self.frame = data.frame + + +class CarlaRGBSensor(CarlaVisionSensor): + def process(self, data): + array = np.frombuffer(data.raw_data, dtype=np.dtype("uint8")) + array = np.reshape(array, (data.height, data.width, 4)) # BGRA format + array = array[:, :, :3] # Take only RGB + array = array[:, :, ::-1] # Revert order + + return array.copy() + + +class CarlaSSSensor(CarlaVisionSensor): + blueprint = "sensor.camera.semantic_segmentation" + + def process(self, data): + if self.convert is not None: + data.convert(self.convert) + + array = np.frombuffer(data.raw_data, dtype=np.dtype("uint8")) + array = np.reshape(array, (data.height, data.width, 4)) # BGRA format + + if self.convert is not None: + array = array[:, :, :3] # Take only RGB + array = array[:, :, ::-1] # Revert order + else: + array = array[:, :, 2] # Take only R + + return array.copy() diff --git a/src/scenic/simulators/carla/simulator.py b/src/scenic/simulators/carla/simulator.py index 6469281d7..3c5e14f61 100644 --- a/src/scenic/simulators/carla/simulator.py +++ b/src/scenic/simulators/carla/simulator.py @@ -38,6 +38,7 @@ def __init__( record="", timestep=0.1, traffic_manager_port=None, + bubble_size=None, ): super().__init__() verbosePrint(f"Connecting to CARLA on port {port}") @@ -54,7 +55,7 @@ def __init__( self.world = self.client.generate_opendrive_world(odr_file.read()) else: raise RuntimeError("CARLA only supports OpenDrive maps") - self.timestep = timestep + if traffic_manager_port is None: traffic_manager_port = port + 6000 @@ -64,13 +65,21 @@ def __init__( # Set to synchronous with fixed timestep settings = self.world.get_settings() settings.synchronous_mode = True - settings.fixed_delta_seconds = timestep # NOTE: Should not exceed 0.1 + self.timestep = timestep + if timestep > 0.1: + settings.fixed_delta_seconds = .1 # NOTE: Should not exceed 0.1 + else: + settings.fixed_delta_seconds = timestep self.world.apply_settings(settings) verbosePrint("Map loaded in simulator.") self.render = render # visualization mode ON/OFF self.record = record # whether to use the carla recorder self.scenario_number = 0 # Number of the scenario executed + if bubble_size is not None: + self.bubble_size = bubble_size + else: + self.bubble_size=None def createSimulation(self, scene, *, timestep, **kwargs): if timestep is not None and timestep != self.timestep: @@ -88,6 +97,7 @@ def createSimulation(self, scene, *, timestep, **kwargs): self.record, self.scenario_number, timestep=self.timestep, + bubble_size = self.bubble_size, **kwargs, ) @@ -101,20 +111,38 @@ def destroy(self): class CarlaSimulation(DrivingSimulation): - def __init__(self, scene, client, tm, render, record, scenario_number, **kwargs): + def __init__(self, scene, client, tm, render, record, scenario_number,timestep,bubble_size=None,**kwargs): self.client = client self.world = self.client.get_world() + self.current_frame = None self.map = self.world.get_map() self.blueprintLib = self.world.get_blueprint_library() self.tm = tm self.render = render self.record = record self.scenario_number = scenario_number - self.cameraManager = None + self.cameraManager = None + if timestep > .1: + self.sim_ticks_per = (int(round(timestep/.1))) + assert math.isclose(self.sim_ticks_per, timestep / 0.1) + else: + self.sim_ticks_per = 1 + self.timestep=timestep + print(f"Setting sim_ticks_per as: {self.sim_ticks_per} and timestep as: {timestep}") + + if bubble_size is not None: + self.bubble_size = bubble_size + self.hybrid_physics = True + else: + self.hybrid_physics = False + self.total_spawned_actors = 0 + self.steps_taken = 0 - super().__init__(scene, **kwargs) + + super().__init__(scene, timestep=timestep, **kwargs) def setup(self): + print("Instantiating new simulator instance") weather = self.scene.params.get("weather") if weather is not None: if isinstance(weather, str): @@ -151,6 +179,11 @@ def setup(self): camIndex = 0 camPosIndex = 0 egoActor = self.objects[0].carlaActor + if self.hybrid_physics: + egoActor.role_name = "hero" + self.tm.set_hybrid_physics_mode(True) + self.tm.set_hybrid_physics_radius(self.bubble_size) + self.cameraManager = visuals.CameraManager(self.world, egoActor, self.hud) self.cameraManager._transform_index = camPosIndex self.cameraManager.set_sensor(camIndex) @@ -164,7 +197,7 @@ def setup(self): carla.VehicleControl(manual_gear_shift=False) ) - self.world.tick() + self.current_frame = self.world.tick() for obj in self.objects: if obj.speed is not None and obj.speed != 0: @@ -172,6 +205,7 @@ def setup(self): f"object {obj} cannot have a nonzero initial speed " "(this is not yet possible in CARLA)" ) + def createObjectInSimulator(self, obj): # Extract blueprint @@ -216,13 +250,14 @@ def createObjectInSimulator(self, obj): # Color, cannot be set for Pedestrians if blueprint.has_attribute("color") and obj.color is not None: c = obj.color - c_str = f"{int(c.r*255)},{int(c.g*255)},{int(c.b*255)}" + c_str = f"{int(c.r * 255)},{int(c.g * 255)},{int(c.b * 255)}" blueprint.set_attribute("color", c_str) # Create Carla actor carlaActor = self.world.try_spawn_actor(blueprint, transform) if carlaActor is None: - raise SimulationCreationError(f"Unable to spawn object {obj}") + # raise SimulationCreationError(f"Unable to spawn object {obj}") + return None obj.carlaActor = carlaActor carlaActor.set_simulate_physics(obj.physics) @@ -236,6 +271,8 @@ def createObjectInSimulator(self, obj): obj.length = ex * 2 if ex > 0 else obj.length obj.height = ez * 2 if ez > 0 else obj.height carlaActor.apply_control(carla.VehicleControl(manual_gear_shift=True, gear=1)) + if obj.behavior is None:# set Autopilot for CARLA TESTS + carlaActor.set_autopilot(True, self.tm.get_port()) elif isinstance(carlaActor, carla.Walker): carlaActor.apply_control(carla.WalkerControl()) # spawn walker controller @@ -248,6 +285,43 @@ def createObjectInSimulator(self, obj): f"Unable to spawn carla controller for object {obj}" ) obj.carlaController = controller + self.total_spawned_actors += 1 + + # Adding sensors if available + if obj.sensors: + for sensor_key, sensor in obj.sensors.items(): + sensor_bp = self.blueprintLib.find(sensor.blueprint) + for key, val in sensor.attributes.items(): + if sensor_bp.has_attribute(key): + sensor_bp.set_attribute(key, str(val)) + + if isinstance(sensor.convert, str): + try: + sensor.convert = carla.ColorConverter.names[sensor.convert] + except KeyError: + raise ValueError( + f"Unknown CARLA ColorConverter '{sensor.convert}'" + ) + + transform = carla.Transform( + carla.Location( + x=sensor.offset[1], # forward (Scenic +Y) -> CARLA +X + y=sensor.offset[0], # right (Scenic +X) -> CARLA +Y + z=sensor.offset[2], # up (same) + ), + carla.Rotation( + pitch=sensor.rotation[1], + yaw=-sensor.rotation[0], # CARLA yaw is clockwise + roll=sensor.rotation[2], + ), + ) + + carla_sensor = self.world.spawn_actor( + sensor_bp, transform, attach_to=obj.carlaActor + ) + carla_sensor.listen(sensor.onData) + sensor.carla_sensor = carla_sensor + return carlaActor def executeActions(self, allActions): @@ -262,47 +336,76 @@ def executeActions(self, allActions): def step(self): # Run simulation for one timestep - self.world.tick() + for _ in range(self.sim_ticks_per): + self.current_frame = self.world.tick() + self.steps_taken += 1 + + # Wait for sensors to get updates + for obj in self.objects: + if obj.sensors: + for sensor in obj.sensors.values(): + while sensor.frame != self.current_frame: + pass # Render simulation if self.render: self.cameraManager.render(self.display) pygame.display.flip() + if self.steps_taken % 100== 0: + print(f"Total spawned actors at step: {self.steps_taken} spawned actors is: {self.total_spawned_actors}") + print(f"Total objects: {len(self.objects)}") + def getProperties(self, obj, properties): # Extract Carla properties - carlaActor = obj.carlaActor - currTransform = carlaActor.get_transform() - currLoc = currTransform.location - currRot = currTransform.rotation - currVel = carlaActor.get_velocity() - currAngVel = carlaActor.get_angular_velocity() - - # Prepare Scenic object properties - position = utils.carlaToScenicPosition(currLoc) - velocity = utils.carlaToScenicPosition(currVel) - speed = math.hypot(*velocity) - angularSpeed = utils.carlaToScenicAngularSpeed(currAngVel) - angularVelocity = utils.carlaToScenicAngularVel(currAngVel) - globalOrientation = utils.carlaToScenicOrientation(currRot) - yaw, pitch, roll = obj.parentOrientation.localAnglesFor(globalOrientation) - elevation = utils.carlaToScenicElevation(currLoc) - - values = dict( - position=position, - velocity=velocity, - speed=speed, - angularSpeed=angularSpeed, - angularVelocity=angularVelocity, - yaw=yaw, - pitch=pitch, - roll=roll, - elevation=elevation, - ) - return values + if hasattr(obj, "carlaActor"): + if obj.carlaActor is not None: + if obj.carlaActor.is_alive: + carlaActor = obj.carlaActor + currTransform = carlaActor.get_transform() + currLoc = currTransform.location + currRot = currTransform.rotation + currVel = carlaActor.get_velocity() + currAngVel = carlaActor.get_angular_velocity() + + # Prepare Scenic object properties + position = utils.carlaToScenicPosition(currLoc) + velocity = utils.carlaToScenicPosition(currVel) + speed = math.hypot(*velocity) + angularSpeed = utils.carlaToScenicAngularSpeed(currAngVel) + angularVelocity = utils.carlaToScenicAngularVel(currAngVel) + globalOrientation = utils.carlaToScenicOrientation(currRot) + # SANITY CHECk + # Check CARLA outputs for hybrid vrs non + # parentOrientation & currRot + + yaw, pitch, roll = obj.parentOrientation.localAnglesFor(globalOrientation) + elevation = utils.carlaToScenicElevation(currLoc) + + values = dict( + position=position, + velocity=velocity, + speed=speed, + angularSpeed=angularSpeed, + angularVelocity=angularVelocity, + yaw=yaw, + pitch=pitch, + roll=roll, + elevation=elevation, + ) + return values + else: + return None def destroy(self): for obj in self.objects: + if not hasattr(obj, "carlaActor"): + continue # Remove this later -- just for CoSim testing purposes + if obj.sensors: + for sensor in obj.sensors.values(): + if sensor.carla_sensor is not None and sensor.carla_sensor.is_alive: + sensor.carla_sensor.stop() + sensor.carla_sensor.destroy() if obj.carlaActor is not None: if isinstance(obj.carlaActor, carla.Vehicle): obj.carlaActor.set_autopilot(False, self.tm.get_port()) diff --git a/src/scenic/simulators/carla/utils/utils.py b/src/scenic/simulators/carla/utils/utils.py index 638161163..4c6862722 100644 --- a/src/scenic/simulators/carla/utils/utils.py +++ b/src/scenic/simulators/carla/utils/utils.py @@ -4,7 +4,7 @@ import scipy from scenic.core.geometry import normalizeAngle -from scenic.core.vectors import Orientation, Vector +from scenic.core.vectors import Orientation, Vector, _getEulerAngles def _snapToGround(world, location, blueprint): @@ -28,14 +28,14 @@ def scenicToCarlaVector3D(x, y, z=0.0): 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 _snapToGround(world, carla.Location(pos.x, -pos.y, pos.z), blueprint) return carla.Location(pos.x, -pos.y, pos.z) def scenicToCarlaRotation(orientation): # 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, pitch, roll = _getEulerAngles(orientation.r, "ZXY", degrees=True) yaw = -yaw - 90 return carla.Rotation(pitch=pitch, yaw=yaw, roll=roll) diff --git a/src/scenic/simulators/carla/utils/visuals.py b/src/scenic/simulators/carla/utils/visuals.py index c73e832e3..4995c0ee9 100644 --- a/src/scenic/simulators/carla/utils/visuals.py +++ b/src/scenic/simulators/carla/utils/visuals.py @@ -271,7 +271,6 @@ def __init__(self, world, actor, hud): self._surface = None self._actor = actor self._hud = hud - self.images = [] self._camera_transforms = [ carla.Transform(carla.Location(x=-5.5, z=2.8), carla.Rotation(pitch=-15)), carla.Transform(carla.Location(x=1.6, z=1.7)), @@ -365,7 +364,6 @@ def _parse_image(weak_self, image): array = array[:, :, :3] array = array[:, :, ::-1] self._surface = pygame.surfarray.make_surface(array.swapaxes(0, 1)) - self.images.append(image) def destroy_sensor(self): if self.sensor is not None: diff --git a/src/scenic/simulators/cosim/__init__.py b/src/scenic/simulators/cosim/__init__.py new file mode 100644 index 000000000..08253344a --- /dev/null +++ b/src/scenic/simulators/cosim/__init__.py @@ -0,0 +1 @@ +from .simulator import CosimSimulator \ No newline at end of file diff --git a/src/scenic/simulators/cosim/model.scenic b/src/scenic/simulators/cosim/model.scenic index cd76bb741..74199fbac 100644 --- a/src/scenic/simulators/cosim/model.scenic +++ b/src/scenic/simulators/cosim/model.scenic @@ -1,19 +1,205 @@ -from scenic.simulators.cosim.simulator import CosimSimulator - -param metsr_host = "localhost" -param metsr_port = 4000 -param carla_host = "localhost" -param carla_port = 2000 -param metsr_map = "Data.properties.CARLA" -param carla_map = "Town05" -param timestep = 0.1 - -simulator CosimSimulator( - metsr_host = globalParameters.metsr_host, - metsr_port = globalParameters.metsr_port, - carla_host = globalParameters.carla_host, - carla_port = globalParameters.carla_port, - metsr_map = globalParameters.metsr_map, - carla_map = globalParameters.carla_map, - timestep = globalParameters.timestep, - ) +import pathlib +from scenic.simulators.carla.model import Vehicle, is2DMode + +import scenic.simulators.carla.blueprints as blueprints +from scenic.simulators.carla.behaviors import * +from scenic.simulators.utils.colors import Color + +from .utils.CoSimActions import * +from .utils.scenarios import * + +from scenic.simulators.metsr.traffic_flows import * + +from scenic.simulators.cosim.simulator import CosimSimulator +map_town = pathlib.Path(globalParameters.map).stem +param xml_path = pathlib.Path(globalParameters.xml_map) +param carla_map = map_town +param metsr_host = "localhost" +param metsr_port = 4000 +param address = "10.0.0.122" +param carla_port = 2000 +param timestep = 0.1 +param snapToGroundDefault = is2DMode() +param bubble_size = 50 +param metsr_sim_dir = None +param run_name = None +param metsr_viz_port = 8080 + + +simulator CosimSimulator( + metsr_host = globalParameters.metsr_host, + metsr_port = globalParameters.metsr_port, + address = globalParameters.address, + carla_port = globalParameters.carla_port, + carla_map = map_town, + xml_map = globalParameters.xml_path, + map_path = globalParameters.map, + timestep = globalParameters.timestep, + bubble_size = globalParameters.bubble_size, + run_name = globalParameters.run_name, + metsr_sim_dir = globalParameters.metsr_sim_dir, + metsr_viz_port = globalParameters.metsr_viz_port, + ) + +param startTime = 6*60*60 +param verbose=False + + +_DAY_MOD = 24*60*60 + +class Car(Vehicle): + """A car. + + The default ``blueprint`` (see `CarlaActor`) is a uniform distribution over the + blueprints listed in :obj:`scenic.simulators.carla.blueprints.carModels`. + """ + blueprint: Uniform(*blueprints.carModels) + + origin: -1 + destination: -1 + + def generateTrajectory(self,trajectory): + self.trajectory = trajectory + self.trajectory_is_active = False + + @property + def isCar(self): + return True + + def distanceToClosest(self, type: type) -> Object: + """Compute the distance to the closest object of the given type. + + For example, one could write :scenic:`self.distanceToClosest(Car)` in a behavior. + """ + objects = simulation().objects + minDist = float('inf') + for obj in objects: + if not isinstance(obj, type): + continue + if obj.carla_actor_flag: # Fitler out vehicles + d = distance from self to obj + if 0 < d < minDist: + minDist = d + return minDist + + def setAutoPilot(self, active_autopilot): + self.autopilot_action = active_autopilot + + +class EgoCar(Car): + """ + Car which defines the corresponding bubble location + based on its current position + """ + carla_actor_flag = True + behavior: FollowLaneBehavior() + + +class NPCCar(Car, BackgroundDriver): + """ + A Non-Ego vehicle which has no effect on the defined bubble region + + :param carla_actor_flag: Dynamic flag representing vehicles presence in the + Cosimluated region. This flag will be set internally and should + not be modifed + :type: carla_actor_flag: bool + + :param behavior: Actor behavior + :type behavior: Scenic Behavior + """ + carla_actor_flag = False + behavior: FollowRandomRoute() + + +""" +Behaviors: + A mix of default behaviors for the Interface +""" + +behavior WaitBehavior(): + """ + Passive wait Behavior + """ + while True: + wait + +behavior DisableAutoPilotThenDrive(): + """ + Disable Auto-pilot then initiate a secondary behavior + """ + take SetAutoPilotAction(False) + while True: + do DriveAvoidingCollisions() + +behavior SetAndFollowTrajectoryBehavior(target_speed, turn_speed): + trajectory = self.trajectory + do FollowTrajectoryBehavior(trajectory=trajectory, target_speed=target_speed, turn_speed=turn_speed) + +behavior StateBehavior(state_map, eval_func, verbose=False): + """ + docstring for StateBehavior + + Alternates between state based behaviors + + :param state_map: Dictionary which maps each state to an intended action + :type state_map: dict[type(state) : Behavior()] + :param eval_func: Fuction which maps an object to a given state + :type eval_func: Function + """ + while True: + curr_state = eval_func() + behavior = state_map(curr_state) + do behavior until curr_state != eval_func() + +behavior CustomBubbleBehavior(): + """ + Object will follow a random route starting at + the origin zone and ending in the destination zone. + If the object enters the bubble then it will envoke + followLaneBehavior or any general behavior + """ + eval_func = lambda : self.carla_actor_flag + state_map = {False: FollowRandomRoute(), True: DisableAutoPilotThenDrive()} + do StateBehavior(state_map, eval_func) + + +behavior FollowSingleTrajectoryBehavior(target_speed = 10, trajectory = None, turn_speed=None): + """ + Follows the given trajectory. The behavior terminates once the end of the trajectory is reached. + If no trajectory is supplied, object will follow METSR proposed trajectory by default + + :param target_speed: Its unit is in m/s. By default, it is set to 10 m/s + :param trajectory: It is a list of sequential lanes to track, from the lane that the vehicle is initially on to the lane it should end up on. + """ + if trajectory is None: + if not hasattr(self, "trajectory"): + self.trajectory = None + take SetAutoPilotAction(True) + while True: + do WaitBehavior() + else: + state_map = {False: SetAutoPilotAndWait(trajectory), True: FollowTrajectoryBehavior(target_speed=target_speed,trajectory=trajectory, turn_speed=turn_speed)} + state_func = lambda: self.carla_actor_flag + while True: + do StateBehavior(state_map, state_func) + +behavior FollowRandomRoute(): + """ + Object will follow a random route starting from its + spawn position to a random target road. Low level controls will be handled + via the METSR driving module and CARLA autopilot + + NOTE: Random route behaviors are restricted to non-ego vehicles + This is due to the fact that CARLA may remove deadlocked or + non-moving vehicles from the simulation + """ + while True: + take SetAutoPilotAction(True) + + +def currentTOD(): + return (simulation().currentTime * simulation().timestep + globalParameters.startTime)%_DAY_MOD + + + + diff --git a/src/scenic/simulators/cosim/run_blank.py b/src/scenic/simulators/cosim/run_blank.py new file mode 100644 index 000000000..f89a9e87f --- /dev/null +++ b/src/scenic/simulators/cosim/run_blank.py @@ -0,0 +1,42 @@ +import sys +import os +import argparse +import time +from Scenic.src.scenic.simulators.metsr.util import read_run_config, prepare_sim_dirs, run_simulations, run_simulations_in_background, run_simulation_in_docker + +# use case: python run_blank.py -r configs/run_cosim_blank.json -v +def get_arguments(argv): + parser = argparse.ArgumentParser(description='METS-R simulation') + parser.add_argument('-r','--run_config', default='configs/run_cosim_CARLAT5.json', + help='the folder that contains all the input data') + parser.add_argument('-v', '--verbose', action='store_true', default=False, + help='verbose mode') + parser.add_argument('-n', '--name', default="random_choice") + args = parser.parse_args(argv) + + config = read_run_config(args.run_config) + config.verbose = args.verbose + + return config + +if __name__ == '__main__': + config = get_arguments(sys.argv[1:]) + os.chdir("docker") + os.system("docker-compose up -d") + os.chdir("..") + + time.sleep(10) # wait 10s for the Kafka servers to be up + + # Prepare simulation directories + dest_data_dirs = prepare_sim_dirs(config) + + try: + # Launch the simulations + container_ids = run_simulation_in_docker(config) + print("Docker Started Successfully") + while True: + time.sleep(1) + finally: + for cid in container_ids: + print(f"Stopping docker cid: {cid}") + os.system(f"docker stop {cid}") diff --git a/src/scenic/simulators/cosim/simulator.py b/src/scenic/simulators/cosim/simulator.py index aab42e424..b104e03e4 100644 --- a/src/scenic/simulators/cosim/simulator.py +++ b/src/scenic/simulators/cosim/simulator.py @@ -1,32 +1,1238 @@ -from scenic.core.simulators import Simulation, Simulator -from scenic.simulators.metsr.simulator import METSRSimulator -from scenic.simulators.carla.simulator import CarlaSimulator -from scenic.core.vectors import Orientation, Vector - -class CosimSimulator(Simulator): - def __init__(self, map_name, carla_host, carla_port, metsr_host, metsr_port, timestep): - super().__init__() - - breakpoint() - - self.map_name = map_name - self.timestep = timestep - self.sim_timestep = sim_timestep - - self.carla_sim = CarlaSimulator() - self.metsr_sim = METSRSimulator() - - def createSimulation(self, scene, timestep, **kwargs): - assert timestep is None or timestep == self.timestep - - return CosimSimulation( - scene, self.timestep, self.carla_sim, self.metsr_sim, **kwargs - ) - - def destroy(self): - self.carla_sim.destroy() - self.metsr_sim.destroy() - super().destroy() - -class CosimSimulation(Simulation): - pass \ No newline at end of file +from scenic.core.simulators import Simulation, Simulator +from scenic.core.vectors import Orientation, Vector +from scenic.syntax.veneer import verbosePrint +from scenic.simulators.metsr.client import METSRClient +from scenic.simulators.cosim.utils.utils import * +from scenic.core.regions import CircularRegion +from scenic.core.object_types import Object +from scenic.core.simulators import SimulationCreationError, ObjectMissingInSimulation +from scenic.domains.driving.roads import Lane, Intersection, Road +from scenic.domains.driving.simulators import DrivingSimulation, DrivingSimulator + +import pygame +import warnings +import os +import math +import random +import scenic.simulators.cosim.utils.utils as _utils +import scenic.simulators.carla.utils.utils as utils +import pandas as pd + +from .utils.network_helper import network_cache +from .utils.global_route_planner import GlobalRoutePlanner + +import scenic.simulators.carla.utils.visuals as visuals +from scenic.simulators.carla.blueprints import oldBlueprintNames + +try: + import carla +except ImportError as e: + raise ModuleNotFoundError('CARLA scenarios require the "carla" Python package') from e + +class CosimSimulator(DrivingSimulator): + def __init__(self, + carla_map, + map_path, + xml_map, + bubble_size = 50, # Might be good to add some logic for what a minimal bubble size is so users cannot make it too small + address="127.0.0.1", + carla_port=2000, + metsr_host="localhost", # Not sure what this actually means here + metsr_port=4000, + timestep=0.1, # Not entirely sure what the distinction between timestep and sim_timestep is in metsr + sim_timestep=0.1, + traffic_manager_port=None, + metsr_sim_dir=None, + timeout=20, + verbose=False, + render=True, + record="", + run_name=None, + metsr_viz_port = 8080 + ): + super().__init__() + + self.timestep = timestep + self.sim_timestep = sim_timestep # This should represent the timestep recorded in the METSR config + self.map_path = map_path + self.sim_ticks_per = int(round((timestep / sim_timestep))) + assert math.isclose(self.sim_ticks_per, timestep / sim_timestep) + + self.bubble_size = bubble_size + self.render= render + self.record = record + self.run_name = run_name + self.metsr_sim_dir = metsr_sim_dir + + # Setting up the Carla Simulator + verbosePrint(f"Connection to CARLA on port {carla_port}") + self.carla_client = carla.Client(address,carla_port) + self.carla_client.set_timeout(timeout) + """ + Need to figure out how to handle the map paths for this + """ + if carla_map is not None: + try: + print(f"Loading carla world: {carla_map}") + self.world = self.carla_client.load_world(carla_map) + self.xml_to_xodr_map = _utils.generate_map(str(xml_map)) #convert pathlib obj to str for XML tree TODO what is best practice? + self.xml_to_xodr_intersections = _utils.generate_signal_map(str(xml_map)) + except Exception as e: + raise RuntimeError(f"CARLA could not load world '{carla_map}'") from e + else: + print(f"Loading carla Map: {map_path}") + #TODO figure out how to properly do the map handling here + if str(map_path).endswith(".xodr"): + with open(map_path) as odr_file: + self.world = self.carla_client.generate_opendrive_world(odr_file.read()) + else: + raise RuntimeError("CARLA only supports OpenDrive maps") + + if traffic_manager_port is None: + traffic_manager_port = carla_port + 6000 + assert traffic_manager_port != metsr_port, f"Specified Traffic manager port {traffic_manager_port} is not available" + self.tm = self.carla_client.get_trafficmanager(traffic_manager_port) + self.tm.set_synchronous_mode(True) + settings = self.world.get_settings() + settings.synchronous_mode = True + print(f"Relaxed timestep restriction for testing?") + assert sim_timestep <= .1 , f"timestep must be less that 0.1" + settings.fixed_delta_seconds = sim_timestep + self.world.apply_settings(settings) + verbosePrint("Map loaded in simulator.") + + # self.scenario_number = 0 + verbosePrint("Carla was initialized correctly proceeding to Metsr") + self.metsr_viz_port = metsr_viz_port + + # Setting up Metsr simulator + if self.metsr_sim_dir is not None: + self.metsr_visualize = True + self.metsr_client = METSRClient(host=metsr_host, + port=metsr_port, + sim_folder=metsr_sim_dir, + verbose=verbose) + else: + self.metsr_client = METSRClient(host=metsr_host, + port=metsr_port, + verbose=verbose) + self.metsr_visualize = False + + + + verbosePrint("Clients have successfully been initialized") + + print(f"Creating CoSimulator with timestep: {self.timestep} and Ticks per step as: {self.sim_ticks_per}") + + def createSimulation(self,scene,*, timestep, **kwargs): #TODO: fix timestep + if timestep is not None and timestep != self.timestep: + raise RuntimeError( + "cannot customize timestep for individual CARLA simulations; " + "set timestep when creating the CarlaSimulator instead" + ) + return CosimSimulation( + scene=scene, + carla_client=self.carla_client, + metsr_client=self.metsr_client, + timestep=self.timestep, + sim_ticks_per=self.sim_ticks_per, + tm=self.tm, + bubble_size=self.bubble_size, + render=self.render, + visualize_metsr=self.metsr_visualize, + record=self.record, + mappings=self.xml_to_xodr_map, + xml_to_xodr_intersections = self.xml_to_xodr_intersections, + run_name= self.run_name, + metsr_viz_port = self.metsr_viz_port, + **kwargs, + ) + def destroy(self): + self.metsr_client.close() + super().destroy() + settings = self.world.get_settings() + settings.synchronous_mode = False + settings.fixed_delta_seconds = None + self.world.apply_settings(settings) + self.tm.set_synchronous_mode(False) + +class CosimSimulation(DrivingSimulation): + def __init__(self, scene, carla_client, metsr_client, timestep, sim_ticks_per, tm, render ,record,visualize_metsr, mappings, xml_to_xodr_intersections, bubble_size=100, run_name=None, metsr_viz_port=8080, **kwargs ): + + # Carla and metrs simulators + self.carla_client = carla_client + self.metsr_client = metsr_client + self.timestep = timestep # Timestep for each step + self.sim_ticks_per = sim_ticks_per + + # Initializing CARLA params + self.tm = tm # Carla Traffic manager + self.carla_world = self.carla_client.get_world() + self.map = self.carla_world.get_map() + self.blueprintLib = self.carla_world.get_blueprint_library() + self.carla_cameraManager = None + self.render = render + self.record = record + self.cameraManager = None + + # Initializing METSR params + self.next_pv_id = 0 + self.pv_id_map = {} + self.frozen_vehicles = set() + self.scenic_to_metsr_map = mappings + self._client_calls = [] + self.count = 0 + self.visualize_metsr = visualize_metsr + self.metsr_viz_port = metsr_viz_port + + # CoSim related params + self.bubble_size = bubble_size + self.bubble_roads = [] + self.workspace = scene.workspace + self.carla_control_roads = {} + self.bubble_spawn_queue = set({}) + self.frozen_scenic_roads = [] + self.xml_to_xodr_intersections = xml_to_xodr_intersections + self.metsr_actors = [] + self.carla_actors = [] + self.metsr_road_lines = [] + self.spawn_points = self.carla_world.get_map().get_spawn_points() + self.completed_route = {} + self.metsr_road_cache = {} + self.road_pop_density = {} + self.grp = None + self.run_name = run_name + + # For tracking / data collection + self.bubble_sizes = [] + self.total_active_vehicles = [] + + + super().__init__(scene, timestep=timestep, **kwargs) + + + def setup(self) -> None: + """ + Docstring for setup + + Setup the simulation instance + """ + # Updated version takes no arguements + self.metsr_client.reset() + # Start the visualization once + verbosePrint(f"Initializing METS-R visualization server") + if self.visualize_metsr: + print(f"Starting METS-R visualization server, client will timeout in 30 seconds") + self.metsr_client.start_viz(server_port=self.metsr_viz_port, startup_timeout=60) + print(f"Please connect to METS-R at port: {self.metsr_viz_port}") + self.valid_metsr_roads = self.metsr_client.query_road()['id_list'] + + self.network_helper = network_cache(self.workspace, + self.scenic_to_metsr_map, + self.valid_metsr_roads) + + world_map = self.carla_world.get_map() + self.grp = GlobalRoutePlanner(world_map, sampling_resolution=2.0) + + weather = self.scene.params.get("weather") + if weather is not None: + if isinstance(weather, str): + self.carla_world.set_weather(getattr(carla.WeatherParameters, weather)) + elif isinstance(weather, dict): + self.carla_world.set_weather(carla.WeatherParameters(**weather)) + + # Setup HUD + # self.render=False + if self.render: + self.displayDim = (1280, 720) + self.displayClock = pygame.time.Clock() + self.camTransform = 0 + pygame.init() + pygame.font.init() + self.hud = visuals.HUD(*self.displayDim) + self.display = pygame.display.set_mode( + self.displayDim, pygame.HWSURFACE | pygame.DOUBLEBUF + ) + self.cameraManager = None + + if self.record: + print(f"starting recording") + 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.carla_client.start_recorder(name) + + # Create objects. + super().setup() + + for obj in self.objects: + if isinstance(obj.carlaActor, carla.Vehicle): + obj.carlaActor.apply_control( + carla.VehicleControl(manual_gear_shift=False) + ) + self.carla_world.tick() + + # Set up camera manager and collision sensor for ego + if self.render: + camIndex = 0 + camPosIndex = 0 + egoActor = self.objects[0].carlaActor + self.cameraManager = visuals.CameraManager(self.carla_world, egoActor, self.hud) + self.cameraManager._transform_index = camPosIndex + self.cameraManager.set_sensor(camIndex) + self.cameraManager.set_transform(self.camTransform) + + self.carla_world.tick() ## allowing manualgearshift to take effect + + for obj in self.scene.objects: + if obj.carla_actor_flag: + if obj.speed is not None and obj.speed != 0: + raise RuntimeError( + f"object {obj} cannot have a nonzero initial speed " + "(this is not yet possible in CARLA)" + ) + + def createObjectInMetsr(self, obj: Object, route_generation_attempts=100, origin: str = None) -> None: + """ + Docstring for createObjectInMetsr + + :param obj: Cosimulation car object + :type obj: Scenic Object + + Creates vehicle inside the METSR simulator + """ + dest = random.choice(self.spawn_points) + pos = utils.carlaToScenicPosition(dest.location) + for _ in range(route_generation_attempts): # max 20 tries + route = self.metsr_client.query_route(obj.position.x, obj.position.y, pos.x, pos.y, transform_coords=True)["DATA"][0] + if route != "KO": + if len(route['road_list']) > 1: + break + + assert route != "KO", f"Failed to generate trajectory for obj in position (x,y) : {(obj.position.x, obj.position.y)}" + + obj.route = route['road_list'] + call_kwargs = { + "vehID": self.getMetsrPrivateVehId(obj), + "origin": obj.route[0], + "destination": obj.route[-1], + } + + self.metsr_client.generate_trip_between_roads(**call_kwargs) + self.metsr_client.teleport_trace_replay_vehicle(vehID= self.getMetsrPrivateVehId(obj), + roadID=obj.route[0], + laneID=0, + x=obj.position.x, + y=obj.position.y, + private_veh = True, + transform_coords=True) + + + self.metsr_client.update_vehicle_route(self.getMetsrPrivateVehId(obj), route['road_list'], private_veh=True) + + + + + def createObjectInCarla(self, obj: Object, trajectory: list[carla.Transform] = None) -> None: + """ + Docstring for createObjectInCarla + + :param obj: Cosimulation car object + :type obj: Scenic Object + """ + try: + blueprint = self.blueprintLib.find(obj.blueprint) + except IndexError as e: + found = False + if obj.blueprint in oldBlueprintNames: + for oldName in oldBlueprintNames[obj.blueprint]: + try: + blueprint = self.blueprintLib.find(oldName) + found = True + warnings.warn( + f"CARLA blueprint {obj.blueprint} not found; " + f"using older version {oldName}" + ) + obj.blueprint = oldName + break + except IndexError: + continue + if not found: + raise SimulationCreationError( + f"Unable to find blueprint {obj.blueprint}" f" for object {obj}" + ) from e + if obj.rolename is not None: + blueprint.set_attribute("role_name", obj.rolename) + + # set walker as not invincible + if blueprint.has_attribute("is_invincible"): + blueprint.set_attribute("is_invincible", "False") + # Set up transform + loc = utils.scenicToCarlaLocation( + obj.position, + world=self.carla_world, + blueprint=obj.blueprint, + snapToGround=obj.snapToGround + ) + rot = utils.scenicToCarlaRotation(obj.orientation) + transform = carla.Transform(loc, rot) + # Color, cannot be set for Pedestrians + if blueprint.has_attribute("color") and obj.color is not None: + c = obj.color + c_str = f"{int(c.r*255)},{int(c.g*255)},{int(c.b*255)}" + blueprint.set_attribute("color", c_str) + try: + carlaActor = self.carla_world.spawn_actor(blueprint, transform) + except Exception as e: + # print(f"Exception: {e}") + # print(f"Failed to spawn actor in position: {transform.location} with rot {rot}") + waypoint = self.carla_world.get_map().get_waypoint(carla.Location(obj.position.x, -obj.position.y, obj.position.z), project_to_road=True, lane_type=carla.LaneType.Driving) + transform = carla.Transform(carla.Location(waypoint.transform.location.x, waypoint.transform.location.y, waypoint.transform.location.z+2.0), waypoint.transform.rotation) + # print(f"Attempting to spawn actor {obj.name} in: {transform.location}, with rot {transform.rotation}") + # out = _utils.within_threshold_to(obj, self.carla_actors, verbose=True) + obj.position = Vector(transform.location.x, transform.location.y, transform.location.z) + # print(f"Within thershold to: {out}") + carlaActor = self.carla_world.try_spawn_actor(blueprint,transform) + if carlaActor is None: + self.bubble_spawn_queue.append(obj) + print(f"Failed to generate actor {obj.name} after location correction. Adding veh to spawn queue") + + obj.carlaActor = carlaActor + carlaActor.set_simulate_physics(obj.physics) + + if isinstance(carlaActor, carla.Vehicle): + # TODO should get dimensions at compile time, not simulation time + extent = carlaActor.bounding_box.extent + ex, ey, ez = extent.x, extent.y, extent.z + # Ensure each extent is positive to work around CARLA issue #5841 + obj.width = ey * 2 if ey > 0 else obj.width + obj.length = ex * 2 if ex > 0 else obj.length + obj.height = ez * 2 if ez > 0 else obj.height + carlaActor.apply_control(carla.VehicleControl(manual_gear_shift=True, gear=1)) + + if trajectory != None: + carlaActor.set_autopilot(True) + self.tm.set_path(carlaActor, trajectory) + + elif isinstance(carlaActor, carla.Walker): + carlaActor.apply_control(carla.WalkerControl()) + # spawn walker controller + controller_bp = self.blueprintLib.find("controller.ai.walker") + controller = self.carla_world.try_spawn_actor( + controller_bp, carla.Transform(), carlaActor + ) + if controller is None: + raise SimulationCreationError( + f"Unable to spawn carla controller for object {obj}" + ) + obj.carlaController = controller + + obj.spawn_guard = 2 + obj.carla_actor_flag = True + return True + + + + def createObjectInSimulator(self, obj: Object) -> None: + """ + Docstring for createObjectInSimulator + + Spawn the object in the appropriate simulator + (i) Ego is spawned in both simulators + (ii)Ticks metsr to allow the vehicle to enter the road if the simulation has not started + + """ + assert obj.origin, "All objects must have an origin" + assert obj.destination, "All objects must have an destination" + + assert hasattr(obj, "carla_actor_flag"), "All objects must have attribute: carla_actor_flag" + if obj == self.objects[0]: # Special handling for ego + self.ego = obj + self.spawn_ego(obj) + self.carla_actors.append(obj) + self.metsr_actors.append(obj) + else: + self.createObjectInMetsr(obj) + self.metsr_actors.append(obj) + obj.finished_route = False # Track route completion for autopilot + if self.count == 0: + self.metsr_client.tick() # allow obj to enter road if possible + obj.carla_actor_flag = False + obj.spawn_guard = 0 + # Track autopilot behaviors TODO redundant? + obj.active_autopilot = False + obj.autopilot_action = False + obj.trip_start = 0 + + def spawn_ego(self,obj: Object) -> None: + """ + docstring for spawn_ego + + :param ego: Simulation ego object + :type ego: EgoCar + + Special handling for spawning the Ego vehicle + (1) First spawn ego in METSR on the appropriate lane (set by Scenic) + (2) Teleport ego to the precise spawn location in MESTR + (3) Collect exact spawn location to define bubbble region + (4) Freeze CoSimulated regions inside METSR + (5) Spawn ego inside CARLA + """ + obj.bubble = CircularRegion(center=[obj.position.x, + obj.position.y], + radius=self.bubble_size) + + bubble_roads = self._get_bubble_roads() + print([f"{road.id}" for road in bubble_roads]) + new_roads, _ = self.classify_bubble_roads(bubble_roads) + self.freeze_roads(new_roads) # Freeze lanes according to Ego Spawn + self.metsr_client.tick() + + self.createObjectInMetsr(obj) # Set the METSR vehicle origin to match ego spawn + self.metsr_client.tick() # Allow the vehicle to spawn + + spawn_success = self.createObjectInCarla(obj) # spawn ego in updated location and update orientation + assert spawn_success, f"Invalid spawn selection point at : {obj.position}" + + def getCarlaProperties(self, obj : Object, properties : dict) -> dict[str, float | Vector | int]: + """ + Docstring for getCarlaProperties + + :param obj: Cosimulation car object + :type obj: Scenic Object + + return objects properties from the Carla simulator + """ + # Extract Carla properties + carlaActor = obj.carlaActor + currTransform = carlaActor.get_transform() + currLoc = currTransform.location + currRot = currTransform.rotation + currVel = carlaActor.get_velocity() + currAngVel = carlaActor.get_angular_velocity() + + # Prepare Scenic object properties + position = utils.carlaToScenicPosition(currLoc) + velocity = utils.carlaToScenicPosition(currVel) + speed = math.hypot(*velocity) + angularSpeed = utils.carlaToScenicAngularSpeed(currAngVel) + angularVelocity = utils.carlaToScenicAngularVel(currAngVel) + globalOrientation = utils.carlaToScenicOrientation(currRot) + yaw, pitch, roll = obj.parentOrientation.localAnglesFor(globalOrientation) + elevation = utils.carlaToScenicElevation(currLoc) + + values = dict( + position=position, + velocity=velocity, + speed=speed, + angularSpeed=angularSpeed, + angularVelocity=angularVelocity, + yaw=yaw, + pitch=pitch, + roll=roll, + elevation=elevation, + ) + return values + + + def getMetsrProperties(self, obj: object, properties : dict) -> dict[str, float | Vector | int]: + """ + Docstring for getMetsrProperties + + :param obj: Cosimulation car object + :type obj: Scenic Object + + return objects properties from the METSR simulator + """ + raw_data = self.obj_data_cache[obj] + # check vehicle state + is_frozen = "roadID" not in raw_data + if obj in self.frozen_vehicles and is_frozen: + return None # skip update if frozen for more than 1 step + + if is_frozen: # update froozen vehicles + self.frozen_vehicles.add(obj) + else: + if obj in self.frozen_vehicles: + self.frozen_vehicles.remove(obj) + + position = Vector(raw_data["x"], raw_data["y"], raw_data["z"] if raw_data["z"] is not None else 0) + speed = raw_data["speed"] + bearing = math.radians(raw_data["bearing"]) + globalOrientation = Orientation.fromEuler(bearing,0,0) + yaw, pitch, roll = obj.parentOrientation.localAnglesFor(globalOrientation) + velocity = Vector(0, speed, 0).rotatedBy(yaw) + angularSpeed = 0 + angularVelocity = Vector(0,0,0) + + values = dict( + position=position, + velocity=velocity, + speed=speed, + angularSpeed=angularSpeed, + angularVelocity=angularVelocity, + yaw=yaw, + pitch=pitch, + roll=roll, + elevation=float(0) + ) + return values + + def getProperties(self, obj : Object, properties : dict)-> dict[str, float | Vector | int]: + """ + Docstring for getProperties + + :param obj: Cosimulation car object + :type obj: Scenic Object + + return objects properties for any CoSim object + """ + assert hasattr(obj, "carla_actor_flag"), f"Object is not assigned properly to a simulator instance" + if obj.carla_actor_flag: + properties = self.getCarlaProperties(obj,properties) + else: + properties = self.getMetsrProperties(obj,properties) + return properties + + def getMetsrPrivateVehId(self, obj: Object) -> int: + """ + Docstring for getMetsrPrivateVehId + + :param obj: Cosimulation car object + :type obj: Scenic Object + + Return unique vehicle idea + Generates a new ID if none exists for vehicle + """ + if obj not in self.pv_id_map: + self.pv_id_map[obj] = self.next_pv_id + self.next_pv_id += 1 + return self.pv_id_map[obj] + + def tick_carla(self) -> None: + """ + Docstring for tick_carla + + Tick Carla client for a single step + """ + for _ in range(self.sim_ticks_per): + self.carla_world.tick() + + + def tick_metsr(self) -> None: + """ + Docstring for tick_metsr + + Tick Metsr client for a single step + """ + for _ in range(self.sim_ticks_per): + self.metsr_client.tick() + + def step(self) -> None: + """ + Docstring for step + + Step both simulators: + (1): Update the high fidelity region based on the ego's new locatin + (2): Spawn and destroy objects according to region changes + (3): Tick both clients and synchronize states + (4): Compute new bubble region + """ + self.road_pop_density = {road: 0 for road in self.valid_metsr_roads} + + # (1): Update the high fidelity region based on the ego's new locatin + bubble_roads = self._get_bubble_roads() + new_roads, old_roads = self.classify_bubble_roads(bubble_roads) + self.release_roads(old_roads) + self.freeze_roads(new_roads) + intersections = self.get_bubble_intersections(bubble_roads=bubble_roads, bubble_region=self.ego.bubble) + + # (2): Spawn and destroy objects according to region changes + bubble_road_ids = [road.id for road in bubble_roads] + intersection_ids = [intersection.id for intersection in intersections] + self.update_bubble_objects(bubble_road_ids, intersection_ids) + + # (3): Tick both clients and synchronize states + self.tick_carla() + self.synchronize_clients() + self.tick_metsr() + + if self.render: + self.cameraManager.render(self.display) + pygame.display.flip() + if self.visualize_metsr: + self.metsr_client.render() + + self.bubble_sizes.append(len(self.carla_actors)) + self.total_active_vehicles.append(len(self.objects) - (len(self.frozen_vehicles) + len(self.bubble_spawn_queue))) + if self.count % 200 == 0: + print(f"Step: {self.count}. Total actors: {len(self.objects)}, bubble queue:{len(self.bubble_spawn_queue)} ") + print(f"Total active vehicles: {self.total_active_vehicles[-1]}, frozen vehicles {len(self.frozen_vehicles)}") + print(f"Total bubble actors: {len(self.carla_actors) + len(self.bubble_spawn_queue)}") + print(f"Completed routes: {len(list(self.completed_route.keys()))}") + print(f"Road densities: {self.road_pop_density}") + + if self.count % 50 == 0: + if self.run_name is not None: + out_file = self.run_name + "_veh_data.csv" + + data_dict_at_i = { + "active_vehicles": self.total_active_vehicles[-1], + "bubble_actors" : len(self.carla_actors), + "bubble_queue": len(self.bubble_spawn_queue), + "completed_routes": len(list(self.completed_route.keys())), + **self.road_pop_density + } + final_df = pd.DataFrame([data_dict_at_i]) + del data_dict_at_i + + if self.count == 0: + os.makedirs(os.path.dirname(out_file), exist_ok=True) + final_df.to_csv(out_file, + mode="w", + header=True) + else: + final_df.to_csv(out_file, + mode="a", + header=False) + + self.count += 1 + # (4): Compute new bubble region and process behavior interrupts + self.ego.bubble = CircularRegion(center=[self.objects[0].x, self.objects[0].y], radius=self.bubble_size) + + + + def get_bubble_intersections(self, bubble_roads: list[Road], bubble_region: CircularRegion) -> list[Intersection]: + """ + Collect any intersections that are either + (1) Intersecting the CoSim bubble + (2) Are connected to the bubble via AT LEAST 2 roads + + :return: The set of all intersections meeting the above criteria + :rtype: list[Intersection] + """ + bubble_intersections = [] + for intersection in self.network_helper.network_intersections: + if intersection.intersects(bubble_region): + bubble_intersections.append(intersection) + continue + intersection_roads = intersection.roads + count = 0 + for road in intersection_roads: + if road in bubble_roads: + count += 1 + if count > 1: + bubble_intersections.append(intersection) + break + return bubble_intersections + + def initiate_autopilot(self, obj : Object) -> bool: + """ + docstring for initiate_autopilot + + :param obj: Target vehicle to initiate autopilot for + + Activates autopilot for a simulation vehicle + (i) If the vehicle is in Carla, queries metsr for the target trajectory + then converts that to a corresponding set of Carla Waypoints for Carla autopilot + (2) If the vehicle is in METSR updates the overwrite flag for manually controlling the vehicle + """ + if obj.carla_actor_flag: + obj.carlaActor.set_autopilot(True) # Set the autopilot with no specific trajectory if none is found? + success = True + else: + obj.override_autopilot = False + success = True + + return success + + + def synchronize_clients(self, obj: Object | list[Object] = None): + """ + Docstring for synchronize_clients + + :param obj: Cosimulation car object + :type obj: Scenic Object[s] + + Default : updates all CoSimulated object states in the METSR simulator + (1) Can choose to specify which objects should be updated with obj arguement + """ + if obj != None and not isinstance(obj, list): + carla_actors = [obj] + elif obj != None and isinstance(obj, list): + carla_actors = obj + else: + carla_actors = self.carla_actors + + all_veh_data = self._collect_metsr_vehicle_data(carla_actors) + + for obj in carla_actors: # TODO clean up all this special handling as some of these cases are unneccesary + try: + loc = obj.carlaActor.get_location() + except Exception as e: + print(f"Caught error {e}") + warnings.warn(f"Object {obj.name if hasattr(obj,'name') else obj} automatically removed by CARLA likely due to deadlock") + self.remove_bubble_object(obj,destroy=False) + print(f"Deadlocked vehicle: {obj.name} was removed") + continue + # raise ObjectMissingInSimulation(f"Unable to access object in simulation, if this issue persists try removing CARLA autopilot") + + if (loc.x,loc.y,loc.z) == (0,0,0): # Carla object still in the process of processing obj spawn + continue + + veh_data = all_veh_data[obj] + vehID = self.getMetsrPrivateVehId(obj) + lane = self.network_helper._nearest_lane(obj) + + road_id = None + if lane: + key = f"{lane.road.id}_{lane.id}" + if key in self.network_helper.scenic_to_metsr_map_lanes: + roads = self.network_helper.scenic_to_metsr_map_lanes[key] + if len(roads) > 1: + metsr_road = self.identify_nearest_road(obj, roads) + else: + metsr_road = roads[0].split("_")[0] + if metsr_road in self.network_helper.metsr_represented_roads: + road_id = metsr_road + if veh_data["roadID"] != road_id and road_id is not None: # Entering new road within metsr network + self.metsr_client.enter_next_road(vehID, roadID=road_id, private_veh = True) + if road_id == veh_data["destRoadID"]: + self.completed_route[obj] = True + obj.finished_route = self.count + + bearing = _utils.get_metsr_rotation(obj.carlaActor.get_transform().rotation.yaw) + # Check if objects are desynchronized + if not math.isclose(loc.x, veh_data['x']) or not math.isclose(-loc.y, veh_data['y']): + self.metsr_client.teleport_cosim_vehicle(vehID, loc.x, -loc.y, bearing=bearing, private_veh = True, transform_coords = True) + + def identify_nearest_road(self, obj: Object, roads: list[str] ) -> str: + """ + Docstring for identify_nearest_road + + :param obj: vehicle to identify road for + :rtype obj: Object + :param roads: List of roads which obj's curr lane maps to + :rtype roads: List[str] + + Given a non-unique mapping between road formats selected the target road based on object distance to the start + lane + + TODO: Should consider the start and the end of the lane for ambiguous mappings + """ + roads = [road_lane.split("_")[0] for road_lane in roads] + best_road = None + best_dist = math.inf + for road in roads: + if road in self.network_helper.intersection_road_links: + continue + elif road not in self.metsr_road_cache: + start = self.metsr_client.query_centerline(road,lane_index=0,transform_coords=True)["DATA"][0]["centerline"][0] + self.metsr_road_cache[road] = start + else: + start = self.metsr_road_cache[road] + + dist = math.dist(start[:2], obj.position[:2]) + if dist < best_dist: + best_dist = dist + best_road = road + + return best_road + + + + def classify_bubble_roads(self, bubble_roads : list[Road]) -> tuple[list[str], list[str]]: + """ + Docstring for update_carla_roads + + :param bubble_regions: List of objects with a designated "bubble region" constituting the CoSim region + :type obj: List[Object] or None + + Collects all roads which are intersecting the bubble region + (1) Default region is defined by the ego the region can be updated by passing objects with their corresponding regions + """ + road_ids = [] + bubble_road_ids = [] + for road in bubble_roads: + r_id = str(road.id) + if r_id not in self.network_helper.scenic_unique_roads: + road_ids.append(r_id) + bubble_road_ids += self.map_scenic_to_metsr_road(road) + bubble_roads += self.network_helper.repair_mapping(road_ids, bubble_road_ids) + self.bubble_roads = bubble_roads # List of roads contained in the CoSim region + + self.frozen_roads = list(self.carla_control_roads.keys()) + # Collect roads into new and old for freeze/unfreezing + new_roads = [id for id in bubble_road_ids if id not in self.frozen_roads] + old_roads = [id for id in self.frozen_roads if id not in bubble_road_ids] + return new_roads, old_roads + + + + def update_bubble_objects(self, bubble_roads: list[Road], bubble_intersections: list[Intersection]) -> None: + """ + Docstring for update_bubble_objects + + :param bubble_roads: a list of Scenic lanes which constitute the cosimulation region + :type bubble_roads: list[Road] + :param intersections: a list of Scenic intersections which are contained or touching the cosimulated region + (A lane must either intersect or have to connecting roads in the cosimulation region) + :type intersections: list[intersection] + + (1) Remove all objects from CARLA which have either + (i) finished their associated route + (ii) left the region + (2) Spawn new objects in the Cosimulation region if + (i) Their is enough room in the obj's current location to spawn + (ii) The vehicle is not currently waiting to spawn in a metsr queue + + """ + all_veh_data = self._collect_metsr_vehicle_data(self.objects[1:]) + for obj in self.objects[1:]: + veh_data = all_veh_data[obj] + obj.spawn_guard = max(0, obj.spawn_guard - self.sim_ticks_per) # Total client ticks, always > 0 + + # Skip vehicles which have not entered the roadway or have completed their route + if ('roadID' not in veh_data) or obj in self.completed_route: + if obj.carla_actor_flag: + self.remove_bubble_object(obj) + else: + if obj not in self.completed_route: + self.completed_route[obj] = True + obj.finished_route = self.count + continue + else: + if obj not in self.bubble_spawn_queue: + self.road_pop_density[veh_data['roadID']] += 1 + + outside_bubble = False + road = self.network_helper._nearest_road(obj) + id = road.id if road else None + intersection = None + + if id not in bubble_roads: + intersection = self.network_helper._get_intersection(obj, road) + if intersection not in bubble_intersections: + outside_bubble = True + + # Remove vehicles which have left the cosimulation region and spawn vehicles which have entered + if outside_bubble: + if obj.carla_actor_flag: + if obj.spawn_guard == 0: + self.remove_bubble_object(obj) + else: + if obj in self.bubble_spawn_queue: + self.bubble_spawn_queue.remove(obj) + + + else: # inside bubble + if not obj.carla_actor_flag: # Vehicle needs to be spawned in CoSim region + not_enough_space = _utils.within_threshold_to(obj, self.carla_actors, verbose=False) + if not_enough_space: # ensure there is sufficient room before spawning + if obj not in self.bubble_spawn_queue: + self.bubble_spawn_queue.add(obj) + continue + else: + self.createObjectInCarla(obj) # Spawn Vehicle + if obj in self.bubble_spawn_queue: + self.bubble_spawn_queue.remove(obj) + + self.carla_actors.append(obj) + self.metsr_actors.remove(obj) + + + + def freeze_roads(self, keys: list[str]) -> None: + """ + Docstring for freeze_roads + + :param keys: RoadIDs for METSR indexed roads + :type keys: list[str] + + Query Metsr to freeze simulation and control of given lanes + """ + keys = set(keys) + for key in keys: + assert key not in self.carla_control_roads, "Attempted to freeze already frozen lane" + if key not in self.network_helper.intersection_road_links: # Skip roads not recognized by metsr + self.carla_control_roads[key] = True # Keep track of frozen lanes + self.metsr_client.set_cosim_road(key) + + + def release_roads(self,keys: list[str]) -> None: + """ + Docstring for release_roads + + :param keys: RoadIDs for METSR indexed roads + :type keys: list[str] + + Query Metsr to begin re-simulating and control given lanes + """ + keys = set(keys) + for key in keys: + assert key in self.carla_control_roads, "Attempted to release non frozen lane" + if key not in self.network_helper.intersection_road_links: # Skip roads not recognized by metsr + del self.carla_control_roads[key] # Remove frozen lane from record + self.metsr_client.release_cosim_road(key) + + def destroy_carla_obj(self,obj) -> None: + """ + Docstring for destroy_carla_obj + + Destroys obj from CARLA simulation + + :param obj: Carla object to be destroyed + """ + if obj.carlaActor is not None: + if isinstance(obj.carlaActor, carla.Vehicle): + obj.carlaActor.set_autopilot(False, self.tm.get_port()) + if isinstance(obj.carlaActor, carla.Walker): + obj.cralaController.stop() + obj.carlaController.destroy() + obj.carlaActor.destroy() + obj.carlaActor = None # Set this to None to prevent reaccess of a previously deleted vehicle? + + def remove_bubble_object(self,obj, destroy=True) -> None: + """ + Docstring for remove_bubble_object + + :param obj: object to be deleted + :type obj: Car + """ + if obj.autopilot_action and obj.active_autopilot: + obj.active_autopilot = not(_utils.disable_carla_autopilot(obj, self.tm)) + obj.trajectory = None + if destroy: + self.destroy_carla_obj(obj) + obj.carla_actor_flag = False + self.carla_actors.remove(obj) + self.metsr_actors.append(obj) + + + def destroy(self) -> None: + """ + Docstring for destroy + + Destroy both simulators instances i.e (METSR, CARLA) + """ + verbosePrint(f"Closing METS-R visualization server") + self.metsr_client.stop_viz() + + print(f"Logging trip times") + print(f"="*25) + if self.run_name is not None: + self._log_trip_times() + print(f"="*25) + + # METSR destroy + if self.metsr_client.verbose: + print("Client Messages Log:") + print("[") + for call in self.client._messagesLog: + print(f" {call},") + print("]") + + # "CARLA destroy" + for obj in self.carla_actors: + if obj.carlaActor is not None: + if isinstance(obj.carlaActor, carla.Vehicle): + obj.carlaActor.set_autopilot(False, self.tm.get_port()) + if isinstance(obj.carlaActor, carla.Walker): + obj.carlaController.stop() + obj.carlaController.destroy() + obj.carlaActor.destroy() + if self.render and self.cameraManager: + self.cameraManager.destroy_sensor() + + self.carla_client.stop_recorder() + + super().destroy() + + def map_scenic_to_metsr_road(self, road: Road) -> list[str]: + """Maps Scenic road to equvialent METSR roads, 1->M mapping""" + return self.network_helper.map_scenic_to_metsr_road(road) + + def map_scenic_to_metsr_lanes(self, lane: Lane) -> set[str]: + """Map Scenic lane to equivalent METSR road, guareneteed 1->1 mapping""" + return self.network_helper.map_scenic_to_metsr_lanes(lane) + + def generate_metsr_trajectory(self, trajectory: list[Lane]) -> list[str] | None: + """Convert a scenic specified trajectory to an equivalent metsr route""" + return self.network_helper.generate_metsr_trajctory(trajectory) + + def _nearest_road(self, obj: Object, allow_offroad: bool = True, radius_size: int = 30) -> tuple[Road, str]: + """Collect the nearest road to obj location""" + return self.network_helper._nearest_road(obj, allow_offroad, radius_size) + + def _nearest_lane(self,obj : Object, allow_offlane : bool = True, radius_size : int = 50, allow_intersection_links : bool = True) -> Lane: + """Collect the nearest lane to obj location""" + return self.network_helper._nearest_lane(obj, allow_offlane, radius_size, allow_intersection_links) + + def _get_intersection(self, obj: Object, road: Road ) -> Intersection | None: + """Returns the intersection the obj is on if any""" + return self.network_helper._get_intersection(obj, road) + + def _get_bubble_roads(self, bubble_region: CircularRegion | None = None) -> list[Lane]: + """Collect all roads which overlap the designated bubble region""" + if bubble_region == None: # Default is attached to ego + bubble_region = self.ego.bubble + else: + bubble_region = bubble_region # User specified (for added functionality later) + return self.network_helper._get_bubble_roads(bubble_region) + + + def executeActions(self, allActions) -> None: + """ + Docstring for executeActions + + Apply control updates which were accumulated while executing the actions + Filters out actions for Carla only objects + + :param allActions: ? + TODO: Clean this up and make it more robust -- right now it is generally optimized for the speccific scenario + """ + carla_actions = {} + for obj in self.agents: + carla_actions[obj] = allActions[obj] + super().executeActions(carla_actions) + for obj in self.agents: + if obj.carla_actor_flag: # Processing CARLA actors + if not obj.autopilot_action and obj.active_autopilot: # Disable autopilot first to enable smooth transitions + obj.active_autopilot = not(_utils.disable_carla_autopilot(obj, self.tm)) + elif obj.autopilot_action and not obj.active_autopilot: + if not hasattr(obj, "trajectory"): + obj.active_autopilot = self.initiate_autopilot(obj) + obj._control = None # TODO What does this do? + elif obj.trajectory is None: + obj.trajectory = self.metsr_trajectory_to_carla(obj) + self.tm.set_path(obj.carlaActor, obj.trajectory) + self.initiate_autopilot(obj) + obj.active_autopilot = True + obj.autopilot_action = True + else: + self.tm.set_path(obj.carlaActor, obj.trajectory) + self.initiate_autopilot(obj) + obj.active_autopilot = True + obj.autopilot_action = True + else: + ctrl = obj._control + if ctrl is not None: + obj.carlaActor.apply_control(ctrl) + obj._control = None + else: + if not obj.autopilot_action: # Default is autopilot + target_acc = obj.target_acceleration if hasattr(obj, "target_accleration") else 0 # apply, if no action is taken no movement + self.metsr_client.control_vehicle(self.getMetsrPrivateVehId(obj), target_acc, private_veh=True) + if hasattr(obj, "trajectory"): + if obj.trajectory and not obj.autopilot_action: + if not self.trajectory_is_active: + metsr_trajectory = self.generate_metsr_trajectory(obj.trajectory) + if metsr_trajectory: + self.metsr_client.update_vehicle_route(self.getMetsrPrivateVehId(obj), metsr_trajectory, private_veh=True) + self.trajectory_is_active = True + + + def metsr_trajectory_to_carla(self, obj : object) -> list[Lane]: + """ + docstring for metsr_tractory_to_carla + + :param obj: Target object to generate trajectory for + :typ obj: Object + + Convert proposed METS-R trajectory to a sequence of equivalent Lane's + """ + cosim_data = self.metsr_client.query_coSimVehicle() + trajectory = None + if obj.carla_actor_flag: + VehID = self.getMetsrPrivateVehId(obj) + for data_entry in cosim_data['DATA']: + if data_entry['ID'] == VehID: + route_data = data_entry['route'] + trajectory = self.generate_carla_trajectory(route = route_data, obj=obj) + + if trajectory is None: + route = obj.route + trajectory = self.generate_carla_trajectory(route=route, obj=obj) + + return trajectory + + def generate_carla_trajectory(self, route: list[str], obj: Object) -> list[Lane]: + """ + Docstring for generate_carla_trajaectory + + Given a sequence of SUMO roads generate a sequence of CARLA waypoints + for CARLA autopilot to follow + + :param route: List of SUMO road IDs + :type route: List[str] + """ + end = route[-1] + target_end = self.metsr_client.query_centerline(end,lane_index=0,transform_coords=True)["DATA"][0]["centerline"][1] + + target_start = carla.Location(obj.position.x, -obj.position.y, 0) # convert METS-R points to CARLA + target_end = carla.Location(target_end[0], -target_end[1], 0) + + route = self.grp.trace_route(target_start, target_end) + + locations = [wp.transform.location for wp, _ in route] + return locations + + def updateObjects(self) -> None: + """ + Docstring for updateObjects + + Update object properties for METSR simulated objects + """ + self.obj_data_cache = self._collect_metsr_vehicle_data(self.metsr_actors) + super().updateObjects() + self.obj_data_cache = None + + def _collect_metsr_vehicle_data(self, objects: list[Object] | None = None): + """ + Docstring for _collect_metsr_vehicle_data + + :param objects: List of objects which data should be queried + :rtype objects: Scenic vehicle object + + Query metsr for state infomation on vehicle's default is all vehicles + """ + if objects is None: # all objects by defualt + obj_veh_ids = [self.getMetsrPrivateVehId(obj) for obj in self.objects] + raw_veh_data = self.metsr_client.query_vehicle(obj_veh_ids, True, True) + all_veh_data = {obj: raw_veh_data['DATA'][i] for i, obj in enumerate(self.objects)} + else: + obj_veh_ids = [self.getMetsrPrivateVehId(obj) for obj in objects] + raw_veh_data = self.metsr_client.query_vehicle(obj_veh_ids, True, True) + all_veh_data = {obj: raw_veh_data['DATA'][i] for i, obj in enumerate(objects)} + return all_veh_data + + + def _save_metsr_state(self, file_name=None) -> None: + """ + docstring for _save_metsr_state + + Saves metsr state to a file to allow for reproducible replay + """ + if file_name == None: + save_file = f"metsr_state_at_{self.count}.bin" + else: + save_file = file_name + self.metsr_client.save(save_file) + + def _log_trip_times(self, file_name=None): + """ + docstring for _log_trip_times + + :param file_name: target location and name for logs + :rtype file_name: str + + Generate csv file containing total time to route completion for each vehicle + """ + out_file = file_name if file_name else f"{self.run_name}_trip_logs.csv" + trip_dict = {obj.name: obj.finished_route - obj.trip_start if hasattr(obj, "finished_route") else None for obj in self.objects[1:]} + trip_df = pd.DataFrame([trip_dict]) + + os.makedirs(os.path.dirname(out_file), exist_ok=True) + trip_df.to_csv(out_file) diff --git a/src/scenic/simulators/cosim/utils/CoSimActions.py b/src/scenic/simulators/cosim/utils/CoSimActions.py new file mode 100644 index 000000000..009ea51bf --- /dev/null +++ b/src/scenic/simulators/cosim/utils/CoSimActions.py @@ -0,0 +1,54 @@ +from scenic.core.simulators import Action +from scenic.simulators.carla.model import Vehicle +from scenic.domains.driving.roads import LaneSection + +class BackgroundDriver: + """ + Non ego background driver + Background vehicles are allowed to use autopilot + behaviors, where low level controllers are handled + by the relevent simulator + (1) Carla if applicable + (2) METSR by default + """ + def setAutoPilot(self, active_autopilot): + self.autopilot_action = active_autopilot + + def setAccelearation(self, acc=0): + self.target_acceleration = acc + +class FollowTrajectory(Action): + """Generate valid trajectory from scenic lanes""" + def __init__(self, trajectory: list[LaneSection] | None): + self.trajectory = trajectory + + def canBeTakenBy(self, agent): + return isinstance(agent, Vehicle) + + def applyTo(self,obj,sim): + obj.generateTrajectory(self.trajectory) + +class SetAutoPilotAction(Action): + """ Set autopilot flag """ + def __init__(self, active_autopilot): + self.active_autopilot = active_autopilot + + def canBeTakenBy(self,agent): + return isinstance(agent, (Vehicle, BackgroundDriver)) + + def applyTo(self, obj, sim): + obj.setAutoPilot(self.active_autopilot) + +class SetAccelerationAction: + """Set obj acclearation for METSR controlled vehs only""" + def canBeTakenBy(self,agent): + if hasattr(agent, "carla_actor_flag"): + if not agent.carla_actor_flag: + return True + else: + return False + + def applyTo(self, obj, sim): + obj.setAcceleration() + + diff --git a/src/scenic/simulators/cosim/utils/global_route_planner.py b/src/scenic/simulators/cosim/utils/global_route_planner.py new file mode 100644 index 000000000..baa954294 --- /dev/null +++ b/src/scenic/simulators/cosim/utils/global_route_planner.py @@ -0,0 +1,426 @@ +# Copyright (c) # Copyright (c) 2018-2020 CVC. +# +# This work is licensed under the terms of the MIT license. +# For a copy, see . + + +""" +This module provides GlobalRoutePlanner implementation. +""" + +import math +import numpy as np +import networkx as nx + +import carla +from enum import IntEnum + +class RoadOption(IntEnum): + """ + RoadOption represents the possible topological configurations when moving from a segment of lane to other. + + """ + VOID = -1 + LEFT = 1 + RIGHT = 2 + STRAIGHT = 3 + LANEFOLLOW = 4 + CHANGELANELEFT = 5 + CHANGELANERIGHT = 6 + +def vector(location_1, location_2): + """ + Returns the unit vector from location_1 to location_2 + + :param location_1, location_2: carla.Location objects + """ + x = location_2.x - location_1.x + y = location_2.y - location_1.y + z = location_2.z - location_1.z + norm = np.linalg.norm([x, y, z]) + np.finfo(float).eps + + return [x / norm, y / norm, z / norm] + +class GlobalRoutePlanner(object): + """ + This class provides a very high level route plan. + """ + + def __init__(self, wmap, sampling_resolution): + self._sampling_resolution = sampling_resolution + self._wmap = wmap + self._topology = None + self._graph = None + self._id_map = None + self._road_id_to_edge = None + + self._intersection_end_node = -1 + self._previous_decision = RoadOption.VOID + + # Build the graph + self._build_topology() + self._build_graph() + self._find_loose_ends() + self._lane_change_link() + + def trace_route(self, origin, destination): + """ + This method returns list of (carla.Waypoint, RoadOption) + from origin to destination + """ + route_trace = [] + route = self._path_search(origin, destination) + current_waypoint = self._wmap.get_waypoint(origin) + destination_waypoint = self._wmap.get_waypoint(destination) + + for i in range(len(route) - 1): + road_option = self._turn_decision(i, route) + edge = self._graph.edges[route[i], route[i+1]] + path = [] + + if edge['type'] != RoadOption.LANEFOLLOW and edge['type'] != RoadOption.VOID: + route_trace.append((current_waypoint, road_option)) + exit_wp = edge['exit_waypoint'] + n1, n2 = self._road_id_to_edge[exit_wp.road_id][exit_wp.section_id][exit_wp.lane_id] + next_edge = self._graph.edges[n1, n2] + if next_edge['path']: + closest_index = self._find_closest_in_list(current_waypoint, next_edge['path']) + closest_index = min(len(next_edge['path'])-1, closest_index+5) + current_waypoint = next_edge['path'][closest_index] + else: + current_waypoint = next_edge['exit_waypoint'] + route_trace.append((current_waypoint, road_option)) + + else: + path = path + [edge['entry_waypoint']] + edge['path'] + [edge['exit_waypoint']] + closest_index = self._find_closest_in_list(current_waypoint, path) + for waypoint in path[closest_index:]: + current_waypoint = waypoint + route_trace.append((current_waypoint, road_option)) + if len(route)-i <= 2 and waypoint.transform.location.distance(destination) < 2*self._sampling_resolution: + break + elif len(route)-i <= 2 and current_waypoint.road_id == destination_waypoint.road_id and current_waypoint.section_id == destination_waypoint.section_id and current_waypoint.lane_id == destination_waypoint.lane_id: + destination_index = self._find_closest_in_list(destination_waypoint, path) + if closest_index > destination_index: + break + + return route_trace + + def _build_topology(self): + """ + This function retrieves topology from the server as a list of + road segments as pairs of waypoint objects, and processes the + topology into a list of dictionary objects with the following attributes + + - entry (carla.Waypoint): waypoint of entry point of road segment + - entryxyz (tuple): (x,y,z) of entry point of road segment + - exit (carla.Waypoint): waypoint of exit point of road segment + - exitxyz (tuple): (x,y,z) of exit point of road segment + - path (list of carla.Waypoint): list of waypoints between entry to exit, separated by the resolution + """ + self._topology = [] + # Retrieving waypoints to construct a detailed topology + for segment in self._wmap.get_topology(): + wp1, wp2 = segment[0], segment[1] + l1, l2 = wp1.transform.location, wp2.transform.location + # Rounding off to avoid floating point imprecision + x1, y1, z1, x2, y2, z2 = np.round([l1.x, l1.y, l1.z, l2.x, l2.y, l2.z], 0) + wp1.transform.location, wp2.transform.location = l1, l2 + seg_dict = dict() + seg_dict['entry'], seg_dict['exit'] = wp1, wp2 + seg_dict['entryxyz'], seg_dict['exitxyz'] = (x1, y1, z1), (x2, y2, z2) + seg_dict['path'] = [] + endloc = wp2.transform.location + if wp1.transform.location.distance(endloc) > self._sampling_resolution: + w = wp1.next(self._sampling_resolution)[0] + while w.transform.location.distance(endloc) > self._sampling_resolution: + seg_dict['path'].append(w) + next_ws = w.next(self._sampling_resolution) + if len(next_ws) == 0: + break + w = next_ws[0] + else: + next_wps = wp1.next(self._sampling_resolution) + if len(next_wps) == 0: + continue + seg_dict['path'].append(next_wps[0]) + self._topology.append(seg_dict) + + def _build_graph(self): + """ + This function builds a networkx graph representation of topology, creating several class attributes: + - graph (networkx.DiGraph): networkx graph representing the world map, with: + Node properties: + vertex: (x,y,z) position in world map + Edge properties: + entry_vector: unit vector along tangent at entry point + exit_vector: unit vector along tangent at exit point + net_vector: unit vector of the chord from entry to exit + intersection: boolean indicating if the edge belongs to an intersection + - id_map (dictionary): mapping from (x,y,z) to node id + - road_id_to_edge (dictionary): map from road id to edge in the graph + """ + + self._graph = nx.DiGraph() + self._id_map = dict() # Map with structure {(x,y,z): id, ... } + self._road_id_to_edge = dict() # Map with structure {road_id: {lane_id: edge, ... }, ... } + + for segment in self._topology: + entry_xyz, exit_xyz = segment['entryxyz'], segment['exitxyz'] + path = segment['path'] + entry_wp, exit_wp = segment['entry'], segment['exit'] + intersection = entry_wp.is_junction + road_id, section_id, lane_id = entry_wp.road_id, entry_wp.section_id, entry_wp.lane_id + + for vertex in entry_xyz, exit_xyz: + # Adding unique nodes and populating id_map + if vertex not in self._id_map: + new_id = len(self._id_map) + self._id_map[vertex] = new_id + self._graph.add_node(new_id, vertex=vertex) + n1 = self._id_map[entry_xyz] + n2 = self._id_map[exit_xyz] + if road_id not in self._road_id_to_edge: + self._road_id_to_edge[road_id] = dict() + if section_id not in self._road_id_to_edge[road_id]: + self._road_id_to_edge[road_id][section_id] = dict() + self._road_id_to_edge[road_id][section_id][lane_id] = (n1, n2) + + entry_carla_vector = entry_wp.transform.rotation.get_forward_vector() + exit_carla_vector = exit_wp.transform.rotation.get_forward_vector() + + # Adding edge with attributes + self._graph.add_edge( + n1, n2, + length=len(path) + 1, path=path, + entry_waypoint=entry_wp, exit_waypoint=exit_wp, + entry_vector=np.array( + [entry_carla_vector.x, entry_carla_vector.y, entry_carla_vector.z]), + exit_vector=np.array( + [exit_carla_vector.x, exit_carla_vector.y, exit_carla_vector.z]), + net_vector=vector(entry_wp.transform.location, exit_wp.transform.location), + intersection=intersection, type=RoadOption.LANEFOLLOW) + + def _find_loose_ends(self): + """ + This method finds road segments that have an unconnected end, and + adds them to the internal graph representation + """ + count_loose_ends = 0 + hop_resolution = self._sampling_resolution + for segment in self._topology: + end_wp = segment['exit'] + exit_xyz = segment['exitxyz'] + road_id, section_id, lane_id = end_wp.road_id, end_wp.section_id, end_wp.lane_id + if road_id in self._road_id_to_edge \ + and section_id in self._road_id_to_edge[road_id] \ + and lane_id in self._road_id_to_edge[road_id][section_id]: + pass + else: + count_loose_ends += 1 + if road_id not in self._road_id_to_edge: + self._road_id_to_edge[road_id] = dict() + if section_id not in self._road_id_to_edge[road_id]: + self._road_id_to_edge[road_id][section_id] = dict() + n1 = self._id_map[exit_xyz] + n2 = -1*count_loose_ends + self._road_id_to_edge[road_id][section_id][lane_id] = (n1, n2) + next_wp = end_wp.next(hop_resolution) + path = [] + while next_wp is not None and next_wp \ + and next_wp[0].road_id == road_id \ + and next_wp[0].section_id == section_id \ + and next_wp[0].lane_id == lane_id: + path.append(next_wp[0]) + next_wp = next_wp[0].next(hop_resolution) + if path: + n2_xyz = (path[-1].transform.location.x, + path[-1].transform.location.y, + path[-1].transform.location.z) + self._graph.add_node(n2, vertex=n2_xyz) + self._graph.add_edge( + n1, n2, + length=len(path) + 1, path=path, + entry_waypoint=end_wp, exit_waypoint=path[-1], + entry_vector=None, exit_vector=None, net_vector=None, + intersection=end_wp.is_junction, type=RoadOption.LANEFOLLOW) + + def _lane_change_link(self): + """ + This method places zero cost links in the topology graph + representing availability of lane changes. + """ + + for segment in self._topology: + left_found, right_found = False, False + + for waypoint in segment['path']: + if not segment['entry'].is_junction: + next_waypoint, next_road_option, next_segment = None, None, None + + if waypoint.right_lane_marking and waypoint.right_lane_marking.lane_change & carla.LaneChange.Right and not right_found: + next_waypoint = waypoint.get_right_lane() + if next_waypoint is not None \ + and next_waypoint.lane_type == carla.LaneType.Driving \ + and waypoint.road_id == next_waypoint.road_id: + next_road_option = RoadOption.CHANGELANERIGHT + next_segment = self._localize(next_waypoint.transform.location) + if next_segment is not None: + self._graph.add_edge( + self._id_map[segment['entryxyz']], next_segment[0], entry_waypoint=waypoint, + exit_waypoint=next_waypoint, intersection=False, exit_vector=None, + path=[], length=0, type=next_road_option, change_waypoint=next_waypoint) + right_found = True + if waypoint.left_lane_marking and waypoint.left_lane_marking.lane_change & carla.LaneChange.Left and not left_found: + next_waypoint = waypoint.get_left_lane() + if next_waypoint is not None \ + and next_waypoint.lane_type == carla.LaneType.Driving \ + and waypoint.road_id == next_waypoint.road_id: + next_road_option = RoadOption.CHANGELANELEFT + next_segment = self._localize(next_waypoint.transform.location) + if next_segment is not None: + self._graph.add_edge( + self._id_map[segment['entryxyz']], next_segment[0], entry_waypoint=waypoint, + exit_waypoint=next_waypoint, intersection=False, exit_vector=None, + path=[], length=0, type=next_road_option, change_waypoint=next_waypoint) + left_found = True + if left_found and right_found: + break + + def _localize(self, location): + """ + This function finds the road segment that a given location + is part of, returning the edge it belongs to + """ + waypoint = self._wmap.get_waypoint(location) + edge = None + try: + edge = self._road_id_to_edge[waypoint.road_id][waypoint.section_id][waypoint.lane_id] + except KeyError: + pass + return edge + + def _distance_heuristic(self, n1, n2): + """ + Distance heuristic calculator for path searching + in self._graph + """ + l1 = np.array(self._graph.nodes[n1]['vertex']) + l2 = np.array(self._graph.nodes[n2]['vertex']) + return np.linalg.norm(l1-l2) + + def _path_search(self, origin, destination): + """ + This function finds the shortest path connecting origin and destination + using A* search with distance heuristic. + origin : carla.Location object of start position + destination : carla.Location object of of end position + return : path as list of node ids (as int) of the graph self._graph + connecting origin and destination + """ + start, end = self._localize(origin), self._localize(destination) + + route = nx.astar_path( + self._graph, source=start[0], target=end[0], + heuristic=self._distance_heuristic, weight='length') + route.append(end[1]) + return route + + def _successive_last_intersection_edge(self, index, route): + """ + This method returns the last successive intersection edge + from a starting index on the route. + This helps moving past tiny intersection edges to calculate + proper turn decisions. + """ + + last_intersection_edge = None + last_node = None + for node1, node2 in [(route[i], route[i+1]) for i in range(index, len(route)-1)]: + candidate_edge = self._graph.edges[node1, node2] + if node1 == route[index]: + last_intersection_edge = candidate_edge + if candidate_edge['type'] == RoadOption.LANEFOLLOW and candidate_edge['intersection']: + last_intersection_edge = candidate_edge + last_node = node2 + else: + break + + return last_node, last_intersection_edge + + def _turn_decision(self, index, route, threshold=math.radians(35)): + """ + This method returns the turn decision (RoadOption) for pair of edges + around current index of route list + """ + + decision = None + previous_node = route[index-1] + current_node = route[index] + next_node = route[index+1] + next_edge = self._graph.edges[current_node, next_node] + if index > 0: + if self._previous_decision != RoadOption.VOID \ + and self._intersection_end_node > 0 \ + and self._intersection_end_node != previous_node \ + and next_edge['type'] == RoadOption.LANEFOLLOW \ + and next_edge['intersection']: + decision = self._previous_decision + else: + self._intersection_end_node = -1 + current_edge = self._graph.edges[previous_node, current_node] + calculate_turn = current_edge['type'] == RoadOption.LANEFOLLOW and not current_edge[ + 'intersection'] and next_edge['type'] == RoadOption.LANEFOLLOW and next_edge['intersection'] + if calculate_turn: + last_node, tail_edge = self._successive_last_intersection_edge(index, route) + self._intersection_end_node = last_node + if tail_edge is not None: + next_edge = tail_edge + cv, nv = current_edge['exit_vector'], next_edge['exit_vector'] + if cv is None or nv is None: + return next_edge['type'] + cross_list = [] + for neighbor in self._graph.successors(current_node): + select_edge = self._graph.edges[current_node, neighbor] + if select_edge['type'] == RoadOption.LANEFOLLOW: + if neighbor != route[index+1]: + sv = select_edge['net_vector'] + cross_list.append(np.cross(cv, sv)[2]) + next_cross = np.cross(cv, nv)[2] + deviation = math.acos(np.clip( + np.dot(cv, nv)/(np.linalg.norm(cv)*np.linalg.norm(nv)), -1.0, 1.0)) + if not cross_list: + cross_list.append(0) + if deviation < threshold: + decision = RoadOption.STRAIGHT + elif cross_list and next_cross < min(cross_list): + decision = RoadOption.LEFT + elif cross_list and next_cross > max(cross_list): + decision = RoadOption.RIGHT + elif next_cross < 0: + decision = RoadOption.LEFT + elif next_cross > 0: + decision = RoadOption.RIGHT + else: + decision = next_edge['type'] + + else: + decision = next_edge['type'] + + self._previous_decision = decision + return decision + + def _find_closest_in_list(self, current_waypoint, waypoint_list): + min_distance = float('inf') + closest_index = -1 + for i, waypoint in enumerate(waypoint_list): + distance = waypoint.transform.location.distance( + current_waypoint.transform.location) + if distance < min_distance: + min_distance = distance + closest_index = i + + return closest_index + + + diff --git a/src/scenic/simulators/cosim/utils/network_helper.py b/src/scenic/simulators/cosim/utils/network_helper.py new file mode 100644 index 000000000..1f2becbae --- /dev/null +++ b/src/scenic/simulators/cosim/utils/network_helper.py @@ -0,0 +1,253 @@ +from scenic.core.regions import CircularRegion +from scenic.domains.driving.roads import LaneSection, Intersection, Road, Lane +from scenic.core.object_types import Object +import matplotlib.pyplot as plt + +class network_cache(): + def __init__(self, + workspace, + scenic_to_metsr_map_lanes, + metsr_represented_roads, + radius_search_size=30): + + self.workspace = workspace + self.metsr_represented_roads = metsr_represented_roads + self.scenic_to_metsr_map_lanes = scenic_to_metsr_map_lanes + self.radius_search_size=radius_search_size + + self.network_lane_sections = [*self.workspace.network.laneSections] + self.network_roads = [*self.workspace.network.allRoads] + self.roads_by_id = {str(road.id): road for road in self.network_roads} + self.network_intersections = [*self.workspace.network.intersections] + + self.scenic_to_metsr_map_roads = {} + self.all_scenic_roads_connected_too = {} + self.intersection_road_links = set([]) + self.scenic_unique_roads = set([]) + self.populate_scenic_to_metsr_roads() + + self.connected_roads_to_intersections = {} + self.populate_roads_to_intersections() + + self.obj_road_cache = {} + self.obj_lane_cache = {} + + # Initialilzation + def _visualize_network(self,bubble_roads: list[Road], ego_position, frozen_metsr_road_lines, count): + self.workspace.network.show(bubble_roads=bubble_roads, ego_position=ego_position, metsr_road_lines=frozen_metsr_road_lines) + plt.savefig(f"./figures/town06/run2/network_at_{count}") + + def populate_scenic_to_metsr_roads(self) -> None: + """ + Generate scenic -> METSR mappings for roads + """ + all_scenic_roads_connected_too = {} + for road_lane,road_lane_map in self.scenic_to_metsr_map_lanes.items(): + scenic_road = road_lane.split("_")[0] + for metsr_map in road_lane_map: + metsr_road = metsr_map.split("_")[0] + if metsr_road in self.metsr_represented_roads: + if scenic_road not in self.scenic_to_metsr_map_roads: + self.scenic_to_metsr_map_roads[scenic_road] = set() + self.scenic_to_metsr_map_roads[scenic_road].add(metsr_road) + if metsr_road not in all_scenic_roads_connected_too: + all_scenic_roads_connected_too[metsr_road] = [scenic_road] + else: + all_scenic_roads_connected_too[metsr_road].append(scenic_road) + else: + if metsr_road not in self.scenic_to_metsr_map_roads[scenic_road]: + self.scenic_to_metsr_map_roads[scenic_road].add(metsr_road) + if metsr_road not in all_scenic_roads_connected_too: + all_scenic_roads_connected_too[metsr_road] = [scenic_road] + else: + all_scenic_roads_connected_too[metsr_road].append(scenic_road) + else: + self.intersection_road_links.add(metsr_road) + + + for road_lane in self.scenic_to_metsr_map_lanes.keys(): + scenic_road = road_lane.split("_")[0] + if scenic_road not in self.scenic_to_metsr_map_roads: + self.scenic_unique_roads.add(scenic_road) + self.all_scenic_roads_connected_too = all_scenic_roads_connected_too + + def populate_roads_to_intersections(self) -> None: + """ + Populate the dictionary + """ + for intersection in [*self.workspace.network.intersections]: + for road in intersection.roads: + if road not in self.connected_roads_to_intersections: + self.connected_roads_to_intersections[road] = [intersection] + else: + self.connected_roads_to_intersections[road].append(intersection) + + """Helpers for generating or collecting map data""" + + def _nearest_lane(self, obj: Object) -> Lane | None: + + if obj in self.obj_lane_cache: + lane = self.obj_lane_cache[obj] + if lane.containsPoint(obj.position): + return lane + + nearest_lane = obj._lane + if nearest_lane is not None: + self.obj_lane_cache[obj] = nearest_lane + + return nearest_lane + + def _nearest_road(self, obj: Object, allow_offroad: bool=True, radius_size: int = 50) -> Road | None: + """ + Docstring for _nearest_road + + :param obj: Object to identify road for + :type obj: Vehicle Object + :param allow_offroad: Flag whether offroad vehicles are allowed in this simulation + :type allow_offroad: Bool + :param radius_size: Radius size in meters of the viable search space for offroad vehicles + :type radius_size: Integer + + Return the nearest road on the map for a given object + """ + nearest_road = None + if obj in self.obj_road_cache: + road = self.obj_road_cache[obj] + if road.containsPoint(obj.position): # try to verify road through the cache + return road + + nearest_road = obj._road # last resort lookup + if nearest_road is not None: # Maintain the previous road + self.obj_road_cache[obj] = nearest_road + + return nearest_road + + def _get_intersection(self, obj: Object, curr_road: Road = None ) -> Intersection | None: + """ + Docstring for _get_intersection + + :param obj: Object to identify lane for + :type obj: Vehicle Object + :param road: Objects current road in map + :type road: road + + Checks if the obj is on an intersection based, on its previous logged road + Looks up the intersection directly if no log exists yet + """ + intersection = None + road = None + if curr_road: # If obj is on road it is not in an intersection + return None + + else: + pos = obj.position + if obj in self.obj_road_cache: + road = self.obj_road_cache[obj] # find the most recent road + + if road in self.connected_roads_to_intersections: # check if obj is in a connected intersection + intersections = self.connected_roads_to_intersections[road] + for intersection in intersections: + if intersection.containsPoint(pos): + intersection = intersection + break + + if road is None or intersection is None: # Previous road was not cached, so we need to do a global lookup + intersection = obj._intersection + + return intersection + + def _get_bubble_roads(self, bubble_region: CircularRegion) -> list[Road]: + """ + docstring for get_bubble_roads + + :return: The current set of roads intersecting the CoSimulation bubble + :rtype: list[road] + """ + bubble_roads = [] + for road in self.network_roads: + if road.intersects(bubble_region): + bubble_roads.append(road) + return bubble_roads + + + def generate_metsr_trajectory(self, scenic_trajectory: list[LaneSection], obj: Object) -> list[str]: + """ + docstring for generate_metsr_trajectory + + :param trajectory: ordered sequence of target lanesections + :type trajectory: list[LandSection] + """ + metsr_trajectory = [] + for laneSection in scenic_trajectory: + if laneSection not in self.intersection_road_links: + mapped_roads = self.scenic_to_metsr_map_lanes(laneSection) + if mapped_roads: + if len(mapped_roads) > 1: + print(f'What do you even do in this case: {mapped_roads} ') + road = mapped_roads.pop() + metsr_trajectory.append(road) + else: + print(f"Skipped non existing mapping for laneSection: {laneSection.road.id}_{laneSection.lane.id}") + return metsr_trajectory + + + """ Translating Scenic -> Metsr representations""" + + def map_scenic_to_metsr_road(self, road : Road) -> list[str] | None: + """ + docstring for map_scenic_to_metsr_road + + :param road: Scenic road ID which should be translated to equivalent METSR road ID(s) + :type road: Road + + """ + if str(road.id) in self.scenic_unique_roads: + return None + + query_key = f'{road.id}' + metsr_keys= None + + if query_key in self.scenic_to_metsr_map_roads: + metsr_keys = self.scenic_to_metsr_map_roads[query_key] + + assert metsr_keys is not None, f"Error identifying associated ID for {query_key}" + return metsr_keys + + def map_scenic_to_metsr_lanes(self, lane: LaneSection) -> set[str] | None: + """ + docstring for map_scenic_to_metsr_lanes + + Given a OpenDrive LaneSection + + """ + metsr_keys=None + query_key = f'{lane.road.id}_{lane.id}' + + if query_key in self.scenic_to_metsr_map_lanes: + metsr_keys = self.scenic_to_metsr_map_lanes[query_key] + metsr_keys = set([metsr_key.split("_")[0] for metsr_key in metsr_keys]) + + return metsr_keys + + def repair_mapping(self,road_ids, bubble_road_ids): + """ + Docstring for repair_mappping + + :param road_ids: List of Scenic road IDs which are to be cosimulated + :type road_ids: List[str] + :param bubble_road_ids: List of METSR road IDs which are to be cosimulated + :type bubble_road_ids: List[str] + + Repair cosimulated region to ensure that METS-R and SCENIC are in synch + If a road falls outside the respective region but is included in the equivalent METS-R + map add it to the represented region + """ + repaired_bubble = [] + for id in bubble_road_ids: + all_connected_roads = self.all_scenic_roads_connected_too[id] + if len(all_connected_roads) > 1: + for road_id in all_connected_roads: + if road_id not in road_ids: + repaired_bubble.append(self.roads_by_id[road_id]) + return repaired_bubble + diff --git a/src/scenic/simulators/cosim/utils/scenarios.scenic b/src/scenic/simulators/cosim/utils/scenarios.scenic new file mode 100644 index 000000000..2929b619a --- /dev/null +++ b/src/scenic/simulators/cosim/utils/scenarios.scenic @@ -0,0 +1,44 @@ +from scenic.simulators.metsr.traffic_flows import * + + +"""Library of useful scenarios for composing CoSim Scenic programs""" +scenario GeneratePrivateTrip(origin, destination, name=None): + if name != None: + new NPCCar with origin origin, with destination destination, with name name + else: + new NPCCar with origin origin, with destination destination + terminate after 1 steps + + +"""Generic traffic Stream which spawns new vehicles according to the probabilities defined by traffic_flow""" +scenario TrafficStream(origin, destination, traffic_flow): + compose: + while True: + raw_prob_spawn = traffic_flow.expected_vehs( + currentTOD(), currentTOD()+simulation().timestep) + if raw_prob_spawn < 0 or raw_prob_spawn > 1: + warnings.warn(f"raw_prob_spawn (={raw_prob_spawn}) fell outside [0,1] and will be clamped.") + prob_spawn = min(1, max(raw_prob_spawn, 0)) + if Range(0,1) < prob_spawn: + do GeneratePrivateTrip(origin, destination) + else: + wait +"""Constant Stream""" +scenario ConstantTrafficStream(origin, destination, num_vehicles, stime=None, etime=None): + compose: + tf = ConstantTrafficFlow(num_vehicles, stime, etime) + do TrafficStream(origin, destination, tf) + +"""Normally distributed Stream""" +scenario NormalTrafficStream(origin, destination, num_vehicles, peak_time, stddev): + compose: + tf = NormalTrafficFlow(num_vehicles, peak_time, stddev) + do TrafficStream(origin, destination, tf) + +"""Two-way stream modeling incoming and outgoing traffic """ +scenario CommuterTrafficStream(origin, destination, num_vehicles, + peak_time_1, peak_time_2, stddev): + compose: + tf1 = NormalTrafficFlow(num_vehicles, peak_time_1, stddev) + tf2 = NormalTrafficFlow(num_vehicles, peak_time_2, stddev) + do TrafficStream(origin, destination, tf1), TrafficStream(destination, origin, tf2) diff --git a/src/scenic/simulators/cosim/utils/utils.py b/src/scenic/simulators/cosim/utils/utils.py new file mode 100644 index 000000000..6094d07d2 --- /dev/null +++ b/src/scenic/simulators/cosim/utils/utils.py @@ -0,0 +1,150 @@ +import xml.etree.ElementTree as ET +import os +import numpy as np +import carla + +def generate_map(map): + try: + tree = ET.parse(map) + except FileNotFoundError: + print(f"Could not find map: {map} from {os.getcwd()}") + return {} + + root = tree.getroot() + lane_mappings = {} + edges = root.iterfind("edge") + + for edge in edges: + + lanes = edge.findall('lane') + for lane in lanes: + metrs_lane = lane.attrib.get("id") + params = lane.findall('param') + + for param in params: + if param.get('key') == "origId": + orig_id = param.get('value') + orig_id = orig_id.split() + if isinstance(orig_id, list): + for id in orig_id: + if id in lane_mappings: + lane_mappings[id].append(metrs_lane) + else: + lane_mappings[id] = [metrs_lane] + else: + if orig_id in lane_mappings: + lane_mappings[orig_id].append(metrs_lane) + else: + lane_mappings[orig_id] = [metrs_lane] + # else: + # print(f"Skipping lane: {lane.attrib.get('id')}") + + if lane_mappings == {}: + print(f"An occured attempting to process map: {map}") + + return lane_mappings + +def generate_signal_map(map): + + try: + tree = ET.parse(map) + except FileNotFoundError: + print(f"Could not find map: {map} from {os.getcwd()}") + return {} + + root = tree.getroot() + signal_mappings = {} + + for tl in root.findall(".//tlLogic"): + tl_id = tl.attrib.get("id") + for param in tl.findall('param'): + + key = param.attrib.get("key","") + + if key.startswith("linkSignalID:"): + metsr_key = f"{tl_id}_{key.split(':')[1]}" + + values = param.attrib.get("value","").split() + if values != "": + signal_mappings[metsr_key] = values + + return signal_mappings + + +def test_mapping(map, test_pairs): + mappings = generate_map(map) + for key,value in test_pairs.items(): + if key in mappings: + if value == mappings[key]: + print(f"key value pair {key,value} mapped correctly") + else: + print(f"expected {value} returned {mappings[key]}") + print(f"Value {mappings[key]} for key {key} was incorrect with actual value {value}") + else: + print(f"Failed on test case {key}, {value}") + + +def within_threshold_to(object, cars, verbose=False) -> bool: + is_close = False + object_pos = np.array(object.position) + obj_distances = {} + for car in cars: + if car != object: + threshold = 1.2 * object.length + dist = np.linalg.norm(np.array(car.position[:2]) - object_pos[:2]) + if dist < threshold: + is_close=True + obj_distances[car.name] = dist + if verbose: + print(f"Min Distance for {object} was: {min(obj_distances.values())}") + return is_close + +def get_metsr_rotation(carla_yaw): + """ + Invert carla_yaw = (bearing - 90) % 360 + to recover the original METSR compass bearing. + """ + # ensure 0 ≤ yaw < 360 + carla_yaw = carla_yaw % 360 + # invert the shift of -90° + return (carla_yaw + 90) % 360 + +def get_carla_light_state(light) -> dict: + light_state_dict = {'green_time':light.get_green_time(), + 'red_time': light.get_red_time(), + 'yellow_time':light.get_yellow_time(), + 'state' :light.get_state() } + + return light_state_dict + +def disable_carla_autopilot(obj, tm) -> bool: + if hasattr(obj, 'carlaActor'): + if obj.carlaActor != None: + obj.carlaActor.set_autopilot(False, tm.get_port()) + return True + else: + return False + + +if __name__ == "__main__": + + map = "Town01.net.xml" + + # key value pairs where key == origID and value == lane id + town01_test_pairs = {"4_1": "4_2", "0_2 11_-2 8_2": "0_1", "8_-3 11_3 0_-3": "-8_0" } + + test_mapping(map, town01_test_pairs) + + map = "Town02.net.xml" + + # Randomly selected test cases from the file to check accuracy + town02_test_pairs = {"177_-1": ":132_3_0", "276_-3":":242_2_0", "1_-2 16_-2 12_2 3_-2 15_2": "-1_1"} + + test_mapping(map, town02_test_pairs) + + map = "Town05.net.xml" + + result = generate_signal_map(map) + + print(f'Result was: {result}') + \ No newline at end of file diff --git a/src/scenic/simulators/metadrive/__init__.py b/src/scenic/simulators/metadrive/__init__.py new file mode 100644 index 000000000..d82483150 --- /dev/null +++ b/src/scenic/simulators/metadrive/__init__.py @@ -0,0 +1,22 @@ +"""Interface to the MetaDrive driving simulator. + +This interface must currently be used in `2D compatibility mode`. + +It supports dynamic scenarios involving vehicles and pedestrians. + +The interface implements the :obj:`scenic.domains.driving` abstract domain, so any +object types, behaviors, utility functions, etc. from that domain may be used freely. +For details of additional MetaDrive-specific functionality, see the world model +:obj:`scenic.simulators.metadrive.model`. +""" + +# Only import MetaDriveSimulator if the metadrive package is installed; otherwise the +# import would raise an exception. +metadrive = None +try: + import metadrive +except ImportError: + pass +if metadrive: + from .simulator import MetaDriveSimulator +del metadrive diff --git a/src/scenic/simulators/metadrive/model.scenic b/src/scenic/simulators/metadrive/model.scenic new file mode 100644 index 000000000..078c6fc33 --- /dev/null +++ b/src/scenic/simulators/metadrive/model.scenic @@ -0,0 +1,190 @@ +"""Scenic world model for traffic scenarios in MetaDrive. + +The model currently supports vehicles and pedestrians. It implements the +basic :obj:`~scenic.domains.driving.model.Car` and `Pedestrian` classes from the :obj:`scenic.domains.driving` domain. +Vehicles and pedestrians support the basic actions and behaviors from the driving domain. + +The model defines several global parameters, whose default values can be overridden +in scenarios using the ``param`` statement or on the command line using the +:option:`--param` option: + +Global Parameters: + sumo_map (str or Path): Path to the SUMO map (``.net.xml`` file) to use in the simulation. + This map should correspond to the **map** file used in the scenario. See the documentation in + :doc:`scenic.domains.driving.model` for details. + timestep (float): The interval (in seconds) between each simulation step. This determines how often Scenic + interrupts MetaDrive to run behaviors, check requirements, and update the simulation state. + The default value is 0.1 seconds. + render (bool): Whether to render the simulation screen. If True (default), it will open a screen and render + the simulation. If False, rendering is disabled. + render3D (bool): Whether to render the simulation in 3D. If True, it will render the simulation in 3D. + If False (default), it will render in 2D. + real_time (bool): If True (default), the simulation will run in real time, ensuring each step takes at least + as long as the specified timestep. If False, the simulation runs as fast as possible. + screen_record (bool): Whether to record a screen capture of the simulation (as a GIF). + Only supported in 2D rendering mode (requires ``render=True`` and ``render3D=False``). + Default is False. + screen_record_filename (str or None): Name of the GIF file to save. If not specified (default), + a timestamp will be used for each scenario recording. + screen_record_path (str): Directory where the GIFs will be saved. Defaults to "metadrive_gifs". + If not customized, recordings are grouped into this folder using timestamps for filenames. +""" +import pathlib + +from scenic.domains.driving.model import * +from scenic.domains.driving.actions import * +from scenic.domains.driving.behaviors import * + +from scenic.core.errors import InvalidScenarioError + +# Sensor imports +from scenic.simulators.metadrive.sensors import MetaDriveRGBSensor as RGBSensor +from scenic.simulators.metadrive.sensors import MetaDriveSSSensor as SSSensor + + +try: + from scenic.simulators.metadrive.simulator import MetaDriveSimulator + from scenic.simulators.metadrive.utils import scenicToMetaDriveHeading, scenicToMetaDrivePosition +except ModuleNotFoundError: + # for convenience when testing without the metadrive package + from scenic.core.simulators import SimulatorInterfaceWarning + import warnings + warnings.warn('The "metadrive-simulator" package is not installed; ' + 'will not be able to run dynamic simulations', + SimulatorInterfaceWarning) + + def MetaDriveSimulator(*args, **kwargs): + """Dummy simulator to allow compilation without the 'metadrive-simulator' package. + + :meta private: + """ + raise RuntimeError('the "metadrive-simulator" package is required to run simulations ' + 'from this scenario') + +if "sumo_map" not in globalParameters: + sumo_map_path = str(pathlib.Path(globalParameters.map).with_suffix(".net.xml")) + + if not pathlib.Path(sumo_map_path).exists(): + raise InvalidScenarioError( + f"Missing SUMO map: Expected '{sumo_map_path}' but the file does not exist.\n" + "The SUMO map should have the same base name as the 'map' parameter, with the '.net.xml' extension.\n" + "Ensure that the corresponding '.net.xml' file is located in the same directory as the '.xodr' file." + ) +else: + sumo_map_path = globalParameters.sumo_map + +param sumo_map = sumo_map_path +param timestep = 0.1 +param render = 1 +param render3D = 0 +param real_time = 1 +param screen_record = 0 +param screen_record_filename = None +param screen_record_path = "metadrive_gifs" + +if bool(globalParameters.screen_record) and bool(globalParameters.render3D): + raise InvalidScenarioError( + "screen_record=True is only supported with 2D rendering. Set render3D=False." + ) + +if bool(globalParameters.screen_record) and not bool(globalParameters.render): + raise InvalidScenarioError( + "screen_record=True requires render=True. Cannot record when rendering is disabled." + ) + +simulator MetaDriveSimulator( + sumo_map=globalParameters.sumo_map, + xodr_map=globalParameters.map, + timestep=float(globalParameters.timestep), + render=bool(globalParameters.render), + render3D=bool(globalParameters.render3D), + real_time=bool(globalParameters.real_time), + screen_record=bool(globalParameters.screen_record), + screen_record_filename=globalParameters.screen_record_filename, + screen_record_path=globalParameters.screen_record_path, +) + +class MetaDriveActor(DrivingObject): + """Abstract class for MetaDrive objects. + + This class serves as a base for objects in the MetaDrive simulator. It provides essential + functionality for associating Scenic objects with their corresponding MetaDrive simulation objects. + + Properties: + metaDriveActor: A reference to the MetaDrive actor (e.g., vehicle or pedestrian) associated + with this Scenic object. This is set when the object is created in the simulator. + It allows interaction with MetaDrive's simulation environment, such as applying actions + or retrieving simulation data (position, velocity, etc.). + """ + metaDriveActor: None + + def setPosition(self, pos, elevation): + position = scenicToMetaDrivePosition(pos, simulation().scenic_offset) + self.metaDriveActor.set_position(position) + + def setVelocity(self, vel): + self.metaDriveActor.set_velocity(vel) + + +class Vehicle(Vehicle, Steers, MetaDriveActor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._control = {"steer": 0.0, "throttle": 0.0, "brake": 0.0} + self._reverse = False + self._handbrake = False + + def setThrottle(self, throttle): + self._control["throttle"] = throttle + + def setSteering(self, steering): + self._control["steer"] = steering + + def setBraking(self, braking): + self._control["brake"] = braking + + def setReverse(self, reverse): + self._reverse = reverse + + def setHandbrake(self, handbrake): + self._handbrake = handbrake + + def _prepare_action(self): + # MetaDrive uses the opposite convention: positive steer turns left + steer = -self._control["steer"] + + # Handbrake overrides everything: disable reverse, full brake. + if self._handbrake: + self.metaDriveActor.enable_reverse = False + return [steer, -1.0] + + # MetaDrive uses a single combined throttle/brake value + throttle_brake = self._control["throttle"] - self._control["brake"] + + # Enable reverse only if requested AND there’s forward drive effort. + enable_reverse = self._reverse and (throttle_brake > 0.0) + self.metaDriveActor.enable_reverse = enable_reverse + if enable_reverse: + throttle_brake = -throttle_brake # negative drives backward + return [steer, throttle_brake] + + +class Car(Vehicle): + @property + def isCar(self): + return True + +class Pedestrian(Pedestrian, MetaDriveActor, Walks): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._walking_direction = None + self._walking_speed = None + + @property + def isPedestrian(self): + return True + + def setWalkingDirection(self, heading): + self._walking_direction = scenicToMetaDriveHeading(heading) + + def setWalkingSpeed(self, speed): + self._walking_speed = speed diff --git a/src/scenic/simulators/metadrive/sensors.py b/src/scenic/simulators/metadrive/sensors.py new file mode 100644 index 000000000..b2e14622e --- /dev/null +++ b/src/scenic/simulators/metadrive/sensors.py @@ -0,0 +1,41 @@ +from scenic.core.sensors import Sensor + +# NOTE: MetaDrive/Panda3D coords: +# position (x, y, z) m relative to the parent object's orgin: +X right, +Y forward, +Z up (Z-up, RH). +# rotation (Heading, Pitch, Roll) degrees: H=yaw about Z (+left), P=pitch about X (+up), R=roll about Y (+left). + + +class MetaDriveVisionSensor(Sensor): + def __init__( + self, + offset=None, + rotation=(0, 0, 0), + width=None, + height=None, + attributes=None, + ): + if width is None or height is None: + raise ValueError("width and height are required for sensors") + self.offset = offset + self.rotation = rotation + self.width = width + self.height = height + self.attributes = attributes or {} + self.metadrive_sensor = None # set by the simulator + + def getObservation(self): + if self.metadrive_sensor is None: + raise RuntimeError( + "MetaDrive sensor not attached; (metadrive_sensor not set by simulator)." + ) + # MetaDrive returns BGR; Scenic expects RGB. + img_bgr = self.metadrive_sensor.perceive(to_float=False) + return img_bgr[..., ::-1] + + +class MetaDriveRGBSensor(MetaDriveVisionSensor): + pass + + +class MetaDriveSSSensor(MetaDriveVisionSensor): + pass diff --git a/src/scenic/simulators/metadrive/simulator.py b/src/scenic/simulators/metadrive/simulator.py new file mode 100644 index 000000000..76607d5bc --- /dev/null +++ b/src/scenic/simulators/metadrive/simulator.py @@ -0,0 +1,408 @@ +"""Simulator interface for MetaDrive.""" + +try: + import metadrive +except ModuleNotFoundError as e: + raise ModuleNotFoundError( + "Metadrive is required. Please install the 'metadrive-simulator' package (and sumolib) or use scenic[metadrive]." + ) from e + +from datetime import datetime +import logging +import os +import sys +import time + +from metadrive.component.sensors.rgb_camera import RGBCamera +from metadrive.component.sensors.semantic_camera import SemanticCamera +from metadrive.component.traffic_participants.pedestrian import Pedestrian +from metadrive.component.vehicle.vehicle_type import DefaultVehicle + +from scenic.core.simulators import InvalidScenarioError, SimulationCreationError +from scenic.domains.driving.actions import * +from scenic.domains.driving.controllers import ( + PIDLateralController, + PIDLongitudinalController, +) +from scenic.domains.driving.simulators import DrivingSimulation, DrivingSimulator +from scenic.simulators.metadrive.sensors import MetaDriveRGBSensor, MetaDriveSSSensor +import scenic.simulators.metadrive.utils as utils + + +class MetaDriveSimulator(DrivingSimulator): + """Implementation of `Simulator` for MetaDrive.""" + + def __init__( + self, + sumo_map, + xodr_map, + timestep=0.1, + render=True, + render3D=False, + real_time=True, + screen_record=False, + screen_record_filename=None, + screen_record_path="metadrive_gifs", + ): + super().__init__() + self.render = render + self.render3D = render3D if render else False + self.scenario_number = 0 + self.timestep = timestep + self.sumo_map = sumo_map + self.xodr_map = xodr_map + self.real_time = real_time + self.screen_record = screen_record + self.screen_record_filename = screen_record_filename + self.screen_record_path = screen_record_path + self.scenic_offset, self.sumo_map_boundary = utils.getMapParameters( + self.sumo_map, self.xodr_map + ) + + if self.screen_record and self.render3D: + raise SimulationCreationError( + "screen_record=True requires 2D rendering: set render3D=False." + ) + + if self.screen_record and not self.render: + raise SimulationCreationError( + "screen_record=True requires rendering to be enabled: set render=True." + ) + + if self.render and not self.render3D: + self.film_size = utils.calculateFilmSize(self.sumo_map_boundary, scaling=5) + else: + self.film_size = None + + def createSimulation(self, scene, *, timestep, **kwargs): + self.scenario_number += 1 + return MetaDriveSimulation( + scene, + render=self.render, + render3D=self.render3D, + scenario_number=self.scenario_number, + timestep=self.timestep, + sumo_map=self.sumo_map, + real_time=self.real_time, + screen_record=self.screen_record, + screen_record_filename=self.screen_record_filename, + screen_record_path=self.screen_record_path, + scenic_offset=self.scenic_offset, + sumo_map_boundary=self.sumo_map_boundary, + film_size=self.film_size, + **kwargs, + ) + + +class MetaDriveSimulation(DrivingSimulation): + def __init__( + self, + scene, + render, + render3D, + scenario_number, + timestep, + sumo_map, + real_time, + screen_record, + screen_record_filename, + screen_record_path, + scenic_offset, + sumo_map_boundary, + film_size, + **kwargs, + ): + if len(scene.objects) == 0: + raise InvalidScenarioError( + "Metadrive requires you to define at least one Scenic object." + ) + if not scene.objects[0].isCar: + raise InvalidScenarioError( + "The first object must be a car to serve as the ego vehicle in Metadrive." + ) + + self.render = render + self.render3D = render3D + self.scenario_number = scenario_number + self.defined_ego = False + self.client = None + self.timestep = timestep + self.sumo_map = sumo_map + self.real_time = real_time + self.screen_record = screen_record + self.screen_record_filename = screen_record_filename + self.screen_record_path = screen_record_path + self.scenic_offset = scenic_offset + self.sumo_map_boundary = sumo_map_boundary + self.film_size = film_size + super().__init__(scene, timestep=timestep, **kwargs) + + # --- sensor helpers --- + def _metadrive_sensor_name(self, obj, base_name: str) -> str: + """Return a unique MetaDrive sensor name by appending the object's index.""" + idx = self.scene.objects.index(obj) + return f"{base_name}__obj{idx}" + + def _attach_sensors(self, obj): + """Attach/track all sensors for this object in MetaDrive.""" + if not obj.sensors: + return + for base_name, sensor in obj.sensors.items(): + md_name = self._metadrive_sensor_name(obj, base_name) + md_sensor = self.client.engine.get_sensor(md_name) + if md_sensor is None: + raise RuntimeError( + f"Metadrive sensor '{md_name}' not found; check setup naming." + ) + offset = ( + sensor.offset if sensor.offset is not None else obj.visionSensorOffset + ) + md_sensor.track(obj.metaDriveActor.origin, offset, sensor.rotation) + sensor.metadrive_sensor = md_sensor + + def setup(self): + self.drive_env_config = {} + + for obj in self.scene.objects: + if obj.sensors: + for base_name, sensor in obj.sensors.items(): + md_name = self._metadrive_sensor_name(obj, base_name) + if isinstance(sensor, MetaDriveRGBSensor): + self.drive_env_config[md_name] = [ + RGBCamera, + sensor.width, + sensor.height, + ] + elif isinstance(sensor, MetaDriveSSSensor): + self.drive_env_config[md_name] = [ + SemanticCamera, + sensor.width, + sensor.height, + ] + else: + raise RuntimeError(f"Unknown sensor type: {type(sensor)}") + + self.using_sensors = bool(self.drive_env_config) + super().setup() + + def createObjectInSimulator(self, obj): + """ + Create an object in the MetaDrive simulator. + + If it's the first object, it initializes the client and sets it up for the ego car. + For additional cars and pedestrians, it spawns objects using the provided position and heading. + """ + converted_position = utils.scenicToMetaDrivePosition( + obj.position, self.scenic_offset + ) + converted_heading = utils.scenicToMetaDriveHeading(obj.heading) + + vehicle_config = {} + if obj.isVehicle: + vehicle_config["spawn_position_heading"] = [ + converted_position, + converted_heading, + ] + vehicle_config["spawn_velocity"] = [obj.velocity.x, obj.velocity.y] + + if not self.defined_ego: + decision_repeat = math.ceil(self.timestep / 0.02) + physics_world_step_size = self.timestep / decision_repeat + + # Initialize the simulator with ego vehicle + self.client = utils.DriveEnv( + dict( + decision_repeat=decision_repeat, + physics_world_step_size=physics_world_step_size, + use_render=self.render3D, + vehicle_config=vehicle_config, + use_mesh_terrain=False, + height_scale=0.0001, + log_level=logging.CRITICAL, + image_observation=self.using_sensors, + sensors=self.drive_env_config, + ) + ) + self.client.config["sumo_map"] = self.sumo_map + self.client.reset() + + # Assign the MetaDrive actor to the ego + metadrive_objects = self.client.engine.get_objects() + obj.metaDriveActor = list(metadrive_objects.values())[0] + self.defined_ego = True + + # Attach sensors (if any) + self._attach_sensors(obj) + return + + # For additional cars + if obj.isVehicle: + metaDriveActor = self.client.engine.agent_manager.spawn_object( + DefaultVehicle, + vehicle_config=vehicle_config, + ) + obj.metaDriveActor = metaDriveActor + + # Attach sensors (if any) + self._attach_sensors(obj) + return + + # For pedestrians + if obj.isPedestrian: + metaDriveActor = self.client.engine.agent_manager.spawn_object( + Pedestrian, + position=converted_position, + heading_theta=converted_heading, + ) + obj.metaDriveActor = metaDriveActor + + # Attach sensors (if any) + self._attach_sensors(obj) + + # Manually initialize pedestrian velocity to Scenic’s starting speed + direction = [math.cos(converted_heading), math.sin(converted_heading)] + metaDriveActor.set_velocity(direction, obj.speed) + return + + # If the object type is unsupported, raise an error + raise SimulationCreationError( + f"Unsupported object type: {type(obj)} for object {obj}." + ) + + def executeActions(self, allActions): + """Execute actions for all vehicles in the simulation.""" + super().executeActions(allActions) + + # Apply control updates to vehicles and pedestrians + for obj in self.agents: + if obj is self.objects[0]: # Skip ego vehicle (it is handled separately) + continue + if obj.isVehicle: + action = obj._prepare_action() + obj.metaDriveActor.before_step(action) + else: + # For Pedestrians + if obj._walking_direction is None: + obj._walking_direction = utils.scenicToMetaDriveHeading(obj.heading) + if obj._walking_speed is None: + obj._walking_speed = obj.speed + direction = [ + math.cos(obj._walking_direction), + math.sin(obj._walking_direction), + ] + obj.metaDriveActor.set_velocity(direction, obj._walking_speed) + + def step(self): + start_time = time.monotonic() + + # Special handling for the ego vehicle + ego_obj = self.objects[0] + action = ego_obj._prepare_action() + self.client.step(action) + + # Render the scene in 2D if needed + if self.render and not self.render3D: + self.client.render( + mode="topdown", + semantic_map=True, + film_size=self.film_size, + scaling=5, + screen_record=self.screen_record, + ) + + # If real-time synchronization is enabled, sleep to maintain real-time pace + if self.real_time: + end_time = time.monotonic() + elapsed_time = end_time - start_time + if elapsed_time < self.timestep: + time.sleep(self.timestep - elapsed_time) + + def destroy(self): + if self.screen_record: + filename = self.screen_record_filename or datetime.now().strftime( + "%Y%m%d_%H%M%S" + ) + if not filename.endswith(".gif"): + filename += ".gif" + path = os.path.join(self.screen_record_path, filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + # Convert timestep (seconds) → milliseconds for GIF duration + duration_ms = int(round(self.timestep * 1000)) + + print(f"Saving screen recording to {path}") + self.client.top_down_renderer.generate_gif(path, duration=duration_ms) + + if self.client and self.client.engine: + object_ids = list(self.client.engine._spawned_objects.keys()) + if object_ids: + self.client.engine.agent_manager.clear_objects(object_ids) + self.client.close() + + super().destroy() + + def getProperties(self, obj, properties): + metaDriveActor = obj.metaDriveActor + position = utils.metadriveToScenicPosition( + metaDriveActor.position, self.scenic_offset + ) + velocity = Vector(*metaDriveActor.velocity, 0) + speed = metaDriveActor.speed + md_ang_vel = metaDriveActor.body.getAngularVelocity() + angularVelocity = Vector(*md_ang_vel) + angularSpeed = math.hypot(*md_ang_vel) + converted_heading = utils.metaDriveToScenicHeading(metaDriveActor.heading_theta) + yaw, pitch, roll = obj.parentOrientation.globalToLocalAngles( + converted_heading, 0, 0 + ) + elevation = 0 + + values = dict( + position=position, + velocity=velocity, + speed=speed, + angularSpeed=angularSpeed, + angularVelocity=angularVelocity, + yaw=yaw, + pitch=pitch, + roll=roll, + elevation=elevation, + ) + + return values + + def getLaneFollowingControllers(self, agent): + dt = self.timestep + if agent.isCar: + lon_controller = PIDLongitudinalController(K_P=0.5, K_D=0.1, K_I=0.7, dt=dt) + lat_controller = PIDLateralController(K_P=0.13, K_D=0.3, K_I=0.05, dt=dt) + else: + lon_controller = PIDLongitudinalController( + K_P=0.25, K_D=0.025, K_I=0.0, dt=dt + ) + lat_controller = PIDLateralController(K_P=0.2, K_D=0.1, K_I=0.0, dt=dt) + return lon_controller, lat_controller + + def getTurningControllers(self, agent): + dt = self.timestep + if agent.isCar: + lon_controller = PIDLongitudinalController(K_P=0.5, K_D=0.1, K_I=0.7, dt=dt) + lat_controller = PIDLateralController(K_P=0.2, K_D=0.2, K_I=0.2, dt=dt) + else: + lon_controller = PIDLongitudinalController( + K_P=0.25, K_D=0.025, K_I=0.0, dt=dt + ) + lat_controller = PIDLateralController(K_P=0.4, K_D=0.1, K_I=0.0, dt=dt) + return lon_controller, lat_controller + + def getLaneChangingControllers(self, agent): + dt = self.timestep + if agent.isCar: + lon_controller = PIDLongitudinalController(K_P=0.5, K_D=0.1, K_I=0.7, dt=dt) + lat_controller = PIDLateralController(K_P=0.2, K_D=0.2, K_I=0.02, dt=dt) + else: + lon_controller = PIDLongitudinalController( + K_P=0.25, K_D=0.025, K_I=0.0, dt=dt + ) + lat_controller = PIDLateralController(K_P=0.1, K_D=0.3, K_I=0.0, dt=dt) + return lon_controller, lat_controller diff --git a/src/scenic/simulators/metadrive/utils.py b/src/scenic/simulators/metadrive/utils.py new file mode 100644 index 000000000..6891295f5 --- /dev/null +++ b/src/scenic/simulators/metadrive/utils.py @@ -0,0 +1,143 @@ +# NOTE: MetaDrive uses a coordinate system where (0, 0) is centered near the +# middle of the SUMO map. +# Some OpenDRIVE maps include a dataset-level
, and +# SUMO/netconvert may apply an additional translation recorded as +# in the .net.xml. +# To align Scenic with MetaDrive, we account for both translations (when +# present) and then recenter using the convBoundary midpoint. + +import math +import xml.etree.ElementTree as ET + +from metadrive.envs import BaseEnv +from metadrive.manager.sumo_map_manager import SumoMapManager +from metadrive.obs.observation_base import DummyObservation + +from scenic.core.vectors import Vector + + +def calculateFilmSize( + sumo_map_boundary, + scaling=5, + margin_factor=1.1, + min_extent=200.0, +): + """Compute film_size (width, height) in pixels for MetaDrive's top-down renderer. + + The SUMO convBoundary gives the map extent (xmin, ymin, xmax, ymax) in meters. + If one dimension is zero (e.g. a single straight road), clamp it to min_extent + to avoid a zero-width/height film size. + """ + xmin, ymin, xmax, ymax = sumo_map_boundary + width = xmax - xmin + height = ymax - ymin + + # Clamp zero-width/height maps (e.g. straight roads) to a reasonable minimum. + width = max(width, min_extent) + height = max(height, min_extent) + + adjusted_width = width * margin_factor + adjusted_height = height * margin_factor + + film_w = int(adjusted_width * scaling) + film_h = int(adjusted_height * scaling) + return film_w, film_h + + +def extractNetOffsetAndBoundary(sumo_map_path): + """Extracts the net offset and boundary from the given SUMO map file.""" + tree = ET.parse(sumo_map_path) + root = tree.getroot() + location_tag = root.find("location") + net_offset = tuple(map(float, location_tag.attrib["netOffset"].split(","))) + sumo_map_boundary = tuple(map(float, location_tag.attrib["convBoundary"].split(","))) + return net_offset, sumo_map_boundary + + +def extractXODROffset(xodr_map_path): + """Return the (x, y) translation from the OpenDRIVE
element. + + If the file has no
or no , returns (0.0, 0.0). + """ + tree = ET.parse(xodr_map_path) + root = tree.getroot() + header = root.find("header") + if header is None: + return 0.0, 0.0 + offset = header.find("offset") + if offset is None: + return 0.0, 0.0 + return float(offset.get("x", "0")), float(offset.get("y", "0")) + + +def getMapParameters(sumo_map_path, xodr_map_path): + """Retrieve the map parameters.""" + net_offset, sumo_map_boundary = extractNetOffsetAndBoundary(sumo_map_path) + xmin, ymin, xmax, ymax = sumo_map_boundary + center_x = (xmin + xmax) / 2 + center_y = (ymin + ymax) / 2 + + xodr_offset = extractXODROffset(xodr_map_path) + combined_offset_x = net_offset[0] + xodr_offset[0] + combined_offset_y = net_offset[1] + xodr_offset[1] + + scenic_offset = (center_x - combined_offset_x, center_y - combined_offset_y) + return scenic_offset, sumo_map_boundary + + +def metadriveToScenicPosition(loc, scenic_offset): + """Converts MetaDrive position to Scenic position using map parameters.""" + x_scenic = loc[0] + scenic_offset[0] + y_scenic = loc[1] + scenic_offset[1] + return Vector(x_scenic, y_scenic, 0) + + +def scenicToMetaDrivePosition(vec, scenic_offset): + """Converts Scenic position to MetaDrive position using map parameters.""" + adjusted_x = vec[0] - scenic_offset[0] + adjusted_y = vec[1] - scenic_offset[1] + return adjusted_x, adjusted_y + + +def scenicToMetaDriveHeading(scenicHeading): + """ + Converts Scenic heading to MetaDrive heading by adding π/2 (90 degrees). + + Scenic's coordinate system has 0 radians pointing North, while MetaDrive uses + 0 radians pointing East. This function shifts the heading to align with MetaDrive's system. + """ + metadriveHeading = scenicHeading + (math.pi / 2) + # Normalize to [-π, π] + return (metadriveHeading + math.pi) % (2 * math.pi) - math.pi + + +def metaDriveToScenicHeading(metaDriveHeading): + """Converts MetaDrive heading to Scenic heading by subtracting π/2 (90 degrees).""" + scenicHeading = metaDriveHeading - (math.pi / 2) + # Normalize to [-π, π] + return (scenicHeading + math.pi) % (2 * math.pi) - math.pi + + +class DriveEnv(BaseEnv): + def reward_function(self, agent): + """Dummy reward function.""" + return 0, {} + + def cost_function(self, agent): + """Dummy cost function.""" + return 0, {} + + def done_function(self, agent): + """Dummy done function.""" + return False, {} + + def get_single_observation(self): + """Dummy observation function.""" + return DummyObservation() + + def setup_engine(self): + """Setup the engine for MetaDrive.""" + super().setup_engine() + self.engine.register_manager( + "map_manager", SumoMapManager(self.config["sumo_map"]) + ) diff --git a/src/scenic/simulators/metsr/__init__.py b/src/scenic/simulators/metsr/__init__.py new file mode 100644 index 000000000..0d415299b --- /dev/null +++ b/src/scenic/simulators/metsr/__init__.py @@ -0,0 +1,2 @@ +from .simulator import METSRSimulator + \ No newline at end of file diff --git a/src/scenic/simulators/metsr/client.py b/src/scenic/simulators/metsr/client.py index a3a943f09..ee99f2d37 100644 --- a/src/scenic/simulators/metsr/client.py +++ b/src/scenic/simulators/metsr/client.py @@ -1,16 +1,742 @@ -import datetime import json -import time +import logging +import os +import socket +import struct import threading +import time +from datetime import datetime - +import networkx as nx from websockets.sync.client import connect +from .util import ( + VEHICLE_SENSOR_CV2X, + VEHICLE_SENSOR_DSRC, + VEHICLE_SENSOR_MOBILE_DEVICE, + VEHICLE_SENSOR_TYPES, + _as_list, + _broadcast, + _configured_trajectory_roots, + _is_sequence, + _latest_trajectory_directory, + _load_raw_config, + _looks_like_centerline, + _normalize_sensor_type, + _read_property_values, + _read_trajectory_manifest, + _request_id_from_record, + _request_zone_from_record, + _resolve_trajectory_root, + _set_road_reference, + _trajectory_format_name, + _trajectory_format_score, + _trajectory_manifest_summary, + prepare_sim_dirs, + register_metsr_client, + read_run_config, + run_simulation_in_docker, + run_visualization_server, + stop_visualization_server, + unregister_metsr_client, + str_list_mapper_gen, +) + +str_list_to_int_list = str_list_mapper_gen(int) +str_list_to_float_list = str_list_mapper_gen(float) + + +def _normalize_config_json_path(config_json): + if config_json is None: + return None + return os.path.abspath(os.fspath(config_json)) + + +def _config_signature_from_path(config_json): + if config_json is None: + return None + raw_config = _load_raw_config(config_json) + return json.dumps(raw_config, sort_keys=True, separators=(",", ":"), default=str) + + +def _port_from_config(config, sim_index=0, default=None): + if config is None: + return default + + for attr_name in ("ports", "metsr_port"): + ports = getattr(config, attr_name, None) + if ports is None: + continue + if isinstance(ports, (list, tuple)): + if not ports: + continue + return int(ports[int(sim_index)]) + return int(ports) + return default + + +def _sim_folder_from_config(config, sim_dirs=None, sim_index=0, default=None): + candidates = sim_dirs if sim_dirs is not None else getattr(config, "sim_dirs", None) + if candidates: + return candidates[int(sim_index)] + + sim_folder = getattr(config, "sim_folder", None) + if isinstance(sim_folder, (list, tuple)): + return sim_folder[int(sim_index)] + if sim_folder: + return sim_folder + return default + + +_VIZ_STREAM_MAGIC = b"MRTB" +_VIZ_STREAM_VERSION = 8 +_VIZ_STREAM_DEFAULT_COORD_SCALE = 100000 +_VIZ_STREAM_FRAME_GROUPS = [ + "vehicle", + "ev_private", + "ev_occupied", + "ev_relocation", + "ev_charging", + "bus", + "link", + "zone", + "chargingStation", +] +_VIZ_STREAM_VEHICLE_FRAME_GROUPS = [ + "vehicle", + "ev_private", + "ev_occupied", + "ev_relocation", + "ev_charging", + "bus", +] +_VIZ_STREAM_VEHICLE_TYPES = { + "vehicle": 0, + "ev_private": 1, + "ev_occupied": 2, + "ev_relocation": 3, + "ev_charging": 4, + "bus": 5, +} +_INT32_MIN = -(2 ** 31) +_INT32_MAX = 2 ** 31 - 1 + +def _viz_float(value, default=0.0): + try: + number = float(value) + except (TypeError, ValueError): + return float(default) + if number != number or number in (float("inf"), float("-inf")): + return float(default) + return number + + +def _viz_int(value, default=0): + try: + return int(value) + except (TypeError, ValueError): + try: + return int(float(value)) + except (TypeError, ValueError): + return int(default) + + +def _viz_int32(value, default=0): + number = _viz_int(value, default=default) + if number < _INT32_MIN: + return _INT32_MIN + if number > _INT32_MAX: + return _INT32_MAX + return number + + +def _viz_pack_int(value, default=0): + return struct.pack(">i", _viz_int32(value, default=default)) + + +def _viz_pack_float(value, default=0.0): + return struct.pack(">f", _viz_float(value, default=default)) + + +def _viz_pack_double(value, default=0.0): + return struct.pack(">d", _viz_float(value, default=default)) + + +def _viz_first(record, *names, default=None): + if not isinstance(record, dict): + return default + for name in names: + value = record.get(name) + if value is not None: + return value + return default + + +def _viz_has_xy(record): + return ( + _viz_first(record, "x", "lon", "longitude") is not None + and _viz_first(record, "y", "lat", "latitude") is not None + ) + + + +def _viz_stream_record(record): + return isinstance(record, dict) and _viz_has_xy(record) + + +def _viz_scaled_coord(value, origin, coord_scale): + scaled = round((_viz_float(value) - float(origin)) * int(coord_scale)) + return _viz_int32(scaled) + + +def _viz_scaled_coord_field(record, field, fallback, origin, coord_scale): + value = _viz_first(record, field, default=None) + if value is None: + value = fallback + return _viz_scaled_coord(value, origin, coord_scale) + + +def _viz_sum(records, *field_names): + total = 0 + for record in records: + total += _viz_int(_viz_first(record, *field_names, default=0)) + return total + + +def _viz_mean_speed(records): + speeds = [ + _viz_float(_viz_first(record, "speed", "speed_mps", default=0.0)) + for record in records + ] + if not speeds: + return 0.0 + return sum(speeds) / len(speeds) + + +def _viz_record_energy_by_class(records): + private_ev = 0.0 + etaxi = 0.0 + ebus = 0.0 + for record in records: + energy = _viz_float(_viz_first(record, "energy", "totalEnergy", "totalEnergyConsumed", "totalConsume", default=0.0)) + vehicle_class = _viz_int(_viz_first(record, "vehicleClass", "v_type", "vehicle_class", default=-1), default=-1) + if vehicle_class == 3: + private_ev += energy + elif vehicle_class == 1: + etaxi += energy + elif vehicle_class == 2: + ebus += energy + return private_ev, etaxi, ebus + + + +def _viz_origin_from_sim_folder(sim_folder): + if not sim_folder: + return None + + properties = _read_property_values(os.path.join(sim_folder, 'data', 'Data.properties')) + if 'INITIAL_X' not in properties and 'INITIAL_Y' not in properties: + return None + + return ( + _viz_float(properties.get('INITIAL_X'), default=0.0), + _viz_float(properties.get('INITIAL_Y'), default=0.0), + ) + + +def _viz_resolve_origin(sim_folder, initial_x=None, initial_y=None): + folder_origin = _viz_origin_from_sim_folder(sim_folder) + if folder_origin is not None: + if initial_x is None: + initial_x = folder_origin[0] + if initial_y is None: + initial_y = folder_origin[1] + + return _viz_float(initial_x, default=0.0), _viz_float(initial_y, default=0.0) + + +def _viz_manifest(road_id_dictionary, coord_scale, initial_x, initial_y, tick_interval, + link_snapshot_interval, zone_dictionary=None, + charging_station_dictionary=None): + return { + "format": "metsr-trajectory-binary", + "version": _VIZ_STREAM_VERSION, + "byteOrder": "bigEndian", + "chunkMagic": _VIZ_STREAM_MAGIC.decode("ascii"), + "coordScale": int(coord_scale), + "initialX": float(initial_x), + "initialY": float(initial_y), + "tickInterval": int(tick_interval), + "linkSnapshotInterval": int(link_snapshot_interval), + "chunkTickLimit": 1, + "chunks": [], + "activeChunk": None, + "roadIdDictionary": [str(road_id) for road_id in (road_id_dictionary or [])], + "zoneDictionary": list(zone_dictionary or []), + "chargingStationDictionary": list(charging_station_dictionary or []), + "busRouteDictionary": [], + "vehicleTypes": dict(_VIZ_STREAM_VEHICLE_TYPES), + "frameGroups": list(_VIZ_STREAM_FRAME_GROUPS), + "sparseFrameGroups": ["zone", "chargingStation"], + "sparseFrameGroupMode": "initialFullFrameThenChangedRecordsAndRemovedIds", + } + +def _viz_vehicle_id(record): + return _viz_first(record, "ID", "id", "vehID", "vehicle_id", "vid", default=-1) + + +def _viz_vehicle_class(record): + return _viz_int(_viz_first( + record, + "vehicleClass", + "v_type", + "vehicle_class", + default=-1, + ), default=-1) + + +def _viz_vehicle_state(record): + return _viz_int(_viz_first(record, "state", "vehicleState", "tripState", default=-1), default=-1) + + +def _viz_same_identifier(left, right): + if left is None or right is None: + return False + return str(left) == str(right) + + +def _viz_vehicle_origin_id(record): + value = _viz_first( + record, + "originZoneID", + "originZoneId", + "origin_zone_id", + "originId", + "origin_id", + "origin", + default=None, + ) + if value is not None: + return value + + value = _viz_first(record, "originID", default=None) + road_id = _viz_first(record, "roadID", "roadId", "road", default=None) + if value is not None and not _viz_same_identifier(value, road_id): + return value + return -1 + + +def _viz_vehicle_dest_id(record): + return _viz_first( + record, + "destZoneID", + "destZoneId", + "dest_zone_id", + "destId", + "dest_id", + "dest", + "destinationID", + "destinationId", + "destination", + "destID", + default=-1, + ) + + +def _viz_vehicle_group_key(record): + vehicle_class = _viz_vehicle_class(record) + state = _viz_vehicle_state(record) + private_flag = _viz_first(record, "_viz_private_veh", default=None) + + if vehicle_class == 0: + return "vehicle" + if vehicle_class == 2 or state == 3: + return "bus" + if vehicle_class == 1: + if state == 4: + return "ev_charging" + if state == 1: + return "ev_occupied" + return "ev_relocation" + if vehicle_class == 3: + if state == 4: + return "ev_charging" + return "ev_private" + if private_flag is True: + return "ev_private" + return None + + +def _viz_group_vehicle_records(records): + groups = {key: [] for key in _VIZ_STREAM_VEHICLE_FRAME_GROUPS} + for record in records: + if not _viz_stream_record(record): + continue + group_key = _viz_vehicle_group_key(record) + if group_key in groups: + groups[group_key].append(record) + return groups + + +def _viz_vehicle_record_bytes(record, coord_scale, initial_x, initial_y): + x = _viz_first(record, "x", "lon", "longitude", default=0.0) + y = _viz_first(record, "y", "lat", "latitude", default=0.0) + vehicle_class = _viz_first(record, "vehicleClass", "v_type", "vehicle_class", default=-1) + + data = bytearray() + data.extend(_viz_pack_int(_viz_vehicle_id(record), default=-1)) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "prevX", x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "prevY", y, initial_y, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(y, initial_y, coord_scale))) + data.extend(_viz_pack_float(_viz_first(record, "bearing", "heading", "heading_deg", default=0.0))) + data.extend(_viz_pack_float(_viz_first(record, "speed", "speed_mps", default=0.0))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "originX", x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "originY", y, initial_y, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "destX", x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "destY", y, initial_y, coord_scale))) + data.extend(_viz_pack_int(vehicle_class, default=-1)) + return data + + +def _viz_ev_base_record_bytes(record, coord_scale, initial_x, initial_y): + x = _viz_first(record, "x", "lon", "longitude", default=0.0) + y = _viz_first(record, "y", "lat", "latitude", default=0.0) + + data = bytearray() + data.extend(_viz_pack_int(_viz_vehicle_id(record), default=-1)) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "prevX", x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "prevY", y, initial_y, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(y, initial_y, coord_scale))) + data.extend(_viz_pack_float(_viz_first(record, "bearing", "heading", "heading_deg", default=0.0))) + data.extend(_viz_pack_float(_viz_first(record, "speed", "speed_mps", default=0.0))) + data.extend(_viz_pack_int(_viz_vehicle_origin_id(record), default=-1)) + data.extend(_viz_pack_int(_viz_vehicle_dest_id(record), default=-1)) + data.extend(_viz_pack_float(_viz_first(record, "battery", "battery_state", "batteryLevel", default=0.0))) + data.extend(_viz_pack_float(_viz_first(record, "energy", "totalEnergy", "totalEnergyConsumed", "totalConsume", "totalEnergyConsumption", default=0.0))) + return data + + +def _viz_private_ev_record_bytes(record, coord_scale, initial_x, initial_y): + data = _viz_ev_base_record_bytes(record, coord_scale, initial_x, initial_y) + data.extend(_viz_pack_int(_viz_first(record, "tripNumber", "trip_number", "numTrip", default=0))) + return data + + +def _viz_etaxi_record_bytes(record, coord_scale, initial_x, initial_y): + data = _viz_ev_base_record_bytes(record, coord_scale, initial_x, initial_y) + data.extend(_viz_pack_int(_viz_first(record, "matchedRequests", "taxiMatchedRequests", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "matchedPassengers", "matchedTaxiPassengers", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "pickupRequests", "pickupTaxiRequests", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "pickupPassengers", "pickupTaxiPassengers", "pass_num", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "dropoffRequests", "dropoffTaxiRequests", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "dropoffPassengers", "dropoffTaxiPassengers", default=0))) + return data + + +def _viz_stop_zones(record): + zones = _viz_first(record, "stopZones", "stop_zones", default=[]) + if zones is None: + return [] + if isinstance(zones, (str, bytes)): + return [] + if not _is_sequence(zones): + return [] + return [zone for zone in zones if zone is not None] + + +def _viz_bus_record_bytes(record, coord_scale, initial_x, initial_y): + x = _viz_first(record, "x", "lon", "longitude", default=0.0) + y = _viz_first(record, "y", "lat", "latitude", default=0.0) + stop_zones = _viz_stop_zones(record) + + data = bytearray() + data.extend(_viz_pack_int(_viz_vehicle_id(record), default=-1)) + data.extend(_viz_pack_int(_viz_first(record, "routeID", "routeId", "route_id", "route", default=-1), default=-1)) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "prevX", x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord_field(record, "prevY", y, initial_y, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(y, initial_y, coord_scale))) + data.extend(_viz_pack_float(_viz_first(record, "bearing", "heading", "heading_deg", default=0.0))) + data.extend(_viz_pack_float(_viz_first(record, "speed", "speed_mps", default=0.0))) + data.extend(_viz_pack_float(_viz_first(record, "battery", "battery_state", "batteryLevel", default=0.0))) + data.extend(_viz_pack_float(_viz_first(record, "energy", "totalEnergy", "totalEnergyConsumed", "totalConsume", "totalEnergyConsumption", default=0.0))) + data.extend(_viz_pack_int(_viz_first(record, "matchedRequests", "busMatchedRequests", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "matchedPassengers", "matchedBusPassengers", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "pickupRequests", "pickupBusRequests", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "pickupPassengers", "pickupBusPassengers", "pass_num", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "dropoffRequests", "dropoffBusRequests", default=0))) + data.extend(_viz_pack_int(_viz_first(record, "dropoffPassengers", "dropoffBusPassengers", default=0))) + data.extend(_viz_pack_int(len(stop_zones))) + for stop_zone in stop_zones: + data.extend(_viz_pack_int(stop_zone, default=-1)) + return data + + +def _viz_vehicle_group_record_bytes(group_key, record, coord_scale, initial_x, initial_y): + if group_key == "vehicle": + return _viz_vehicle_record_bytes(record, coord_scale, initial_x, initial_y) + if group_key == "ev_private": + return _viz_private_ev_record_bytes(record, coord_scale, initial_x, initial_y) + if group_key in ("ev_occupied", "ev_relocation", "ev_charging"): + return _viz_etaxi_record_bytes(record, coord_scale, initial_x, initial_y) + if group_key == "bus": + return _viz_bus_record_bytes(record, coord_scale, initial_x, initial_y) + return bytearray() + +def _viz_record_int(record, *names, default=0): + return _viz_int(_viz_first(record, *names, default=default), default=default) + + +def _viz_record_float(record, *names, default=0.0): + return _viz_float(_viz_first(record, *names, default=default), default=default) + + +def _viz_link_record_bytes(record, road_id_index): + road_id = _viz_first(record, "ID", "roadID", "roadId", "originID", "origID", "orig_id", default=None) + index = None if road_id is None else road_id_index.get(str(road_id)) + if index is None: + index = _viz_first(record, "roadIndex", "road_index", default=None) + if index is None: + return None + + data = bytearray() + data.extend(_viz_pack_int(index)) + data.extend(_viz_pack_int(_viz_record_int(record, "num_veh", "nVehicles", "count"))) + data.extend(_viz_pack_float(_viz_record_float(record, "speed", "avgSpeed", "meanSpeed"))) + data.extend(_viz_pack_int(_viz_record_int(record, "flow", "totalFlow"))) + data.extend(_viz_pack_float(_viz_record_float(record, "energy", "energy_consumed", "totalEnergy"))) + data.extend(_viz_pack_int(_viz_record_int(record, "parkingCapacity", "parking_capacity"))) + data.extend(_viz_pack_int(_viz_record_int(record, "parkedNum", "parked_num"))) + return data + + +def _viz_zone_record_bytes(record, coord_scale, initial_x, initial_y): + x = _viz_first(record, "x", "lon", "longitude", default=None) + y = _viz_first(record, "y", "lat", "latitude", default=None) + if x is None or y is None: + return None + + data = bytearray() + data.extend(_viz_pack_int(_viz_record_int(record, "ID", "id", "zoneID", "zoneId", default=-1), default=-1)) + data.extend(_viz_pack_int(_viz_scaled_coord(x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(y, initial_y, coord_scale))) + data.extend(_viz_pack_int(_viz_record_int(record, "zoneType", "z_type"))) + data.extend(_viz_pack_int(_viz_record_int(record, "capacity"))) + data.extend(_viz_pack_int(_viz_record_int(record, "vehicleStock", "veh_stock"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiRequest", "taxi_demand"))) + data.extend(_viz_pack_int(_viz_record_int(record, "busRequest", "bus_demand"))) + data.extend(_viz_pack_int(_viz_record_int(record, "generatedTaxi"))) + data.extend(_viz_pack_int(_viz_record_int(record, "generatedBus"))) + data.extend(_viz_pack_int(_viz_record_int(record, "generatedPrivateEV"))) + data.extend(_viz_pack_int(_viz_record_int(record, "generatedPrivateGV"))) + data.extend(_viz_pack_int(_viz_record_int(record, "arrivedPrivateEV"))) + data.extend(_viz_pack_int(_viz_record_int(record, "arrivedPrivateGV"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiPickup", "pickupTaxiRequests"))) + data.extend(_viz_pack_int(_viz_record_int(record, "busPickup", "pickupBusRequests"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiServed", "dropoffTaxiRequests", "taxiDropoffRequests"))) + data.extend(_viz_pack_int(_viz_record_int(record, "busServed", "dropoffBusRequests", "busDropoffRequests"))) + data.extend(_viz_pack_int(_viz_record_int(record, "leftTaxiRequests"))) + data.extend(_viz_pack_int(_viz_record_int(record, "leftBusRequests"))) + data.extend(_viz_pack_int(_viz_record_int(record, "leftTaxiPassengers"))) + data.extend(_viz_pack_int(_viz_record_int(record, "leftBusPassengers"))) + data.extend(_viz_pack_int(_viz_record_int(record, "relocatedVehicles"))) + data.extend(_viz_pack_int(_viz_record_int(record, "futureSupply"))) + data.extend(_viz_pack_float(_viz_record_float(record, "futureDemand"))) + data.extend(_viz_pack_float(_viz_record_float(record, "vehicleSurplus"))) + data.extend(_viz_pack_float(_viz_record_float(record, "vehicleDeficiency"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiServedWait", "taxiDropoffWait"))) + data.extend(_viz_pack_int(_viz_record_int(record, "busServedWait", "busDropoffWait"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiLeftWait"))) + data.extend(_viz_pack_int(_viz_record_int(record, "busLeftWait"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiParkingTime"))) + data.extend(_viz_pack_int(_viz_record_int(record, "taxiCruisingTime"))) + return data + + +def _viz_charging_station_record_bytes(record, coord_scale, initial_x, initial_y): + x = _viz_first(record, "x", "lon", "longitude", default=None) + y = _viz_first(record, "y", "lat", "latitude", default=None) + if x is None or y is None: + return None + + data = bytearray() + data.extend(_viz_pack_int(_viz_record_int(record, "ID", "id", "stationID", "stationId", default=-1), default=-1)) + data.extend(_viz_pack_int(_viz_scaled_coord(x, initial_x, coord_scale))) + data.extend(_viz_pack_int(_viz_scaled_coord(y, initial_y, coord_scale))) + data.extend(_viz_pack_int(_viz_record_int(record, "queueL2", "queue_l2"))) + data.extend(_viz_pack_int(_viz_record_int(record, "queueL3", "queue_dcfc"))) + data.extend(_viz_pack_int(_viz_record_int(record, "queueBus", "queue_bus"))) + data.extend(_viz_pack_int(_viz_record_int(record, "chargingL2", "charging_l2"))) + data.extend(_viz_pack_int(_viz_record_int(record, "chargingL3", "charging_dcfc"))) + data.extend(_viz_pack_int(_viz_record_int(record, "chargingBus", "charging_bus"))) + data.extend(_viz_pack_int(_viz_record_int(record, "freeL2", "num_available_l2"))) + data.extend(_viz_pack_int(_viz_record_int(record, "freeL3", "num_available_dcfc"))) + data.extend(_viz_pack_int(_viz_record_int(record, "freeBus", "num_available_bus"))) + data.extend(_viz_pack_int(_viz_record_int(record, "numL2", "l2_charger"))) + data.extend(_viz_pack_int(_viz_record_int(record, "numL3", "dcfc_charger"))) + data.extend(_viz_pack_int(_viz_record_int(record, "numBus", "bus_charger"))) + data.extend(_viz_pack_int(_viz_record_int(record, "chargedCar"))) + data.extend(_viz_pack_int(_viz_record_int(record, "chargedBus"))) + data.extend(_viz_pack_float(_viz_record_float(record, "priceL2", "l2_price"))) + data.extend(_viz_pack_float(_viz_record_float(record, "priceL3", "dcfc_price"))) + data.extend(_viz_pack_float(_viz_record_float(record, "waitingTimeL2"))) + data.extend(_viz_pack_float(_viz_record_float(record, "waitingTimeL3"))) + data.extend(_viz_pack_int(_viz_record_int(record, "active", default=1), default=1)) + return data + + +def _viz_sparse_group_bytes(records, encode_record, removed_ids=None): + encoded_records = [] + for record in records or []: + if not isinstance(record, dict): + continue + encoded = encode_record(record) + if encoded is not None: + encoded_records.append(encoded) + + data = bytearray() + data.extend(_viz_pack_int(len(encoded_records))) + for encoded in encoded_records: + data.extend(encoded) + + valid_removed_ids = [item for item in (removed_ids or []) if item is not None] + data.extend(_viz_pack_int(len(valid_removed_ids))) + for removed_id in valid_removed_ids: + data.extend(_viz_pack_int(removed_id)) + return data, len(encoded_records), len(valid_removed_ids) + + +def _viz_link_and_facility_sections(link_records, road_id_index, zone_records, + charging_station_records, coord_scale, + initial_x, initial_y, removed_zone_ids=None, + removed_charging_station_ids=None): + road_id_index = road_id_index or {} + link_payloads = [] + for record in link_records or []: + if not isinstance(record, dict): + continue + payload = _viz_link_record_bytes(record, road_id_index) + if payload is not None: + link_payloads.append(payload) + + data = bytearray() + data.extend(_viz_pack_int(len(link_payloads))) + for payload in link_payloads: + data.extend(payload) + + zone_data, zone_count, _ = _viz_sparse_group_bytes( + zone_records, + lambda record: _viz_zone_record_bytes(record, coord_scale, initial_x, initial_y), + removed_zone_ids, + ) + charging_station_data, charging_station_count, _ = _viz_sparse_group_bytes( + charging_station_records, + lambda record: _viz_charging_station_record_bytes(record, coord_scale, initial_x, initial_y), + removed_charging_station_ids, + ) + data.extend(zone_data) + data.extend(charging_station_data) + return data, len(link_payloads), zone_count, charging_station_count + + +def _viz_chunk(records, tick, coord_scale, initial_x, initial_y, tick_interval, + link_snapshot_interval, link_records=None, road_id_index=None, + zone_records=None, charging_station_records=None, + removed_zone_ids=None, removed_charging_station_ids=None): + records = [record for record in records if _viz_stream_record(record)] + vehicle_groups = _viz_group_vehicle_records(records) + records = [ + record + for group_key in _VIZ_STREAM_VEHICLE_FRAME_GROUPS + for record in vehicle_groups.get(group_key, []) + ] + private_ev_energy, etaxi_energy, ebus_energy = _viz_record_energy_by_class(records) + + data = bytearray() + data.extend(_VIZ_STREAM_MAGIC) + data.extend(_viz_pack_int(_VIZ_STREAM_VERSION)) + data.extend(_viz_pack_int(coord_scale)) + data.extend(_viz_pack_double(initial_x)) + data.extend(_viz_pack_double(initial_y)) + data.extend(_viz_pack_int(tick_interval)) + data.extend(_viz_pack_int(link_snapshot_interval)) + + data.extend(_viz_pack_int(tick)) + data.extend(_viz_pack_int(_viz_sum(records, "matchedRequests", "taxiMatchedRequests", "busMatchedRequests"))) + data.extend(_viz_pack_int(_viz_sum(records, "matchedPassengers", "matchedTaxiPassengers", "matchedBusPassengers"))) + data.extend(_viz_pack_int(_viz_sum(records, "pickupRequests", "pickupTaxiRequests", "pickupBusRequests"))) + data.extend(_viz_pack_int(_viz_sum(records, "pickupPassengers", "pickupTaxiPassengers", "pickupBusPassengers"))) + data.extend(_viz_pack_int(_viz_sum(records, "dropoffRequests", "dropoffTaxiRequests", "dropoffBusRequests"))) + data.extend(_viz_pack_int(_viz_sum(records, "dropoffPassengers", "dropoffTaxiPassengers", "dropoffBusPassengers"))) + data.extend(_viz_pack_int(_viz_sum(records, "leftRequests", "leftTaxiRequests", "leftBusRequests"))) + data.extend(_viz_pack_int(_viz_sum(records, "leftPassengers", "leftTaxiPassengers", "leftBusPassengers"))) + data.extend(_viz_pack_float(private_ev_energy + etaxi_energy + ebus_energy)) + data.extend(_viz_pack_int(len(records))) + data.extend(_viz_pack_float(_viz_mean_speed(records))) + data.extend(_viz_pack_float(private_ev_energy)) + data.extend(_viz_pack_float(etaxi_energy)) + data.extend(_viz_pack_float(ebus_energy)) + + for group_key in _VIZ_STREAM_VEHICLE_FRAME_GROUPS: + group_records = vehicle_groups.get(group_key, []) + data.extend(_viz_pack_int(len(group_records))) + for record in group_records: + data.extend(_viz_vehicle_group_record_bytes( + group_key, + record, + coord_scale, + initial_x, + initial_y, + )) + + sections, link_count, zone_count, charging_station_count = _viz_link_and_facility_sections( + link_records, + road_id_index, + zone_records, + charging_station_records, + coord_scale, + initial_x, + initial_y, + removed_zone_ids=removed_zone_ids, + removed_charging_station_ids=removed_charging_station_ids, + ) + data.extend(sections) + return bytes(data), len(records), link_count, zone_count, charging_station_count + +""" +Implementation of the remote data client + +A client directly communicates with a specific METSR-SIM server. + +Acknowledgement: Eric Vin for helping with the revision of the code +""" + +# 2. listerize the query and control function by adding a for loop (is list, go for list, otherwise make it a list with one element) class METSRClient: + SENSOR_DSRC = VEHICLE_SENSOR_DSRC + SENSOR_CV2X = VEHICLE_SENSOR_CV2X + SENSOR_MOBILE_DEVICE = VEHICLE_SENSOR_MOBILE_DEVICE + VEHICLE_SENSOR_TYPES = VEHICLE_SENSOR_TYPES - def __init__(self, host, port, sim_folder = None, manager = None, - max_connection_attempts = 5, timeout = 30, verbose = False): + def __init__( + self, + host, + port, + sim_folder = None, + manager = None, + max_connection_attempts = 5, + timeout = 30, + verbose = False, + connection_retry_interval = 0.5, + connection_open_timeout = 1, + max_connection_wait = None, + config_json = None, + run_config = None, + config = None, + sim_index = 0): super().__init__() # Websocket config @@ -18,6 +744,17 @@ def __init__(self, host, port, sim_folder = None, manager = None, self.port = port self.uri = f"ws://{host}:{port}" + self.config_json = _normalize_config_json_path(config_json or run_config) + self.config_signature = _config_signature_from_path(self.config_json) + self.config = config + self.sim_index = int(sim_index) + self._connection_settings = { + "max_connection_attempts": max_connection_attempts, + "connection_retry_interval": connection_retry_interval, + "connection_open_timeout": connection_open_timeout, + "max_connection_wait": max_connection_wait, + } + self.sim_folder = sim_folder # this is required for open the visualization server self.state = "connecting" self.timeout = timeout # time out for resending the same message if no response @@ -30,66 +767,237 @@ def __init__(self, host, port, sim_folder = None, manager = None, # visualization server and event self.viz_server = None self.viz_event = None + self.viz_port = None + + self.viz_stream_server = None + self.viz_stream_servers = [] + self.viz_stream_thread = None + self.viz_stream_threads = [] + self.viz_stream_stop_event = None + self.viz_stream_host = None + self.viz_stream_port = None + self.viz_stream_manifest = None + self.viz_stream_options = None + self.viz_stream_start_kwargs = None + self.viz_stream_clients = [] + self.viz_stream_lock = threading.Lock() + self.viz_stream_chunk_counter = 0 + self.viz_stream_last_tick = None + self.viz_stream_last_active_road_ids = set() + self.offline_viz_start_kwargs = None # Track the tick of the corresponding simulator self.current_tick = None # Establish connection + connection_start = time.time() + max_connection_wait = ( + max_connection_wait + if max_connection_wait is not None + else max_connection_attempts * 10 + ) failed_attempts = 0 while True: try: - self.ws = connect(self.uri) + self.ws = connect( + self.uri, + max_size = 10 * 1024 * 1024, + ping_interval = None, + ping_timeout = None, + open_timeout = connection_open_timeout, + ) self.state = "connected" if self.verbose: print(f"Connected to {self.uri}") break - except ConnectionRefusedError: - print(f"Attempt to connect to {self.uri} failed. " - f"Waiting for 10 seconds before trying again... " - f"({max_connection_attempts - failed_attempts} attempts remaining)") + except OSError as exc: failed_attempts += 1 - if failed_attempts >= max_connection_attempts: + elapsed = time.time() - connection_start + if elapsed >= max_connection_wait: self.state = "failed" - raise RuntimeError("Could not connect to METS-R Sim") - time.sleep(10) + raise RuntimeError( + f"Could not connect to METS-R SIM at {self.uri} " + f"after {elapsed:.1f} seconds and {failed_attempts} attempts" + ) from exc + + sleep_seconds = min(connection_retry_interval, max_connection_wait - elapsed) + if self.verbose: + print( + f"Attempt {failed_attempts} to connect to {self.uri} failed. " + f"Retrying in {sleep_seconds:.1f} seconds..." + ) + time.sleep(sleep_seconds) + + + print("Connection established!") # Ensure server is initialized by waiting to receive an initial packet # (could be ANS_ready or a heartbeat) - self.receive_msg(ignore_heartbeats=False) + self.receive_msg(ignore_heartbeats=False, return_ready=True) self.lock = threading.Lock() + register_metsr_client(self) + + def _connect( + self, + max_connection_attempts = 5, + connection_retry_interval = 0.5, + connection_open_timeout = 1, + max_connection_wait = None): + connection_start = time.time() + max_connection_wait = ( + max_connection_wait + if max_connection_wait is not None + else max_connection_attempts * 10 + ) + failed_attempts = 0 + while True: + try: + self.ws = connect( + self.uri, + max_size = 10 * 1024 * 1024, + ping_interval = None, + ping_timeout = None, + open_timeout = connection_open_timeout, + ) + self.state = "connected" + if self.verbose: + print(f"Connected to {self.uri}") + break + except OSError as exc: + failed_attempts += 1 + elapsed = time.time() - connection_start + if elapsed >= max_connection_wait: + self.state = "failed" + raise RuntimeError( + f"Could not connect to METS-R SIM at {self.uri} " + f"after {elapsed:.1f} seconds and {failed_attempts} attempts" + ) from exc + + sleep_seconds = min(connection_retry_interval, max_connection_wait - elapsed) + if self.verbose: + print( + f"Attempt {failed_attempts} to connect to {self.uri} failed. " + f"Retrying in {sleep_seconds:.1f} seconds..." + ) + time.sleep(sleep_seconds) + + print("Connection established!") + self.receive_msg(ignore_heartbeats=False, return_ready=True) + + def _fatal_log_error(self): + if not self.sim_folder: + return None + + log_path = os.path.join(self.sim_folder, "logs", "mets_r.log") + try: + with open(log_path, "rb") as log_file: + log_file.seek(0, os.SEEK_END) + size = log_file.tell() + log_file.seek(max(0, size - 65536)) + tail = log_file.read().decode("utf-8", errors="replace") + except OSError: + return None + + if "FATAL JVM ERROR" not in tail and "Unresolved compilation problem" not in tail: + return None + + lines = tail.splitlines() + marker_index = 0 + for index, line in enumerate(lines): + if "FATAL JVM ERROR" in line or "Unresolved compilation problem" in line: + marker_index = index + + excerpt = "\n".join(lines[marker_index:marker_index + 8]) + guidance = "" + if "maxWaitingTime cannot be resolved or is not a field" in tail: + guidance = ( + "\n\nThis matches a METS-R_SIM build issue in the maxWaitingTime " + "Control API change. Add an Integer maxWaitingTime field to " + "MessageClass.ZoneIDOrigDestRouteNameNum and rebuild METS-R_SIM." + ) + return f"METS-R simulator reported a fatal JVM error in {log_path}:\n{excerpt}{guidance}" def send_msg(self, msg): if self.verbose: self._logMessage("SENT", msg) self.ws.send(json.dumps(msg)) - def receive_msg(self, ignore_heartbeats, waiting_forever = True): + def _update_current_tick_from_message(self, msg): + msg_type = msg.get("TYPE") + if msg_type not in {"STEP", "ANS_tick", "CTRL_load", "CTRL_reset"}: + return False + tick_value = msg.get("TICK", msg.get("tick")) + if tick_value is None: + return False + server_tick = int(tick_value) + if msg_type in {"CTRL_load", "CTRL_reset"}: + self.current_tick = server_tick + return True + if self.current_tick is None or server_tick > int(self.current_tick): + self.current_tick = server_tick + return True + return False + + def receive_msg( + self, + ignore_heartbeats, + waiting_forever = True, + return_ready = False, + print_timeout = True, + timeout = None): + timeout = self.timeout if timeout is None else timeout start_time = time.time() while True: - raw_msg = self.ws.recv(timeout = self.timeout) + fatal_error = self._fatal_log_error() + if fatal_error: + raise RuntimeError(fatal_error) + try: + raw_msg = self.ws.recv(timeout = min(5, max(0.1, timeout))) - # Decode the json string - msg = json.loads(str(raw_msg)) + # Decode the json string + msg = json.loads(str(raw_msg)) - if self.verbose: - self._logMessage("RECEIVED", msg) - - # EVERY decoded msg must have a TYPE field - assert "TYPE" in msg.keys(), "No type field in received message" - assert msg["TYPE"].split("_")[0] in {"STEP", "ANS", "CTRL", "ATK"}, "Uknown message type: " + str(msg["TYPE"]) + if self.verbose: + self._logMessage("RECEIVED", msg) - # Allow tick() - if msg["TYPE"] in {"ANS_ready"}: - self.current_tick = 0 - continue + # EVERY decoded msg must have a TYPE field + if "TYPE" not in msg.keys(): + raise RuntimeError("No type field in received message") + if msg["TYPE"].split("_")[0] not in {"STEP", "ANS", "CTRL", "ATK"}: + raise RuntimeError("Uknown message type: " + str(msg["TYPE"])) + + self._update_current_tick_from_message(msg) + + # Allow tick() + if msg["TYPE"] in {"ANS_ready"}: + self.current_tick = 0 + if return_ready: + return msg + continue + + # Allow error message + if msg["TYPE"] in {"ANS_error"}: + print(f"Error: {msg['MSG']}") + return None - # Return decoded message, if it's not an ignored heartbeat - if not ignore_heartbeats or msg["TYPE"] != "STEP": - return msg + # Return decoded message, if it's not an ignored heartbeat + if not ignore_heartbeats or msg["TYPE"] != "STEP": + return msg + except KeyboardInterrupt: + raise + except TimeoutError: + pass + except Exception as exc: + fatal_error = self._fatal_log_error() + if fatal_error: + raise RuntimeError(fatal_error) from exc + self.state = "failed" + raise RuntimeError(f"Error while receiving message from METS-R SIM at {self.uri}: {exc}") from exc - if time.time() - start_time > self.timeout and not waiting_forever: - print("Timeout while waiting for message.") + if time.time() - start_time > timeout and not waiting_forever: + if print_timeout: + print("Timeout while waiting for message.") return None def send_receive_msg(self, msg, ignore_heartbeats, max_attempts=5): @@ -101,39 +1009,280 @@ def send_receive_msg(self, msg, ignore_heartbeats, max_attempts=5): num_attempts += 1 self.send_msg(msg) if(max_attempts > 0): - res = self.receive_msg(ignore_heartbeats=ignore_heartbeats, waiting_forever=False) + res = self.receive_msg( + ignore_heartbeats=ignore_heartbeats, + waiting_forever=False, + print_timeout=False, + ) if num_attempts >= max_attempts: - print(f"Failed to receive response after {max_attempts} attempts") - break + raise TimeoutError( + f"No response received for '{msg.get('TYPE', 'unknown')}' " + f"after {max_attempts} attempts; last STEP tick seen was {self.current_tick}" + ) else: res = self.receive_msg(ignore_heartbeats=ignore_heartbeats, waiting_forever=True) except KeyboardInterrupt: print("\nKeyboardInterrupt detected. Stopping the current operation but keeping the server active.") - # Reset state or resources if necessary to allow future operations return None # Return None to indicate the operation was interrupted - except Exception as e: - print(f"An unexpected error occurred: {e}") - # Optional: Handle other types of exceptions if needed return res - def tick(self, step_num = 1, wait_forever = False): - assert self.current_tick is not None, "self.current_tick is None. Reset should be called first" - msg = {"TYPE": "STEP", "TICK": self.current_tick, "NUM": step_num} - self.send_msg(msg) + def _apply_tick_response(self, res): + if res.get("TYPE") != "ANS_tick": + raise RuntimeError(f"Expected ANS_tick, received {res.get('TYPE')}") + if res.get("CODE", "OK") != "OK": + raise RuntimeError(f"METS-R SIM rejected QUERY_tick: {res}") + if "TICK" not in res: + raise RuntimeError(f"METS-R SIM QUERY_tick response is missing TICK: {res}") + self._update_current_tick_from_message(res) + return int(res["TICK"]) - while True: - # Move through messages until we get to an up to date heartbeat - res = self.receive_msg(ignore_heartbeats=False, waiting_forever=wait_forever) + def _query_tick_locked(self, timeout = None): + """Query server tick while self.lock is already held.""" + before_tick = self.current_tick + self.send_msg({"TYPE": "QUERY_tick"}) + res = self.receive_msg( + ignore_heartbeats=True, + waiting_forever=False, + print_timeout=False, + timeout=min(5, max(0.1, self.timeout if timeout is None else timeout)), + ) + if res is None: + if before_tick != self.current_tick: + return int(self.current_tick) + return None + return self._apply_tick_response(res) - assert res["TYPE"] == "STEP", res["TYPE"] - if res["TICK"] == self.current_tick + step_num: - break + def query_tick(self): + """Return the current simulation tick reported by METS-R SIM.""" + res = self.send_receive_msg({"TYPE": "QUERY_tick"}, ignore_heartbeats=True) + return self._apply_tick_response(res) + + def query_tick_status(self): + """Return server-side stepping status. + + Recent METS-R SIM versions include active-road stepping fields such as + ``activeRoadStepping`` and ``activeRoadCount`` when that scheduler mode + is enabled. + """ + res = self.send_receive_msg({"TYPE": "QUERY_stepStatus"}, ignore_heartbeats=True) + return res + + def tick( + self, + step_num = 1, + wait_forever = False, + retry_interval = None, + max_wait_seconds = None, + poll_timeout = 5, + max_stalled_seconds = None): + """Advance the simulator and wait until the requested tick is reached. - self.current_tick = res["TICK"] + ``wait_forever`` keeps tolerating slow steps, but it should not hide a + dead server or a permanently stalled tick. Progress is therefore based + on the server tick actually increasing, not just on receiving a reply. + """ + assert self.current_tick is not None, "self.current_tick is None. Maybe there is another METS-R SIM instance unclosed." + + step_num = int(step_num) + if step_num < 1: + raise ValueError("step_num must be a positive integer") + + poll_timeout = min(max(0.1, float(poll_timeout)), max(0.1, float(self.timeout))) + + with self.lock: + start_tick = int(self.current_tick) + target_tick = start_tick + step_num + overall_start = time.time() + last_progress_time = overall_start + last_send_time = overall_start + + if retry_interval is None: + retry_interval = min(float(self.timeout), 30.0) + + if not wait_forever and max_wait_seconds is None: + max_wait_seconds = self.timeout + + if wait_forever and max_stalled_seconds is None: + max_stalled_seconds = max(60.0, min(float(self.timeout), 300.0)) + + def send_step_request(): + nonlocal last_send_time + remaining_steps = target_tick - int(self.current_tick) + if remaining_steps <= 0: + return + msg = {"TYPE": "STEP", "TICK": int(self.current_tick), "NUM": remaining_steps} + self.send_msg(msg) + last_send_time = time.time() + + send_step_request() + + while True: + if int(self.current_tick) >= target_tick: + break + + if max_wait_seconds is not None and time.time() - overall_start > max_wait_seconds: + raise TimeoutError( + f"Timed out waiting for METS-R SIM to reach tick {target_tick}; " + f"last received tick was {self.current_tick}" + ) + + # Always use a bounded receive here so wait_forever=True can still retry + # the STEP request instead of blocking forever on a missed heartbeat. + tick_before_receive = int(self.current_tick) + res = self.receive_msg( + ignore_heartbeats=False, + waiting_forever=False, + print_timeout=False, + timeout=poll_timeout, + ) + now = time.time() + if int(self.current_tick) > tick_before_receive: + last_progress_time = now + + if int(self.current_tick) >= target_tick: + break + + if ( + max_stalled_seconds is not None + and now - last_progress_time > max_stalled_seconds): + raise TimeoutError( + f"METS-R SIM made no tick progress for " + f"{now - last_progress_time:.1f} seconds while waiting for " + f"tick {target_tick}; last server tick was {self.current_tick}. " + "The simulator or WebSocket server is likely stalled." + ) + + if res is None: + tick_before_query = int(self.current_tick) + synced_tick = self._query_tick_locked() + now = time.time() + if synced_tick is not None and int(self.current_tick) > tick_before_query: + last_progress_time = now + + if int(self.current_tick) >= target_tick: + break + + if not wait_forever: + raise TimeoutError( + f"Timed out waiting for METS-R SIM to reach tick {target_tick}; " + f"last received tick was {self.current_tick}" + ) + + should_retry_step = ( + retry_interval is None + or int(self.current_tick) > tick_before_query + or now - max(last_send_time, last_progress_time) >= retry_interval + ) + if should_retry_step: + if self.verbose: + print( + f"Still waiting for STEP tick {target_tick}; " + f"last received tick was {self.current_tick}. Retrying STEP request." + ) + send_step_request() + continue + + if res["TYPE"] == "ANS_tick": + continue + + if res["TYPE"] != "STEP": + raise RuntimeError(f"Expected STEP while ticking, received {res['TYPE']}") + + if res.get("CODE") == "KO": + tick_before_query = int(self.current_tick) + synced_tick = self._query_tick_locked() + now = time.time() + if synced_tick is not None and int(self.current_tick) > tick_before_query: + last_progress_time = now + if int(self.current_tick) >= target_tick: + break + if not wait_forever: + raise RuntimeError( + f"METS-R SIM rejected STEP request for tick {target_tick}; " + f"server reported tick {self.current_tick}" + ) + send_step_request() + continue + + step_tick = int(res["TICK"]) + if step_tick < int(self.current_tick): + continue + + if step_tick > int(self.current_tick): + self.current_tick = step_tick + last_progress_time = now + + if step_tick >= target_tick: + break # QUERY: inspect the state of the simulator # By default query public vehicles def query_vehicle(self, id = None, private_veh = False, transform_coords = False): + """Query the full kinematic state of one or more vehicles. + + Without ``id`` the server returns two lists:: + + { + 'public_vids': [...], # IDs of public vehicles (taxis + buses) + 'private_vids': [...], # IDs of private vehicles (EV / GV) + 'TYPE': 'ANS_vehicle' + } + + With ``id`` each matched vehicle produces:: + + { + 'ID': internal vehicle ID, + 'v_type': vehicle class: + 0 = GV (private gasoline vehicle) + 1 = ETAXI + 2 = EBUS + 3 = EV (private electric vehicle), + 'state': trip state (see Vehicle.java): + -1 = NONE_OF_THE_ABOVE (not on network / idle) + 0 = PARKING + 1 = OCCUPIED_TRIP + 2 = INACCESSIBLE_RELOCATION_TRIP + 3 = BUS_TRIP + 4 = CHARGING_TRIP + 5 = CRUISING_TRIP + 6 = PICKUP_TRIP + 7 = ACCESSIBLE_RELOCATION_TRIP + 8 = PRIVATE_TRIP, + 'x': SIM/internal x coordinate; projected/local + SUMO x if transform_coords=True, + 'y': SIM/internal y coordinate; projected/local + SUMO y if transform_coords=True, + 'z': elevation, + 'bearing': heading in degrees (0 = north, clockwise), + 'acc': current longitudinal acceleration (m/s²), + 'speed': current speed (m/s), + 'originZoneID': current trip origin zone ID, + 'destZoneID': current trip destination zone ID, + 'originRoadID': original road ID for the trip origin road, + 'destRoadID': original road ID for the trip destination road, + 'battery': EV battery energy (kWh, EV classes only), + 'totalEnergyConsumed': EV cumulative energy use (kWh), + 'roadID': SUMO road ID of the road the vehicle is on + (only present when vehicle is on a road), + 'lane': lane index on that road (present when on a lane), + 'dist': distance to the next downstream junction (m) + (present when on a lane), + 'currentParkingRoad': internal road ID where the vehicle is + parked or has reserved parking, when set + } + + Parameters + ---------- + id : int | list[int] | None + Vehicle ID(s) to query. Pass ``None`` to get all fleet IDs. + private_veh : bool | list[bool] + ``True`` for private vehicles (EV/GV), ``False`` for public + (taxi/bus). Must match the length of ``id`` if both are lists. + transform_coords : bool | list[bool] + ``False`` returns SIM/internal coordinates (WGS-84 lon/lat for + georeferenced networks). ``True`` returns projected/local SUMO + coordinates, which can be passed to the CARLA coordinate helpers. + """ msg = {"TYPE": "QUERY_vehicle"} if id is not None: msg["DATA"] = [] @@ -150,8 +1299,60 @@ def query_vehicle(self, id = None, private_veh = False, transform_coords = False assert res["TYPE"] == "ANS_vehicle", res["TYPE"] return res - # query taxi + def query_on_road_vehicles(self, roadID=None): + """Query IDs for vehicles currently on active roads, optionally by road.""" + msg = {"TYPE": "QUERY_onRoadVehicles"} + if roadID is not None: + msg["DATA"] = roadID + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] in ("ANS_onRoadVehicles", "ANS_onRoadVehicle"), res["TYPE"] + return res + + def query_active_roads(self): + """Query compact records for roads currently in the active-road index.""" + msg = {"TYPE": "QUERY_activeRoads"} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] in ("ANS_activeRoads", "ANS_activeRoad"), res["TYPE"] + return res + def query_taxi(self, id = None): + """Query the state of one or more e-taxis. + + Without ``id`` returns a fleet-level summary:: + + {'id_list': [...], 'TYPE': 'ANS_taxi'} + + With ``id`` each matched taxi produces:: + + { + 'ID': internal vehicle ID, + 'state': operational state: + 0 = PARKING – parked at a zone, waiting for a request + 1 = OCCUPIED_TRIP – carrying passenger(s) to drop-off + 2 = INACCESSIBLE_RELOCATION_TRIP – repositioning, not assignable + 4 = CHARGING_TRIP – heading to a charging station + 5 = CRUISING_TRIP – cruising without a passenger + 6 = PICKUP_TRIP – en-route to pick up a passenger + 7 = ACCESSIBLE_RELOCATION_TRIP – repositioning but still assignable + -1 = NONE_OF_THE_ABOVE – not on network, idle, or in an unrecognized state + 'x': SIM/internal x coordinate, + 'y': SIM/internal y coordinate, + 'z': elevation, + 'origin': current origin zone ID, + 'dest': current destination zone ID + (negative → heading to a charging station), + 'pass_num': number of passengers currently on board, + 'remainingDistance': remaining active-trip distance in meters, + 'remainingDistanceMiles': remaining active-trip distance in miles, + 'currentParkingRoad': internal road ID where the taxi is + parked or has reserved parking, when set + } + + Parameters + ---------- + id : int | list[int] | None + Taxi ID(s) to query. Pass ``None`` to get the full fleet ID list. + """ my_msg = {"TYPE": "QUERY_taxi"} if id is not None: my_msg['DATA'] = [] @@ -163,9 +1364,80 @@ def query_taxi(self, id = None): res = self.send_receive_msg(my_msg, ignore_heartbeats=True) assert res["TYPE"] == "ANS_taxi", res["TYPE"] return res - - # query bus + + def query_available_taxis(self, zoneID = None): + """Query taxis currently available for dispatch. + + Without ``zoneID`` returns available taxis across all zones. With a + zone ID, returns only the available-taxi pool for that zone. + + Each entry in ``DATA`` includes the taxi ID, the pool zone ID, state, + position, battery level, battery feasibility, and passenger count. + """ + msg = {"TYPE": "QUERY_availableTaxis"} + if zoneID is not None: + msg["DATA"] = zoneID + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_availableTaxis", res["TYPE"] + return res + + def query_almost_finished_taxis( + self, + distance_threshold_miles = None, + distance_threshold_meters = None, + zoneID = None): + """Query occupied taxis expected to become available soon. + + The simulator filters to occupied taxis with one onboard request, no + queued pickup requests, and remaining trip distance below the supplied + threshold. A ``zoneID`` filter selects the request destination zone. + """ + msg = {"TYPE": "QUERY_almostFinishedTaxis"} + params = {} + if distance_threshold_meters is not None: + params["distanceThresholdMeters"] = distance_threshold_meters + elif distance_threshold_miles is not None: + params["distanceThresholdMiles"] = distance_threshold_miles + if zoneID is not None: + params["zoneID"] = zoneID + if params: + msg["DATA"] = params + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_almostFinishedTaxis", res["TYPE"] + return res + def query_bus(self, id = None): + """Query the state of one or more electric buses. + + Without ``id`` returns:: + + {'id_list': [...], 'TYPE': 'ANS_bus'} + + With ``id`` each matched bus produces:: + + { + 'ID': internal vehicle ID, + 'route': name of the current bus route + (empty string if the bus is idle), + 'stopZones': zone IDs in the assigned stop sequence, + 'current_stop': index of the last completed stop + in the route's stop list (0-based), + 'pass_num': number of passengers currently on board, + 'battery_state': remaining battery energy (kWh) + } + + Notes + ----- + Bus trip states are the same integer codes as other vehicles + (see :meth:`query_vehicle`), but buses only use: + ``BUS_TRIP (3)``, ``CHARGING_TRIP (4)``, and + ``CHARGING_RETURN_TRIP (9)`` during normal operation. + + Parameters + ---------- + id : int | list[int] | None + Bus ID(s) to query. Pass ``None`` to get the full fleet ID list. + """ my_msg = {"TYPE": "QUERY_bus"} if id is not None: my_msg['DATA'] = [] @@ -178,8 +1450,36 @@ def query_bus(self, id = None): return res - # query road def query_road(self, id = None): + """Query static and real-time attributes of one or more roads. + + Without ``id`` returns the full road index:: + + {'id_list': [...], 'orig_id': [...], 'TYPE': 'ANS_road'} + + With ``id`` (SUMO road IDs, i.e. ``orig_id`` strings) each matched + road produces:: + + { + 'ID': SUMO original road ID, + 'r_type': road type code, + 'num_veh': current number of vehicles on the road, + 'speed_limit': posted speed limit (m/s), + 'avg_travel_time': recent mean travel time (s), + 'length': road length (m), + 'energy_consumed': cumulative energy consumed on this road (kWh), + 'down_stream_road': list of downstream road orig-IDs, + 'parking_capacity': parking capacity on this road, + 'parked_num': current parked or reserved vehicles, + 'enteringVehicleQueue': vehicle IDs waiting to enter + this road, useful for co-sim roads + } + + Parameters + ---------- + id : str | list[str] | None + SUMO road ID(s) to query. Pass ``None`` to get the full road index. + """ my_msg = {"TYPE": "QUERY_road"} if id is not None: my_msg['DATA'] = [] @@ -191,37 +1491,330 @@ def query_road(self, id = None): assert res["TYPE"] == "ANS_road", res["TYPE"] return res - # query zone - def query_zone(self, id = None): - my_msg = {"TYPE": "QUERY_zone"} + def query_entering_vehicle_queue(self, roadID = None): + """Query vehicles waiting in one or more road entering queues. + + Co-simulation roads hold this queue until the external simulator calls + :meth:`enter_road_from_queue`. Without ``roadID`` the server returns + the road index. With road IDs, each record includes ``enteringVehicleIDs`` + and detailed queue entries with visible/private IDs, internal IDs, + vehicle type, departure tick, and readiness. + """ + msg = {"TYPE": "QUERY_enteringVehicleQueue"} + if roadID is not None: + msg["DATA"] = _as_list(roadID) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_enteringVehicleQueue", res["TYPE"] + return res + + def query_cosim_entering_vehicle_queue(self): + """Query entering queues for every road currently marked as co-sim.""" + msg = {"TYPE": "QUERY_coSimEnteringVehicleQueue"} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_coSimEnteringVehicleQueue", res["TYPE"] + return res + + def query_centerline(self, id, lane_index = -1, transform_coords = False): + """Query the geometric center-line of a road or a specific lane. + + Each matched road returns:: + + { + 'ID': SUMO road ID, + 'centerline': [[x, y, z], ...] ordered coordinate list + } + + Parameters + ---------- + id : str | list[str] + SUMO road ID(s) to query. Required; cannot be ``None``. + lane_index : int | list[int] + Index of the lane whose centerline to return. + Use ``-1`` (default) to get the road's overall start/end points + instead of a per-lane polyline. + transform_coords : bool | list[bool] + ``False`` returns SIM/internal coordinates (WGS-84 lon/lat for + georeferenced networks). ``True`` returns projected/local SUMO + coordinates. + """ + my_msg = {"TYPE": "QUERY_centerLine"} if id is not None: my_msg['DATA'] = [] if not isinstance(id, list): id = [id] - for i in id: - my_msg['DATA'].append(i) + if not isinstance(lane_index, list): + lane_index = [lane_index] * len(id) + if not isinstance(transform_coords, list): + transform_coords = [transform_coords] * len(id) + for i, lane_idx, tran in zip(id, lane_index, transform_coords): + my_msg['DATA'].append({"roadID": i, "laneIndex": lane_idx, "transformCoord": tran}) + else: + raise ValueError("id cannot be None for query_centerLine") res = self.send_receive_msg(my_msg, ignore_heartbeats=True) - assert res["TYPE"] == "ANS_zone", res["TYPE"] + assert res["TYPE"] == "ANS_centerLine", res["TYPE"] return res - # query signal - def query_signal(self, id = None): - my_msg = {"TYPE": "QUERY_signal"} + def query_zone(self, id = None): + """Query demand and supply statistics for one or more traffic zones. + + Without ``id`` returns the full zone index:: + + {'id_list': [...], 'TYPE': 'ANS_zone'} + + With ``id`` each matched zone produces:: + + { + 'ID': zone ID, + 'z_type': zone type code, + 'taxi_demand': number of pending taxi requests currently + waiting in this zone, + 'bus_demand': number of pending bus requests currently + waiting in this zone, + 'veh_stock': number of available taxis parked / cruising + in this zone at this tick, + 'x': centroid x coordinate (network CRS), + 'y': centroid y coordinate (network CRS), + 'z': centroid elevation, + 'leftTaxiRequests': cumulative taxi requests that + abandoned after waiting too long, + 'leftTaxiPassengers': cumulative passengers in those + abandoned taxi requests, + 'leftBusRequests': cumulative bus requests that + abandoned after waiting too long, + 'leftBusPassengers': cumulative passengers in those + abandoned bus requests + } + + Parameters + ---------- + id : int | list[int] | None + Zone ID(s) to query. Pass ``None`` to get the full zone ID list. + """ + my_msg = {"TYPE": "QUERY_zone"} if id is not None: my_msg['DATA'] = [] if not isinstance(id, list): id = [id] for i in id: - my_msg['DATA'].append(i) + my_msg['DATA'].append(i) res = self.send_receive_msg(my_msg, ignore_heartbeats=True) - assert res["TYPE"] == "ANS_signal", res["TYPE"] + assert res["TYPE"] == "ANS_zone", res["TYPE"] return res - - # query chargingStation - def query_chargingStation(self, id = None): - my_msg = {"TYPE": "QUERY_chargingStation"} - if id is not None: - my_msg['DATA'] = [] + + def query_pending_requests(self, zoneID = None): + """Query pending taxi and bus requests. + + Without ``zoneID`` returns pending requests across all zones. With a + zone ID, returns pending requests in that zone. Request records include + ``ID`` (the request ID used by ``dispatch_taxi``), origin/destination + zones and roads, party size, waiting-time fields, and a queue status. + """ + msg = {"TYPE": "QUERY_pendingRequests"} + if zoneID is not None: + msg["DATA"] = zoneID + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_pendingRequests", res["TYPE"] + return res + + def query_request(self, reqID): + """Query one or more ride-hailing or bus request records by ID.""" + msg = {"TYPE": "QUERY_request", "DATA": []} + if not isinstance(reqID, list): + reqID = [reqID] + for rid in reqID: + msg["DATA"].append(rid) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_request", res["TYPE"] + return res + + def _infer_request_zone_id(self, reqID): + response = self.query_request(reqID) + for record in response.get("DATA", []): + zone_id = _request_zone_from_record(record) + if zone_id is not None: + return zone_id + return None + + def query_pickup_taxi_info(self, reqID=None): + """Query taxi requests that have been matched but not picked up. + + METS-R SIM added this endpoint with the cancellation API. Passing + ``reqID`` filters the result to one or more request IDs. + """ + msg = {"TYPE": "QUERY_pickupTaxiInfo"} + if reqID is not None: + msg["DATA"] = _as_list(reqID) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_pickupTaxiInfo", res["TYPE"] + return res + + def query_occupied_taxi_info(self, reqID=None): + """Query taxi requests that are already on board a taxi. + + Passing ``reqID`` filters the result to one or more request IDs. + Cancellation eligibility is reported by the simulator in the response + to :meth:`cancel_requests`. + """ + msg = {"TYPE": "QUERY_occupiedTaxiInfo"} + if reqID is not None: + msg["DATA"] = _as_list(reqID) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_occupiedTaxiInfo", res["TYPE"] + return res + + def query_signal(self, id = None): + """Query the phase state of one or more traffic signals. + + Without ``id`` returns the full signal index:: + + {'id_list': [...], 'TYPE': 'ANS_signal'} + + With ``id`` each matched signal produces:: + + { + 'ID': internal signal ID, + 'groupID': SUMO junction ID this signal belongs to, + 'state': current phase: + 0 = Green + 1 = Yellow + 2 = Red, + 'nex_state': next phase (same encoding), + 'next_update_time': simulation tick at which the phase will change, + 'phase_ticks': [green_ticks, yellow_ticks, red_ticks] + duration of each phase in simulation ticks + } + + Notes + ----- + Phase cycle order is always Green → Yellow → Red → Green. + Each tick corresponds to ``GlobalVariables.SIMULATION_STEP_SIZE`` seconds + (typically 0.5 s, so multiply ticks × 0.5 to get seconds). + + Use :meth:`query_signal_group` to map a SUMO junction ID to the list of + individual signal IDs it contains. + + Parameters + ---------- + id : int | list[int] | None + Signal ID(s) to query. Pass ``None`` to get the full signal ID list. + """ + my_msg = {"TYPE": "QUERY_signal"} + if id is not None: + my_msg['DATA'] = [] + if not isinstance(id, list): + id = [id] + for i in id: + my_msg['DATA'].append(i) + res = self.send_receive_msg(my_msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_signal", res["TYPE"] + return res + + def query_signal_group(self, id = None): + """Query the set of signal IDs that belong to each signal group (junction). + + Without ``id`` returns all group IDs:: + + {'id_list': [...], 'TYPE': 'ANS_signalGroup'} + + With ``id`` (SUMO junction / group IDs) each group produces:: + + { + 'groupID': SUMO junction ID, + 'signalIDs': list of individual signal IDs in this group + } + + Parameters + ---------- + id : str | list[str] | None + Group / junction ID(s) to query. Pass ``None`` to list all groups. + """ + my_msg = {"TYPE": "QUERY_signalGroup"} + if id is not None: + my_msg['DATA'] = [] + if not isinstance(id, list): + id = [id] + for i in id: + my_msg['DATA'].append(i) + res = self.send_receive_msg(my_msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_signalGroup", res["TYPE"] + return res + + def query_signal_between_roads(self, upstream_road, downstream_road): + """Query the signal controlling the connection between two consecutive roads. + + Each connection produces:: + + { + 'upStreamRoad': SUMO ID of the upstream road, + 'downStreamRoad': SUMO ID of the downstream road, + 'signalID': signal ID (use with :meth:`query_signal`), + 'state': current phase (0=Green, 1=Yellow, 2=Red), + 'next_state': next phase (same encoding), + 'next_update_tick': simulation tick at which the phase changes, + 'phase_ticks': [green_ticks, yellow_ticks, red_ticks], + 'junction_id': internal junction ID, + 'STATUS': 'OK' or 'KO' (road / junction not found) + } + + Parameters + ---------- + upstream_road : str | list[str] + SUMO road ID(s) of the upstream road. + downstream_road : str | list[str] + SUMO road ID(s) of the downstream road (matched pairwise). + """ + msg = {"TYPE": "QUERY_signalForConnection", "DATA": []} + if not isinstance(upstream_road, list): + upstream_road = [upstream_road] + if not isinstance(downstream_road, list): + downstream_road = [downstream_road] * len(upstream_road) + assert len(upstream_road) == len(downstream_road), "Length of upstream_road and downstream_road must be the same" + + for up_road, down_road in zip(upstream_road, downstream_road): + msg["DATA"].append({"upStreamRoad": up_road, "downStreamRoad": down_road}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_signalForConnection", res["TYPE"] + return res + + def query_chargingStation(self, id = None): + """Query the status and capacity of one or more charging stations. + + Without ``id`` returns the full station index:: + + {'id_list': [...], 'TYPE': 'ANS_chargingStation'} + + With ``id`` each matched station produces:: + + { + 'ID': internal station ID, + 'l2_charger': total number of L2 (AC) charger ports, + 'dcfc_charger': total number of DCFC (L3 / fast) charger ports, + 'l2_price': current price per kWh at L2 chargers, + 'dcfc_price': current price per kWh at DCFC chargers, + 'bus_charger': total number of bus-dedicated charger ports, + 'num_available_l2': number of L2 ports not currently occupied, + 'num_available_dcfc': number of DCFC ports not currently occupied, + 'x': station x coordinate (network CRS), + 'y': station y coordinate (network CRS), + 'z': elevation + } + + Notes + ----- + Charger type codes used internally by the server: + ``ChargingStation.L2 = 0`` (AC Level-2), + ``ChargingStation.L3 = 1`` (DC fast charging), + ``ChargingStation.BUS = 2`` (depot charger for electric buses). + + Parameters + ---------- + id : int | list[int] | None + Charging station ID(s) to query. Pass ``None`` to get the full list. + """ + my_msg = {"TYPE": "QUERY_chargingStation"} + if id is not None: + my_msg['DATA'] = [] if not isinstance(id, list): id = [id] for i in id: @@ -230,13 +1823,564 @@ def query_chargingStation(self, id = None): assert res["TYPE"] == "ANS_chargingStation", res["TYPE"] return res - # query vehicleID within the co-sim road def query_coSimVehicle(self): + """Query vehicles currently on co-simulation (CARLA-managed) roads. + + Returns all vehicles that are located on roads previously registered + with :meth:`set_cosim_road`. Each entry in ``DATA`` represents one + vehicle:: + + { + 'ID': vehicle ID + – for private vehicles (EV/GV) this is the + *external* private-vehicle ID + – for public vehicles (taxi/bus) this is the + internal simulation ID, + 'v_type': True → private vehicle (EV / GV) + False → public vehicle (taxi / bus), + 'coord_map': recent coordinate history (up to 6 entries), + each entry is [x, y, z, bearing, speed], + 'route': list of upcoming road orig-IDs in the vehicle's + current planned route + } + + Returns + ------- + dict + ``{'DATA': [...], 'TYPE': 'ANS_coSimVehicle'}`` + """ my_msg = {"TYPE": "QUERY_coSimVehicle"} res = self.send_receive_msg(my_msg, ignore_heartbeats=True) assert res["TYPE"] == "ANS_coSimVehicle", res["TYPE"] return res + def query_route(self, orig_x, orig_y, dest_x, dest_y, transform_coords = False): + """Query the shortest path between two coordinate pairs. + + Each query pair returns:: + + {'road_list': [, ...]} + + or ``'KO'`` if no path was found. + + Parameters + ---------- + orig_x, orig_y : float | list[float] + Origin coordinate(s) in the SIM/internal frame, or projected/local + SUMO coordinates when ``transform_coords=True``. + dest_x, dest_y : float | list[float] + Destination coordinate(s) (matched pairwise with origin). + transform_coords : bool | list[bool] + ``True`` if the input coordinates are projected/local SUMO + coordinates and should be transformed into the SIM/internal frame + before routing. + """ + msg = {"TYPE": "QUERY_routesBwCoords", "DATA": []} + if not isinstance(orig_x, list): + orig_x = [orig_x] + orig_y = [orig_y] + dest_x = [dest_x] + dest_y = [dest_y] + + if not isinstance(transform_coords, list): + transform_coords = [transform_coords] * len(orig_x) + + assert len(orig_x) == len(orig_y) == len(dest_x) == len(dest_y), "Length of orig_x, orig_y, dest_x, and dest_y must be the same" + + for orig_x, orig_y, dest_x, dest_y, transform_coord in zip(orig_x, orig_y, dest_x, dest_y, transform_coords): + msg["DATA"].append({"origX": orig_x, "origY": orig_y, "destX": dest_x, "destY": dest_y, "transformCoord": transform_coord}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + + assert res["TYPE"] == "ANS_routesBwCoords", res["TYPE"] + return res + + def query_k_routes(self, orig_x, orig_y, dest_x, dest_y, k, transform_coords = False): + """Query the *k* shortest paths between two coordinate pairs. + + Each query pair returns:: + + {'road_lists': [[, ...], ...]} # k routes + + or ``'KO'`` if no path was found. + + Parameters + ---------- + orig_x, orig_y : float | list[float] + Origin coordinate(s). + dest_x, dest_y : float | list[float] + Destination coordinate(s). + k : int | list[int] + Number of alternative routes to return per query. + transform_coords : bool | list[bool] + ``True`` to interpret coordinates as projected/local SUMO + coordinates and transform them into the SIM/internal frame. + """ + msg = {"TYPE": "QUERY_multiRoutesBwCoords", "DATA": []} + if not isinstance(orig_x, list): + orig_x = [orig_x] + orig_y = [orig_y] + dest_x = [dest_x] + dest_y = [dest_y] + k = [k] + if not isinstance(transform_coords, list): + transform_coords = [transform_coords] * len(orig_x) + if not isinstance(k, list): + k = [k] * len(orig_x) + + assert len(orig_x) == len(orig_y) == len(dest_x) == len(dest_y), "Length of orig_x, orig_y, dest_x, and dest_y must be the same" + + for orig_x, orig_y, dest_x, dest_y, transform_coord, k in zip(orig_x, orig_y, dest_x, dest_y, transform_coords, k): + msg["DATA"].append({"origX": orig_x, "origY": orig_y, "destX": dest_x, "destY": dest_y, "transformCoord": transform_coord, "K": k}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_kRoutes", res["TYPE"] + return res + + def query_route_between_roads(self, orig_road, dest_road): + """Query the shortest path between two roads identified by their SUMO IDs. + + Each query pair returns:: + + {'road_list': [, ...]} + + or ``'KO'`` if no path was found. + + Parameters + ---------- + orig_road : str | list[str] + SUMO road ID(s) of the origin road. + dest_road : str | list[str] + SUMO road ID(s) of the destination road (matched pairwise). + """ + msg = {"TYPE": "QUERY_routesBwRoads", "DATA": []} + if not isinstance(orig_road, list): + orig_road = [orig_road] + + if not isinstance(dest_road, list): + dest_road = [dest_road] * len(orig_road) + assert len(orig_road) == len(dest_road), "Length of orig_road and dest_road must be the same" + + for orig_road, dest_road in zip(orig_road, dest_road): + msg["DATA"].append({"orig": orig_road, "dest": dest_road}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + + assert res["TYPE"] == "ANS_routesBwRoads", res["TYPE"] + return res + + def query_k_routes_between_roads(self, orig_road, dest_road, k): + """Query the *k* shortest paths between two roads. + + Each query pair returns:: + + {'road_lists': [[, ...], ...]} # k routes + + or ``'KO'`` if no path was found. + + Parameters + ---------- + orig_road : str | list[str] + SUMO road ID(s) of the origin road. + dest_road : str | list[str] + SUMO road ID(s) of the destination road. + k : int | list[int] + Number of alternative routes to return per query. + """ + msg = {"TYPE": "QUERY_multiRoutesBwRoads", "DATA": []} + if not isinstance(orig_road, list): + orig_road = [orig_road] + dest_road = [dest_road] + k = [k] + if not isinstance(k, list): + k = [k] * len(orig_road) + + assert len(orig_road) == len(dest_road), "Length of orig_road and dest_road must be the same" + + for orig_road, dest_road, k in zip(orig_road, dest_road, k): + msg["DATA"].append({"orig": orig_road, "dest": dest_road, "K": k}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_multiRoutesBwRoads", res["TYPE"] + return res + + def query_road_weights(self, roadID = None): + """Query the routing-graph edge weight in seconds for one or more roads. + + Without ``roadID`` returns all road/edge IDs:: + + {'id_list': [...], 'orig_id': [...], 'TYPE': 'ANS_edgeWeight'} + + With ``roadID`` each matched road produces:: + + { + 'ID': SUMO road ID, + 'r_type': road type code, + 'avg_travel_time': recent mean travel time (s), + 'length': road length (m), + 'weight': current edge weight in seconds used + by the router (typically travel time, + may be overridden via + :meth:`update_edge_weight`) + } + + Parameters + ---------- + roadID : str | list[str] | None + SUMO road ID(s). Pass ``None`` to get all edges. + """ + msg = {"TYPE": "QUERY_edgeWeight"} + if roadID is not None: + msg["DATA"] = [] + if not isinstance(roadID, list): + roadID = [roadID] + for i in roadID: + msg["DATA"].append(i) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_edgeWeight", res["TYPE"] + return res + + def query_bus_route(self, routeID = None): + """Query the stop sequence and road IDs of one or more bus routes. + + Without ``routeID`` returns all route identifiers:: + + {'id_list': [...], 'orig_id': [...], 'TYPE': 'ANS_busRoute'} + + With ``routeID`` (route *name* strings) each matched route produces:: + + { + 'routeName': human-readable route name, + 'routeID': internal integer route ID, + 'stopZones': ordered list of zone IDs the bus visits, + 'stopRoads': corresponding SUMO road IDs at each stop + } + + Parameters + ---------- + routeID : str | list[str] | None + Route name(s) to query. Pass ``None`` to list all routes. + """ + msg = {"TYPE": "QUERY_busRoute"} + if routeID is not None: + msg["DATA"] = [] + if not isinstance(routeID, list): + routeID = [routeID] + for i in routeID: + msg["DATA"].append(i) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_busRoute", res["TYPE"] + return res + + def query_route_bus(self, routeID = None): + """Query the IDs of all buses currently operating on a given route. + + Without ``routeID`` returns all route identifiers:: + + {'id_list': [...], 'orig_id': [...], 'TYPE': 'ANS_busWithRoute'} + + With ``routeID`` (route *name* strings) each matched route produces:: + + { + 'routeName': human-readable route name, + 'routeID': internal integer route ID, + 'busIDs': IDs of buses currently assigned to this route + } + + Parameters + ---------- + routeID : str | list[str] | None + Route name(s) to query. Pass ``None`` to list all routes. + """ + msg = {"TYPE": "QUERY_busWithRoute"} + if routeID is not None: + msg["DATA"] = [] + if not isinstance(routeID, list): + routeID = [routeID] + for i in routeID: + msg["DATA"].append(i) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_busWithRoute", res["TYPE"] + return res + + @staticmethod + def _routing_first(record, *names, default=None): + if not isinstance(record, dict): + return default + for name in names: + value = record.get(name) + if value is not None: + return value + return default + + @staticmethod + def _routing_truthy(value): + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "y") + return bool(value) + + @classmethod + def _routing_float(cls, record, *names, default=0.0): + value = cls._routing_first(record, *names, default=default) + try: + number = float(value) + except (TypeError, ValueError): + return float(default) + if number != number or number in (float("inf"), float("-inf")): + return float(default) + return number + + @classmethod + def _routing_road_id_from(cls, record): + road_id = cls._routing_first(record, "ID", "roadID", "roadId", "id", "orig_id", "origID") + return None if road_id is None else str(road_id) + + @classmethod + def _routing_downstream_from(cls, record, default=None): + downstream = cls._routing_first( + record, + "down_stream_road", + "downstream_road", + "downStreamRoad", + "downStreamRoads", + "downstreamRoad", + "downstreamRoads", + default=default, + ) + if downstream is None: + return None + if isinstance(downstream, str): + return [downstream] + return [str(road_id) for road_id in downstream if road_id is not None] + + @classmethod + def _routing_node_attrs_from(cls, record, previous=None): + previous = previous or {} + distance = cls._routing_float( + record, + "distance", + "distance_m", + "length", + default=previous.get("distance", previous.get("length", 0.0)), + ) + speed_limit = cls._routing_float( + record, + "speed_limit", + "speedLimit", + default=previous.get("speed_limit", 0.0), + ) + travel_time = cls._routing_float( + record, + "travel_time", + "travelTime", + "travel_time_s", + "avg_travel_time", + default=previous.get("travel_time", previous.get("weight", 0.0)), + ) + if travel_time <= 0.0 and distance > 0.0 and speed_limit > 0.0: + travel_time = distance / speed_limit + + energy_consumed = cls._routing_float( + record, + "energy_consumed", + "energyConsumed", + "totalEnergy", + "totalEnergyConsumed", + "energy", + default=previous.get("energy_consumed", previous.get("total_energy", 0.0)), + ) + avg_energy = cls._routing_float( + record, + "avg_energy_consumption", + "avgEnergyConsumption", + "energy_consumption", + "energyConsumption", + default=previous.get("avg_energy_consumption", previous.get("energy_consumption", 0.0)), + ) + if cls._routing_first(record, "weight", default=None) is None: + weight = travel_time + else: + weight = cls._routing_float(record, "weight", default=travel_time) + if weight <= 0.0: + weight = travel_time + + attrs = { + "distance": distance, + "travel_time": travel_time, + "energy_consumption": avg_energy, + "avg_energy_consumption": avg_energy, + "energy_consumed": energy_consumed, + "total_energy": energy_consumed, + "length": distance, + "weight": weight, + "speed_limit": speed_limit, + } + passthrough = ( + "roadID", + "roadIndex", + "r_type", + "num_veh", + "nVehicles", + "speed", + "flow", + "parking_capacity", + "parkingCapacity", + "parked_num", + "parkedNum", + "STATUS", + ) + for name in passthrough: + value = cls._routing_first(record, name, default=None) + if value is not None: + attrs[name] = value + if "roadIndex" in attrs: + attrs["road_index"] = attrs["roadIndex"] + return attrs + + @staticmethod + def _routing_edge_attrs_from(node_attrs): + keys = ( + "distance", + "travel_time", + "energy_consumption", + "avg_energy_consumption", + "energy_consumed", + "total_energy", + "length", + "weight", + "speed_limit", + "r_type", + ) + return {key: node_attrs[key] for key in keys if key in node_attrs} + + @classmethod + def _apply_routing_graph_metadata(cls, graph, response): + graph.graph["edge_cost_road"] = "source" + graph.graph["snapshot_required"] = cls._routing_truthy(response.get("snapshotRequired", False)) + metadata_fields = ( + ("tick", "tick"), + ("TICK", "tick"), + ("topologyVersion", "topology_version"), + ("version", "metric_version"), + ("version", "weight_version"), + ("weightVersion", "weight_version"), + ) + for response_key, graph_key in metadata_fields: + if response_key in response: + graph.graph[graph_key] = response[response_key] + + def query_routing_graph_updates(self): + """Query road routing-metric deltas since the last full road snapshot. + + The updated SIM endpoint returns ``DATA`` records only for roads whose + routing metrics changed. If topology changed, the response sets + ``snapshotRequired`` and callers should rebuild with + :meth:`query_routing_graph`. + """ + msg = {"TYPE": "QUERY_routingGraphUpdates"} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "ANS_routingGraphUpdates", res["TYPE"] + return res + + def query_routing_graph(self, batch_size=500): + """Build and return the full road-level NetworkX routing graph. + + Nodes are SUMO road IDs. Directed edges represent downstream road + connectivity, with source-road metrics copied onto the edge. Useful + edge weights include ``weight`` / ``travel_time``, ``distance``, and + ``energy_consumption``. + """ + all_roads_res = self.query_road() + if all_roads_res.get("CODE") == "KO": + raise RuntimeError(f"METS-R SIM rejected QUERY_road: {all_roads_res}") + road_ids = all_roads_res.get("id_list") or all_roads_res.get("orig_id") or [] + + graph = nx.DiGraph() + downstream_by_road = {} + metadata_response = all_roads_res + batch_size = max(1, int(batch_size or 1)) + for batch_start in range(0, len(road_ids), batch_size): + batch = road_ids[batch_start: batch_start + batch_size] + response = self.query_road(id=batch) + if response.get("CODE") == "KO": + raise RuntimeError(f"METS-R SIM rejected QUERY_road batch: {response}") + metadata_response = response + for road in response.get("DATA", []): + if not isinstance(road, dict): + continue + road_id = self._routing_road_id_from(road) + if road_id is None: + continue + graph.add_node(road_id, **self._routing_node_attrs_from(road)) + downstream_by_road[road_id] = self._routing_downstream_from(road, default=[]) + + for road_id, downstream_ids in downstream_by_road.items(): + edge_attrs = self._routing_edge_attrs_from(graph.nodes[road_id]) + for downstream_id in downstream_ids or []: + graph.add_edge(road_id, downstream_id, **edge_attrs) + + self._apply_routing_graph_metadata(graph, metadata_response) + graph.graph["snapshot_required"] = False + return graph + + def update_routing_graph( + self, + graph, + update_response=None, + include_topology=False, + reload_on_snapshot_required=True, + batch_size=500): + """Apply ``QUERY_routingGraphUpdates`` results to an existing graph. + + Normal update responses are metric-only. When SIM reports + ``snapshotRequired``, the topology has changed and this method reloads a + full graph unless ``reload_on_snapshot_required`` is false. + ``include_topology`` is kept for compatibility and is used only if an + update record explicitly carries downstream road IDs. + """ + if update_response is None: + update_response = self.query_routing_graph_updates() + if update_response.get("CODE") == "KO": + raise RuntimeError(f"METS-R SIM rejected QUERY_routingGraphUpdates: {update_response}") + + if self._routing_truthy(update_response.get("snapshotRequired", False)): + if not reload_on_snapshot_required: + self._apply_routing_graph_metadata(graph, update_response) + raise RuntimeError("METS-R SIM requested a fresh routing graph snapshot") + return self.query_routing_graph(batch_size=batch_size) + + removed_ids = update_response.get("removed", []) or update_response.get("REMOVED", []) or [] + for road_id in removed_ids: + road_id = str(road_id) + if road_id in graph: + graph.remove_node(road_id) + + for road in update_response.get("DATA", []): + if not isinstance(road, dict): + continue + road_id = self._routing_road_id_from(road) + if road_id is None: + continue + status = str(road.get("STATUS", road.get("status", "OK"))).upper() + if status in {"KO", "REMOVED", "DELETE", "DELETED"}: + if road_id in graph and status != "KO": + graph.remove_node(road_id) + continue + + previous = graph.nodes[road_id] if road_id in graph else {} + graph.add_node(road_id, **self._routing_node_attrs_from(road, previous=previous)) + edge_attrs = self._routing_edge_attrs_from(graph.nodes[road_id]) + downstream_ids = self._routing_downstream_from(road, default=None) + if downstream_ids is not None: + graph.remove_edges_from(list(graph.out_edges(road_id))) + for downstream_id in downstream_ids: + graph.add_edge(road_id, downstream_id, **edge_attrs) + else: + for _, downstream_id in list(graph.out_edges(road_id)): + graph.edges[road_id, downstream_id].update(edge_attrs) + + self._apply_routing_graph_metadata(graph, update_response) + return graph # CONTROL: change the state of the simulator # generate a vehicle trip between origin and destination zones @@ -298,7 +2442,7 @@ def set_cosim_road(self, roadID): # release the road for co-simulation def release_cosim_road(self, roadID): msg = { - "TYPE": "CTRL_releaseCoSimRoad", + "TYPE": "CTRL_releaseCosimRoad", "DATA": [] } if not isinstance(roadID, list): @@ -306,56 +2450,194 @@ def release_cosim_road(self, roadID): for i in roadID: msg['DATA'].append(i) res = self.send_receive_msg(msg, ignore_heartbeats=True) - assert res["TYPE"] == "CTRL_releaseCoSimRoad", res["TYPE"] + assert res["TYPE"] == "CTRL_releaseCosimRoad", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def enter_road_from_queue(self, vehID = None, roadID = None, private_veh = None, + internal_vehicle_id = None, requests = None): + """Release queued vehicle(s) onto co-simulation road(s). + + The latest METS-R SIM keeps vehicles from automatically spawning onto + roads marked ``Road.COSIM``. Query the queue with + :meth:`query_entering_vehicle_queue` or + :meth:`query_cosim_entering_vehicle_queue`, then call this method when + the external simulator is ready to spawn a queued vehicle. + + Parameters + ---------- + vehID : int | list[int] | None + Visible vehicle ID. For private EV/GV this is the private/external + ID returned by the co-sim bridge; for taxis/buses it is the internal + vehicle ID. If omitted with ``roadID``, the road queue head is + released. + roadID : str | list[str] | None + Optional SUMO/original road ID. If omitted, all co-sim entering + queues are searched for ``vehID``. + private_veh : bool | list[bool] | None + Optional vehicle-type filter. ``True`` means private EV/GV, + ``False`` means public taxi/bus. + internal_vehicle_id : int | list[int] | None + Optional internal METS-R vehicle ID, useful when visible private IDs + are not available. + requests : dict | list[dict] | None + Fully formed simulator request record(s). When provided, other + parameters are ignored. + """ + msg = {"TYPE": "CTRL_enterRoadFromQueue", "DATA": []} + if requests is not None: + msg["DATA"] = _as_list(requests) + else: + if vehID is None and roadID is None and internal_vehicle_id is None: + raise ValueError("vehID, roadID, internal_vehicle_id, or requests is required") + + lengths = [ + len(value) for value in (vehID, roadID, private_veh, internal_vehicle_id) + if _is_sequence(value) and not isinstance(value, str) + ] + count = max(lengths) if lengths else 1 + veh_ids = _broadcast(vehID, count) + road_ids = _broadcast(roadID, count) + private_flags = _broadcast(private_veh, count) + internal_ids = _broadcast(internal_vehicle_id, count) + + for vid, rid, prv, internal_id in zip(veh_ids, road_ids, private_flags, internal_ids): + record = {} + if vid is not None: + record["vehID"] = vid + if internal_id is not None: + record["internalVehicleID"] = internal_id + if prv is not None: + record["vehType"] = prv + if rid is not None: + record["roadID"] = rid + msg["DATA"].append(record) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_enterRoadFromQueue", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res # teleport vehicle to a target location specified by road and coordiantes, only work when the road is a cosim road - def teleport_cosim_vehicle(self, vehID, roadID, x, y, private_veh = False, transform_coords = False): + def teleport_cosim_vehicle(self, vehID, x, y, bearing, speed = 0, z = 0.0, private_veh = False, transform_coords = False): msg = { "TYPE": "CTRL_teleportCoSimVeh", "DATA": [] } if not isinstance(vehID, list): vehID = [vehID] - roadID = [roadID] x = [x] y = [y] + z = [z] + speed = [speed] + bearing = [bearing] + if not isinstance(z, list): + z = [z] * len(vehID) + if not isinstance(bearing, list): + bearing = [bearing] * len(vehID) + if not isinstance(speed, list): + speed = [speed] * len(vehID) if not isinstance(private_veh, list): private_veh = [private_veh] * len(vehID) if not isinstance(transform_coords, list): transform_coords = [transform_coords] * len(vehID) - for vehID, roadID, x, y, private_veh, transform_coords in zip(vehID, roadID, x, y, private_veh, transform_coords): - msg["DATA"].append({"vehID": vehID, "roadID": roadID, "x": x, "y": y, "vehType": private_veh, "transformCoord": transform_coords}) + for vehID, x, y, z, bearing, speed, private_veh, transform_coords in zip(vehID, x, y, z, bearing, speed, private_veh, transform_coords): + msg["DATA"].append({"vehID": vehID, "x": x, "y": y, "z": z, "bearing": bearing, "speed": speed, "vehType": private_veh, "transformCoord": transform_coords}) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_teleportCoSimVeh", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - # teleport vehicle to a target location specified by road, lane, and distance to the downstream junction - def teleport_trace_replay_vehicle(self, vehID, roadID, laneID, dist, private_veh = False): + # teleport vehicle to a target location specified by road/lane plus distance or projected coordinates + def teleport_trace_replay_vehicle( + self, + vehID, + roadID, + laneID, + dist = None, + private_veh = False, + x = None, + y = None, + transform_coords = False): + """Teleport trace-replay vehicles by lane distance or by coordinates. + + ``dist`` is the distance to the downstream junction. Recent METS-R SIM + versions also accept ``x``/``y`` coordinates, which are projected onto + the target lane by the simulator. Set ``transform_coords=True`` when + those coordinates are projected/local SUMO, XODR, or CARLA-meter + coordinates; leave it ``False`` for SIM/internal coordinates. + """ msg = { "TYPE": "CTRL_teleportTraceReplayVeh", "DATA": [] } + veh_ids = _as_list(vehID) + count = len(veh_ids) + + def _field_values(value, name): + if _is_sequence(value): + values = list(value) + assert len(values) == count, f"{name} must have the same length as vehID" + return values + return [value] * count + + road_ids = _field_values(roadID, "roadID") + lane_ids = _field_values(laneID, "laneID") + dists = _field_values(dist, "dist") + private_flags = _field_values(private_veh, "private_veh") + xs = _field_values(x, "x") + ys = _field_values(y, "y") + transform_flags = _field_values(transform_coords, "transform_coords") + + for veh_id, road_id, lane_id, dist_value, private_flag, x_value, y_value, transform_flag in zip( + veh_ids, road_ids, lane_ids, dists, private_flags, xs, ys, transform_flags): + record = { + "vehID": veh_id, + "roadID": road_id, + "laneID": lane_id, + "vehType": private_flag, + } + if x_value is not None or y_value is not None: + if x_value is None or y_value is None: + raise ValueError("Both x and y are required for coordinate trace replay teleport") + record["x"] = x_value + record["y"] = y_value + record["transformCoord"] = transform_flag + elif dist_value is not None: + record["dist"] = dist_value + else: + raise ValueError("teleport_trace_replay_vehicle requires dist or x/y") + msg["DATA"].append(record) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_teleportTraceReplayVeh", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # enter the next road + def enter_next_road(self, vehID, roadID="", private_veh = False): + msg = { + "TYPE": "CTRL_enterNextRoad", + "DATA": [] + } if not isinstance(vehID, list): vehID = [vehID] - roadID = [roadID] - laneID = [laneID] - dist = [dist] if not isinstance(private_veh, list): private_veh = [private_veh] * len(vehID) - for vehID, roadID, laneID, dist, private_veh in zip(vehID, roadID, laneID, dist, private_veh): - msg["DATA"].append({"vehID": vehID, "roadID": roadID, "laneID": laneID, "dist": dist, "vehType": private_veh}) + if not isinstance(roadID, list): + roadID = [roadID] * len(vehID) + + for vehID, private_veh, roadID in zip(vehID, private_veh, roadID): + msg["DATA"].append({"vehID": vehID, "vehType": private_veh, "roadID": roadID}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) - assert res["TYPE"] == "CTRL_teleportTraceReplayVeh", res["TYPE"] + assert res["TYPE"] == "CTRL_enterNextRoad", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - - # enter the next road - def enter_next_road(self, vehID, private_veh = False): + + # reach destination + def reach_dest(self, vehID, private_veh = False): msg = { - "TYPE": "CTRL_enterNextRoad", + "TYPE": "CTRL_reachDest", "DATA": [] } if not isinstance(vehID, list): @@ -367,7 +2649,7 @@ def enter_next_road(self, vehID, private_veh = False): msg["DATA"].append({"vehID": vehID, "vehType": private_veh}) res = self.send_receive_msg(msg, ignore_heartbeats=True) - assert res["TYPE"] == "CTRL_enterNextRoad", res["TYPE"] + assert res["TYPE"] == "CTRL_reachDest", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res @@ -389,69 +2671,210 @@ def control_vehicle(self, vehID, acc, private_veh = False): assert res["CODE"] == "OK", res["CODE"] return res - # update the sensor type of specified vehicle def update_vehicle_sensor_type(self, vehID, sensorType, private_veh = False): + """Update vehicle sensor type used by V2X/DSRC data collection. + + ``sensorType`` may be the simulator integer code or a readable alias: + ``0``/``"dsrc"``, ``1``/``"cv2x"``, or + ``2``/``"mobile_device"``. DSRC and C-V2X vehicles emit BSM-style + safety records when ``V2X = true``; mobile-device vehicles emit link + travel-time and energy probe records. + """ msg = { "TYPE": "CTRL_updateVehicleSensorType", "DATA": [] } - if not isinstance(vehID, list): - vehID = [vehID] - if not isinstance(private_veh, list): + vehID = _as_list(vehID) + if not _is_sequence(private_veh): private_veh = [private_veh] * len(vehID) - if not isinstance(sensorType, list): + else: + private_veh = list(private_veh) + if not _is_sequence(sensorType): sensorType = [sensorType] * len(vehID) + else: + sensorType = list(sensorType) + assert len(vehID) == len(sensorType) == len(private_veh), \ + "vehID, sensorType, and private_veh must have the same length" for vehID, sensorType, private_veh in zip(vehID, sensorType, private_veh): - msg["DATA"].append({"vehID": vehID, "sensorType": sensorType, "vehType": private_veh}) + msg["DATA"].append({ + "vehID": vehID, + "sensorType": _normalize_sensor_type(sensorType), + "vehType": private_veh, + }) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_updateVehicleSensorType", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res + + set_vehicle_sensor_type = update_vehicle_sensor_type + updateVehicleSensorType = update_vehicle_sensor_type - # dispatch taxi - def dispatch_taxi(self, vehID, orig, dest, num): + # Match available taxi(s) to existing pending request(s). + def dispatch_taxi(self, vehID, reqID): + """Dispatch taxi(s) to serve already-pending request(s). + + The METS-R SIM Control API separates request creation from dispatching. + Use ``add_taxi_requests`` or ``add_taxi_requests_between_roads`` first, + read the returned ``reqID``, then pass ``vehID`` and ``reqID`` here. + + Recent METS-R SIM versions can release a parked taxi from parking, + queue the request after an unfinished passenger-free trip, and return + fields such as ``remainingCapacity``, ``requestPassengers``, and + ``parkingReservationReleased`` in each response record. + """ msg = { "TYPE": "CTRL_dispatchTaxi", "DATA": [] } if not isinstance(vehID, list): vehID = [vehID] - if not isinstance(orig, list): - orig = [orig] * len(vehID) - if not isinstance(dest, list): - dest = [dest] * len(vehID) - if not isinstance(num, list): - num = [num] * len(vehID) + if not isinstance(reqID, list): + reqID = [reqID] * len(vehID) + assert len(vehID) == len(reqID), "vehID and reqID must have the same length" - for vehID, orig, dest, num in zip(vehID, orig, dest, num): - msg["DATA"].append({"vehID": vehID, "orig": orig, "dest": dest, "num": num}) + for vehID, reqID in zip(vehID, reqID): + msg["DATA"].append({"vehID": vehID, "reqID": reqID}) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_dispatchTaxi", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - - def dispatch_taxi_between_roads(self, vehID, orig, dest, num): - msg = { - "TYPE": "CTRL_dispTaxiBwRoads", - "DATA": [] - } + + def cancel_requests(self, reqID, zoneID=None): + """Cancel one or more taxi/bus requests. + + The latest METS-R SIM control API uses ``CTRL_cancelRequests`` and + requires the request's origin zone for each record. ``reqID`` may be a + scalar request ID, a list of request IDs, a request record, or a list + of request records containing request ID and origin-zone fields. When + ``zoneID`` is omitted, the client attempts to infer it with + :meth:`query_request`. + + The returned ``DATA`` list contains per-request ``STATUS``/``WARN`` + details from the simulator; a top-level ``CODE`` of ``OK`` only means + the control message itself was processed. + """ + if zoneID is None and isinstance(reqID, dict): + request_records = [reqID] + elif ( + zoneID is None + and _is_sequence(reqID) + and all(isinstance(record, dict) for record in reqID) + ): + request_records = list(reqID) + else: + request_ids = [_request_id_from_record(record) for record in _as_list(reqID)] + if zoneID is None: + zone_ids = [None] * len(request_ids) + elif _is_sequence(zoneID): + zone_ids = list(zoneID) + else: + zone_ids = [zoneID] * len(request_ids) + assert len(request_ids) == len(zone_ids), \ + "reqID and zoneID must have the same length" + request_records = [ + {"reqID": rid, "zoneID": zid} + for rid, zid in zip(request_ids, zone_ids) + ] + + msg = {"TYPE": "CTRL_cancelRequests", "DATA": []} + missing_zone_ids = [] + for record in request_records: + rid = _request_id_from_record(record) + zid = _request_zone_from_record(record) + if rid is None: + raise ValueError("reqID is required for cancel_requests") + if zid is None: + zid = self._infer_request_zone_id(rid) + if zid is None: + missing_zone_ids.append(rid) + continue + msg["DATA"].append({"reqID": rid, "zoneID": zid}) + + if missing_zone_ids: + raise ValueError( + "zoneID is required for cancel_requests; could not infer it " + "for reqID(s): {}".format(missing_zone_ids) + ) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_cancelRequests", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + cancel_request = cancel_requests + cancelRequests = cancel_requests + + def reposition_taxi(self, vehID, zoneID): + """Reposition idle/cruising taxi(s) to destination zone(s). + + If the taxi was already traveling to reserved parking, the simulator can + release that reservation and report ``parkingReservationReleased`` in + the response record. + """ + msg = {"TYPE": "CTRL_repositionTaxi", "DATA": []} if not isinstance(vehID, list): vehID = [vehID] - if not isinstance(orig, list): - orig = [orig] * len(vehID) - if not isinstance(dest, list): - dest = [dest] * len(vehID) - if not isinstance(num, list): - num = [num] * len(vehID) + if not isinstance(zoneID, list): + zoneID = [zoneID] * len(vehID) + assert len(vehID) == len(zoneID), "vehID and zoneID must have the same length" + + for vehID, zoneID in zip(vehID, zoneID): + msg["DATA"].append({"vehID": vehID, "zoneID": zoneID}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_repositionTaxi", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res - for vehID, orig, dest, num in zip(vehID, orig, dest, num): - msg["DATA"].append({"vehID": vehID, "orig": orig, "dest": dest, "num": num}) + def go_parking(self, vehID, zoneID=None, roadID=None): + """Send idle taxi(s) to park at a target zone or road. + + Provide ``zoneID``, ``roadID``, or both. If only ``zoneID`` is supplied, + METS-R SIM samples a parking road in that zone. If only ``roadID`` is + supplied, the zone is inferred from the road. When both are supplied, + the road must belong to the zone and have available parking capacity. + """ + msg = {"TYPE": "CTRL_goParking", "DATA": []} + vehID = _as_list(vehID) + if zoneID is None: + zoneID = [None] * len(vehID) + elif not _is_sequence(zoneID): + zoneID = [zoneID] * len(vehID) + else: + zoneID = list(zoneID) + if roadID is None: + roadID = [None] * len(vehID) + elif not _is_sequence(roadID): + roadID = [roadID] * len(vehID) + else: + roadID = list(roadID) + assert len(vehID) == len(zoneID) == len(roadID), \ + "vehID, zoneID, and roadID must have the same length" + + for vid, zid, rid in zip(vehID, zoneID, roadID): + if zid is None and rid is None: + raise ValueError("zoneID or roadID is required for go_parking") + record = {"vehID": vid} + if zid is not None: + record["zoneID"] = zid + if rid is not None: + record["roadID"] = rid + msg["DATA"].append(record) res = self.send_receive_msg(msg, ignore_heartbeats=True) - assert res["TYPE"] == "CTRL_dispTaxiBwRoads", res["TYPE"] + assert res["TYPE"] == "CTRL_goParking", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - def add_taxi_requests(self, zoneID, dest, num): + def add_taxi_requests(self, zoneID, dest, num, max_waiting_time = None, maxWaitingTime = None): + """Add one or more pending taxi requests. + + ``max_waiting_time`` is optional and uses simulation ticks. It maps to + the API field ``maxWaitingTime``. When it is positive, the simulator + overrides the request's default waiting tolerance; omitted, ``None``, + or non-positive values keep the simulator default. + """ + if max_waiting_time is None and maxWaitingTime is not None: + max_waiting_time = maxWaitingTime + msg = { "TYPE": "CTRL_addTaxiRequests", "DATA": [] @@ -462,59 +2885,168 @@ def add_taxi_requests(self, zoneID, dest, num): dest = [dest] * len(zoneID) if not isinstance(num, list): num = [num] * len(zoneID) + if max_waiting_time is None: + max_waiting_time = [None] * len(zoneID) + elif not isinstance(max_waiting_time, list): + max_waiting_time = [max_waiting_time] * len(zoneID) + assert len(zoneID) == len(dest) == len(num) == len(max_waiting_time), \ + "zoneID, dest, num, and max_waiting_time must have the same length" - for zoneID, dest, num in zip(zoneID, dest, num): - msg["DATA"].append({"zoneID": zoneID, "dest": dest, "num": num}) + for zoneID, dest, num, max_wait in zip(zoneID, dest, num, max_waiting_time): + record = {"zoneID": zoneID, "dest": dest, "num": num} + if max_wait is not None: + record["maxWaitingTime"] = max_wait + msg["DATA"].append(record) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_addTaxiRequests", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - def add_taxi_requests_between_roads(self, zoneID, orig, dest, num): + def add_taxi_requests_between_roads(self, orig, dest, num): msg = { "TYPE": "CTRL_addTaxiReqBwRoads", "DATA": [] } if not isinstance(orig, list): orig = [orig] - if not isinstance(zoneID, list): - zoneID = [zoneID] * len(orig) if not isinstance(dest, list): - dest = [dest] * len(zoneID) + dest = [dest] * len(orig) if not isinstance(num, list): - num = [num] * len(zoneID) + num = [num] * len(orig) - for zoneID, orig, dest, num in zip(zoneID, orig, dest, num): - msg["DATA"].append({"zoneID": zoneID, "orig": orig, "dest": dest, "num": num}) + for orig, dest, num in zip(orig, dest, num): + msg["DATA"].append({"orig": orig, "dest": dest, "num": num}) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_addTaxiReqBwRoads", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - # assign bus - def assign_request_to_bus(self, vehID, orig, dest, num): + def add_bus_route(self, routeName, zone, road, paths = None): + has_paths = paths is not None + if not has_paths: + msg = { + "TYPE": "CTRL_addBusRoute", + "DATA": [] + } + else: + msg = { + "TYPE": "CTRL_addBusRouteWithPath", + "DATA": [] + } + if not isinstance(routeName, list): + routeName = [routeName] + zone = [zone] + road = [road] + if has_paths: + paths = [paths] + elif has_paths and not isinstance(paths, list): + paths = [paths] * len(routeName) + + if not has_paths: + for routeName, zone, road in zip(routeName, zone, road): + msg["DATA"].append({"routeName": routeName, "zones": zone, "roads": road}) + else: + for routeName, zone, road, paths in zip(routeName, zone, road, paths): + msg["DATA"].append({"routeName": routeName, "zones": zone, "roads": road, "paths": paths}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + + if not has_paths: + assert res["TYPE"] == "CTRL_addBusRoute", res["TYPE"] + else: + assert res["TYPE"] == "CTRL_addBusRouteWithPath", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def add_bus_run(self, routeName, departTime): + msg = { + "TYPE": "CTRL_addBusRun", + "DATA": [] + } + if not isinstance(routeName, list): + routeName = [routeName] + departTime = [departTime] + + for routeName, departTime in zip(routeName, departTime): + msg["DATA"].append({"routeName": routeName, "departTime": departTime}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_addBusRun", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def insert_bus_stop(self, busID, routeName, zoneID, roadName, stopIndex): + msg = { + "TYPE": "CTRL_insertStopToRoute", + "DATA": [] + } + if not isinstance(busID, list): + busID = [busID] + routeName = [routeName] * len(busID) + zoneID = [zoneID] * len(busID) + roadName = [roadName] * len(busID) + stopIndex = [stopIndex] * len(busID) + + for busID, routeName, zoneID, roadName, stopIndex in zip(busID, routeName, zoneID, roadName, stopIndex): + msg["DATA"].append({"busID": busID, "routeName": routeName, "zone": zoneID, "road": roadName, "stopIndex": stopIndex}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_insertStopToRoute", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def remove_bus_stop(self, busID, routeName, stopIndex): + msg = { + "TYPE": "CTRL_removeStopFromRoute", + "DATA": [] + } + if not isinstance(busID, list): + busID = [busID] + routeName = [routeName] * len(busID) + stopIndex = [stopIndex] * len(busID) + + for busID, routeName, stopIndex in zip(busID, routeName, stopIndex): + msg["DATA"].append({"busID": busID, "routeName": routeName, "stopIndex": stopIndex}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_removeStopFromRoute", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + + def assign_request_to_bus(self, busID, reqID): + """Match existing pending bus request(s) to bus(es). + + The latest METS-R SIM Control API separates bus-request creation from + assignment. Use ``add_bus_requests`` first, read the returned + ``reqID``, then pass ``busID`` and ``reqID`` here. + """ msg = { "TYPE": "CTRL_assignRequestToBus", "DATA": [] } - if not isinstance(vehID, list): - vehID = [vehID] - if not isinstance(orig, list): - orig = [orig] * len(vehID) - if not isinstance(dest, list): - dest = [dest] * len(vehID) - if not isinstance(num, list): - num = [num] * len(vehID) + if not isinstance(busID, list): + busID = [busID] + if not isinstance(reqID, list): + reqID = [reqID] * len(busID) + assert len(busID) == len(reqID), "busID and reqID must have the same length" - for vehID, orig, dest, num in zip(vehID, orig, dest, num): - msg["DATA"].append({"vehID": vehID, "orig": orig, "dest": dest, "num": num}) + for bus_id, req_id in zip(busID, reqID): + msg["DATA"].append({"busID": bus_id, "reqID": req_id}) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_assignRequestToBus", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res - def add_bus_requests(self, zoneID, dest, num): + def add_bus_requests(self, zoneID, dest, routeName, num, max_waiting_time = None, maxWaitingTime = None): + """Add one or more pending bus requests. + + ``max_waiting_time`` is optional and uses simulation ticks. It maps to + the API field ``maxWaitingTime``. When it is positive, the simulator + overrides the request's default waiting tolerance. + """ + if max_waiting_time is None and maxWaitingTime is not None: + max_waiting_time = maxWaitingTime + msg = { "TYPE": "CTRL_addBusRequests", "DATA": [] @@ -525,59 +3057,644 @@ def add_bus_requests(self, zoneID, dest, num): dest = [dest] * len(zoneID) if not isinstance(num, list): num = [num] * len(zoneID) + if not isinstance(routeName, list): + routeName = [routeName] * len(zoneID) + if max_waiting_time is None: + max_waiting_time = [None] * len(zoneID) + elif not isinstance(max_waiting_time, list): + max_waiting_time = [max_waiting_time] * len(zoneID) + assert len(zoneID) == len(dest) == len(num) == len(routeName) == len(max_waiting_time), \ + "zoneID, dest, num, routeName, and max_waiting_time must have the same length" - for zoneID, dest, num in zip(zoneID, dest, num): - msg["DATA"].append({"zoneID": zoneID, "dest": dest, "num": num}) + for zoneID, dest, num, routeName, max_wait in zip(zoneID, dest, num, routeName, max_waiting_time): + record = {"zoneID": zoneID, "dest": dest, "num": num, "routeName": routeName} + if max_wait is not None: + record["maxWaitingTime"] = max_wait + msg["DATA"].append(record) res = self.send_receive_msg(msg, ignore_heartbeats=True) assert res["TYPE"] == "CTRL_addBusRequests", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] return res + # update vehicle route + def update_vehicle_route(self, vehID, route, private_veh = False): + msg = { + "TYPE": "CTRL_updateVehicleRoute", + "DATA": [] + } + if not isinstance(vehID, list): + vehID = [vehID] + route = [route] + if not isinstance(private_veh, list): + private_veh = [private_veh] * len(vehID) + + for vehID, route, private_veh in zip(vehID, route, private_veh): + msg["DATA"].append({"vehID": vehID, "route": route, "vehType": private_veh}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_updateVehicleRoute", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # update road weights in the routing map + def update_road_weights(self, roadID, weight): + msg = {"TYPE": "CTRL_updateEdgeWeight", "DATA": []} + if not isinstance(roadID, list): + roadID = [roadID] + weight = [weight] + if not isinstance(weight, list): + weight = [weight] * len(roadID) + for roadID, weight in zip(roadID, weight): + msg["DATA"].append({"roadID": roadID, "weight": weight}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_updateEdgeWeight", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def update_road_parking_capacity( + self, roadID, parking_capacity=None, parkingCapacity=None, capacity=None): + """Update parking capacity for one or more roads. + + ``parking_capacity`` is the preferred Python argument. ``parkingCapacity`` + and ``capacity`` are accepted to mirror the METS-R SIM Control API + aliases. + """ + if parking_capacity is None: + parking_capacity = parkingCapacity + if parking_capacity is None: + parking_capacity = capacity + if parking_capacity is None: + raise ValueError("parking_capacity is required") + + msg = {"TYPE": "CTRL_updateRoadParkingCapacity", "DATA": []} + roadID = _as_list(roadID) + if not _is_sequence(parking_capacity): + parking_capacity = [parking_capacity] * len(roadID) + else: + parking_capacity = list(parking_capacity) + assert len(roadID) == len(parking_capacity), \ + "roadID and parking_capacity must have the same length" + + for rid, cap in zip(roadID, parking_capacity): + msg["DATA"].append({"roadID": rid, "parkingCapacity": cap}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_updateRoadParkingCapacity", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # update charging station prices + def update_charging_prices(self, stationID, stationType, price): + msg = {"TYPE": "CTRL_updateChargingPrice", "DATA": []} + if not isinstance(stationID, list): + stationID = [stationID] + stationType = [stationType] + price = [price] + if not isinstance(stationType, list): + stationType = [stationType] * len(stationID) + if not isinstance(price, list): + price = [price] * len(stationID) + for stationID, stationType, price in zip(stationID, stationType, price): + msg["DATA"].append({"chargerID": stationID, "chargerType": stationType, "weight": price}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_updateChargingPrice", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res - # reset the simulation with a property file - def reset(self, prop_file): - msg = {"TYPE": "CTRL_reset", "propertyFile": prop_file} + # Traffic signal phase control + # Update the signal phase given signal ID and target phase (optionally with phase time offset) + # If only phase is provided, starts from the beginning of that phase (phaseTime = 0) + def update_signal(self, signalID, targetPhase, phaseTime = None): + msg = {"TYPE": "CTRL_updateSignal", "DATA": []} + if not isinstance(signalID, list): + signalID = [signalID] + targetPhase = [targetPhase] + if not isinstance(targetPhase, list): + targetPhase = [targetPhase] * len(signalID) + if phaseTime is None: + phaseTime = [None] * len(signalID) + elif not isinstance(phaseTime, list): + phaseTime = [phaseTime] * len(signalID) + else: + # If phaseTime is a list, ensure it matches the length + if len(phaseTime) != len(signalID): + phaseTime = phaseTime * (len(signalID) // len(phaseTime) + 1) + phaseTime = phaseTime[:len(signalID)] + + assert len(signalID) == len(targetPhase) == len(phaseTime), "Length of signalID, targetPhase, and phaseTime must be the same" + + for sig_id, tgt_phase, ph_time in zip(signalID, targetPhase, phaseTime): + signal_data = {"signalID": sig_id, "targetPhase": tgt_phase} + if ph_time is not None: + signal_data["phaseTime"] = ph_time + msg["DATA"].append(signal_data) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_updateSignal", res["TYPE"] + return res + + # Update signal phase timing (green, yellow, red durations) + def update_signal_timing(self, signalID, greenTime, yellowTime, redTime): + msg = {"TYPE": "CTRL_updateSignalTiming", "DATA": []} + if not isinstance(signalID, list): + signalID = [signalID] + greenTime = [greenTime] + yellowTime = [yellowTime] + redTime = [redTime] + if not isinstance(greenTime, list): + greenTime = [greenTime] * len(signalID) + if not isinstance(yellowTime, list): + yellowTime = [yellowTime] * len(signalID) + if not isinstance(redTime, list): + redTime = [redTime] * len(signalID) + + assert len(signalID) == len(greenTime) == len(yellowTime) == len(redTime), "Length of signalID, greenTime, yellowTime, and redTime must be the same" + + for sig_id, green, yellow, red in zip(signalID, greenTime, yellowTime, redTime): + msg["DATA"].append({"signalID": sig_id, "greenTime": green, "yellowTime": yellow, "redTime": red}) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_updateSignalTiming", res["TYPE"] + return res + + # Set a complete new phase plan for a signal (phase timing + starting state + offset) + # Time values are in seconds + def set_signal_phase_plan(self, signalID, greenTime, yellowTime, redTime, startPhase, phaseOffset = None): + msg = {"TYPE": "CTRL_setSignalPhasePlan", "DATA": []} + if not isinstance(signalID, list): + signalID = [signalID] + greenTime = [greenTime] + yellowTime = [yellowTime] + redTime = [redTime] + startPhase = [startPhase] + if not isinstance(greenTime, list): + greenTime = [greenTime] * len(signalID) + if not isinstance(yellowTime, list): + yellowTime = [yellowTime] * len(signalID) + if not isinstance(redTime, list): + redTime = [redTime] * len(signalID) + if not isinstance(startPhase, list): + startPhase = [startPhase] * len(signalID) + if phaseOffset is None: + phaseOffset = [None] * len(signalID) + elif not isinstance(phaseOffset, list): + phaseOffset = [phaseOffset] * len(signalID) + else: + # If phaseOffset is a list, ensure it matches the length + if len(phaseOffset) != len(signalID): + phaseOffset = phaseOffset * (len(signalID) // len(phaseOffset) + 1) + phaseOffset = phaseOffset[:len(signalID)] + + assert len(signalID) == len(greenTime) == len(yellowTime) == len(redTime) == len(startPhase) == len(phaseOffset), "Length of all parameters must match" + + for sig_id, green, yellow, red, start_phase, ph_offset in zip(signalID, greenTime, yellowTime, redTime, startPhase, phaseOffset): + signal_data = {"signalID": sig_id, "greenTime": green, "yellowTime": yellow, "redTime": red, "startPhase": start_phase} + if ph_offset is not None: + signal_data["phaseOffset"] = ph_offset + msg["DATA"].append(signal_data) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_setSignalPhasePlan", res["TYPE"] + return res + + # Set a complete new phase plan with tick-level precision + # Time values are in simulation ticks for more precise control + def set_signal_phase_plan_ticks(self, signalID, greenTicks, yellowTicks, redTicks, startPhase, tickOffset = None): + msg = {"TYPE": "CTRL_setSignalPhasePlanTicks", "DATA": []} + if not isinstance(signalID, list): + signalID = [signalID] + greenTicks = [greenTicks] + yellowTicks = [yellowTicks] + redTicks = [redTicks] + startPhase = [startPhase] + if not isinstance(greenTicks, list): + greenTicks = [greenTicks] * len(signalID) + if not isinstance(yellowTicks, list): + yellowTicks = [yellowTicks] * len(signalID) + if not isinstance(redTicks, list): + redTicks = [redTicks] * len(signalID) + if not isinstance(startPhase, list): + startPhase = [startPhase] * len(signalID) + if tickOffset is None: + tickOffset = [None] * len(signalID) + elif not isinstance(tickOffset, list): + tickOffset = [tickOffset] * len(signalID) + else: + # If tickOffset is a list, ensure it matches the length + if len(tickOffset) != len(signalID): + tickOffset = tickOffset * (len(signalID) // len(tickOffset) + 1) + tickOffset = tickOffset[:len(signalID)] + + assert len(signalID) == len(greenTicks) == len(yellowTicks) == len(redTicks) == len(startPhase) == len(tickOffset), "Length of all parameters must match" + + for sig_id, green, yellow, red, start_phase, tck_offset in zip(signalID, greenTicks, yellowTicks, redTicks, startPhase, tickOffset): + signal_data = {"signalID": sig_id, "greenTicks": green, "yellowTicks": yellow, "redTicks": red, "startPhase": start_phase} + if tck_offset is not None: + signal_data["tickOffset"] = tck_offset + msg["DATA"].append(signal_data) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_setSignalPhasePlanTicks", res["TYPE"] + return res + + + # Dynamically add one or more zones at given coordinates. + # x, y: map coordinates; z: elevation (default 0.0); + # capacity: zone capacity; zone_type: zone type int; + # transform_coord: set True for projected/local SUMO coordinates. + # Returns assigned zone IDs. + def add_zone(self, x, y, capacity, zone_type, z=0.0, transform_coord=False): + msg = {"TYPE": "CTRL_addZone", "DATA": []} + if not isinstance(x, list): + x = [x] + y = [y] + z = [z] + capacity = [capacity] + zone_type = [zone_type] + if not isinstance(z, list): + z = [z] * len(x) + if not isinstance(transform_coord, list): + transform_coord = [transform_coord] * len(x) + assert len(x) == len(y) == len(z) == len(capacity) == len(zone_type), \ + "x, y, z, capacity, and zone_type must have the same length" + for xi, yi, zi, cap, ztype, tc in zip(x, y, z, capacity, zone_type, transform_coord): + msg["DATA"].append({"x": xi, "y": yi, "z": zi, "transformCoord": tc, "capacity": cap, "type": ztype}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_addZone", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # Dynamically add one or more roads and generated lanes. + # centerline: [[x, y], ...] or [[x, y, z], ...] per road. + # upstream_road/downstream_road: road orig_id string or list of orig_id strings. + # parking_capacity: optional road-level parking slots. + # Pass roads=[{...}] to send fully formed simulator records directly. + def add_roads(self, centerline=None, upstream_road=None, downstream_road=None, + orig_id=None, road_type=None, control_type=None, + upstream_control_type=None, downstream_control_type=None, + num_lanes=1, lane_width=None, transform_coord=False, roads=None, + parking_capacity=None): + msg = {"TYPE": "CTRL_addRoads", "DATA": []} + + if roads is not None: + msg["DATA"] = _as_list(roads) + else: + if centerline is None: + raise ValueError("centerline is required when roads is not provided") + if upstream_road is None or downstream_road is None: + raise ValueError("upstream_road and downstream_road are required") + + centerlines = [centerline] if _looks_like_centerline(centerline) else list(centerline) + if len(centerlines) == 0: + raise ValueError("At least one road centerline is required") + + count = len(centerlines) + orig_ids = _broadcast(orig_id, count) + upstream_roads = _broadcast(upstream_road, count) + downstream_roads = _broadcast(downstream_road, count) + road_types = _broadcast(road_type, count) + control_types = _broadcast(control_type, count) + upstream_control_types = _broadcast(upstream_control_type, count) + downstream_control_types = _broadcast(downstream_control_type, count) + lane_counts = _broadcast(num_lanes, count) + lane_widths = _broadcast(lane_width, count) + transform_coords = _broadcast(transform_coord, count) + parking_capacities = _broadcast(parking_capacity, count) + + for cl, oid, up, down, r_type, c_type, up_control, down_control, lane_count, width, tc, pcap in zip( + centerlines, orig_ids, upstream_roads, downstream_roads, road_types, + control_types, upstream_control_types, downstream_control_types, + lane_counts, lane_widths, transform_coords, parking_capacities): + record = { + "centerline": cl, + "numLanes": lane_count, + "transformCoord": tc, + } + if oid is not None: + record["origID"] = oid + _set_road_reference(record, "upStream", up) + _set_road_reference(record, "downStream", down) + if r_type is not None: + record["roadType"] = r_type + if c_type is not None: + record["controlType"] = c_type + if up_control is not None: + record["upStreamControlType"] = up_control + if down_control is not None: + record["downStreamControlType"] = down_control + if width is not None: + record["laneWidth"] = width + if pcap is not None: + record["parkingCapacity"] = pcap + msg["DATA"].append(record) + + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_addRoads", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def remove_zone(self, zoneID): + """Dynamically remove one or more zones by internal zone ID.""" + msg = {"TYPE": "CTRL_removeZone", "DATA": _as_list(zoneID)} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_removeZone", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def remove_road(self, roadID): + """Dynamically remove one or more roads by SUMO/original road ID.""" + msg = {"TYPE": "CTRL_removeRoad", "DATA": _as_list(roadID)} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_removeRoad", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # Dynamically add one or more charging stations at given coordinates. + # num_l2/l3/bus: charger counts; price_l2/l3: per-unit prices; + # z: elevation (default 0.0); + # transform_coord: set True for projected/local SUMO coordinates. + # Returns assigned (negative) station IDs. + def add_charging_station(self, x, y, num_l2, num_l3, num_bus, price_l2, price_l3, z=0.0, transform_coord=False): + msg = {"TYPE": "CTRL_addChargingStation", "DATA": []} + if not isinstance(x, list): + x = [x] + y = [y] + z = [z] + num_l2 = [num_l2] + num_l3 = [num_l3] + num_bus = [num_bus] + price_l2 = [price_l2] + price_l3 = [price_l3] + if not isinstance(z, list): + z = [z] * len(x) + if not isinstance(transform_coord, list): + transform_coord = [transform_coord] * len(x) + assert len(x) == len(y) == len(z) == len(num_l2) == len(num_l3) == len(num_bus) == len(price_l2) == len(price_l3), \ + "All positional arguments must have the same length" + for xi, yi, zi, nl2, nl3, nbus, pl2, pl3, tc in zip(x, y, z, num_l2, num_l3, num_bus, price_l2, price_l3, transform_coord): + msg["DATA"].append({"x": xi, "y": yi, "z": zi, "transformCoord": tc, + "numL2": nl2, "numL3": nl3, "numBus": nbus, + "priceL2": pl2, "priceL3": pl3}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_addChargingStation", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def remove_charging_station(self, stationID): + """Dynamically remove one or more charging stations by station ID.""" + msg = {"TYPE": "CTRL_removeChargingStation", "DATA": _as_list(stationID)} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_removeChargingStation", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + remove_chargingStation = remove_charging_station + + # Spawn e-taxis parked at given zone(s). + # zoneID: zone ID or list of zone IDs; num: number of taxis to spawn per zone. + # Returns spawned vehicle IDs grouped by zone. + def add_taxi(self, zoneID, num): + msg = {"TYPE": "CTRL_addTaxi", "DATA": []} + if not isinstance(zoneID, list): + zoneID = [zoneID] + if not isinstance(num, list): + num = [num] * len(zoneID) + assert len(zoneID) == len(num), "zoneID and num must have the same length" + for zid, n in zip(zoneID, num): + msg["DATA"].append({"zoneID": zid, "num": n}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_addTaxi", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # Spawn e-buses on existing named route(s). + # routeName: route name or list of route names; num: buses to spawn per route. + # Returns spawned vehicle IDs grouped by route. + def add_bus(self, routeName, num): + msg = {"TYPE": "CTRL_addBus", "DATA": []} + if not isinstance(routeName, list): + routeName = [routeName] + if not isinstance(num, list): + num = [num] * len(routeName) + assert len(routeName) == len(num), "routeName and num must have the same length" + for rname, n in zip(routeName, num): + msg["DATA"].append({"routeName": rname, "num": n}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_addBus", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + # Command vehicle(s) to interrupt current activity and go charge. + # veh_type: True = private EV, False = public taxi. + # charger_type: ChargingStation.L2 / L3 / BUS. + # cs_id: 0 = auto-select (nearest for public taxis/buses, cheapest usable + # station for private EVs); nonzero int = specific station ID. + # After charging the vehicle returns to its pre-charging destination. + def go_charging(self, vehID, veh_type, charger_type, cs_id=0): + msg = {"TYPE": "CTRL_goCharging", "DATA": []} + if not isinstance(vehID, list): + vehID = [vehID] + if not isinstance(veh_type, list): + veh_type = [veh_type] * len(vehID) + if not isinstance(charger_type, list): + charger_type = [charger_type] * len(vehID) + if not isinstance(cs_id, list): + cs_id = [cs_id] * len(vehID) + assert len(vehID) == len(veh_type) == len(charger_type) == len(cs_id), \ + "vehID, veh_type, charger_type, and cs_id must have the same length" + for vid, vtype, ctype, csid in zip(vehID, veh_type, charger_type, cs_id): + msg["DATA"].append({"vehID": vid, "vehType": vtype, "chargerType": ctype, "csID": csid}) + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_goCharging", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + + def _remember_config(self, config_json=None, config_signature=None, config=None): + if config_json is not None: + self.config_json = _normalize_config_json_path(config_json) + if config_signature is not None or config_json is not None: + self.config_signature = ( + config_signature + if config_signature is not None + else _config_signature_from_path(self.config_json) + ) + if config is not None: + self.config = config + + def _read_reset_config(self, config_json): + config_json_path = _normalize_config_json_path(config_json) + config_signature = _config_signature_from_path(config_json_path) + config = read_run_config(config_json_path) + return config_json_path, config_signature, config + + def _config_requires_restart(self, config_json_path, config_signature, config): + if self.config_signature is not None: + return config_signature != self.config_signature + if self.config_json is not None: + return config_json_path != self.config_json + + target_host = getattr(config, "metsr_host", self.host) + target_port = _port_from_config(config, self.sim_index, default=self.port) + if str(target_host) != str(self.host): + return True + return target_port is not None and int(target_port) != int(self.port) + + def _reset_current_simulation(self): + msg = {"TYPE": "CTRL_reset"} res = self.send_receive_msg(msg, ignore_heartbeats=True, max_attempts=-1) assert res["TYPE"] == "CTRL_reset", res["TYPE"] assert res["CODE"] == "OK", res["CODE"] - self.current_tick = -1 - self.tick() - assert self.current_tick == 0 + if "TICK" in res or "tick" in res: + self.current_tick = int(res.get("TICK", res.get("tick"))) + else: + self.current_tick = -1 + self.tick() + assert self.current_tick == 0 # if viz is running, stop and restart it if self.viz_server is not None: - self.stop_viz() + self.stop_offline_viz() - time.sleep(1) # wait for five secs if start viz + time.sleep(1) - self.start_viz() - - # reset the simulation with a map name - def reset_map(self, map_name): - # find the property file for the map - if map_name == "CARLA": - # copy CARLA data in the sim folder - # source_path = "data/CARLA" - # specify the property file - prop_file = "Data.properties.CARLA" - elif map_name == "NYC": - # copy NYC data in the sim folder - # source_path = "data/NYC" - # specify the property file - prop_file = "Data.properties.NYC" - elif map_name == "UA": - # copy UA data in the sim folder - # source_path = "data/UA" - # specify the property file - prop_file = "Data.properties.UA" - - # docker_cp_command = f"docker cp {source_path} {self.docker_id}:/home/test/data/" - # subprocess.run(docker_cp_command, shell=True, check=True) + self.start_offline_viz() + + return res + + def _capture_viz_state(self): + live_kwargs = None + if self.viz_stream_server is not None: + live_kwargs = dict(self.viz_stream_start_kwargs or {}) + if not live_kwargs: + live_kwargs = { + "server_port": self.viz_stream_port or 8765, + "host": self.viz_stream_host or "0.0.0.0", + } + + offline_kwargs = None + if self.viz_server is not None: + offline_kwargs = dict(self.offline_viz_start_kwargs or {}) + if not offline_kwargs: + offline_kwargs = {"server_port": self.viz_port or 8000} + + return live_kwargs, offline_kwargs + + def _restore_viz_state(self, live_kwargs=None, offline_kwargs=None): + if live_kwargs is not None: + self.start_viz(**live_kwargs) + if offline_kwargs is not None: + self.start_offline_viz(**offline_kwargs) + + def _terminate_simulation_only(self): + if self.ws is None: + return None + try: + msg = {"TYPE": "CTRL_end"} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + if res is not None: + assert res["TYPE"] == "CTRL_end", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + finally: + if self.ws is not None: + try: + self.ws.close() + except Exception: + pass + self.ws = None + self.state = "closed" + + def _wait_for_sim_port_release(self, host, port, timeout=10): + if str(host) not in {"localhost", "127.0.0.1", "0.0.0.0", "::1"}: + return + deadline = time.time() + max(0.0, float(timeout or 0.0)) + while time.time() <= deadline: + try: + with socket.create_connection(("127.0.0.1", int(port)), timeout=0.25): + pass + except OSError: + return + time.sleep(0.2) + + def _restart_simulation_with_config(self, config_json_path, config_signature, config): + sim_dirs = prepare_sim_dirs(config) + target_host = getattr(config, "metsr_host", self.host) + target_port = _port_from_config(config, self.sim_index, default=self.port) + if target_port is None: + raise RuntimeError("Restart config does not define a METS-R websocket port.") + + live_kwargs, offline_kwargs = self._capture_viz_state() + old_host = self.host + old_port = self.port + + self._terminate_simulation_only() + self._wait_for_sim_port_release(old_host, old_port) + run_simulation_in_docker(config) + + self.host = target_host + self.port = int(target_port) + self.uri = f"ws://{self.host}:{self.port}" + self.sim_folder = _sim_folder_from_config( + config, + sim_dirs=sim_dirs, + sim_index=self.sim_index, + default=self.sim_folder, + ) + self.current_tick = None + self._remember_config(config_json_path, config_signature, config) + self._connect(**self._connection_settings) + self._restore_viz_state(live_kwargs, offline_kwargs) + + # reset the current simulation; restart the simulator if a different config json is supplied + def reset(self, config_json=None): + if config_json is None: + self._reset_current_simulation() + return + + config_json_path, config_signature, config = self._read_reset_config(config_json) + if not self._config_requires_restart(config_json_path, config_signature, config): + self._remember_config(config_json_path, config_signature, config) + self._reset_current_simulation() + return + + self._restart_simulation_with_config(config_json_path, config_signature, config) + + # save the simulation instance to zip + def save(self, filename): + msg = {"TYPE": "CTRL_save", "DATA": {"path": filename}} + res = self.send_receive_msg(msg, ignore_heartbeats=True) - # reset the simulation with the property file - self.reset(prop_file) + assert res["TYPE"] == "CTRL_save", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + return res + + def load(self, filename, reload_network=True): + """Restore the simulator from a saved snapshot. + + Parameters + ---------- + filename : str + Path to the ``.zip`` snapshot previously written by :meth:`save`. + reload_network : bool + When ``True`` the simulator fully rebuilds the road + network and all facilities from the archive. When ``False`` the + simulator skips network and facility reconstruction and only + restores agent state, which is substantially faster when the + snapshot was taken from the same running instance (e.g. to replay + an experiment from a preheated checkpoint). The response includes + a ``fastLoad`` field confirming whether fast restoration was used. + """ + msg = {"TYPE": "CTRL_load", "DATA": {"path": filename, "reloadNetwork": bool(reload_network)}} + res = self.send_receive_msg(msg, ignore_heartbeats=True) + assert res["TYPE"] == "CTRL_load", res["TYPE"] + assert res["CODE"] == "OK", res["CODE"] + if "TICK" in res or "tick" in res: + self.current_tick = int(res.get("TICK", res.get("tick"))) + else: + synced_tick = self.query_tick() + self.current_tick = int(synced_tick) + return res # terminate the simulation def terminate(self): @@ -595,30 +3712,877 @@ def close(self): self.state = "closed" if self.viz_server is not None: - self.stop_viz() + self.stop_offline_viz() + + if self.viz_stream_server is not None: + self.stop_viz_stream() + unregister_metsr_client(self) + + + def _query_viz_road_dictionary(self): + try: + response = self.query_road() + except Exception: + return [] + ids = response.get("orig_id") or response.get("id_list") or [] + return sorted(str(road_id) for road_id in ids if road_id is not None) + + def _query_viz_zone_dictionary(self): + try: + response = self.query_zone() + except Exception: + return [] + return [zone_id for zone_id in (response.get("id_list") or []) if zone_id is not None] + + def _query_viz_charging_station_dictionary(self): + try: + response = self.query_chargingStation() + except Exception: + return [] + return [station_id for station_id in (response.get("id_list") or []) if station_id is not None] + + def _query_viz_records_by_ids(self, query_func, ids, batch_size=1000): + ids = [item for item in (ids or []) if item is not None] + if not ids: + return [] + + records = [] + batch_size = max(1, int(batch_size or 1)) + for start in range(0, len(ids), batch_size): + batch_ids = ids[start:start + batch_size] + try: + response = query_func(id=batch_ids) + except Exception: + continue + if response.get("CODE") == "KO": + continue + for record in response.get("DATA", []): + if isinstance(record, dict): + records.append(record) + return records + + def _query_viz_active_road_ids(self): + try: + response = self.query_active_roads() + except Exception: + return [] + if response.get("CODE") == "KO": + return [] + + ids = response.get("orig_id") or response.get("id_list") or [] + if not ids: + ids = [ + _viz_first(record, "ID", "originID", "roadID", "origID", default=None) + for record in response.get("DATA", []) + if isinstance(record, dict) + ] + return [str(road_id) for road_id in ids if road_id is not None] + + def _viz_clear_link_record(self, road_id): + return { + "ID": road_id, + "originID": road_id, + "num_veh": 0, + "nVehicles": 0, + "speed": 0.0, + "flow": 0, + "energy": 0.0, + "energy_consumed": 0.0, + "parkingCapacity": 0, + "parking_capacity": 0, + "parkedNum": 0, + "parked_num": 0, + } + + def _query_viz_link_records(self, active_road_ids=None, batch_size=1000): + if active_road_ids is None: + active_road_ids = self._query_viz_active_road_ids() + active_road_ids = [str(road_id) for road_id in (active_road_ids or []) if road_id is not None] + active_id_set = set(active_road_ids) + previous_id_set = set(getattr(self, "viz_stream_last_active_road_ids", set()) or set()) + query_ids = sorted(active_id_set | previous_id_set) + records = self._query_viz_records_by_ids( + self.query_road, + query_ids, + batch_size=batch_size, + ) + returned_ids = { + str(_viz_first(record, "ID", "originID", "roadID", "origID", default="")) + for record in records + if isinstance(record, dict) + } + for road_id in sorted(previous_id_set - active_id_set - returned_ids): + records.append(self._viz_clear_link_record(road_id)) + self.viz_stream_last_active_road_ids = active_id_set + return records + + def _query_viz_zone_records(self, zone_dictionary=None, batch_size=1000): + ids = list(zone_dictionary or self._query_viz_zone_dictionary()) + return self._query_viz_records_by_ids( + self.query_zone, + ids, + batch_size=batch_size, + ) + + def _query_viz_charging_station_records(self, charging_station_dictionary=None, + batch_size=1000): + ids = list(charging_station_dictionary or self._query_viz_charging_station_dictionary()) + return self._query_viz_records_by_ids( + self.query_chargingStation, + ids, + batch_size=batch_size, + ) + + def _query_viz_stream_vehicle_records( + self, + transform_coords=True, + include_public=True, + include_private=True, + vehicle_ids=None, + private_veh=False, + public_vehicle_ids=None, + private_vehicle_ids=None, + batch_size=1000, + road_ids=None): + query_groups = [] + + def add_query_group(group_ids, private_flag): + group_ids = list(group_ids or []) + if group_ids: + query_groups.append((group_ids, bool(private_flag))) + + if private_vehicle_ids is not None: + add_query_group(_as_list(private_vehicle_ids), True) + if public_vehicle_ids is not None: + add_query_group(_as_list(public_vehicle_ids), False) + if vehicle_ids is not None: + vehicle_id_list = _as_list(vehicle_ids) + private_flags = _broadcast(private_veh, len(vehicle_id_list)) + add_query_group( + [veh_id for veh_id, flag in zip(vehicle_id_list, private_flags) if bool(flag)], + True, + ) + add_query_group( + [veh_id for veh_id, flag in zip(vehicle_id_list, private_flags) if not bool(flag)], + False, + ) + + if not query_groups: + try: + fleet = self.query_on_road_vehicles(roadID=road_ids) + except Exception: + return [] + if not isinstance(fleet, dict) or fleet.get("CODE") == "KO": + return [] + if fleet.get("DATA"): + private_ids = [] + public_ids = [] + for road_record in fleet.get("DATA", []): + if isinstance(road_record, dict) and road_record.get("STATUS") != "KO": + private_ids.extend(road_record.get("private_vids") or []) + public_ids.extend(road_record.get("public_vids") or []) + fleet = { + "private_vids": list(dict.fromkeys(private_ids)), + "public_vids": list(dict.fromkeys(public_ids)), + } + if include_private: + add_query_group(fleet.get("private_vids") or [], True) + if include_public: + add_query_group(fleet.get("public_vids") or [], False) + + if not query_groups: + return [] + + records = [] + batch_size = max(1, int(batch_size or 1)) + for group_ids, private_flag in query_groups: + for start in range(0, len(group_ids), batch_size): + batch_ids = group_ids[start:start + batch_size] + try: + response = self.query_vehicle( + id=batch_ids, + private_veh=[private_flag] * len(batch_ids), + transform_coords=transform_coords, + ) + except Exception: + continue + if response.get("CODE") == "KO": + continue + for record in response.get("DATA", []): + if _viz_stream_record(record): + record = dict(record) + record["_viz_private_veh"] = bool(private_flag) + records.append(record) + return records + + def _wait_for_viz_stream_server(self, host, server_port, startup_timeout=3): + connect_host = host if host not in ("", "0.0.0.0", "::") else "127.0.0.1" + uri = f"ws://{connect_host}:{int(server_port)}" + deadline = time.time() + max(0.1, float(startup_timeout or 0.1)) + last_error = None + while time.time() <= deadline: + try: + try: + websocket = connect(uri, open_timeout=0.5) + except TypeError: + websocket = connect(uri) + with websocket: + return + except Exception as exc: + last_error = exc + time.sleep(0.1) + raise RuntimeError( + f"METS-R Vis stream server did not become reachable at {uri}" + ) from last_error + + def _viz_stream_url(self): + connect_host = ( + self.viz_stream_host + if self.viz_stream_host not in (None, "", "0.0.0.0", "::") + else "127.0.0.1" + ) + server_port = self.viz_stream_port if self.viz_stream_port is not None else 8765 + return f"ws://{connect_host}:{int(server_port)}" + + def _viz_stream_no_client_error(self, wait_seconds=0): + url = self._viz_stream_url() + waited = ( + f" within {wait_seconds:g} seconds" + if wait_seconds and wait_seconds > 0 + else "" + ) + return ( + f"No METS-R Vis client connected to the live stream at {url}{waited}, " + "so render() cannot deliver a frame.\n" + "Fix: open https://engineering.purdue.edu/HSEES/METSRVis/ in a browser, " + "click Stream, set the WebSocket URL to the URL printed by start_viz() " + f"({url}), and then run render() again.\n" + "If the browser is on another machine, restart the stream with " + f"start_viz(host='0.0.0.0', server_port={int(self.viz_stream_port or 8765)}) " + "and connect METS-R Vis to ws://:" + f"{int(self.viz_stream_port or 8765)}." + ) + + def _wait_for_viz_stream_client(self, client_wait_timeout=5): + try: + wait_seconds = max(0.0, float(client_wait_timeout or 0.0)) + except (TypeError, ValueError): + wait_seconds = 0.0 + if wait_seconds != wait_seconds or wait_seconds in (float("inf"), float("-inf")): + wait_seconds = 0.0 + deadline = time.time() + wait_seconds + while True: + with self.viz_stream_lock: + client_count = len(self.viz_stream_clients) + if client_count > 0: + return client_count + if wait_seconds == 0 or time.time() >= deadline: + raise RuntimeError(self._viz_stream_no_client_error(wait_seconds)) + time.sleep(min(0.1, max(0.0, deadline - time.time()))) + + def _serve_viz_stream_connection(self, websocket, stop_event, manifest): + try: + websocket.send(json.dumps({"type": "manifest", "manifest": manifest})) + with self.viz_stream_lock: + if websocket not in self.viz_stream_clients: + self.viz_stream_clients.append(websocket) + + while not stop_event.is_set(): + try: + websocket.recv(timeout=0.5) + except TimeoutError: + continue + except Exception: + break + finally: + with self.viz_stream_lock: + if websocket in self.viz_stream_clients: + self.viz_stream_clients.remove(websocket) + + def start_viz( + self, + server_port=8765, + host="0.0.0.0", + tick_interval=1, + transform_coords=False, + include_public=True, + include_private=True, + vehicle_ids=None, + private_veh=False, + public_vehicle_ids=None, + private_vehicle_ids=None, + batch_size=1000, + coord_scale=_VIZ_STREAM_DEFAULT_COORD_SCALE, + initial_x=None, + initial_y=None, + link_snapshot_interval=1, + road_id_dictionary=None, + zone_dictionary=None, + charging_station_dictionary=None, + include_links=True, + include_zones=False, + include_charging_stations=False, + link_batch_size=1000, + facility_batch_size=1000, + startup_timeout=3): + """Start a live METS-R Vis WebSocket stream. + + This only opens the WebSocket server and sends the VIS binary manifest + to each connected browser. Call :meth:`render` whenever you want to + query METS-R and push a new frame into METSR_VIS. + + If `initial_x` and `initial_y` are omitted, they are read from + `sim_folder/data/Data.properties` so the live stream uses the same + origin as the run config prepared for METS-R. + """ + start_kwargs = { + "server_port": server_port, + "host": host, + "tick_interval": tick_interval, + "transform_coords": transform_coords, + "include_public": include_public, + "include_private": include_private, + "vehicle_ids": vehicle_ids, + "private_veh": private_veh, + "public_vehicle_ids": public_vehicle_ids, + "private_vehicle_ids": private_vehicle_ids, + "batch_size": batch_size, + "coord_scale": coord_scale, + "initial_x": initial_x, + "initial_y": initial_y, + "link_snapshot_interval": link_snapshot_interval, + "road_id_dictionary": road_id_dictionary, + "zone_dictionary": zone_dictionary, + "charging_station_dictionary": charging_station_dictionary, + "include_links": include_links, + "include_zones": include_zones, + "include_charging_stations": include_charging_stations, + "link_batch_size": link_batch_size, + "facility_batch_size": facility_batch_size, + "startup_timeout": startup_timeout, + } + try: + from websockets.sync.server import serve + except ImportError as exc: + raise RuntimeError( + "start_viz requires the 'websockets' package from requirements.txt." + ) from exc + + for logger_name in ("websockets", "websockets.server", "websockets.sync.server"): + logging.getLogger(logger_name).setLevel(logging.CRITICAL) + + tick_interval = max(1, int(tick_interval)) + coord_scale = max(1, int(coord_scale)) + link_snapshot_interval = max(1, int(link_snapshot_interval)) + link_batch_size = max(1, int(link_batch_size or 1)) + facility_batch_size = max(1, int(facility_batch_size or 1)) + include_links = bool(include_links) + include_zones = bool(include_zones) + include_charging_stations = bool(include_charging_stations) + initial_x, initial_y = _viz_resolve_origin(self.sim_folder, initial_x, initial_y) + + if road_id_dictionary is None: + road_id_dictionary = self._query_viz_road_dictionary() if include_links else [] + elif road_id_dictionary == "query": + road_id_dictionary = self._query_viz_road_dictionary() + if zone_dictionary is None: + zone_dictionary = self._query_viz_zone_dictionary() if include_zones else [] + elif zone_dictionary == "query": + zone_dictionary = self._query_viz_zone_dictionary() + if charging_station_dictionary is None: + charging_station_dictionary = ( + self._query_viz_charging_station_dictionary() + if include_charging_stations else [] + ) + elif charging_station_dictionary == "query": + charging_station_dictionary = self._query_viz_charging_station_dictionary() + + road_id_dictionary = list(road_id_dictionary or []) + zone_dictionary = list(zone_dictionary or []) + charging_station_dictionary = list(charging_station_dictionary or []) + manifest = _viz_manifest( + road_id_dictionary, + coord_scale, + initial_x, + initial_y, + tick_interval, + link_snapshot_interval, + zone_dictionary=zone_dictionary, + charging_station_dictionary=charging_station_dictionary, + ) + + if self.viz_stream_server is not None: + self.stop_viz_stream() + + stop_event = threading.Event() + options = { + "tick_interval": tick_interval, + "transform_coords": bool(transform_coords), + "include_public": bool(include_public), + "include_private": bool(include_private), + "vehicle_ids": vehicle_ids, + "private_veh": private_veh, + "public_vehicle_ids": public_vehicle_ids, + "private_vehicle_ids": private_vehicle_ids, + "batch_size": batch_size, + "coord_scale": coord_scale, + "initial_x": float(initial_x), + "initial_y": float(initial_y), + "link_snapshot_interval": link_snapshot_interval, + "include_links": include_links, + "include_zones": include_zones, + "include_charging_stations": include_charging_stations, + "link_batch_size": link_batch_size, + "facility_batch_size": facility_batch_size, + "road_id_dictionary": road_id_dictionary, + "road_id_index": { + str(road_id): index + for index, road_id in enumerate(road_id_dictionary) + }, + "zone_dictionary": zone_dictionary, + "charging_station_dictionary": charging_station_dictionary, + } + + def handler(websocket): + self._serve_viz_stream_connection(websocket, stop_event, manifest) + servers = [] + server_threads = [] + + def start_server(bind_host): + server = serve(handler, bind_host, int(server_port)) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + servers.append(server) + server_threads.append(server_thread) + return server, server_thread + + server, server_thread = start_server(host) + if host in (None, "", "0.0.0.0", "127.0.0.1", "localhost"): + try: + start_server("::1") + except Exception: + # Optional compatibility binding for browsers that resolve + # localhost to IPv6 before IPv4 on Windows. + pass + + self.viz_stream_server = server + self.viz_stream_servers = servers + self.viz_stream_thread = server_thread + self.viz_stream_threads = server_threads + self.viz_stream_stop_event = stop_event + self.viz_stream_host = host + self.viz_stream_port = int(server_port) + self.viz_stream_manifest = manifest + self.viz_stream_options = options + self.viz_stream_chunk_counter = 0 + self.viz_stream_last_tick = None + self.viz_stream_last_active_road_ids = set() + with self.viz_stream_lock: + self.viz_stream_clients = [] + + localhost_reachable = False + try: + self._wait_for_viz_stream_server(host, server_port, startup_timeout=startup_timeout) + try: + localhost_timeout = max(0.1, min(1.0, float(startup_timeout or 1.0))) + self._wait_for_viz_stream_server( + "localhost", + server_port, + startup_timeout=localhost_timeout, + ) + localhost_reachable = True + except Exception: + localhost_reachable = False + except Exception: + self.stop_viz_stream() + raise + + + connect_host = host if host not in ("", "0.0.0.0", "::") else "127.0.0.1" + url = f"ws://{connect_host}:{int(server_port)}" + localhost_url = f"ws://localhost:{int(server_port)}" + browser_url = localhost_url if localhost_reachable else url + self.viz_stream_start_kwargs = start_kwargs + print(f"METS-R Vis live stream is available at {url}; origin=({initial_x}, {initial_y}); call render() to send frames.") + return { + "host": host, + "port": int(server_port), + "url": url, + "local_url": url, + "localhost_url": localhost_url, + "browser_url": browser_url, + "localhost_reachable": localhost_reachable, + "manifest": manifest, + "initial_x": float(initial_x), + "initial_y": float(initial_y), + "mode": "live", + } + + def _send_viz_stream_messages(self, *messages): + with self.viz_stream_lock: + clients = list(self.viz_stream_clients) + + stale_clients = [] + sent_clients = 0 + for websocket in clients: + try: + for message in messages: + websocket.send(message) + sent_clients += 1 + except Exception: + stale_clients.append(websocket) + + if stale_clients: + with self.viz_stream_lock: + for websocket in stale_clients: + if websocket in self.viz_stream_clients: + self.viz_stream_clients.remove(websocket) + return sent_clients + + def render(self, client_wait_timeout=5): + """Query the current METS-R tick and push one frame to METSR_VIS. + + Raises if no METS-R Vis client connects within client_wait_timeout seconds. + """ + if self.viz_stream_server is None or self.viz_stream_manifest is None: + raise RuntimeError("Call start_viz() before render().") + + self._wait_for_viz_stream_client(client_wait_timeout=client_wait_timeout) + options = dict(self.viz_stream_options or {}) + tick = int(self.query_tick()) + + first_stream_chunk = self.viz_stream_chunk_counter == 0 + include_link_snapshot = ( + options["include_links"] + and (first_stream_chunk or tick % options["link_snapshot_interval"] == 0) + ) + active_road_ids = self._query_viz_active_road_ids() if options["include_links"] else None + records = self._query_viz_stream_vehicle_records( + transform_coords=options["transform_coords"], + include_public=options["include_public"], + include_private=options["include_private"], + vehicle_ids=options["vehicle_ids"], + private_veh=options["private_veh"], + public_vehicle_ids=options["public_vehicle_ids"], + private_vehicle_ids=options["private_vehicle_ids"], + batch_size=options["batch_size"], + road_ids=active_road_ids, + ) + vehicle_group_counts = { + group_key: len(group_records) + for group_key, group_records in _viz_group_vehicle_records(records).items() + } + link_records = ( + self._query_viz_link_records( + active_road_ids=active_road_ids, + batch_size=options["link_batch_size"], + ) + if include_link_snapshot else [] + ) + zone_records = ( + self._query_viz_zone_records( + options["zone_dictionary"], + batch_size=options["facility_batch_size"], + ) + if options["include_zones"] else [] + ) + charging_station_records = ( + self._query_viz_charging_station_records( + options["charging_station_dictionary"], + batch_size=options["facility_batch_size"], + ) + if options["include_charging_stations"] else [] + ) + chunk, vehicle_count, link_count, zone_count, charging_station_count = _viz_chunk( + records, + tick, + options["coord_scale"], + options["initial_x"], + options["initial_y"], + options["tick_interval"], + options["link_snapshot_interval"], + link_records=link_records, + road_id_index=options["road_id_index"], + zone_records=zone_records, + charging_station_records=charging_station_records, + ) + self.viz_stream_chunk_counter += 1 + chunk_meta = { + "file": f"stream-{tick}-{self.viz_stream_chunk_counter}.bin", + "firstTick": tick, + "lastTick": tick, + "tickCount": 1, + "vehicleCount": vehicle_count, + "vehicleGroupCounts": vehicle_group_counts, + "linkCount": link_count, + "zoneUpdateCount": zone_count, + "chargingStationUpdateCount": charging_station_count, + "closed": True, + } + + messages = [] + if self.viz_stream_last_tick is not None and tick < self.viz_stream_last_tick: + messages.append(json.dumps({"type": "reset"})) + messages.append(json.dumps({"type": "manifest", "manifest": self.viz_stream_manifest})) + messages.append(json.dumps({"type": "chunkMeta", "chunk": chunk_meta})) + messages.append(chunk) + + client_count = self._send_viz_stream_messages(*messages) + if client_count == 0: + raise RuntimeError( + self._viz_stream_no_client_error(0) + + "\nA client was connected when render() started, but it disconnected before the frame was sent." + ) + self.viz_stream_last_tick = tick + return { + "tick": tick, + "vehicle_count": vehicle_count, + "vehicle_group_counts": vehicle_group_counts, + "link_count": link_count, + "zone_update_count": zone_count, + "charging_station_update_count": charging_station_count, + "client_count": client_count, + "chunk": chunk_meta, + } + + def stop_viz_stream(self, join_timeout=2.0): + if self.viz_stream_stop_event is not None: + self.viz_stream_stop_event.set() + + with self.viz_stream_lock: + clients = list(self.viz_stream_clients) + self.viz_stream_clients = [] + for websocket in clients: + close = getattr(websocket, "close", None) + if callable(close): + try: + close() + except Exception: + pass + + servers = list(getattr(self, "viz_stream_servers", []) or []) + if self.viz_stream_server is not None and self.viz_stream_server not in servers: + servers.append(self.viz_stream_server) + for server in servers: + shutdown = getattr(server, "shutdown", None) + close = getattr(server, "close", None) + try: + if callable(shutdown): + shutdown() + elif callable(close): + close() + except Exception: + pass + + threads = list(getattr(self, "viz_stream_threads", []) or []) + if self.viz_stream_thread is not None and self.viz_stream_thread not in threads: + threads.append(self.viz_stream_thread) + for thread in threads: + thread.join(timeout=join_timeout) + + self.viz_stream_server = None + self.viz_stream_servers = [] + self.viz_stream_thread = None + self.viz_stream_threads = [] + self.viz_stream_stop_event = None + self.viz_stream_host = None + self.viz_stream_port = None + self.viz_stream_manifest = None + self.viz_stream_options = None + self.viz_stream_start_kwargs = None + self.viz_stream_chunk_counter = 0 + self.viz_stream_last_tick = None + self.viz_stream_last_active_road_ids = set() + + def latest_trajectory_output_dir(self, trajectory_output_dir=None, prefer_binary=True, wait_seconds=0): + """Return the newest trajectory output directory for visualization. + + The latest METS-R SIM writes binary trajectory chunks with a + ``manifest.json`` file by default. Older runs may still write JSON + chunks, so this method accepts both formats and prefers binary output + when available. + """ + if not self.sim_folder: + raise ValueError("sim_folder is required to locate trajectory output") + wait_seconds = max(0.0, float(wait_seconds or 0)) + + if trajectory_output_dir is not None: + roots = [_resolve_trajectory_root(self.sim_folder, trajectory_output_dir)] + else: + roots = _configured_trajectory_roots(self.sim_folder) + + deadline = time.time() + wait_seconds + while True: + candidates = [] + for root in roots: + latest = _latest_trajectory_directory(root, prefer_binary=prefer_binary) + if latest is not None: + candidates.append(latest) + + if candidates: + if prefer_binary: + binary_candidates = [ + directory for directory in candidates + if _trajectory_format_score(directory) >= 2 + ] + if binary_candidates: + return max(binary_candidates, key=os.path.getmtime) + return max(candidates, key=os.path.getmtime) + + if wait_seconds <= 0 or time.time() >= deadline: + roots_text = ", ".join(str(root) for root in roots if root) + raise FileNotFoundError( + "No trajectory output directory found under " + roots_text + ) + time.sleep(0.5) + + def get_trajectory_manifest(self, trajectory_output_dir=None, prefer_binary=True, wait_seconds=0): + """Return the manifest for the latest binary trajectory output. + METS-R SIM binary trajectory format v6 writes zone and charging-station + frames as sparse deltas. This helper exposes the manifest, including + ``sparseFrameGroups`` and ``sparseFrameGroupMode``, without making + callers locate or parse ``manifest.json`` manually. + """ + latest_directory = self.latest_trajectory_output_dir( + trajectory_output_dir=trajectory_output_dir, + prefer_binary=prefer_binary, + wait_seconds=wait_seconds, + ) + manifest = _read_trajectory_manifest(latest_directory) + if manifest is None: + raise FileNotFoundError( + "No manifest.json found in trajectory output directory: " + + latest_directory + ) + + manifest = dict(manifest) + manifest["_directory"] = latest_directory + manifest["_manifest_path"] = os.path.join(latest_directory, "manifest.json") + return manifest + + def get_trajectory_summary(self, trajectory_output_dir=None, prefer_binary=True, wait_seconds=0): + """Return high-level metadata for the latest trajectory output.""" + latest_directory = self.latest_trajectory_output_dir( + trajectory_output_dir=trajectory_output_dir, + prefer_binary=prefer_binary, + wait_seconds=wait_seconds, + ) + manifest = _read_trajectory_manifest(latest_directory) + if manifest is not None: + return _trajectory_manifest_summary(latest_directory, manifest) + + return { + "directory": latest_directory, + "format": _trajectory_format_name(latest_directory), + "version": None, + "sparse_frame_groups": [], + "sparse_frame_group_mode": None, + "has_sparse_frame_groups": False, + "has_sparse_zone_frames": False, + "has_sparse_charging_station_frames": False, + "has_zone_attributes": False, + "has_charging_station_attributes": False, + "has_split_energy_fields": False, + } + + def _wait_for_viz_server(self, server_port, startup_timeout=3): + deadline = time.time() + max(0.1, float(startup_timeout or 0.1)) + last_error = None + while time.time() <= deadline: + try: + with socket.create_connection(("127.0.0.1", int(server_port)), timeout=0.5): + return + except OSError as exc: + last_error = exc + time.sleep(0.1) + raise RuntimeError( + f"Visualization server did not become reachable at http://127.0.0.1:{server_port}/" + ) from last_error + + # open visualization server for existing trajectory files + def start_offline_viz( + self, + trajectory_output_dir=None, + server_port=8000, + prefer_binary=True, + wait_seconds=30, + startup_timeout=3): + start_kwargs = { + "trajectory_output_dir": trajectory_output_dir, + "server_port": server_port, + "prefer_binary": prefer_binary, + "wait_seconds": wait_seconds, + "startup_timeout": startup_timeout, + } + if trajectory_output_dir is not None: + if os.path.isabs(str(trajectory_output_dir)): + latest_directory = os.path.normpath(str(trajectory_output_dir)) + elif os.path.isdir(str(trajectory_output_dir)): + latest_directory = os.path.abspath(str(trajectory_output_dir)) + elif self.sim_folder: + latest_directory = _resolve_trajectory_root(self.sim_folder, trajectory_output_dir) + else: + latest_directory = os.path.abspath(str(trajectory_output_dir)) + + if not os.path.isdir(latest_directory): + raise FileNotFoundError("Trajectory output directory does not exist: " + latest_directory) + if _read_trajectory_manifest(latest_directory) is None: + raise FileNotFoundError( + "No manifest.json found directly inside trajectory output directory: " + + latest_directory + ) + else: + latest_directory = self.latest_trajectory_output_dir( + trajectory_output_dir=None, + prefer_binary=prefer_binary, + wait_seconds=wait_seconds, + ) - # open visualization server - def start_viz(self): - # obtain the latest directory in the sim_folder/trajectory_output - # get the latest directory - list_of_files = [os.path.join(self.sim_folder + "/trajectory_output", f) for f in os.listdir(self.sim_folder + "/trajectory_output")] - # sort the list of files by creation time - latest_directory = max(list_of_files, key=os.path.getmtime) - # open the visualization server - self.viz_event, self.viz_server = run_visualization_server(latest_directory) + if self.viz_server is not None: + self.stop_offline_viz() + + print( + f"Starting offline visualization server for {_trajectory_format_name(latest_directory)} " + f"trajectory output: {latest_directory}" + ) + self.viz_event, self.viz_server = run_visualization_server(latest_directory, server_port) + self.viz_port = server_port + try: + self._wait_for_viz_server(server_port, startup_timeout=startup_timeout) + except Exception: + self.stop_offline_viz() + raise + self.offline_viz_start_kwargs = start_kwargs + print(f"Visualization files are available at http://127.0.0.1:{server_port}/") + return { + "directory": latest_directory, + "port": server_port, + "url": f"http://127.0.0.1:{server_port}/", + "manifest_url": f"http://127.0.0.1:{server_port}/manifest.json", + "mode": "offline", + } - def stop_viz(self): + def stop_offline_viz(self, verbose=True): if self.viz_server is not None: - stop_visualization_server(self.viz_event, self.viz_server) + stop_visualization_server(self.viz_event, self.viz_server, self.viz_port or 8000, verbose=verbose) self.viz_event = None self.viz_server = None - + self.viz_port = None + self.offline_viz_start_kwargs = None + + def stop_viz(self, verbose=True): + if self.viz_stream_server is not None: + self.stop_viz_stream() + if self.viz_server is not None: + self.stop_offline_viz(verbose=verbose) + def _logMessage(self, direction, msg): self._messagesLog.append( - (datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), direction, tuple(msg.items())) + (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), direction, tuple(msg.items())) ) - + print(self._messagesLog[-1]) + # override __str__ for logging def __str__(self): s = f"-----------\n" \ @@ -627,4 +4591,4 @@ def __str__(self): f"output folder :\t {self.sim_folder}\n" \ f"address :\t {self.uri}\n" \ f"state :\t {self.state}\n" - return s \ No newline at end of file + return s diff --git a/src/scenic/simulators/metsr/simulator.py b/src/scenic/simulators/metsr/simulator.py index 827c63650..197d8d28c 100644 --- a/src/scenic/simulators/metsr/simulator.py +++ b/src/scenic/simulators/metsr/simulator.py @@ -1,125 +1,125 @@ -"""Simulator interface for METS-R Sim.""" - -import math - -from scenic.core.simulators import Simulation, Simulator -from scenic.core.vectors import Orientation, Vector -from scenic.simulators.metsr.client import METSRClient - - -class METSRSimulator(Simulator): - def __init__(self, host, port, map_name, timestep, sim_timestep, verbose=False): - super().__init__() - self.client = METSRClient(host=host, port=port, verbose=verbose) - - self.map_name = map_name - self.timestep = timestep - self.sim_timestep = sim_timestep - - def createSimulation(self, scene, timestep, **kwargs): - assert timestep is None or timestep == self.timestep - return METSRSimulation( - scene, self.client, self.map_name, self.timestep, self.sim_timestep, **kwargs - ) - - def destroy(self): - self.client.close() - super().destroy() - - -class METSRSimulation(Simulation): - def __init__(self, scene, client, map_name, timestep, sim_timestep, **kwargs): - self.client = client - self.map_name = map_name - - self.timestep = timestep - self.sim_timestep = sim_timestep - self.sim_ticks_per = int(timestep / sim_timestep) - assert self.sim_ticks_per == timestep / sim_timestep - - self.next_pv_id = 0 - self.pv_id_map = {} - self.frozen_vehicles = set() - - self._client_calls = [] - - self.count = 0 - - super().__init__(scene, timestep=timestep, **kwargs) - - def setup(self): - # Reset map - self.client.reset("Data.properties.CARLA") - - super().setup() # Calls createObjectInSimulator for each object - - def createObjectInSimulator(self, obj): - assert obj.origin - assert obj.destination - - call_kwargs = { - "vehID": self.getPrivateVehId(obj), - "origin": obj.origin, - "destination": obj.destination, - } - - self.client.generate_trip(**call_kwargs) - - def step(self): - self.count += 1 - if self.count % 100 == 0: - print(".", end="", flush=True) - for _ in range(self.sim_ticks_per): - self.client.tick() - - def updateObjects(self): - obj_veh_ids = [self.getPrivateVehId(obj) for obj in self.objects] - raw_veh_data = self.client.query_vehicle(obj_veh_ids, True, True) - self.obj_data_cache = {obj: raw_veh_data['DATA'][i] for i, obj in enumerate(self.objects)} - super().updateObjects() - self.obj_data_cache = None - - def getProperties(self, obj, properties): - if obj in self.frozen_vehicles: - return None - - raw_data = self.obj_data_cache[obj] - - if "road" not in raw_data and raw_data["state"] <= 0: - self.frozen_vehicles.add(obj) - - position = Vector(raw_data["x"], raw_data["y"], 0) - speed = raw_data["speed"] - bearing = math.radians(raw_data["bearing"]) - globalOrientation = Orientation.fromEuler(bearing, 0, 0) - yaw, pitch, roll = obj.parentOrientation.localAnglesFor(globalOrientation) - velocity = Vector(0, speed, 0).rotatedBy(yaw) - angularSpeed = 0 - angularVelocity = Vector(0, 0, 0) - - values = dict( - position=position, - velocity=velocity, - speed=speed, - angularSpeed=angularSpeed, - angularVelocity=angularVelocity, - yaw=yaw, - pitch=pitch, - roll=roll, - ) - return values - - def destroy(self): - if self.client.verbose: - print("Client Messages Log:") - print("[") - for call in self.client._messagesLog: - print(f" {call},") - print("]") - - def getPrivateVehId(self, obj): - if obj not in self.pv_id_map: - self.pv_id_map[obj] = self.next_pv_id - self.next_pv_id += 1 - - return self.pv_id_map[obj] +"""Simulator interface for METS-R Sim.""" + +import math + +from scenic.core.simulators import Simulation, Simulator +from scenic.core.vectors import Orientation, Vector +from scenic.simulators.metsr.client import METSRClient + + +class METSRSimulator(Simulator): + def __init__(self, host, port, map_name, timestep, sim_timestep, verbose=False): + super().__init__() + self.client = METSRClient(host=host, port=port, verbose=verbose) + + self.map_name = map_name + self.timestep = timestep + self.sim_timestep = sim_timestep + + def createSimulation(self, scene, timestep, **kwargs): + assert timestep is None or timestep == self.timestep + return METSRSimulation( + scene, self.client, self.map_name, self.timestep, self.sim_timestep, **kwargs + ) + + def destroy(self): + self.client.close() + super().destroy() + + +class METSRSimulation(Simulation): + def __init__(self, scene, client, map_name, timestep, sim_timestep, **kwargs): + self.client = client + self.map_name = map_name + + self.timestep = timestep + self.sim_timestep = sim_timestep + self.sim_ticks_per = int(timestep / sim_timestep) + assert self.sim_ticks_per == timestep / sim_timestep + + self.next_pv_id = 0 + self.pv_id_map = {} + self.frozen_vehicles = set() + + self._client_calls = [] + + self.count = 0 + + super().__init__(scene, timestep=timestep, **kwargs) + + def setup(self): + # Reset map + self.client.reset() + + super().setup() # Calls createObjectInSimulator for each object + + def createObjectInSimulator(self, obj): + assert obj.origin + assert obj.destination + + call_kwargs = { + "vehID": self.getPrivateVehId(obj), + "origin": obj.origin, + "destination": obj.destination, + } + + self.client.generate_trip(**call_kwargs) + + def step(self): + self.count += 1 + if self.count % 100 == 0: + print(".", end="", flush=True) + for _ in range(self.sim_ticks_per): + self.client.tick() + + def updateObjects(self): + obj_veh_ids = [self.getPrivateVehId(obj) for obj in self.objects] + raw_veh_data = self.client.query_vehicle(obj_veh_ids, True, True) + self.obj_data_cache = {obj: raw_veh_data['DATA'][i] for i, obj in enumerate(self.objects)} + super().updateObjects() + self.obj_data_cache = None + + def getProperties(self, obj, properties): + if obj in self.frozen_vehicles: + return None + + raw_data = self.obj_data_cache[obj] + + if "road" not in raw_data and raw_data["state"] <= 0: + self.frozen_vehicles.add(obj) + + position = Vector(raw_data["x"], raw_data["y"], 0) + speed = raw_data["speed"] + bearing = math.radians(raw_data["bearing"]) + globalOrientation = Orientation.fromEuler(bearing, 0, 0) + yaw, pitch, roll = obj.parentOrientation.localAnglesFor(globalOrientation) + velocity = Vector(0, speed, 0).rotatedBy(yaw) + angularSpeed = 0 + angularVelocity = Vector(0, 0, 0) + + values = dict( + position=position, + velocity=velocity, + speed=speed, + angularSpeed=angularSpeed, + angularVelocity=angularVelocity, + yaw=yaw, + pitch=pitch, + roll=roll, + ) + return values + + def destroy(self): + if self.client.verbose: + print("Client Messages Log:") + print("[") + for call in self.client._messagesLog: + print(f" {call},") + print("]") + + def getPrivateVehId(self, obj): + if obj not in self.pv_id_map: + self.pv_id_map[obj] = self.next_pv_id + self.next_pv_id += 1 + + return self.pv_id_map[obj] diff --git a/src/scenic/simulators/metsr/traffic_flows.py b/src/scenic/simulators/metsr/traffic_flows.py index 38b5f0359..f162399a7 100644 --- a/src/scenic/simulators/metsr/traffic_flows.py +++ b/src/scenic/simulators/metsr/traffic_flows.py @@ -14,11 +14,11 @@ class ConstantTrafficFlow(TrafficFlow): def __init__(self, num_vehs, stime=None, etime=None): self.num_vehs = num_vehs self.stime = stime if stime is not None else 0 - self.etime = etime if stime is not None else 24 * 60 * 60 - if etime <= stime: - raise ValueError("etime must be greater than stime.") + self.etime = etime if etime is not None else 24 * 60 * 60 + if self.etime <= self.stime: + raise ValueError(f"etime must be greater than stime {self.stime, self.etime}.") - self.vps = self.num_vehs / (etime - stime) + self.vps = self.num_vehs / (self.etime - self.stime) def expected_vehs(self, stime, etime): if etime <= stime: diff --git a/src/scenic/simulators/metsr/util.py b/src/scenic/simulators/metsr/util.py new file mode 100644 index 000000000..d4e2fa81a --- /dev/null +++ b/src/scenic/simulators/metsr/util.py @@ -0,0 +1,1330 @@ +"""Helper functions for METSR-SIM and METSR-HPC.""" + +import socket +import json +import os +import re +import shlex +import subprocess +import time +import shutil +from os import path +import platform +from contextlib import closing +from types import SimpleNamespace +import sys +import zipfile +import threading +import weakref +from threading import Event +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from datetime import datetime + + +# --------------------------------------------------------------------------- +_SERVER_REGISTRY_LOCK = threading.RLock() +_VISUALIZATION_SERVER_REGISTRY = [] +_METSR_CLIENT_REGISTRY = weakref.WeakSet() + + +def register_metsr_client(client): + """Track a METSRClient so process-local helper servers can be stopped.""" + with _SERVER_REGISTRY_LOCK: + _METSR_CLIENT_REGISTRY.add(client) + return client + + +def unregister_metsr_client(client): + with _SERVER_REGISTRY_LOCK: + try: + _METSR_CLIENT_REGISTRY.discard(client) + except TypeError: + pass + + +def _register_visualization_server(stop_event, server_thread, port=None, directory=None): + entry = { + "stop_event": stop_event, + "server_thread": server_thread, + "port": int(port) if port is not None else None, + "directory": os.path.abspath(directory) if directory else None, + } + with _SERVER_REGISTRY_LOCK: + _VISUALIZATION_SERVER_REGISTRY.append(entry) + return entry + + +def _unregister_visualization_server(server_thread=None, stop_event=None): + with _SERVER_REGISTRY_LOCK: + _VISUALIZATION_SERVER_REGISTRY[:] = [ + entry for entry in _VISUALIZATION_SERVER_REGISTRY + if not ( + (server_thread is not None and entry.get("server_thread") is server_thread) + or (stop_event is not None and entry.get("stop_event") is stop_event) + ) + ] + + +def stop_all_visualization_servers(verbose=True, join_timeout=2.0): + """Stop all file/CORS visualization servers started in this Python process.""" + with _SERVER_REGISTRY_LOCK: + entries = list(_VISUALIZATION_SERVER_REGISTRY) + stopped = [] + for entry in entries: + record = { + "port": entry.get("port"), + "directory": entry.get("directory"), + "returncode": 0, + "stderr": "", + } + try: + stop_visualization_server( + entry.get("stop_event"), + entry.get("server_thread"), + port=entry.get("port") or 8000, + join_timeout=join_timeout, + verbose=verbose, + ) + except Exception as exc: + record["returncode"] = 1 + record["stderr"] = str(exc).splitlines()[0] + if verbose: + print(f"Failed to stop visualization server on port {record['port']}: {record['stderr']}") + stopped.append(record) + if verbose and not stopped: + print("No process-local visualization file servers found.") + return stopped + + +def stop_all_metsr_client_servers(verbose=True): + """Stop live stream and file servers owned by registered METSRClient objects.""" + with _SERVER_REGISTRY_LOCK: + clients = list(_METSR_CLIENT_REGISTRY) + stopped = [] + for client in clients: + record = { + "client": repr(client), + "had_stream_server": getattr(client, "viz_stream_server", None) is not None, + "had_file_server": getattr(client, "viz_server", None) is not None, + "returncode": 0, + "stderr": "", + } + if not record["had_stream_server"] and not record["had_file_server"]: + continue + try: + stop_viz = getattr(client, "stop_viz", None) + if callable(stop_viz): + try: + stop_viz(verbose=verbose) + except TypeError: + stop_viz() + else: + if record["had_stream_server"] and hasattr(client, "stop_viz_stream"): + client.stop_viz_stream() + if record["had_file_server"] and hasattr(client, "stop_offline_viz"): + client.stop_offline_viz() + if verbose: + print( + "Stopped METS-R client helper servers " + f"stream={record['had_stream_server']} file={record['had_file_server']}" + ) + except Exception as exc: + record["returncode"] = 1 + record["stderr"] = str(exc).splitlines()[0] + if verbose: + print(f"Failed to stop METS-R client helper servers: {record['stderr']}") + stopped.append(record) + if verbose and not stopped: + print("No registered METS-R client helper servers found.") + return stopped + + +# Generic helpers +# --------------------------------------------------------------------------- + +def check_socket(host, port): + flag = True + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + if sock.connect_ex((host, port)) == 0: + flag = True + else: + flag = False + time.sleep(1) + return flag + + +def str_list_mapper_gen(func): + def str_list_mapper(str_list): + return [func(str) for str in str_list] + return str_list_mapper + + +def _is_sequence(value): + return isinstance(value, (list, tuple)) + + +def _as_list(value): + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +def _broadcast(value, length): + if length == 1: + return [value] + if _is_sequence(value) and len(value) == length: + return list(value) + return [value] * length + + +# --------------------------------------------------------------------------- +# METSRClient payload helpers +# --------------------------------------------------------------------------- + +VEHICLE_SENSOR_DSRC = 0 +VEHICLE_SENSOR_CV2X = 1 +VEHICLE_SENSOR_MOBILE_DEVICE = 2 + +VEHICLE_SENSOR_TYPES = { + "dsrc": VEHICLE_SENSOR_DSRC, + "80211p": VEHICLE_SENSOR_DSRC, + "cv2x": VEHICLE_SENSOR_CV2X, + "c-v2x": VEHICLE_SENSOR_CV2X, + "c_v2x": VEHICLE_SENSOR_CV2X, + "mobile": VEHICLE_SENSOR_MOBILE_DEVICE, + "mobiledevice": VEHICLE_SENSOR_MOBILE_DEVICE, + "mobile_device": VEHICLE_SENSOR_MOBILE_DEVICE, + "mobile-device": VEHICLE_SENSOR_MOBILE_DEVICE, +} + +REQUEST_ID_FIELDS = ("reqID", "requestID", "requestId", "ID", "id") +REQUEST_ZONE_FIELDS = ( + "zoneID", + "zoneId", + "zone", + "originZone", + "origZone", + "origin", + "orig", +) + + +def _request_id_from_record(record): + if not isinstance(record, dict): + return record + for key in REQUEST_ID_FIELDS: + value = record.get(key) + if value is not None: + return value + return None + + +def _request_zone_from_record(record): + if not isinstance(record, dict): + return None + for key in REQUEST_ZONE_FIELDS: + value = record.get(key) + if value is None: + continue + if isinstance(value, dict): + nested_value = _request_zone_from_record(value) + if nested_value is not None: + return nested_value + continue + return value + return None + + +def _normalize_sensor_type(sensor_type): + if isinstance(sensor_type, str): + key = sensor_type.strip().lower() + compact_key = key.replace(" ", "").replace("_", "").replace("-", "") + if key in VEHICLE_SENSOR_TYPES: + return VEHICLE_SENSOR_TYPES[key] + if compact_key in VEHICLE_SENSOR_TYPES: + return VEHICLE_SENSOR_TYPES[compact_key] + raise ValueError( + "Unknown sensorType. Use 0/'dsrc', 1/'cv2x', or 2/'mobile_device'." + ) + return sensor_type + + +def _looks_like_centerline(value): + if not _is_sequence(value) or len(value) == 0: + return False + first_point = value[0] + if not _is_sequence(first_point) or len(first_point) < 2: + return False + return not _is_sequence(first_point[0]) + + +def _set_road_reference(record, field_prefix, value): + if value is None: + return + if _is_sequence(value) and not isinstance(value, str): + record[field_prefix + "Roads"] = list(value) + else: + record[field_prefix + "Road"] = value + + +# --------------------------------------------------------------------------- +# Simulation property/config helpers +# --------------------------------------------------------------------------- + +_PROPERTY_RE = re.compile(r"^(\s*)([A-Za-z0-9_]+)\s*=\s*(.*?)(\r?\n?)$") +_MISSING = object() + +_PROPERTY_OPTION_ALIASES = { + "SIMULATION_STEP_SIZE": ("sim_step_size",), + "ENABLE_JSON_WRITE": ("json_output",), + "NUM_OF_EV": ("num_etaxi",), + "NUM_OF_BUS": ("num_ebus",), + "RH_SHARE_PERCENTAGE": ("rh_share_file",), + "RH_WAITING_TIME": ("rh_wait_file",), + "BT_STD_FILE": ("bt_event_std_file",), + "EV_DEMAND_FILE": ("private_ev_demand_file",), + "GV_DEMAND_FILE": ("private_gv_demand_file",), + "EV_CHARGING_PREFERENCE": ("private_ev_charging_preference",), +} + + +def _camel_to_snake(name): + """Return a config-friendly lowercase form for mixed-case property names.""" + name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + return name.lower() + + +def _config_names_for_property(property_name): + names = [property_name.lower(), _camel_to_snake(property_name)] + names.extend(_PROPERTY_OPTION_ALIASES.get(property_name, ())) + + deduped = [] + for name in names: + if name not in deduped: + deduped.append(name) + return deduped + + +def _get_option(options, name): + if isinstance(options, dict): + return options.get(name, _MISSING) + return getattr(options, name, _MISSING) + + +def _first_option_value(options, names): + for name in names: + value = _get_option(options, name) + if value is not _MISSING and value is not None: + return True, value + return False, None + + +def _format_property_value(value): + if isinstance(value, bool): + return str(value).lower() + return str(value) + + +def _property_line(key, value, newline): + newline = newline or "\n" + return f"{key} = {_format_property_value(value)}{newline}" + + +def _ensure_extension(value, extension): + value = str(value) + if value.lower().endswith(extension): + return value + return value + extension + + +def _rewrite_data_path(line, src_data_dir): + if "data/" not in line: + return line + src_data_dir = src_data_dir.replace("\\", "/").rstrip("/") + return line.replace("data/", src_data_dir + "/") + + +def _property_override_value(key, options, port, instance): + """Find the value that should be written for a Data.properties key. + + Most properties are now mapped automatically from the lowercase key name + used in JSON configs, for example CAR_FOLLOWING_MODEL -> car_following_model. + Existing HPC config names are kept as aliases so old configs still work. + """ + if key == "NETWORK_LISTEN_PORT": + return True, port + if key == "RANDOM_SEED": + found, seeds = _first_option_value(options, ("random_seeds",)) + if found: + return True, seeds[instance] + return False, None + if key == "STANDALONE": + return True, False + if key == "SYNCHRONIZED": + return True, True + if key == "AGG_DEFAULT_PATH": + return True, "agg_output" + if key == "JSON_DEFAULT_PATH": + return True, "trajectory_output" + + if key == "ZONES_SHAPEFILE": + found, value = _first_option_value(options, ("zones_shapefile",)) + if found: + return True, value + found, value = _first_option_value(options, ("zone_file",)) + if found: + return True, _ensure_extension(value, ".shp") + elif key == "ZONES_CSV": + found, value = _first_option_value(options, ("zones_csv",)) + if found: + return True, value + found, value = _first_option_value(options, ("zone_file",)) + if found: + return True, _ensure_extension(value, ".csv") + elif key == "CHARGER_SHAPEFILE": + found, value = _first_option_value(options, ("charger_shapefile",)) + if found: + return True, value + found, value = _first_option_value(options, ("charging_station_file",)) + if found: + return True, _ensure_extension(value, ".shp") + elif key == "CHARGER_CSV": + found, value = _first_option_value(options, ("charger_csv",)) + if found: + return True, value + found, value = _first_option_value(options, ("charging_station_file",)) + if found: + return True, _ensure_extension(value, ".csv") + elif key == "RH_DEMAND_SHARABLE": + found, value = _first_option_value(options, _config_names_for_property(key)) + if found: + return True, value + found, _ = _first_option_value(options, ("rh_wait_file", "rh_waiting_time")) + if found: + return True, True + return False, None + + return _first_option_value(options, _config_names_for_property(key)) + + +# --------------------------------------------------------------------------- +# Simulation file preparation +# --------------------------------------------------------------------------- + +def modify_property_file(options, src_data_dir, dest_data_dir, port, instance, template): + fname = src_data_dir + "/Data.properties." + template + if not path.exists(fname): + print("ERROR, cannot find the property template file at ", fname) + sys.exit(-1) + + f = open(fname, "r") + lines = f.readlines() + f.close() + fname = dest_data_dir + "/Data.properties" + f_new = open(fname, "w") + for l in lines: + match = _PROPERTY_RE.match(l) + if match: + _, key, _, newline = match.groups() + found, value = _property_override_value(key, options, port, instance) + if found: + l = _property_line(key, value, newline) + l = _rewrite_data_path(l, src_data_dir) + + f_new.write(l) + f_new.close() + +def force_copytree(src, dst): + """ + Recursively copy a directory tree, overwriting the destination directory if it exists. + """ + # Check if the destination directory exists + if os.path.exists(dst): + # Remove the destination directory and all its contents + shutil.rmtree(dst) + + # Copy the source directory to the destination + shutil.copytree(src, dst) + +# Copy necessary files for running the simulation +def prepare_sim_dirs(options): + src_data_dir = "data" + # check if metsr_port in the NameSpace options + if hasattr(options, 'metsr_port'): + # check if metsr_port number is equal to the number of simulations + if options.num_simulations > len(options.metsr_port): + print("ERROR , port number is less than the number of simulation instances") + sys.exit(-1) + else: + options.ports = options.metsr_port + else: + print("No port number specified, find available ports for simulation instances") + find_free_ports(options, options.num_simulations) + if len(options.ports) != options.num_simulations: + print("ERROR , cannot specify port number for all simulation instances") + sys.exit(-1) + + + dest_data_dirs = [] + options.sim_dirs = [] + for i in range(options.num_simulations): + # make a directory to run the simulator + dir_name = get_sim_dir(options, i) + if not path.exists(dir_name): + os.makedirs(dir_name) + options.sim_dirs.append(dir_name) + shutil.copy(src_data_dir+"/log4j.properties", dir_name + "/log4j.properties") + # copy the simulation config files + dest_data_dir = dir_name + "/" + "data" + + if not path.exists(dest_data_dir): + os.mkdir(dest_data_dir) + # copy the entire data directory + force_copytree(src_data_dir, dest_data_dir) + + modify_property_file(options, src_data_dir, dest_data_dir, options.ports[i], i, options.template) + dest_data_dirs.append(dest_data_dir[:-5]) # -5 to remove the "/data" part + + return dest_data_dirs + +# Function for getting the file name list of demand scenarios +# def prepare_scenario_dict(options, path): +# scenarios = os.listdir(path) +# i = 0 +# scenarios = sorted(scenarios) +# options.scenarios=[] +# options.cases = [[] for j in range(len(scenarios))] +# for scenario in scenarios: +# options.scenarios.append(scenario) +# cases = os.listdir(path+"/"+scenario) +# cases = sorted(cases) +# for case in cases: +# options.cases[i].append(case.split("_")[1]) +# i+=1 + +# --------------------------------------------------------------------------- +# Port and config helpers +# --------------------------------------------------------------------------- + +def find_free_ports(options, num_simulations): + options.ports = [] + while True: + for i in range(num_simulations): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(('localhost', 0)) + options.ports.append(s.getsockname()[1]) + try: + for port in options.ports: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('', port)) + s.close() + break + except: + print("The port is not valid anymore, regenerate it") + continue + time.sleep(1) + +# Read json format configuration +def _load_raw_config(fname): + """Recursively load a config JSON, merging parent_config fields first.""" + fname = os.path.abspath(fname) + with open(fname, "r") as f: + raw = json.load(f) + + if "parent_config" in raw: + parent_path = os.path.abspath( + os.path.join(os.path.dirname(fname), raw["parent_config"]) + ) + parent_raw = _load_raw_config(parent_path) + child_fields = {k: v for k, v in raw.items() if k != "parent_config"} + return {**parent_raw, **child_fields} + + return raw + + +def read_run_config(fname): + merged = _load_raw_config(fname) + config = SimpleNamespace(**merged) + + if len(config.random_seeds) != config.num_simulations: + print("ERROR, please specify random seeds for all simulation instances") + sys.exit(-1) + + return config + +# --------------------------------------------------------------------------- +# Java classpath helpers +# --------------------------------------------------------------------------- + +def get_classpath(options, includeBin=True, separator=":"): + + classpath = "" + + if not path.exists(options.repast_plugin_dir): + print(f"ERROR , repast plugins not found at {options.repast_plugin_dir}") + sys.exit(-1) + + classpath += options.repast_plugin_dir + "repast.simphony.runtime_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.runtime_2.7.0/lib/*" + separator + \ + options.sim_dir + "lib/*" + separator + + + + + return classpath + +def get_classpath2(options, includeBin=True, separator=":"): + + classpath = "" + + classpath += options.repast_plugin_dir + "repast.simphony.runtime_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.runtime_2.7.0/lib/*" + separator + \ + options.repast_plugin_dir + "repast.simphony.batch_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.batch_2.7.0/lib/*" + separator + \ + options.repast_plugin_dir + "repast.simphony.distributed.batch_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.distributed.batch_2.7.0/lib/*" + separator + \ + options.repast_plugin_dir + "repast.simphony.core_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.core_2.7.0/lib/*" + separator + \ + options.sim_dir + "bin" + separator + \ + options.sim_dir + "lib/*" + separator + \ + options.repast_plugin_dir + "repast.simphony.bin_and_src_2.7.0/repast.simphony.bin_and_src.jar" + separator + \ + options.repast_plugin_dir + "repast.simphony.essentials_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.gis_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.gis_2.7.0/lib/*" + separator + \ + options.repast_plugin_dir + "repast.simphony.sql_2.7.0/bin" + separator + \ + options.repast_plugin_dir + "repast.simphony.sql_2.7.0/lib/*" + separator + \ + options.repast_plugin_dir + "repast.simphony.scenario_2.7.0/bin" + separator + + return classpath + +# --------------------------------------------------------------------------- +# Simulation launch helpers +# --------------------------------------------------------------------------- + +def _shell_args(value): + if value in (None, ""): + return [] + if isinstance(value, str): + return shlex.split(value) + if isinstance(value, (list, tuple)): + return [str(item) for item in value if str(item)] + return [str(value)] + + +def _default_appcontainer_executable(): + for candidate in ("apptainer", "singularity", "appcontainer"): + if shutil.which(candidate): + return candidate + return "apptainer" + +def run_simulations(options): + for i in range(0, options.num_simulations): + cwd = str(os.getcwd()) + if platform.system() == "Windows": + # go to sim directory + os.chdir(options.sim_dirs[i]) + + # print(get_classpath(options, False, separator = ";")) + # run the simulation on a new terminal + sim_command = '"' + options.java_path + 'java"' + " " + \ + options.java_options + " " + \ + "-classpath " + \ + '"' +get_classpath(options, False, separator = ";") + '" ' + \ + "repast.simphony.runtime.RepastMain " + \ + options.sim_dir + "mets_r.rs" + # print(sim_command) + if options.verbose: # print the sim output to the console + subprocess.Popen(sim_command, shell=True) + else: + subprocess.Popen(sim_command + " > sim_{}.log 2>&1 &".format(i), shell=True) + else: + # go to sim directory + os.chdir(options.sim_dirs[i]) + # run simulator on new terminal + sim_command = options.java_path + "java " + \ + options.java_options + " " + \ + "-classpath " + \ + get_classpath(options, False) + " " + \ + "repast.simphony.runtime.RepastMain " + \ + options.sim_dir + "mets_r.rs" + if options.verbose: + os.system(sim_command) + else: + os.system(sim_command + " > sim_{}.log 2>&1 &".format(i)) + # go back to test directory + os.chdir(cwd) + +def run_simulations_in_background(options): + for i in range(0, options.num_simulations): + cwd = str(os.getcwd()) + if platform.system() == "Windows": + # go to sim directory + os.chdir(options.sim_dirs[i]) + # run the simulation on a new terminal + sim_command = '"' + options.java_path + 'java"'+ " -Xmx16G " + \ + "-cp " + \ + '"' + get_classpath2(options, False, separator = ";") + '" ' + \ + "repast.simphony.batch.BatchMain " + \ + "-params " + options.sim_dir + "mets_r.rs/batch_params.xml " +\ + "-interactive " + options.sim_dir + "mets_r.rs " + # print(sim_command) + if options.verbose: # print the sim output to the console + subprocess.Popen(sim_command) + else: + subprocess.Popen(sim_command + " > sim_{}.log 2>&1 &".format(i), shell=True) + else: + # go to sim directory + os.chdir(options.sim_dirs[i]) + # run simulator on new terminal + sim_command = '' + options.java_path + 'java'+ " -Xmx16G " + \ + "-cp " + \ + get_classpath2(options, False) + ' ' + \ + "repast.simphony.batch.BatchMain " + \ + "-params " + options.sim_dir + "mets_r.rs/batch_params.xml " +\ + "-interactive " + options.sim_dir + "mets_r.rs " + if options.verbose: + os.system(sim_command) + else: + os.system(sim_command + " > sim_{}.log 2>&1 &".format(i)) + # go back to test directory + os.chdir(cwd) + +def run_simulation_in_docker(options): + for i in range(0, options.num_simulations): + cwd = str(os.getcwd()) + os.chdir(options.sim_dirs[i]) + + sim_command = '' + options.java_path + 'java'+ " -Xmx16G " + \ + "-cp " + \ + get_classpath2(options, False) + ' ' + \ + "repast.simphony.batch.BatchMain " + \ + "-params " + options.sim_dir + "mets_r.rs/batch_params.xml " +\ + "-interactive " + options.sim_dir + "mets_r.rs" + + docker_command = f'docker run -d --rm --mount src="{os.getcwd()}",target=/home/test,type=bind --net=host ennuilei/mets-r_sim /bin/bash -c "cd /home/test && ' + sim_command + '"' + result = subprocess.run(docker_command, shell=True, text=True, capture_output=True) + if options.verbose: + print("Container ID:", result.stdout) + # print("Error msg:", result.stderr) + # container_id = result.stdout.strip() + container_id = result.stdout.strip() + os.chdir(cwd) + return container_id + + +def run_simulation_in_appcontainer(options): + """Launch METS-R SIM with an AppContainer/Apptainer-style runtime. + + This mode starts only the METS-R Java simulator. Kafka/docker-compose + services are not available here, so Kafka-backed V2X examples should still + use Docker mode or an externally managed Kafka service. + + Optional config/environment overrides: + - appcontainer_executable / METSR_APPCONTAINER_EXECUTABLE + - appcontainer_image / METSR_APPCONTAINER_IMAGE + - appcontainer_args / METSR_APPCONTAINER_ARGS + - appcontainer_bind_target / METSR_APPCONTAINER_BIND_TARGET + - appcontainer_command / METSR_APPCONTAINER_COMMAND + """ + note = ( + "NOTE: AppContainer mode starts only METS-R SIM; Kafka/docker-compose " + "services are not available in this mode." + ) + if ( + getattr(options, "verbose", False) + or getattr(options, "kafka_bootstrap_servers", None) + or getattr(options, "kafka_topics", None) + ): + print(note) + + executable = ( + getattr(options, "appcontainer_executable", None) + or os.environ.get("METSR_APPCONTAINER_EXECUTABLE") + or _default_appcontainer_executable() + ) + image = ( + getattr(options, "appcontainer_image", None) + or os.environ.get("METSR_APPCONTAINER_IMAGE") + or "docker://ennuilei/mets-r_sim" + ) + runtime_args = _shell_args( + getattr(options, "appcontainer_args", None) + or os.environ.get("METSR_APPCONTAINER_ARGS") + ) + bind_target = ( + getattr(options, "appcontainer_bind_target", None) + or os.environ.get("METSR_APPCONTAINER_BIND_TARGET") + or "/home/test" + ) + command_name = ( + getattr(options, "appcontainer_command", None) + or os.environ.get("METSR_APPCONTAINER_COMMAND") + or "exec" + ) + + for i in range(0, options.num_simulations): + cwd = str(os.getcwd()) + log_file = None + try: + os.chdir(options.sim_dirs[i]) + + sim_command = '' + options.java_path + 'java'+ " -Xmx16G " + \ + "-cp " + \ + get_classpath2(options, False) + ' ' + \ + "repast.simphony.batch.BatchMain " + \ + "-params " + options.sim_dir + "mets_r.rs/batch_params.xml " +\ + "-interactive " + options.sim_dir + "mets_r.rs" + + appcontainer_command = [ + executable, + command_name, + *runtime_args, + "--bind", + f"{os.getcwd()}:{bind_target}", + image, + "/bin/bash", + "-lc", + f"cd {shlex.quote(bind_target)} && {sim_command}", + ] + + if getattr(options, "verbose", False): + process = subprocess.Popen(appcontainer_command) + print("AppContainer METS-R process ID:", process.pid) + else: + log_file = open("sim_{}.log".format(i), "a") + process = subprocess.Popen( + appcontainer_command, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"AppContainer executable {executable!r} was not found. " + "Set options.appcontainer_executable or METSR_APPCONTAINER_EXECUTABLE." + ) from exc + finally: + if log_file is not None: + log_file.close() + os.chdir(cwd) + +def clear_all(patterns=None, docker_executable="docker", verbose=True, stop_servers=True): + """Stop process-local METS-R helper servers and running METS-R Docker containers. + + This stops live METS-R Vis streams and file/CORS visualization servers that + were started by METSRClient or utility helpers in the current Python + process, then stops running Docker containers whose image/name appears to + belong to METS-R. + + Parameters + ---------- + patterns : sequence[str], optional + Case-insensitive substrings to match against container image/name. + Defaults to METS-R naming variants. + docker_executable : str, optional + Docker CLI executable to invoke. + verbose : bool, optional + Print a short summary of stopped resources. + stop_servers : bool, optional + Stop process-local METS-R client streams and file servers before Docker + cleanup. + + Returns + ------- + dict + ``metsr_clients`` records helper servers stopped through registered + clients, ``visualization_servers`` records standalone file servers, and + ``docker_containers`` records stopped Docker containers. + """ + cleanup = { + "metsr_clients": [], + "visualization_servers": [], + "docker_containers": [], + } + if stop_servers: + cleanup["metsr_clients"] = stop_all_metsr_client_servers(verbose=verbose) + cleanup["visualization_servers"] = stop_all_visualization_servers(verbose=verbose) + + patterns = tuple(patterns or ("mets-r", "metsr", "mets_r")) + lowered_patterns = tuple(str(pattern).lower() for pattern in patterns) + ps_command = [ + docker_executable, + "ps", + "--format", + "{{.ID}}\t{{.Image}}\t{{.Names}}", + ] + try: + ps_result = subprocess.run( + ps_command, + text=True, + capture_output=True, + check=True, + ) + except FileNotFoundError as exc: + raise RuntimeError(f"Docker executable {docker_executable!r} was not found.") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError( + "Failed to list running Docker containers: " + + (exc.stderr or exc.stdout or str(exc)).strip() + ) from exc + + targets = [] + for line in ps_result.stdout.splitlines(): + parts = line.split("\t", 2) + if len(parts) != 3: + continue + container_id, image, name = parts + haystack = f"{image} {name}".lower() + if any(pattern in haystack for pattern in lowered_patterns): + targets.append({"id": container_id, "image": image, "name": name}) + + if not targets: + if verbose: + print("No running METS-R Docker containers found.") + return cleanup + + for target in targets: + result = subprocess.run( + [docker_executable, "stop", target["id"]], + text=True, + capture_output=True, + ) + record = dict(target) + record.update( + { + "returncode": result.returncode, + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + } + ) + cleanup["docker_containers"].append(record) + if verbose: + status = "stopped" if result.returncode == 0 else "failed" + print( + f"{status}: {target['id']} " + f"image={target['image']} name={target['name']}" + ) + if result.returncode != 0 and result.stderr: + print(result.stderr.strip()) + + return cleanup +# --------------------------------------------------------------------------- +# Visualization server helpers +# --------------------------------------------------------------------------- + +class CORSRequestHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def __init__(self, *args, directory=None, **kwargs): + self.custom_directory = directory + super().__init__(*args, directory=directory, **kwargs) + + def end_headers(self): + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type') + if self.path.rstrip('/').endswith('manifest.json'): + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + self.send_header('Pragma', 'no-cache') + self.send_header('Expires', '0') + super().end_headers() + + def do_OPTIONS(self): + self.send_response(200, "ok") + self.end_headers() + + def log_message(self, format, *args): + """Suppress noisy dashboard polling access logs.""" + return + + +class ReusableThreadingHTTPServer(ThreadingHTTPServer): + allow_reuse_address = True + daemon_threads = True + request_queue_size = 64 + + +def start_cors_http_server(directory, stop_event, port=8000): + """Start a CORS-enabled HTTP server for the specified directory.""" + handler_class = lambda *args, **kwargs: CORSRequestHandler(*args, directory=directory, **kwargs) + server_address = ('', port) + httpd = ReusableThreadingHTTPServer(server_address, handler_class) + httpd.timeout = 0.5 + + def run_server(): + try: + httpd.serve_forever(poll_interval=0.2) + finally: + httpd.server_close() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.httpd = httpd + server_thread.start() + return server_thread + +def run_visualization_server(data_folder, server_port = 8000): + # store the current work directory + # workdir = os.getcwd() + # Ensure the data folder exists + if not os.path.exists(data_folder): + os.makedirs(data_folder) + print(f"Created data folder: {data_folder}") + + # Start the HTTP server in a separate thread + # os.chdir(data_folder) # Change to the specified directory + stop_event = Event() + server_thread = start_cors_http_server(data_folder, stop_event, server_port) + _register_visualization_server(stop_event, server_thread, port=server_port, directory=data_folder) + print(f"Serving {data_folder} with CORS enabled on port {server_port}...") + + # recovery the work directory + # os.chdir(workdir) + + return stop_event, server_thread + +def stop_visualization_server(stop_event, server_thread, port=8000, join_timeout=2.0, verbose=True): + if stop_event is not None: + stop_event.set() + httpd = getattr(server_thread, "httpd", None) + if httpd is not None: + httpd.shutdown() + + # Send dummy request to unblock handle_request() + if httpd is None: + try: + with socket.create_connection(("localhost", port), timeout=1) as sock: + sock.sendall(b"HEAD / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + except Exception: + pass + + if server_thread is None: + _unregister_visualization_server(stop_event=stop_event) + return + server_thread.join(timeout=join_timeout) + _unregister_visualization_server(server_thread=server_thread, stop_event=stop_event) + if server_thread.is_alive(): + if verbose: + print(f"Visualization server thread did not stop within {join_timeout:.1f} seconds.") + else: + if verbose: + print("Visualization server stopped.") + + +def _simulation_folder_from_config(config, sim_index=0, output_root="output"): + sim_dirs = getattr(config, "sim_dirs", None) + if sim_dirs: + try: + return sim_dirs[sim_index] + except IndexError as exc: + raise IndexError( + f"sim_index {sim_index} is outside config.sim_dirs with {len(sim_dirs)} entries" + ) from exc + + sim_folder = getattr(config, "sim_folder", None) + if sim_folder: + if isinstance(sim_folder, (list, tuple)): + try: + return sim_folder[sim_index] + except IndexError as exc: + raise IndexError( + f"sim_index {sim_index} is outside config.sim_folder with {len(sim_folder)} entries" + ) from exc + return sim_folder + + sim_dir = getattr(config, "sim_dir", None) + if sim_dir: + sim_dir_candidates = sim_dir if isinstance(sim_dir, (list, tuple)) else [sim_dir] + try: + sim_dir_candidate = sim_dir_candidates[sim_index] + except IndexError as exc: + raise IndexError( + f"sim_index {sim_index} is outside config.sim_dir with {len(sim_dir_candidates)} entries" + ) from exc + if _folder_has_trajectory_output(sim_dir_candidate): + return sim_dir_candidate + + run_name = getattr(config, "name", None) + if not run_name: + raise ValueError( + "Cannot infer simulation output folder: config needs sim_dirs, sim_folder, or a name." + ) + + seeds = getattr(config, "random_seeds", None) or [] + seed = seeds[sim_index] if sim_index < len(seeds) else None + output_root = os.path.abspath(output_root) + try: + names = os.listdir(output_root) + except OSError as exc: + raise FileNotFoundError(f"Simulation output root does not exist: {output_root}") from exc + + prefix = f"{run_name}_" + seed_suffix = f"_seed_{seed}" if seed is not None else None + candidates = [] + for name in names: + if not name.startswith(prefix): + continue + if seed_suffix is not None and not name.endswith(seed_suffix): + continue + candidate = os.path.join(output_root, name) + if os.path.isdir(candidate): + candidates.append(candidate) + + if not candidates: + seed_text = f" and seed {seed}" if seed is not None else "" + raise FileNotFoundError( + f"No finished simulation output folder found for config name {run_name!r}{seed_text} under {output_root}" + ) + + return max(candidates, key=os.path.getmtime) + + +def _folder_has_trajectory_output(sim_folder): + if not sim_folder or not os.path.isdir(sim_folder): + return False + for root in _configured_trajectory_roots(sim_folder): + if _latest_trajectory_directory(root, prefer_binary=False) is not None: + return True + return False + + +def latest_trajectory_output_dir_from_config( + config, + sim_index=0, + trajectory_output_dir=None, + prefer_binary=True, + wait_seconds=0, + output_root="output"): + sim_folder = _simulation_folder_from_config( + config, + sim_index=sim_index, + output_root=output_root, + ) + if trajectory_output_dir is not None: + roots = [_resolve_trajectory_root(sim_folder, trajectory_output_dir)] + else: + roots = _configured_trajectory_roots(sim_folder) + + deadline = time.time() + max(0, float(wait_seconds or 0)) + while True: + for root in roots: + latest_directory = _latest_trajectory_directory(root, prefer_binary=prefer_binary) + if latest_directory is not None: + return latest_directory + + if time.time() >= deadline: + break + time.sleep(0.5) + + roots_text = ", ".join(str(root) for root in roots if root) + raise FileNotFoundError("No trajectory output directory found under " + roots_text) + + +def start_visualization_server_from_config( + config, + sim_index=0, + trajectory_output_dir=None, + server_port=8000, + prefer_binary=True, + wait_seconds=0, + output_root="output"): + latest_directory = latest_trajectory_output_dir_from_config( + config, + sim_index=sim_index, + trajectory_output_dir=trajectory_output_dir, + prefer_binary=prefer_binary, + wait_seconds=wait_seconds, + output_root=output_root, + ) + print( + f"Starting visualization server for {_trajectory_format_name(latest_directory)} " + f"trajectory output: {latest_directory}" + ) + stop_event, server_thread = run_visualization_server(latest_directory, server_port) + + def stop(): + stop_visualization_server(stop_event, server_thread, server_port) + + return SimpleNamespace( + directory=latest_directory, + port=server_port, + stop_event=stop_event, + server_thread=server_thread, + stop=stop, + ) + + +# --------------------------------------------------------------------------- +# Trajectory output helpers +# --------------------------------------------------------------------------- + +def _read_property_values(properties_path): + values = {} + try: + with open(properties_path, "r") as properties_file: + for raw_line in properties_file: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + except OSError: + pass + return values + + +def _read_trajectory_manifest(directory): + manifest_path = os.path.join(directory, "manifest.json") + try: + with open(manifest_path, "r") as manifest_file: + return json.load(manifest_file) + except (OSError, json.JSONDecodeError): + return None + + +def _resolve_trajectory_root(sim_folder, configured_path): + if not configured_path: + return None + configured_path = os.path.normpath(configured_path) + if os.path.isabs(configured_path): + return configured_path + return os.path.join(sim_folder, configured_path) + + +def _configured_trajectory_roots(sim_folder): + properties = _read_property_values(os.path.join(sim_folder, "data", "Data.properties")) + roots = [] + for key in ("TRAJECTORY_BINARY_DEFAULT_PATH", "JSON_DEFAULT_PATH"): + root = _resolve_trajectory_root(sim_folder, properties.get(key)) + if root and root not in roots: + roots.append(root) + + default_root = os.path.join(sim_folder, "trajectory_output") + if default_root not in roots: + roots.append(default_root) + return roots + + +def _trajectory_format_score(directory): + try: + names = os.listdir(directory) + except OSError: + return 0 + + if "manifest.json" in names: + return 3 + lowered = [name.lower() for name in names] + if any(name.endswith(".bin") for name in lowered): + return 2 + if any(name.endswith(".json") for name in lowered): + return 1 + return 0 + + +def _trajectory_format_name(directory): + manifest = _read_trajectory_manifest(directory) + if manifest is not None: + output_format = manifest.get("format", "binary") + version = manifest.get("version") + sparse_frame_groups = manifest.get("sparseFrameGroups") or [] + sparse_suffix = " sparse" if sparse_frame_groups else "" + if version is not None: + return f"{output_format} v{version}{sparse_suffix}" + return f"{output_format}{sparse_suffix}" + + score = _trajectory_format_score(directory) + if score >= 2: + return "binary" + if score == 1: + return "JSON" + return "trajectory" + + +def _trajectory_manifest_summary(directory, manifest): + chunks = manifest.get("chunks", []) + active_chunk = manifest.get("activeChunk", {}) + road_dictionary = manifest.get("roadIdDictionary", []) + zone_dictionary = manifest.get("zoneDictionary", []) + charging_station_dictionary = manifest.get("chargingStationDictionary", []) + schemas = manifest.get("schemas", {}) + frame_groups = manifest.get("frameGroups", []) + sparse_frame_groups = manifest.get("sparseFrameGroups") or [] + sparse_frame_group_mode = manifest.get("sparseFrameGroupMode") + + return { + "directory": directory, + "manifest_path": os.path.join(directory, "manifest.json"), + "format": manifest.get("format"), + "version": manifest.get("version"), + "byte_order": manifest.get("byteOrder"), + "coord_scale": manifest.get("coordScale"), + "initial_x": manifest.get("initialX"), + "initial_y": manifest.get("initialY"), + "tick_interval": manifest.get("tickInterval"), + "link_snapshot_interval": manifest.get("linkSnapshotInterval"), + "chunk_tick_limit": manifest.get("chunkTickLimit"), + "chunk_count": len(chunks), + "active_chunk": active_chunk, + "road_count": len(road_dictionary), + "zone_count": len(zone_dictionary), + "charging_station_count": len(charging_station_dictionary), + "frame_groups": frame_groups, + "sparse_frame_groups": sparse_frame_groups, + "sparse_frame_group_mode": sparse_frame_group_mode, + "has_sparse_frame_groups": bool(sparse_frame_groups), + "has_sparse_zone_frames": "zone" in sparse_frame_groups, + "has_sparse_charging_station_frames": "chargingStation" in sparse_frame_groups, + "schema_names": sorted(schemas.keys()), + "has_zone_attributes": bool(zone_dictionary) or "zone" in schemas, + "has_charging_station_attributes": ( + bool(charging_station_dictionary) or "chargingStation" in schemas + ), + "has_split_energy_fields": ( + "frameHeader" in schemas + and any("energyPrivateEV" in field for field in schemas["frameHeader"]) + ), + } + + +def _latest_trajectory_directory(root, prefer_binary=True): + if root is None or not os.path.isdir(root): + return None + + candidates = [] + root_score = _trajectory_format_score(root) + if root_score > 0: + candidates.append((root_score, os.path.getmtime(root), root)) + + for name in os.listdir(root): + candidate = os.path.join(root, name) + if not os.path.isdir(candidate): + continue + score = _trajectory_format_score(candidate) + if score > 0: + candidates.append((score, os.path.getmtime(candidate), candidate)) + + if not candidates: + subdirs = [ + os.path.join(root, name) + for name in os.listdir(root) + if os.path.isdir(os.path.join(root, name)) + ] + if not subdirs: + return None + return max(subdirs, key=os.path.getmtime) + + if prefer_binary: + binary_candidates = [candidate for candidate in candidates if candidate[0] >= 2] + if binary_candidates: + return max(binary_candidates, key=lambda item: item[1])[2] + + return max(candidates, key=lambda item: item[1])[2] + +# --------------------------------------------------------------------------- +# Simulation output path helpers +# --------------------------------------------------------------------------- + +def get_sim_dir(options, i): + sim_dir = "output/"+ options.name + "_" + datetime.now().strftime("%Y%m%d_%H%M%S") + "_seed_" + str(options.random_seeds[i]) + return sim_dir + +# diff --git a/src/scenic/simulators/newtonian/car.png b/src/scenic/simulators/newtonian/car.png index 46f39d574..a6f7d014d 100644 Binary files a/src/scenic/simulators/newtonian/car.png and b/src/scenic/simulators/newtonian/car.png differ diff --git a/src/scenic/simulators/newtonian/driving_model.scenic b/src/scenic/simulators/newtonian/driving_model.scenic index 1c01ccab2..e9f7fd396 100644 --- a/src/scenic/simulators/newtonian/driving_model.scenic +++ b/src/scenic/simulators/newtonian/driving_model.scenic @@ -6,6 +6,10 @@ Vehicles support the basic actions and behaviors from the driving domain. A path to a map file for the scenario should be provided as the ``map`` global parameter; see the driving domain's documentation for details. + +Global Parameters: + debugRender (bool): If ``True``, enables a debug view that draws simple + polygons for objects in the Newtonian window. Default is ``False``. """ from scenic.simulators.newtonian.model import * @@ -14,7 +18,9 @@ from scenic.domains.driving.model import * # includes basic actions and behavio from scenic.simulators.utils.colors import Color -simulator NewtonianSimulator(network, render=render) +param debugRender = False + +simulator NewtonianSimulator(network, render=render, debug_render=globalParameters.debugRender) class NewtonianActor(DrivingObject): throttle: 0 @@ -57,7 +63,15 @@ class Car(Vehicle, Steers): return True class Pedestrian(Pedestrian, NewtonianActor, Walks): - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.control = {'heading': None, 'speed': None} + + def setWalkingDirection(self, heading): + self.control['heading'] = heading + + def setWalkingSpeed(self, speed): + self.control['speed'] = speed class Debris: """Abstract class for debris scattered randomly in the workspace.""" diff --git a/src/scenic/simulators/newtonian/simulator.py b/src/scenic/simulators/newtonian/simulator.py index fd38aa427..4c23adaec 100644 --- a/src/scenic/simulators/newtonian/simulator.py +++ b/src/scenic/simulators/newtonian/simulator.py @@ -5,6 +5,7 @@ from math import copysign, degrees, radians, sin import os import pathlib +import statistics import time from PIL import Image @@ -34,7 +35,7 @@ WIDTH = 1280 HEIGHT = 800 MAX_ACCELERATION = 5.6 # in m/s2, seems to be a pretty reasonable value -MAX_BRAKING = 4.6 +MAX_BRAKING = 6.8 ROAD_COLOR = (0, 0, 0) ROAD_WIDTH = 2 @@ -58,15 +59,16 @@ class NewtonianSimulator(DrivingSimulator): when not otherwise specified is still 0.1 seconds. """ - def __init__(self, network=None, render=False, export_gif=False): + def __init__(self, network=None, render=False, debug_render=False, export_gif=False): super().__init__() self.export_gif = export_gif self.render = render + self.debug_render = debug_render self.network = network def createSimulation(self, scene, **kwargs): simulation = NewtonianSimulation( - scene, self.network, self.render, self.export_gif, **kwargs + scene, self.network, self.render, self.export_gif, self.debug_render, **kwargs ) if self.export_gif and self.render: simulation.generate_gif("simulation.gif") @@ -76,11 +78,15 @@ def createSimulation(self, scene, **kwargs): class NewtonianSimulation(DrivingSimulation): """Implementation of `Simulation` for the Newtonian simulator.""" - def __init__(self, scene, network, render, export_gif, timestep, **kwargs): + def __init__( + self, scene, network, render, export_gif, debug_render, timestep, **kwargs + ): self.export_gif = export_gif self.render = render self.network = network + self.screen = None self.frames = [] + self.debug_render = debug_render if timestep is None: timestep = 0.1 @@ -102,10 +108,31 @@ def setup(self): ) self.screen.fill((255, 255, 255)) x, y, _ = self.objects[0].position - self.min_x, self.max_x = min_x - 50, max_x + 50 - self.min_y, self.max_y = min_y - 50, max_y + 50 + self.min_x, self.max_x = min_x - 40, max_x + 40 + self.min_y, self.max_y = min_y - 40, max_y + 40 self.size_x = self.max_x - self.min_x self.size_y = self.max_y - self.min_y + + # Generate a uniform screen scaling (applied to width and height) + # that includes all of both dimensions. + self.screenScaling = min(WIDTH / self.size_x, HEIGHT / self.size_y) + + # Calculate a screen translation that brings the mean vehicle + # position to the center of the screen. + + # N.B. screenTranslation is initialized to (0, 0) here intentionally. + # so that the actual screenTranslation can be set later based off what + # was computed with this null value. + self.screenTranslation = (0, 0) + + scaled_positions = map( + lambda x: self.scenicToScreenVal(x.position), self.objects + ) + mean_x, mean_y = map(statistics.mean, zip(*scaled_positions)) + + self.screenTranslation = (WIDTH / 2 - mean_x, HEIGHT / 2 - mean_y) + + # Create screen polygon to avoid rendering entirely invisible images self.screen_poly = shapely.geometry.Polygon( ( (self.min_x, self.min_y), @@ -117,9 +144,7 @@ def setup(self): img_path = os.path.join(current_dir, "car.png") self.car = pygame.image.load(img_path) - self.car_width = int(3.5 * WIDTH / self.size_x) - self.car_height = self.car_width - self.car = pygame.transform.scale(self.car, (self.car_width, self.car_height)) + self.parse_network() self.draw_objects() @@ -149,9 +174,14 @@ def addRegion(region, color, width=1): def scenicToScreenVal(self, pos): x, y = pos[:2] - x_prop = (x - self.min_x) / self.size_x - y_prop = (y - self.min_y) / self.size_y - return int(x_prop * WIDTH), HEIGHT - 1 - int(y_prop * HEIGHT) + + screen_x = (x - self.min_x) * self.screenScaling + screen_y = HEIGHT - 1 - (y - self.min_y) * self.screenScaling + + screen_x = screen_x + self.screenTranslation[0] + screen_y = screen_y + self.screenTranslation[1] + + return int(screen_x), int(screen_y) def createObjectInSimulator(self, obj): # Set actor's initial speed @@ -166,7 +196,27 @@ def isOnScreen(self, x, y): def step(self): for obj in self.objects: current_speed = obj.velocity.norm() - if hasattr(obj, "hand_brake"): + # 1) Pedestrians using walking controls (SetWalkingSpeed/Direction) + if ( + hasattr(obj, "control") + and "speed" in obj.control + and ( + obj.control["heading"] is not None or obj.control["speed"] is not None + ) + ): + h = ( + obj.control["heading"] + if obj.control["heading"] is not None + else obj.heading + ) + s = ( + obj.control["speed"] + if obj.control["speed"] is not None + else obj.speed + ) + obj.velocity = Vector(0, s).rotatedBy(h) + # 2) Vehicle: throttle/brake/steer physics + elif getattr(obj, "isCar", False): forward = obj.velocity.dot(Vector(0, 1).rotatedBy(obj.heading)) >= 0 signed_speed = current_speed if forward else -current_speed if obj.hand_brake or obj.brake > 0: @@ -191,12 +241,21 @@ def step(self): else: obj.angularSpeed = 0 obj.speed = abs(signed_speed) + + # 3) Everything else: else: obj.speed = current_speed + + # 4) Integrate motion for all obj.position += obj.velocity * self.timestep obj.heading += obj.angularSpeed * self.timestep if self.render: + # Handle closing out pygame screen + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.destroy() + return self.draw_objects() pygame.event.pump() @@ -207,21 +266,14 @@ def draw_objects(self): for i, obj in enumerate(self.objects): color = (255, 0, 0) if i == 0 else (0, 0, 255) - h, w = obj.length, obj.width - pos_vec = Vector(-1.75, 1.75) - neg_vec = Vector(w / 2, h / 2) - heading_vec = Vector(0, 10).rotatedBy(obj.heading) - dx, dy = int(heading_vec.x), -int(heading_vec.y) - x, y = self.scenicToScreenVal(obj.position) - rect_x, rect_y = self.scenicToScreenVal(obj.position + pos_vec) + + if self.debug_render: + self.draw_rect(obj, color) + if hasattr(obj, "isCar") and obj.isCar: - self.rotated_car = pygame.transform.rotate( - self.car, math.degrees(obj.heading) - ) - self.screen.blit(self.rotated_car, (rect_x, rect_y)) + self.draw_car(obj) else: - corners = [self.scenicToScreenVal(corner) for corner in obj._corners2D] - pygame.draw.polygon(self.screen, color, corners) + self.draw_rect(obj, color) pygame.display.update() @@ -232,6 +284,19 @@ def draw_objects(self): time.sleep(self.timestep) + def draw_rect(self, obj, color): + corners = [self.scenicToScreenVal(corner) for corner in obj._corners2D] + pygame.draw.polygon(self.screen, color, corners) + + def draw_car(self, obj): + car_width = int(obj.width * self.screenScaling) + car_height = int(obj.height * self.screenScaling) + scaled_car = pygame.transform.scale(self.car, (car_width, car_height)) + rotated_car = pygame.transform.rotate(scaled_car, math.degrees(obj.heading)) + car_rect = rotated_car.get_rect() + car_rect.center = self.scenicToScreenVal(obj.position) + self.screen.blit(rotated_car, car_rect) + def generate_gif(self, filename="simulation.gif"): imgs = [Image.fromarray(frame) for frame in self.frames] imgs[0].save(filename, save_all=True, append_images=imgs[1:], duration=50, loop=0) diff --git a/src/scenic/simulators/webots/model.scenic b/src/scenic/simulators/webots/model.scenic index 0df1b70f7..4df257402 100644 --- a/src/scenic/simulators/webots/model.scenic +++ b/src/scenic/simulators/webots/model.scenic @@ -222,7 +222,7 @@ class Ground(WebotsObject): side_path = trimesh.path.Path3D(entities=[trimesh.path.entities.Line(side_indices)], vertices=vertices) - flat_side_path, transform = side_path.to_planar(to_2D=None, normal=None, check=True) + flat_side_path, transform = side_path.to_2D(to_2D=None, normal=None, check=True) side_vertices_2d, side_faces = flat_side_path.triangulate() side_vertices_3d = trimesh.transformations.transform_points( diff --git a/src/scenic/simulators/webots/road/interface.py b/src/scenic/simulators/webots/road/interface.py index f9eed5356..cc9346091 100644 --- a/src/scenic/simulators/webots/road/interface.py +++ b/src/scenic/simulators/webots/road/interface.py @@ -64,11 +64,11 @@ def __init__(self, attrs, driveOnLeft=False): ] self.waypoints = tuple(cleanChain(pts, 0.05)) assert len(self.waypoints) > 1, pts - self.width = float(attrs.get("width", 7)) - self.lanes = int(attrs.get("numberOfLanes", 2)) + self.width = float(attrs.get("width", (7,))[0]) + self.lanes = int(attrs.get("numberOfLanes", (2,))[0]) if self.lanes < 1: raise RuntimeError(f"Road {self.osmID} has fewer than 1 lane!") - self.forwardLanes = int(attrs.get("numberOfForwardLanes", 1)) + self.forwardLanes = int(attrs.get("numberOfForwardLanes", (1,))[0]) # if self.forwardLanes < 1: # raise RuntimeError(f'Road {self.osmID} has fewer than 1 forward lane!') self.backwardLanes = self.lanes - self.forwardLanes diff --git a/src/scenic/syntax/ast.py b/src/scenic/syntax/ast.py index e64e3be48..f21a49aaf 100644 --- a/src/scenic/syntax/ast.py +++ b/src/scenic/syntax/ast.py @@ -1,15 +1,49 @@ import ast +import functools +import inspect +import operator +import sys import typing -from typing import Optional, Union +from typing import Any, ForwardRef, Optional, Union class AST(ast.AST): - "Scenic AST base class" + """Scenic AST base class. + + N.B. The attributes ``_fields`` and ``_field_types`` expected in subclasses + of `ast.AST` are synthesized automatically from a dataclass-like syntax: + see below for many examples. + """ _attributes = ("lineno", "col_offset", "end_lineno", "end_col_offset") - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + def __init_subclass__(cls): + super().__init_subclass__() + fts = _getFields(cls) + if sys.version_info[:2] == (3, 13): + # The Python 3.13 ast.AST initializer expects types like X | None, but we + # use Optional[X] for compatibility; convert the latter into the former. + # (In 3.14+, Optional[X] and X | None are identical at runtime.) + optionalTy = type(Optional[str]) + for field, ty in fts.items(): + if isinstance(ty, optionalTy): + args = typing.get_args(ty) + if any(isinstance(arg, ForwardRef) for arg in args): + # ForwardRef doesn't support | in 3.13, so simplify to Any + newTy = Any | None + else: + newTy = functools.reduce(operator.or_, args) + fts[field] = newTy + cls._field_types = fts + cls._fields = tuple(cls._field_types) + cls.__match_args__ = tuple(cls._fields) + + +if sys.version_info >= (3, 10): + _getFields = inspect.get_annotations +else: + # We won't bother with un-stringizing annotations: they won't be used anyway + _getFields = lambda cls: cls.__dict__.get("__annotations__", {}) # special statements @@ -18,74 +52,32 @@ def __init__(self, *args: any, **kwargs: any) -> None: class TryInterrupt(AST): """Scenic AST node that represents try-interrupt statements""" - __match_args__ = ( - "body", - "interrupt_when_handlers", - "except_handlers", - "orelse", - "finalbody", - ) - - def __init__( - self, - body: typing.List[ast.stmt], - interrupt_when_handlers: typing.List["InterruptWhenHandler"], - except_handlers: typing.List[ast.ExceptHandler], - orelse: typing.List[ast.stmt], - finalbody: typing.List[ast.AST], - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.body = body - self.interrupt_when_handlers = interrupt_when_handlers - self.except_handlers = except_handlers - self.orelse = orelse - self.finalbody = finalbody - self._fields = [ - "body", - "interrupt_when_handlers", - "except_handlers", - "orelse", - "finalbody", - ] - self._attributes = [] + body: typing.List[ast.stmt] + interrupt_when_handlers: typing.List["InterruptWhenHandler"] + except_handlers: typing.List[ast.ExceptHandler] + orelse: typing.List[ast.stmt] + finalbody: typing.List[ast.AST] class InterruptWhenHandler(AST): - __match_args__ = ("cond", "body") - - def __init__( - self, cond: ast.AST, body: typing.List[ast.AST], *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.cond = cond - self.body = body - self._fields = ["cond", "body"] + cond: ast.AST + body: typing.List[ast.AST] class TrackedAssign(AST): - __match_args__ = ( - "target", - "value", - ) - - def __init__( - self, target: Union["Ego", "Workspace"], value: ast.AST, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.value = value - self._fields = ["target", "value"] + target: Union["Ego", "Workspace"] + value: ast.AST class Ego(AST): - "`ego` tracked assign target" + """`ego` tracked assign target""" + functionName = "ego" class Workspace(AST): - ":term:`workspace` tracked assign target" + """:term:`workspace` tracked assign target""" + functionName = "workspace" @@ -94,325 +86,130 @@ class InitialScenario(AST): class PropertyDef(AST): - __match_args__ = ("property", "attributes", "value") - - def __init__( - self, - property: str, - attributes: typing.List[Union["Additive", "Dynamic", "Final"]], - value=ast.AST, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.property = property - self.attributes = attributes - self.value = value - self._fields = ["property", "attributes", "value"] + property: str + attributes: typing.List[Union["Additive", "Dynamic", "Final"]] + value: ast.AST class Additive(AST): keyword = "additive" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - class Dynamic(AST): keyword = "dynamic" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - class Final(AST): keyword = "final" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - # behavior / monitor class BehaviorDef(AST): - __match_args__ = ("name", "args", "docstring", "header", "body") - - def __init__( - self, - name: str, - args: ast.arguments, - docstring: Optional[str], - header: typing.List[Union["Precondition", "Invariant"]], - body: typing.List[any], - *_args: any, - **kwargs: any, - ) -> None: - super().__init__(*_args, **kwargs) - self.name = name - self.args = args - self.docstring = docstring - self.header = header - self.body = body - self._fields = ["name", "args", "docstring", "header", "body"] + name: str + args: ast.arguments + docstring: Optional[str] + header: typing.List[Union["Precondition", "Invariant"]] + body: typing.List[ast.AST] class MonitorDef(AST): - __match_args__ = ("name", "args", "docstring", "body") - - def __init__( - self, - name: str, - args: ast.arguments, - docstring: Optional[str], - body: typing.List[ast.AST], - *_args: any, - **kwargs: any, - ) -> None: - super().__init__(*_args, **kwargs) - self.name = name - self.args = args - self.docstring = docstring - self.body = body - self._fields = ["name", "args", "docstring", "body"] + name: str + args: ast.arguments + docstring: Optional[str] + body: typing.List[ast.AST] class Precondition(AST): - __match_args__ = ("value",) - - def __init__(self, value: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] + value: ast.AST class Invariant(AST): - __match_args__ = ("value",) - - def __init__(self, value: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] + value: ast.AST # modular scenarios class ScenarioDef(AST): - __match_args__ = ("name", "args", "docstring", "header", "setup", "compose") - - def __init__( - self, - name: str, - args: ast.arguments, - docstring: Optional[str], - header: Optional[typing.List[Union[Precondition, Invariant]]], - setup: typing.List[ast.AST], - compose: typing.List[ast.AST], - *_args: any, - **kwargs: any, - ) -> None: - super().__init__(*_args, **kwargs) - self.name = name - self.args = args - self.docstring = docstring - self.header = header - self.setup = setup - self.compose = compose - self._fields = ["name", "args", "docstring", "header", "setup", "compose"] + name: str + args: ast.arguments + docstring: Optional[str] + header: Optional[typing.List[Union[Precondition, Invariant]]] + setup: typing.List[ast.AST] + compose: typing.List[ast.AST] # simple statements class Model(AST): - __match_args__ = ("name",) - - def __init__(self, name: str, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.name = name - self._fields = ["name"] + name: str class Param(AST): - ":keyword:`param` statements" + """:keyword:`param` statements""" - __match_args__ = ("elts",) - - def __init__(self, elts: typing.List["parameter"], *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self._fields = ["elts"] + elts: typing.List["parameter"] class parameter(AST): - "represents a parameter that is defined with `param` statements" - __match_args__ = ("identifier", "value") + """Represents a parameter that is defined with `param` statements""" - def __init__( - self, identifier: str, value: ast.AST, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.identifier = identifier - self.value = value - self._fields = ["identifier", "value"] + identifier: str + value: ast.AST class Require(AST): - __match_args__ = ("cond", "prob", "name") - - def __init__( - self, - cond: ast.AST, - prob: Optional[float] = None, - name: Optional[str] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.cond = cond - self.prob = prob - self.name = name - self._fields = ["cond", "prob", "name"] + cond: ast.AST + prob: Optional[float] = None + name: Optional[str] = None class RequireMonitor(AST): - __match_args__ = ("monitor", "name") - - def __init__( - self, monitor: ast.AST, name: Optional[str] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.monitor = monitor - self.name = name - self._fields = ["monitor", "name"] + monitor: ast.AST + name: Optional[str] = None class Always(AST): - __match_args__ = ("value",) - - def __init__(self, value: ast.AST, *args, **kwargs): - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] - - def __reduce__(self): - return ( - type(self), - (self.value,), - { - "lineno": self.lineno, - "end_lineno": self.end_lineno, - "col_offset": self.col_offset, - "end_col_offset": self.end_col_offset, - }, - ) + value: ast.AST class Eventually(AST): - __match_args__ = ("value",) - - def __init__(self, value: ast.AST, *args, **kwargs): - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] - - def __reduce__(self): - return ( - type(self), - (self.value,), - { - "lineno": self.lineno, - "end_lineno": self.end_lineno, - "col_offset": self.col_offset, - "end_col_offset": self.end_col_offset, - }, - ) + value: ast.AST class Next(AST): - __match_args__ = ("value",) - - def __init__(self, value: ast.AST, *args, **kwargs): - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] - - def __reduce__(self): - return ( - type(self), - (self.value,), - { - "lineno": self.lineno, - "end_lineno": self.end_lineno, - "col_offset": self.col_offset, - "end_col_offset": self.end_col_offset, - }, - ) + value: ast.AST class Record(AST): - __match_args__ = ("value", "name") - - def __init__( - self, value: ast.AST, name: Optional[str] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.value = value - self.name = name - self._fields = ["value", "name"] + value: ast.AST + name: Optional[str] = None + recorder: Optional[Any] = None + period: Optional[Union["Seconds", "Steps"]] = None + delay: Optional[Union["Seconds", "Steps"]] = None class RecordInitial(AST): - __match_args__ = ("value", "name") - - def __init__( - self, value: ast.AST, name: Optional[str] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.value = value - self.name = name - self._fields = ["value", "name"] + value: ast.AST + name: Optional[str] = None class RecordFinal(AST): - __match_args__ = ("value", "name") - - def __init__( - self, value: ast.AST, name: Optional[str] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.value = value - self.name = name - self._fields = ["value", "name"] + value: ast.AST + name: Optional[str] = None class Mutate(AST): - __match_args__ = ("elts", "scale") - - def __init__( - self, - elts: typing.List[ast.Name], - scale: Optional[ast.AST] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self.scale = scale - self._fields = ["elts", "scale"] + elts: typing.List[ast.Name] + scale: Optional[ast.AST] = None class Override(AST): - __match_args__ = ("target", "specifiers") - - def __init__( - self, target: ast.AST, specifiers: typing.List[ast.AST], *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.specifiers = specifiers - self._fields = ["target", "specifiers"] + target: ast.AST + specifiers: typing.List[ast.AST] class Abort(AST): @@ -420,18 +217,21 @@ class Abort(AST): class Take(AST): - __match_args__ = ("elts",) - - def __init__(self, elts: typing.List[ast.AST], *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self._fields = ["elts"] + elts: typing.List[ast.AST] class Wait(AST): pass +class WaitFor(AST): + duration: Union["Seconds", "Steps"] + + +class WaitUntil(AST): + cond: ast.AST + + class Terminate(AST): pass @@ -441,822 +241,412 @@ class TerminateSimulation(AST): class TerminateSimulationWhen(AST): - __match_args__ = ( - "cond", - "name", - ) - - def __init__( - self, cond: ast.AST, name: Optional[str] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.cond = cond - self.name = name - self._fields = ["cond", "name"] + cond: ast.AST + name: Optional[str] = None class TerminateWhen(AST): - __match_args__ = ( - "cond", - "name", - ) - - def __init__( - self, cond: ast.AST, name: Optional[str] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.cond = cond - self.name = name - self._fields = ["cond", "name"] + cond: ast.AST + name: Optional[str] = None class TerminateAfter(AST): - __match_args__ = ("duration",) - - def __init__( - self, duration: Union["Seconds", "Steps"], *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.duration = duration - self._fields = ["duration"] + duration: Union["Seconds", "Steps"] class DoFor(AST): - __match_args__ = ("elts", "duration") - - def __init__( - self, - elts: typing.List[ast.AST], - duration: Union["Seconds", "Steps"], - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self.duration = duration - self._fields = ["elts", "duration"] + elts: typing.List[ast.AST] + duration: Union["Seconds", "Steps"] class Seconds(AST): - __match_args__ = ("value",) unitStr = "seconds" - def __init__(self, value: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] + value: ast.AST class Steps(AST): - __match_args__ = ("value",) unitStr = "steps" - def __init__(self, value: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] + value: ast.AST class DoUntil(AST): - __match_args__ = ("elts", "cond") - - def __init__( - self, elts: typing.List[ast.AST], cond: ast.AST, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self.cond = cond - self._fields = ["elts", "cond"] + elts: typing.List[ast.AST] + cond: ast.AST class DoChoose(AST): - __match_args__ = ("elts",) - - def __init__(self, elts: typing.List[ast.AST], *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self._fields = ["elts"] + elts: typing.List[ast.AST] class DoShuffle(AST): - __match_args__ = ("elts",) - - def __init__(self, elts: typing.List[ast.AST], *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self._fields = ["elts"] + elts: typing.List[ast.AST] class Do(AST): - __match_args__ = ("elts",) - - def __init__(self, elts: typing.List[ast.AST], *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.elts = elts - self._fields = ["elts"] + elts: typing.List[ast.AST] class Simulator(AST): - __match_args__ = ("value",) - - def __init__(self, value: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.value = value - self._fields = ["value"] + value: ast.AST # Instance Creation class New(AST): - __match_args__ = ("className", "specifiers") + className: str + specifiers: Optional[typing.List[ast.AST]] - def __init__( - self, className: str, specifiers: list, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.className = className - self.specifiers = specifiers if specifiers is not None else [] - self._fields = ["className", "specifiers"] + def __init__(self, className, specifiers, **kwargs): + specs = specifiers if specifiers is not None else [] + super().__init__(className, specs, **kwargs) # Specifiers class WithSpecifier(AST): - __match_args__ = ("prop", "value") - - def __init__(self, prop: str, value: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.prop = prop - self.value = value - self._fields = ["prob", "value"] + prop: str + value: ast.AST class AtSpecifier(AST): - __match_args__ = ("position",) - - def __init__(self, position: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.position = position - self._fields = ["position"] + position: ast.AST class OffsetBySpecifier(AST): - __match_args__ = ("offset",) - - def __init__(self, offset: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.offset = offset - self._fields = ["offset"] + offset: ast.AST class OffsetAlongSpecifier(AST): - __match_args__ = ("direction", "offset") - - def __init__( - self, direction: ast.AST, offset: ast.AST, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.direction = direction - self.offset = offset - self._fields = ["direction", "offset"] + direction: ast.AST + offset: ast.AST class DirectionOfSpecifier(AST): - __match_args__ = ("direction", "position", "distance") - - def __init__( - self, - direction: Union["LeftOf", "RightOf", "AheadOf", "Behind"], - position: ast.AST, - distance: Optional[ast.AST], - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.direction = direction - self.position = position - self.distance = distance - self._fields = ["direction", "position", "distance"] + direction: Union["LeftOf", "RightOf", "AheadOf", "Behind"] + position: ast.AST + distance: Optional[ast.AST] = None class LeftOf(AST): - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + pass class RightOf(AST): - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + pass class AheadOf(AST): - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + pass class Behind(AST): - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + pass class Above(AST): - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + pass class Below(AST): - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + pass class BeyondSpecifier(AST): - __match_args__ = ("position", "offset", "base") - - def __init__( - self, - position: ast.AST, - offset: ast.AST, - base: Optional[ast.AST] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.position = position - self.offset = offset - self.base = base - self._fields = ["position", "offset", "base"] + position: ast.AST + offset: ast.AST + base: Optional[ast.AST] = None class VisibleSpecifier(AST): - __match_args__ = ("base",) - - def __init__(self, base: Optional[ast.AST] = None, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.base = base - self._fields = ["base"] + base: Optional[ast.AST] = None class NotVisibleSpecifier(AST): - __match_args__ = ("base",) - - def __init__(self, base: Optional[ast.AST] = None, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.base = base - self._fields = ["base"] + base: Optional[ast.AST] = None class InSpecifier(AST): - __match_args__ = ("region",) - - def __init__(self, region: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self._fields = ["region"] + region: ast.AST class OnSpecifier(AST): - __match_args__ = ("region",) - - def __init__(self, region: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self._fields = ["region"] + region: ast.AST class ContainedInSpecifier(AST): - __match_args__ = ("region",) - - def __init__(self, region: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self._fields = ["region"] + region: ast.AST class FollowingSpecifier(AST): - __match_args__ = ("field", "distance", "base") - - def __init__( - self, - field: ast.AST, - distance: ast.AST, - base: Optional[ast.AST] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.field = field - self.distance = distance - self.base = base - self._fields = ["field", "distance", "base"] + field: ast.AST + distance: ast.AST + base: Optional[ast.AST] = None class FacingSpecifier(AST): - __match_args__ = ("heading",) - - def __init__(self, heading: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.heading = heading - self._fields = ["heading"] + heading: ast.AST class FacingTowardSpecifier(AST): - __match_args__ = ("position",) - - def __init__(self, position: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.position = position - self._fields = ["position"] + position: ast.AST class FacingAwayFromSpecifier(AST): - __match_args__ = ("position",) - - def __init__(self, position: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.position = position - self._fields = ["position"] + position: ast.AST class FacingDirectlyTowardSpecifier(AST): - __match_args__ = ("position",) - - def __init__(self, position: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.position = position - self._fields = ["position"] + position: ast.AST class FacingDirectlyAwayFromSpecifier(AST): - __match_args__ = ("position",) - - def __init__(self, position: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.position = position - self._fields = ["position"] + position: ast.AST class ApparentlyFacingSpecifier(AST): - __match_args__ = ("heading", "base") - - def __init__( - self, heading: ast.AST, base: Optional[ast.AST] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.heading = heading - self.base = base - self._fields = ["heading", "base"] + heading: ast.AST + base: Optional[ast.AST] = None # Operators class ImpliesOp(AST): - __match_args__ = ("hypothesis", "conclusion") - - def __init__( - self, hypothesis: ast.AST, conclusion: ast.AST, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.hypothesis = hypothesis - self.conclusion = conclusion - self._fields = ["hypothesis", "conclusion"] - - def __reduce__(self): - return ( - type(self), - (self.hypothesis, self.conclusion), - { - "lineno": self.lineno, - "end_lineno": self.end_lineno, - "col_offset": self.col_offset, - "end_col_offset": self.end_col_offset, - }, - ) + hypothesis: ast.AST + conclusion: ast.AST class UntilOp(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"] - - def __reduce__(self): - return ( - type(self), - (self.left, self.right), - { - "lineno": self.lineno, - "end_lineno": self.end_lineno, - "col_offset": self.col_offset, - "end_col_offset": self.end_col_offset, - }, - ) + left: ast.AST + right: ast.AST class RelativePositionOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, target: ast.AST, base: ast.AST = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + target: ast.AST + base: Optional[ast.AST] = None class RelativeHeadingOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, target: ast.AST, base: ast.AST = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + target: ast.AST + base: Optional[ast.AST] = None class ApparentHeadingOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, target: ast.AST, base: ast.AST = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + target: ast.AST + base: Optional[ast.AST] = None class DistanceFromOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, - # because `to` and `from` are symmetric, the first operand will be `target` and the second will be `base` - target: ast.AST, - base: Optional[ast.AST] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + # because `to` and `from` are symmetric, the first operand will be `target` and the second will be `base` + target: ast.AST + base: Optional[ast.AST] = None class DistancePastOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, target: ast.AST, base: Optional[ast.AST] = None, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + target: ast.AST + base: Optional[ast.AST] = None class AngleFromOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, - target: Optional[ast.AST] = None, - base: Optional[ast.AST] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + target: Optional[ast.AST] = None + base: Optional[ast.AST] = None class AltitudeFromOp(AST): - __match_args__ = ("target", "base") - - def __init__( - self, - target: Optional[ast.AST] = None, - base: Optional[ast.AST] = None, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self._fields = ["target", "base"] + target: Optional[ast.AST] = None + base: Optional[ast.AST] = None class FollowOp(AST): - __match_args__ = ("target", "base", "distance") - - def __init__( - self, target: ast.AST, base: ast.AST, distance: ast.AST, *args: any, **kwargs: any - ) -> None: - super().__init__(*args, **kwargs) - self.target = target - self.base = base - self.distance = distance - self._fields = ["target", "base", "distance"] + target: ast.AST + base: ast.AST + distance: ast.AST class VisibleOp(AST): - __match_args__ = ("region",) - - def __init__(self, region: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self._fields = ["region"] + region: ast.AST class NotVisibleOp(AST): - __match_args__ = ("region",) - - def __init__(self, region: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self._fields = ["region"] + region: ast.AST class VisibleFromOp(AST): - __match_args__ = ("region", "base") - - def __init__(self, region: ast.AST, base: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self.base = base - self._fields = ["region", "base"] + region: ast.AST + base: ast.AST class NotVisibleFromOp(AST): - __match_args__ = ("region", "base") - - def __init__(self, region: ast.AST, base: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.region = region - self.base = base - self._fields = ["region", "base"] + region: ast.AST + base: ast.AST class PositionOfOp(AST): - __match_args__ = ("position", "target") - - def __init__( - self, - position: Union[ - "Front", - "Back", - "Left", - "Right", - "Top", - "Bottom", - "FrontLeft", - "FrontRight", - "BackLeft", - "BackRight", - "TopFrontLeft", - "TopFrontRight", - "TopBackLeft", - "TopBackRight", - "BottomFrontLeft", - "BottomFrontRight", - "BottomBackLeft", - "BottomBackRight", - ], - target: ast.AST, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.position = position - self.target = target - self._fields = ["position", "target"] + position: Union[ + "Front", + "Back", + "Left", + "Right", + "Top", + "Bottom", + "FrontLeft", + "FrontRight", + "BackLeft", + "BackRight", + "TopFrontLeft", + "TopFrontRight", + "TopBackLeft", + "TopBackRight", + "BottomFrontLeft", + "BottomFrontRight", + "BottomBackLeft", + "BottomBackRight", + ] + target: ast.AST class Front(AST): - "Represents position of :scenic:`front of` operator" - functionName = "Front" + """Represents position of :scenic:`front of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "Front" class Back(AST): - "Represents position of :scenic:`back of` operator" - functionName = "Back" + """Represents position of :scenic:`back of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "Back" class Left(AST): - "Represents position of :scenic:`left of` operator" - functionName = "Left" + """Represents position of :scenic:`left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "Left" class Right(AST): - "Represents position of :scenic:`right of` operator" - functionName = "Right" + """Represents position of :scenic:`right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "Right" class Top(AST): - "Represents position of :scenic:`top of` operator" - functionName = "Top" + """Represents position of :scenic:`top of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "Top" class Bottom(AST): - "Represents position of :scenic:`bottom of` operator" - functionName = "Bottom" + """Represents position of :scenic:`bottom of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "Bottom" class FrontLeft(AST): - "Represents position of :scenic:`front left of` operator" - functionName = "FrontLeft" + """Represents position of :scenic:`front left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "FrontLeft" class FrontRight(AST): - "Represents position of :scenic:`front right of` operator" - functionName = "FrontRight" + """Represents position of :scenic:`front right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "FrontRight" class BackLeft(AST): - "Represents position of :scenic:`back left of` operator" - functionName = "BackLeft" + """Represents position of :scenic:`back left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "BackLeft" class BackRight(AST): - "Represents position of :scenic:`back right of` operator" - functionName = "BackRight" + """Represents position of :scenic:`back right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "BackRight" class TopFrontLeft(AST): - "Represents position of :scenic:`top front left of` operator" - functionName = "TopFrontLeft" + """Represents position of :scenic:`top front left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "TopFrontLeft" class TopFrontRight(AST): - "Represents position of :scenic:`top front right of` operator" - functionName = "TopFrontRight" + """Represents position of :scenic:`top front right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "TopFrontRight" class TopBackLeft(AST): - "Represents position of :scenic:`top back left of` operator" - functionName = "TopBackLeft" + """Represents position of :scenic:`top back left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "TopBackLeft" class TopBackRight(AST): - "Represents position of :scenic:`top back right of` operator" - functionName = "TopBackRight" + """Represents position of :scenic:`top back right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "TopBackRight" class BottomFrontLeft(AST): - "Represents position of :scenic:`bottom front left of` operator" - functionName = "BottomFrontLeft" + """Represents position of :scenic:`bottom front left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "BottomFrontLeft" class BottomFrontRight(AST): - "Represents position of :scenic:`bottom front right of` operator" - functionName = "BottomFrontRight" + """Represents position of :scenic:`bottom front right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "BottomFrontRight" class BottomBackLeft(AST): - "Represents position of :scenic:`bottom back left of` operator" - functionName = "BottomBackLeft" + """Represents position of :scenic:`bottom back left of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "BottomBackLeft" class BottomBackRight(AST): - "Represents position of :scenic:`bottom back right of` operator" - functionName = "BottomBackRight" + """Represents position of :scenic:`bottom back right of` operator""" - def __init__(self, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) + functionName = "BottomBackRight" class DegOp(AST): - __match_args__ = ("operand",) - - def __init__(self, operand: ast.AST, *args: any, **kwargs: any) -> None: - super().__init__(*args, **kwargs) - self.operand = operand - self._fields = ["operand"] + operand: ast.AST class VectorOp(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"] + left: ast.AST + right: ast.AST class FieldAtOp(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"] + left: ast.AST + right: ast.AST class RelativeToOp(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"] + left: ast.AST + right: ast.AST class OffsetAlongOp(AST): - __match_args__ = ("base", "direction", "offset") - - def __init__( - self, - base: ast.AST, - direction: ast.AST, - offset: ast.AST, - *args: any, - **kwargs: any, - ) -> None: - super().__init__(*args, **kwargs) - self.base = base - self.direction = direction - self.offset = offset - self._fields = ["base", "direction", "offset"] + base: ast.AST + direction: ast.AST + offset: ast.AST class CanSeeOp(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"] + left: ast.AST + right: ast.AST 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"] + left: ast.AST + right: ast.AST diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index 5328c0c6d..d1408ce42 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -2,7 +2,7 @@ from copy import copy from enum import IntFlag, auto import itertools -from typing import Any, Callable, List, Literal, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union from scenic.core.errors import ScenicParseError, getText import scenic.syntax.ast as s @@ -1163,10 +1163,15 @@ def visit_Param(self, node: s.Param): def visit_Require(self, node: s.Require): prob = node.prob - if prob is not None and not 0 <= prob <= 1: - raise self.makeSyntaxError("probability must be between 0 and 1", node) + if prob is not None: + if not 0 <= prob <= 1: + raise self.makeSyntaxError("probability must be between 0 and 1", node) + kwargs = {"prob": ast.Constant(prob)} + else: + kwargs = {} + return self.createRequirementLike( - "require", node.cond, node.lineno, node.name, prob + "require", node.cond, node.lineno, node.name, kwargs ) def visit_RequireMonitor(self, node: s.RequireMonitor): @@ -1202,6 +1207,28 @@ def visit_Take(self, node: s.Take): def visit_Wait(self, node: s.Wait): return self.generateInvocation(node, ast.Constant(())) + @context(Context.DYNAMIC) + def visit_WaitFor(self, node: s.WaitFor): + modifier = ast.Call( + func=ast.Name("Modifier", loadCtx), + args=[ + ast.Constant("for"), + self.visit(node.duration.value), + ast.Constant(node.duration.unitStr), + ], + keywords=[], + ) + return self.makeDoLike(node, [], modifier=modifier) + + @context(Context.DYNAMIC) + def visit_WaitUntil(self, node: s.WaitUntil): + modifier = ast.Call( + func=ast.Name("Modifier", loadCtx), + args=[ast.Constant("until"), ast.Lambda(noArgs, self.visit(node.cond))], + keywords=[], + ) + return self.makeDoLike(node, [], modifier=modifier) + @context(Context.DYNAMIC) def visit_Terminate(self, node: s.Terminate): termination = ast.Call( @@ -1270,9 +1297,11 @@ def visit_DoUntil(self, node: s.DoUntil): ), ) + @context(Context.DYNAMIC) def visit_DoChoose(self, node: s.DoChoose): return self.makeDoLike(node, node.elts, schedule="choose") + @context(Context.DYNAMIC) def visit_DoShuffle(self, node: s.DoChoose): return self.makeDoLike(node, node.elts, schedule="shuffle") @@ -1322,7 +1351,28 @@ def makeDoLike( @context(Context.TOP_LEVEL) def visit_Record(self, node: s.Record): - return self.createRequirementLike("record", node.value, node.lineno, node.name) + if node.name and node.recorder: + raise self.makeSyntaxError( + 'cannot use both "as" and "to" in "record" statement', node + ) + kwargs = {} + if node.recorder: + kwargs["recorder"] = self.visit(node.recorder) + if node.period: + elts = [self.visit(node.period.value), ast.Constant(node.period.unitStr)] + kwargs["period"] = ast.Tuple(elts, loadCtx) + if node.delay: + elts = [self.visit(node.delay.value), ast.Constant(node.delay.unitStr)] + kwargs["delay"] = ast.Tuple(elts, loadCtx) + if node.name: + if isinstance(node.name, ast.AST): + node.name = self.visit(node.name) + else: + node.name = ast.Constant(node.name) + + return self.createRequirementLike( + "record", node.value, node.lineno, node.name, kwargs + ) @context(Context.TOP_LEVEL) def visit_RecordInitial(self, node: s.RecordInitial): @@ -1354,36 +1404,39 @@ def createRequirementLike( body: ast.AST, lineno: int, name: Optional[str] = None, - prob: Optional[float] = None, + kwargs: Dict[str, Union[Tuple[ast.AST, ...], ast.AST]] = {}, ): """Create a call to a function that implements requirement-like features, such as `record` and `terminate when`. Args: - functionName (str): Name of the requirement-like function to call. Its signature must be `(reqId: int, body: () -> bool, lineno: int, name: str | None)` + functionName (str): Name of the requirement-like function to call. Its signature + must be `(reqId: int, body: () -> bool, lineno: int, name: str | None)` body (ast.AST): AST node to evaluate for checking the condition lineno (int): Line number in the source code name (Optional[str], optional): Optional name for requirements. Defaults to None. - prob (Optional[float], optional): Optional probability for requirements. Defaults to None. + kwargs: Optional keyword arguments. """ propTransformer = PropositionTransformer(self.filename) newBody, self.nextSyntaxId = propTransformer.transform(body, self.nextSyntaxId) newBody = self.visit(newBody) requirementId = self._register_requirement_syntax(body) + keywords = [] + for datum, node in kwargs.items(): + if isinstance(node, tuple): + node = ast.Tuple(*node) + keywords.append(ast.keyword(arg=datum, value=node)) + return ast.Expr( value=ast.Call( func=ast.Name(functionName, loadCtx), args=[ - ast.Constant(requirementId), # requirement IDre + ast.Constant(requirementId), # requirement ID newBody, # body ast.Constant(lineno), # line number - ast.Constant(name), # requirement name + name if isinstance(name, ast.AST) else ast.constant(name), # requirement name ], - keywords=( - [ast.keyword(arg="prob", value=ast.Constant(prob))] - if prob is not None - else [] - ), + keywords=keywords, ) ) @@ -1418,6 +1471,9 @@ def visit_Simulator(self, node: s.Simulator): # Instance & Specifier + # N.B. We can't use @context(Context.TOP_LEVEL) here because we actually + # allow `new Point` anywhere; we'll catch `new Object` inside behaviors in + # the veneer object registration code. def visit_New(self, node: s.New): return ast.Call( func=ast.Name(id="new", ctx=loadCtx), diff --git a/src/scenic/syntax/pygment.py b/src/scenic/syntax/pygment.py index 8af6d5630..fa9f123e7 100644 --- a/src/scenic/syntax/pygment.py +++ b/src/scenic/syntax/pygment.py @@ -1004,6 +1004,7 @@ class PegenLexer(BetterPythonLexer): (words(constants, suffix=r"\b"), Keyword.Constant), (r"[^\S\n]+", Whitespace), (r"#.*$", Comment.Single), + (r"\\\n", Punctuation), # we count line continuations as punctuation (r"[|?*+.&!~]", Operator), (r"'[^']*'", String.Single), (r'"[^"]*"', String.Double), diff --git a/src/scenic/syntax/scenic.gram b/src/scenic/syntax/scenic.gram index fa181e190..e4a8a649b 100644 --- a/src/scenic/syntax/scenic.gram +++ b/src/scenic/syntax/scenic.gram @@ -56,31 +56,6 @@ EXPR_NAME_MAPPING = { } -def parse_file( - path: str, - py_version: Optional[tuple]=None, - token_stream_factory: Optional[ - Callable[[Callable[[], str]], Iterator[tokenize.TokenInfo]] - ] = None, - verbose:bool = False, -) -> ast.Module: - """Parse a file.""" - with open(path) as f: - tok_stream = ( - token_stream_factory(f.readline) - if token_stream_factory else - tokenize.generate_tokens(f.readline) - ) - tokenizer = Tokenizer(tok_stream, verbose=verbose, path=path) - parser = ScenicParser( - tokenizer, - verbose=verbose, - filename=os.path.basename(path), - py_version=py_version - ) - return parser.parse("file") - - def parse_string( source: str, mode: Union[Literal["eval"], Literal["exec"]], @@ -99,7 +74,24 @@ def parse_string( ) tokenizer = Tokenizer(tok_stream, verbose=verbose) parser = ScenicParser(tokenizer, verbose=verbose, py_version=py_version, filename=filename) - return parser.parse(mode if mode == "eval" else "file") + + # Tokenization / early indentation errors are raised before ScenicParser + # runs; wrap them in ScenicParseError so they use Scenic's normal syntax + # error reporting (correct filename, consistent formatting). + try: + return parser.parse(mode if mode == "eval" else "file") + except tokenize.TokenError as e: + msg, (lineno, offset) = e.args + syn = SyntaxError(msg) + syn.filename = filename + syn.lineno = lineno + syn.offset = offset + raise ScenicParseError(syn) from None + + except IndentationError as e: + e.filename = filename + raise ScenicParseError(e) from None + class Target(enum.Enum): @@ -337,7 +329,8 @@ class Parser(Parser): line, col_offset = t.end source += "\\n)" try: - m = ast.parse(source) + # Use the Scenic parser's filename so f-string errors report the right file. + m = ast.parse(source, filename=self.filename) except SyntaxError as err: args = (err.filename, err.lineno + line_offset - 2, err.offset, err.text) if sys.version_info >= (3, 10): @@ -637,6 +630,8 @@ scenic_stmt: | scenic_terminate_when_stmt | scenic_terminate_after_stmt | scenic_take_stmt + | scenic_wait_for_stmt + | scenic_wait_until_stmt | scenic_wait_stmt | scenic_terminate_simulation_stmt | scenic_terminate_stmt @@ -856,7 +851,7 @@ scenic_class_statement[list]: scenic_class_property_stmt: # not a simple statement; reads NEWLINE - | a=NAME b=['[' attrs=','.scenic_class_property_attribute+ ']' { attrs } ] ':' c=expression NEWLINE { + | a=NAME b=['[' attrs=','.scenic_class_property_attribute+ ']' { attrs } ] ':' c=expression NEWLINE { s.PropertyDef( property=a.string, attributes=b if b is not None else [], @@ -930,10 +925,10 @@ scenic_behavior_statement[list]: scenic_invalid_behavior_statement: | a="invariant" ':' a=expression { - self.raise_syntax_error_known_location("invariant can only be set at the beginning of behavior definitions", a) + self.raise_syntax_error_known_location("invariant can only be set at the beginning of behavior definitions", a) } | a="precondition" ':' a=expression { - self.raise_syntax_error_known_location("precondition can only be set at the beginning of behavior definitions", a) + self.raise_syntax_error_known_location("precondition can only be set at the beginning of behavior definitions", a) } scenic_behavior_header: a=(x=(scenic_precondition_stmt | scenic_invariant_stmt) NEWLINE { x })+ { a } @@ -1408,7 +1403,7 @@ pattern_capture_target[str]: | !"_" name=NAME !('.' | '(' | '=') { name.string } wildcard_pattern["ast.MatchAs"]: - | "_" { ast.MatchAs(pattern=None, target=None, LOCATIONS) } + | "_" { ast.MatchAs(pattern=None, name=None, LOCATIONS) } value_pattern["ast.MatchValue"]: | attr=attr !('.' | '(' | '=') { ast.MatchValue(value=attr, LOCATIONS) } @@ -1726,18 +1721,23 @@ scenic_specifiers: ss=','.scenic_specifier+ { ss } scenic_specifier: | scenic_valid_specifier | invalid_scenic_specifier +# Split into non-operator-like vs operator-like instance specifiers. scenic_valid_specifier: - | 'with' p=NAME v=expression { s.WithSpecifier(prop=p.string, value=v, LOCATIONS) } + | scenic_instance_specifier_nonoperator + | scenic_instance_specifier_operator_like +scenic_instance_specifier_operator_like: | 'at' position=expression { s.AtSpecifier(position=position, LOCATIONS) } | "offset" 'by' o=expression { s.OffsetBySpecifier(offset=o, LOCATIONS) } | "offset" "along" d=expression 'by' o=expression { s.OffsetAlongSpecifier(direction=d, offset=o, LOCATIONS) } + | 'in' r=expression { s.InSpecifier(region=r, LOCATIONS) } +scenic_instance_specifier_nonoperator: + | 'with' p=NAME v=expression { s.WithSpecifier(prop=p.string, value=v, LOCATIONS) } | direction=scenic_specifier_position_direction position=expression distance=['by' e=expression { e }] { s.DirectionOfSpecifier(direction=direction, position=position, distance=distance, LOCATIONS) } | "beyond" v=expression 'by' o=expression b=['from' a=expression {a}] { s.BeyondSpecifier(position=v, offset=o, base=b) } | "visible" b=['from' r=expression { r }] { s.VisibleSpecifier(base=b, LOCATIONS) } | 'not' "visible" b=['from' r=expression { r }] { s.NotVisibleSpecifier(base=b, LOCATIONS) } - | 'in' r=expression { s.InSpecifier(region=r, LOCATIONS) } | 'on' r=expression { s.OnSpecifier(region=r, LOCATIONS) } | "contained" 'in' r=expression { s.ContainedInSpecifier(region=r, LOCATIONS) } | "following" f=expression b=['from' e=expression {e}] 'for' d=expression { @@ -2120,13 +2120,18 @@ scenic_require_stmt: | 'require' p=['[' a=NUMBER ']' { float(a.string) }] e=scenic_temporal_expression n=['as' a=scenic_require_stmt_name { a }] { s.Require(cond=e, prob=p, name=n, LOCATIONS) } + scenic_require_stmt_name: + | a=strings {a} | a=(NAME | NUMBER) { a.string } - | a=STRING { a.string[1:-1] } scenic_record_stmt: - | "record" e=expression n=['as' a=scenic_require_stmt_name { a }] { - s.Record(value=e, name=n, LOCATIONS) + | "record" e=expression \ + p=["every" a=scenic_dynamic_duration { a }] \ + d=["after" a=scenic_dynamic_duration { a }] \ + n=['as' a=scenic_require_stmt_name { a }] \ + r=['to' a=expression { a }] { + s.Record(value=e, name=n, recorder=r, delay=d, period=p, LOCATIONS) } scenic_record_initial_stmt: @@ -2149,6 +2154,10 @@ scenic_abort_stmt: "abort" { s.Abort(LOCATIONS) } scenic_take_stmt: "take" elts=(','.expression+) { s.Take(elts=elts, LOCATIONS) } +scenic_wait_for_stmt: "wait" 'for' u=scenic_dynamic_duration { s.WaitFor(duration=u, LOCATIONS) } + +scenic_wait_until_stmt: "wait" 'until' cond=expression { s.WaitUntil(cond=cond, LOCATIONS) } + scenic_wait_stmt: "wait" { s.Wait(LOCATIONS) } scenic_terminate_simulation_when_stmt: "terminate" "simulation" "when" v=expression n=['as' a=scenic_require_stmt_name { a }] { s.TerminateSimulationWhen(v, name=n, LOCATIONS) } @@ -2489,7 +2498,7 @@ invalid_kwarg[NoReturn]: } invalid_scenic_instance_creation[NoReturn]: - | n=NAME s=scenic_valid_specifier { + | n=NAME s=scenic_instance_specifier_nonoperator { self.raise_syntax_error_known_range("invalid syntax. Perhaps you forgot 'new'?", n, s) } invalid_scenic_specifier[NoReturn]: @@ -2891,7 +2900,7 @@ invalid_while_stmt[NoReturn]: ) } invalid_for_stmt[NoReturn]: - | [ASYNC] 'for' star_targets 'in' star_expressions NEWLINE { self.raise_syntax_error("expected ':'") } + | ['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]}" diff --git a/src/scenic/syntax/translator.py b/src/scenic/syntax/translator.py index 994e65b8b..0d4189703 100644 --- a/src/scenic/syntax/translator.py +++ b/src/scenic/syntax/translator.py @@ -41,6 +41,7 @@ from scenic.core.errors import InvalidScenarioError, PythonCompileError from scenic.core.lazy_eval import needsLazyEvaluation import scenic.core.pruning as pruning +from scenic.core.serialization import deterministicHash from scenic.core.utils import cached_property from scenic.syntax.compiler import compileScenicAST from scenic.syntax.parser import parse_string @@ -53,7 +54,7 @@ class CompileOptions: """Internal class for capturing options used when compiling a scenario.""" - # N.B. update `hash` below when adding a new field + # N.B. update `_hashMapping` below when adding a new field #: Whether or not the scenario uses `2D compatibility mode`. mode2D: bool = False @@ -64,25 +65,20 @@ class CompileOptions: #: Selected modular scenario, if any. scenario: Optional[str] = None + def _hashMapping(self): + mapping = {"mode2D": self.mode2D} + if self.modelOverride: + mapping["modelOverride"] = self.modelOverride + if self.scenario: + mapping["scenario"] = self.scenario + for k, v in self.paramOverrides.items(): + mapping[f"param:{k}"] = v + return mapping + @cached_property def hash(self): """Deterministic hash saved in serialized scenes to catch option mismatches.""" - stream = io.BytesIO() - stream.write(bytes([self.mode2D])) - if self.modelOverride: - stream.write(self.modelOverride.encode()) - for key in sorted(self.paramOverrides.keys()): - stream.write(key.encode()) - value = self.paramOverrides[key] - if isinstance(value, (int, float, str)): - stream.write(str(value).encode()) - else: - stream.write([0]) - if self.scenario: - stream.write(self.scenario.encode()) - # We can't use `hash` because it is not deterministic - # (e.g. the hashes of strings are randomized) - return hashlib.blake2b(stream.getvalue(), digest_size=4).digest() + return deterministicHash(self._hashMapping(), digest_size=4) def scenarioFromString( @@ -651,6 +647,7 @@ def registerNamespace(modName, ns): def constructScenarioFrom(namespace, scenarioName=None): """Build a Scenario object from an executed Scenic module.""" modularScenarios = namespace["_scenarios"] + topLevelScenario = namespace["_scenario"] def isModularScenario(thing): return isinstance(thing, type) and issubclass(thing, DynamicScenario) @@ -675,12 +672,17 @@ def isModularScenario(thing): elif modularScenarios and not modularScenarios[0]._requiresArguments(): dynScenario = modularScenarios[0]() else: - dynScenario = namespace["_scenario"] + dynScenario = topLevelScenario if not dynScenario._prepared: # true for all except top-level scenarios + # Inherit ego, workspace, etc. from top level + dynScenario._inherit(topLevelScenario) + # Execute setup block (if any) to create objects and requirements; # extract any requirements and scan for relations used for pruning + dynScenario._inherit(topLevelScenario) dynScenario._prepare(delayPreconditionCheck=True) + scenario = dynScenario._toScenario(namespace) # Prune infeasible parts of the space diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index 4b550fbdd..37994e21d 100644 --- a/src/scenic/syntax/veneer.py +++ b/src/scenic/syntax/veneer.py @@ -120,6 +120,9 @@ "VerifaiRange", "VerifaiDiscreteRange", "VerifaiOptions", + "TimeSeries", + "File", + "Files", # Constructible types "Point", "OrientedPoint", @@ -199,6 +202,7 @@ from scenic.core.dynamics.invocables import BlockConclusion, runTryInterrupt from scenic.core.dynamics.scenarios import DynamicScenario from scenic.core.external_params import ( + TimeSeries, VerifaiDiscreteRange, VerifaiOptions, VerifaiParameter, @@ -222,6 +226,7 @@ everywhere, nowhere, ) +from scenic.core.sensors import File, Files from scenic.core.shapes import ( BoxShape, ConeShape, @@ -279,6 +284,7 @@ import scenic.core.propositions as propositions from scenic.core.regions import convertToFootprint import scenic.core.requirements as requirements +from scenic.core.sensors import Recorder, RecordingConfiguration from scenic.core.simulators import RejectSimulationException from scenic.core.specifiers import ModifyingSpecifier, Specifier from scenic.core.type_support import ( @@ -420,6 +426,8 @@ def registerObject(obj): elif activity > 0 or currentScenario: assert not evaluatingRequirement assert isinstance(obj, Object) + if currentScenario and currentScenario._isRunning: + raise InvalidScenarioError("tried to create an object inside a compose block") currentScenario._registerObject(obj) if currentSimulation: currentSimulation._createObject(obj) @@ -523,31 +531,27 @@ def executeInRequirement(scenario, boundEgo, values): assert activity == 0 assert not evaluatingRequirement evaluatingRequirement = True - if currentScenario is None: - currentScenario = scenario - clearScenario = True - else: - assert currentScenario is scenario - clearScenario = False - oldEgo = currentScenario._ego - oldObjects = currentScenario._objects - currentScenario._objects = tuple(values[obj] for obj in currentScenario.objects) + with executeInScenario(scenario): + oldEgo = scenario._ego + oldObjects = scenario._objects - if boundEgo: - currentScenario._ego = boundEgo - try: - yield - except RandomControlFlowError as e: - # Such errors should not be possible inside a requirement, since all values - # should have already been sampled: something's gone wrong with our rebinding. - raise RuntimeError("internal error: requirement dependency not sampled") from e - finally: - evaluatingRequirement = False - currentScenario._ego = oldEgo - currentScenario._objects = oldObjects - if clearScenario: - currentScenario = None + scenario._objects = tuple(values[obj] for obj in scenario.objects) + + if boundEgo: + scenario._ego = boundEgo + try: + yield + except RandomControlFlowError as e: + # Such errors should not be possible inside a requirement, since all values + # should have already been sampled: something's gone wrong with our rebinding. + raise RuntimeError( + "internal error: requirement dependency not sampled" + ) from e + finally: + evaluatingRequirement = False + scenario._ego = oldEgo + scenario._objects = oldObjects # Dynamic scenarios @@ -559,11 +563,14 @@ def registerDynamicScenarioClass(cls): @contextmanager def executeInScenario(scenario, inheritEgo=False): - global currentScenario + global currentScenario, _globalParameters oldScenario = currentScenario if inheritEgo and oldScenario is not None: scenario._ego = oldScenario._ego # inherit ego from parent + scenario._workspace = oldScenario._workspace currentScenario = scenario + oldParams = _globalParameters + _globalParameters = scenario._globalParameters try: yield except AttributeError as e: @@ -578,6 +585,7 @@ def executeInScenario(scenario, inheritEgo=False): raise finally: currentScenario = oldScenario + _globalParameters = oldParams def prepareScenario(scenario): @@ -765,10 +773,52 @@ def require_monitor(reqID, value, line, name): ) -def record(reqID, value, line, name): +def record(reqID, value, line, name, recorder=None, period=None, delay=None): if not name: name = f"record{line}" - makeRequirement(requirements.RequirementType.record, reqID, value, line, name) + if recorder is not None: + if isinstance(recorder, str): + recorder = Recorder._forPattern(recorder) + if not isinstance(recorder, Recorder): + raise TypeError( + f'"record X to Y" on line {line} with Y not a str or Recorder' + ) + if period is not None: + val, unit = period + if not isinstance(val, numbers.Real): + raise TypeError( + f'period of "record" statement on line {line} must be a number' + ) + if val <= 0: + raise ValueError( + f'period of "record" statement on line {line} must be positive' + ) + if unit == "steps" and not isinstance(val, int): + raise TypeError( + f'"record every X steps" on line {line} with X not an integer' + ) + else: + period = (1, "steps") + if delay is not None: + val, unit = delay + if not isinstance(val, numbers.Real): + raise TypeError( + f'delay of "record" statement on line {line} must be a number' + ) + if val < 0: + raise ValueError( + f'delay of "record" statement on line {line} must be nonnegative' + ) + if unit == "steps" and not isinstance(val, int): + raise TypeError( + f'"record after X steps" on line {line} with X not an integer' + ) + else: + delay = (0, "steps") + config = RecordingConfiguration( + name=name, recorder=recorder, period=period, delay=delay + ) + makeRequirement(requirements.RequirementType.record, reqID, value, line, name, config) def record_initial(reqID, value, line, name): @@ -783,22 +833,6 @@ def record_final(reqID, value, line, name): makeRequirement(requirements.RequirementType.recordFinal, reqID, value, line, name) -def require_always(reqID, req, line, name): - """Function implementing the 'require always' statement.""" - if not name: - name = f"requirement on line {line}" - makeRequirement(requirements.RequirementType.requireAlways, reqID, req, line, name) - - -def require_eventually(reqID, req, line, name): - """Function implementing the 'require eventually' statement.""" - if not name: - name = f"requirement on line {line}" - makeRequirement( - requirements.RequirementType.requireEventually, reqID, req, line, name - ) - - def terminate_when(reqID, req, line, name): """Function implementing the 'terminate when' statement.""" if not name: @@ -815,15 +849,13 @@ def terminate_simulation_when(reqID, req, line, name): ) -def makeRequirement(ty, reqID, req, line, name): +def makeRequirement(ty, reqID, req, line, name, recConfig=None): if evaluatingRequirement: raise InvalidScenarioError(f'tried to use "{ty.value}" inside a requirement') elif currentBehavior is not None: raise InvalidScenarioError(f'"{ty.value}" inside a behavior on line {line}') - elif currentSimulation is not None: - currentScenario._addDynamicRequirement(ty, req, line, name) - else: # requirement being defined at compile time - currentScenario._addRequirement(ty, reqID, req, line, name, 1) + else: + currentScenario._addRequirement(ty, reqID, req, line, name, 1, recConfig) def terminate_after(timeLimit, terminator=None): diff --git a/tests/conftest.py b/tests/conftest.py index 3fe2f4d92..d6f3cccec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,17 @@ def loader(relpath, **kwargs): return loader +@pytest.fixture +def launchWebots(): + DISPLAY = os.environ.get("DISPLAY") + if not DISPLAY: + pytest.skip("DISPLAY env variable not set.") + WEBOTS_ROOT = os.environ.get("WEBOTS_ROOT") + if not WEBOTS_ROOT: + pytest.skip("WEBOTS_ROOT env variable not set.") + return WEBOTS_ROOT + + ## Command-line options @@ -108,15 +119,15 @@ def pytest_collection_modifyitems(config, items): item.add_marker(mark) -def pytest_ignore_collect(path, config): +def pytest_ignore_collect(collection_path, config): """ Some test files use Python syntax (e.g., structural pattern matching) that is only available in newer versions of Python. To avoid syntax errors on older versions, put `# pytest: python>=x.y` where x and y represent major and minor versions, respectively, required to run the test file. """ - if path.isfile() and path.ext in (".py", ".scenic"): + if collection_path.is_file() and collection_path.suffix in (".py", ".scenic"): firstLine = "" - with path.open() as f: + with collection_path.open() as f: firstLine = f.readline() m = re.search( r"^#\s*pytest:\s*python\s*>=\s*(?P\d+)\.(?P\d+)\s*$", firstLine diff --git a/tests/core/test_regions.py b/tests/core/test_regions.py index b7293ab3c..2f1f0dc8a 100644 --- a/tests/core/test_regions.py +++ b/tests/core/test_regions.py @@ -1,6 +1,7 @@ import math from pathlib import Path +import fcl import pytest import shapely.geometry import trimesh.voxel @@ -8,10 +9,20 @@ 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 scenic.core.shapes import ConeShape, MeshShape +from scenic.core.vectors import Orientation, VectorField from tests.utils import deprecationTest, sampleSceneFrom +def assertPolygonsEqual(p1, p2, prec=1e-6): + assert p1.difference(p2).area == pytest.approx(0, abs=prec) + assert p2.difference(p1).area == pytest.approx(0, abs=prec) + + +def assertPolygonCovers(p1, p2, prec=1e-6): + assert p2.difference(p1).area == pytest.approx(0, abs=prec) + + def sample_ignoring_rejections(region, num_samples): samples = [] for _ in range(num_samples): @@ -288,6 +299,13 @@ def test_mesh_region_fromFile(getAssetPath): ) +def test_mesh_region_invalid_mesh(): + with pytest.raises(TypeError): + MeshVolumeRegion(42) + with pytest.raises(TypeError): + MeshSurfaceRegion(42) + + def test_mesh_volume_region_zero_dimension(): for dims in ((0, 1, 1), (1, 0, 1), (1, 1, 0)): with pytest.raises(ValueError): @@ -338,6 +356,158 @@ def test_mesh_intersects(): assert not r1.getSurfaceRegion().intersects(r2.getSurfaceRegion()) +def test_mesh_boundingPolygon(getAssetPath, pytestconfig): + r = BoxRegion(dimensions=(8, 6, 2)).difference(BoxRegion(dimensions=(2, 2, 3))) + bp = r.boundingPolygon + poly = shapely.geometry.Polygon( + [(-4, 3), (4, 3), (4, -3), (-4, -3)], [[(-1, 1), (1, 1), (1, -1), (-1, -1)]] + ) + assertPolygonsEqual(bp.polygons, poly) + poly = shapely.geometry.Polygon([(-4, 3), (4, 3), (4, -3), (-4, -3)]) + assertPolygonsEqual(r._boundingPolygonHull, poly) + + shape = MeshShape(BoxRegion(dimensions=(1, 2, 3)).mesh) + r = MeshVolumeRegion(shape.mesh, dimensions=(2, 4, 2), _shape=shape) + bp = r.boundingPolygon + poly = shapely.geometry.Polygon([(-1, 2), (1, 2), (1, -2), (-1, -2)]) + assertPolygonsEqual(bp.polygons, poly) + assertPolygonsEqual(r._boundingPolygonHull, poly) + + o = Orientation.fromEuler(0, 0, math.pi / 4) + r = MeshVolumeRegion(shape.mesh, dimensions=(2, 4, 2), rotation=o, _shape=shape) + bp = r.boundingPolygon + sr2 = math.sqrt(2) + poly = shapely.geometry.Polygon([(-sr2, 2), (sr2, 2), (sr2, -2), (-sr2, -2)]) + assertPolygonsEqual(bp.polygons, poly) + assertPolygonsEqual(r._boundingPolygonHull, poly) + + samples = 50 if pytestconfig.getoption("--fast") else 200 + regions = [] + # Convex + r = BoxRegion(dimensions=(1, 2, 3), position=(4, 5, 6)) + regions.append(r) + # Convex, with scaledShape plus transform + bo = Orientation.fromEuler(math.pi / 4, math.pi / 4, math.pi / 4) + regions.append( + MeshVolumeRegion(r.mesh, position=(15, 20, 5), rotation=bo, _scaledShape=r) + ) + # Convex, with shape and scaledShape plus transform + s = MeshShape(r.mesh) + regions.append( + MeshVolumeRegion( + r.mesh, rotation=bo, position=(4, 5, 6), _shape=s, _scaledShape=r + ) + ) + # Not convex + planePath = getAssetPath("meshes/classic_plane.obj.bz2") + regions.append(MeshVolumeRegion.fromFile(planePath, dimensions=(20, 20, 10))) + # Not convex, with shape plus transform + shape = MeshShape.fromFile(planePath) + regions.append( + MeshVolumeRegion(shape.mesh, dimensions=(0.5, 2, 1.5), rotation=bo, _shape=shape) + ) + for reg in regions: + bp = reg.boundingPolygon + for pt in trimesh.sample.volume_mesh(reg.mesh, samples): + pt[2] = 0 + # exact containment check may fail since polygon is approximate + assert bp.distanceTo(pt) <= 1e-3 + bphull = reg._boundingPolygonHull + assertPolygonCovers(bphull, bp.polygons) + simple = shapely.multipoints(reg.mesh.vertices).convex_hull + assertPolygonsEqual(bphull, simple) + + +def test_mesh_circumradius(getAssetPath): + r1 = BoxRegion(dimensions=(1, 2, 3), position=(4, 5, 6)) + + bo = Orientation.fromEuler(math.pi / 4, math.pi / 4, math.pi / 4) + r2 = MeshVolumeRegion(r1.mesh, position=(15, 20, 5), rotation=bo, _scaledShape=r1) + + planePath = getAssetPath("meshes/classic_plane.obj.bz2") + r3 = MeshVolumeRegion.fromFile(planePath, dimensions=(20, 20, 10)) + + shape = MeshShape.fromFile(planePath) + r4 = MeshVolumeRegion(shape.mesh, dimensions=(0.5, 2, 1.5), rotation=bo, _shape=shape) + + r = BoxRegion(dimensions=(1, 2, 3)).difference(BoxRegion(dimensions=(0.5, 1, 1))) + shape = MeshShape(r.mesh) + scaled = MeshVolumeRegion(shape.mesh, dimensions=(6, 5, 4)).mesh + r5 = MeshVolumeRegion(scaled, position=(-10, -5, 30), rotation=bo, _shape=shape) + + for reg in (r1, r2, r3, r4, r5): + pos = reg.position + d = 2.01 * reg._circumradius + assert SpheroidRegion(dimensions=(d, d, d), position=pos).containsRegion(reg) + + +@pytest.mark.skip( + reason="Temporarily skipping due to inconsistencies; needs further investigation." +) +def test_mesh_interiorPoint(): + regions = [ + BoxRegion(dimensions=(1, 2, 3), position=(4, 5, 6)), + BoxRegion().difference(BoxRegion(dimensions=(0.1, 0.1, 0.1))), + ] + d = 1e6 + r = BoxRegion(dimensions=(d, d, d)).difference( + BoxRegion(dimensions=(d - 1, d - 1, d - 1)) + ) + r._num_samples = 8 # ensure sampling won't yield a good point + regions.append(r) + + bo = Orientation.fromEuler(math.pi / 4, math.pi / 4, math.pi / 4) + r2 = MeshVolumeRegion(r.mesh, position=(15, 20, 5), rotation=bo, _scaledShape=r) + regions.append(r2) + + shape = MeshShape(BoxRegion(dimensions=(1, 2, 3)).mesh) + r3 = MeshVolumeRegion(shape.mesh, position=(-10, -5, 30), rotation=bo, _shape=shape) + regions.append(r3) + + r = BoxRegion(dimensions=(1, 2, 3)).difference(BoxRegion(dimensions=(0.5, 1, 1))) + shape = MeshShape(r.mesh) + scaled = MeshVolumeRegion(shape.mesh, dimensions=(0.1, 0.1, 0.1)).mesh + r4 = MeshVolumeRegion(scaled, position=(-10, -5, 30), rotation=bo, _shape=shape) + regions.append(r4) + + for reg in regions: + cp = reg._interiorPoint + # N.B. _containsPointExact can fail with embreex installed! + assert reg.containsPoint(cp) + inr, circumr = reg._interiorPointRadii + d = 1.99 * inr + assert reg.containsRegion(SpheroidRegion(dimensions=(d, d, d), position=cp)) + d = 2.01 * circumr + assert SpheroidRegion(dimensions=(d, d, d), position=cp).containsRegion(reg) + + +def test_mesh_fcl(): + """Test internal construction of FCL models for MeshVolumeRegions.""" + r1 = BoxRegion(dimensions=(2, 2, 2)).difference(BoxRegion(dimensions=(1, 1, 3))) + + for heading, shouldInt in ((0, False), (math.pi / 4, True), (math.pi / 2, False)): + o = Orientation.fromEuler(heading, 0, 0) + r2 = BoxRegion(dimensions=(1.5, 1.5, 0.5), position=(2, 0, 0), rotation=o) + assert r1.intersects(r2) == shouldInt + + o1 = fcl.CollisionObject(*r1._fclData) + o2 = fcl.CollisionObject(*r2._fclData) + assert fcl.collide(o1, o2) == shouldInt + + bo = Orientation.fromEuler(math.pi / 4, math.pi / 4, math.pi / 4) + r3 = MeshVolumeRegion(r1.mesh, position=(15, 20, 5), rotation=bo, _scaledShape=r1) + o3 = fcl.CollisionObject(*r3._fclData) + r4pos = r3.position.offsetLocally(bo, (0, 2, 0)) + + for heading, shouldInt in ((0, False), (math.pi / 4, True), (math.pi / 2, False)): + o = bo * Orientation.fromEuler(heading, 0, 0) + r4 = BoxRegion(dimensions=(1.5, 1.5, 0.5), position=r4pos, rotation=o) + assert r3.intersects(r4) == shouldInt + + o4 = fcl.CollisionObject(*r4._fclData) + assert fcl.collide(o3, o4) == shouldInt + + def test_mesh_empty_intersection(): r1 = BoxRegion(position=(0, 0, 0)) r2 = BoxRegion(position=(10, 10, 10)) @@ -554,6 +724,33 @@ def test_mesh_path_intersection(): assert r2.containsPoint(point) +def test_polygonal_footprint_path_intersection(): + polyline_list = [] + + for z in range(-2, 3): + polyline_list.append([]) + target_list = polyline_list[-1] + for y in range(-5, 6, 2): + for x in range(-5, 6, 2): + target_list.append((x, y, 0)) + target_list.append((x, y + 1, 0)) + target_list.append((x + 1, y + 1, 0)) + target_list.append((x + 1, y, 0)) + + r1 = PathRegion(polylines=polyline_list) + r2 = CircularRegion((0, 0), 5) + + r = r1.intersect(r2.footprint) + + assert isinstance(r, PathRegion) + + for _ in range(100): + point = r.uniformPointInner() + assert r.containsPoint(point) + assert r1.containsPoint(point) + assert r2.containsPoint(point) + + def test_pointset_region(): PointSetRegion("foo", [(1, 2), (3, 4), (5, 6)]) PointSetRegion("bar", [(1, 2, 1), (3, 4, 2), (5, 6, 3)]) diff --git a/tests/core/test_serialization.py b/tests/core/test_serialization.py index af0b6705e..6ae1d1c95 100644 --- a/tests/core/test_serialization.py +++ b/tests/core/test_serialization.py @@ -13,7 +13,7 @@ import numpy import pytest -from scenic.core.serialization import SerializationError, Serializer +from scenic.core.serialization import SerializationError, Serializer, deterministicHash from scenic.core.simulators import DivergenceError, DummySimulator from tests.utils import ( areEquivalent, @@ -54,6 +54,7 @@ def assertSceneEquivalence(scene1, scene2, ignoreDynamics=False, ignoreConstProp if ignoreDynamics: del scene1.dynamicScenario, scene2.dynamicScenario for obj in scene1.objects + scene2.objects: + del obj._sampleParent if ignoreConstProps: del obj._constProps if ignoreDynamics: @@ -159,7 +160,7 @@ def test_float(self): def test_bytes(self): checkValueEncoding(b"", bytes) checkValueEncoding(b"\x00", bytes) - checkValueEncoding(b"\xFF", bytes) + checkValueEncoding(b"\xff", bytes) checkValueEncoding(b"\x00123456", bytes) def test_str(self): @@ -259,6 +260,18 @@ def test_scene_inconsistent_mode(self): with pytest.raises(SerializationError): sc2.sceneFromBytes(data) + def test_scene_inconsistent_params(self): + code = """ + ego = new Object + param x = 1 + """ + sc1 = compileScenic(code, params={"x": 1}) + sc2 = compileScenic(code, params={"x": 2}) + scene1 = sampleScene(sc1) + data = sc1.sceneToBytes(scene1) + with pytest.raises(SerializationError): + sc2.sceneFromBytes(data) + def test_scene_behavior(self): scenario = compileScenic( """ @@ -481,3 +494,16 @@ def test_combined_serialization(self): data = scenario.simulationToBytes(sim1) sim2 = scenario.simulationFromBytes(data, simulator, maxSteps=1) assert getEgoActionsFrom(sim1) == getEgoActionsFrom(sim2) + + +def test_deterministic_hash_non_scalar_values(): + class Foo: + pass + + mapping1 = {"a": Foo()} + mapping2 = {"a": Foo()} # different instance, still non-scalar + + digest1 = deterministicHash(mapping1) + digest2 = deterministicHash(mapping2) + # Non-scalar values should hash in a stable way, independent of identity. + assert digest1 == digest2 diff --git a/tests/core/test_shapes.py b/tests/core/test_shapes.py index 95e257f8f..a27fd6b3d 100644 --- a/tests/core/test_shapes.py +++ b/tests/core/test_shapes.py @@ -3,7 +3,8 @@ import pytest -from scenic.core.shapes import BoxShape, MeshShape +from scenic.core.regions import BoxRegion +from scenic.core.shapes import BoxShape, CylinderShape, MeshShape def test_shape_fromFile(getAssetPath): @@ -21,3 +22,15 @@ def test_invalid_dimension(badDim): BoxShape(dimensions=dims) with pytest.raises(ValueError): BoxShape(scale=badDim) + + +def test_circumradius(): + s = CylinderShape(dimensions=(3, 1, 17)) # dimensions don't matter + assert s._circumradius == pytest.approx(math.sqrt(2) / 2) + + +def test_interiorPoint(): + s = MeshShape(BoxRegion().difference(BoxRegion(dimensions=(0.1, 0.1, 0.1))).mesh) + pt = s._interiorPoint + assert all(-0.5 <= coord <= 0.5 for coord in pt) + assert not all(-0.05 <= coord <= 0.05 for coord in pt) diff --git a/tests/core/test_typing.py b/tests/core/test_typing.py index ec438e41f..6bdc13f1e 100644 --- a/tests/core/test_typing.py +++ b/tests/core/test_typing.py @@ -5,7 +5,13 @@ import numpy import pytest -from scenic.core.distributions import DiscreteRange, Options, Range, distributionFunction +from scenic.core.distributions import ( + DiscreteRange, + Options, + Range, + TupleDistribution, + distributionFunction, +) from scenic.core.object_types import Object from scenic.core.type_support import ( CoercionFailure, @@ -151,6 +157,33 @@ def check(values, fail=False): check([(1, 2), (1, 2, 3, 4)], fail=True) +def test_coerce_tupledist_vector(): + # 2D and 3D TupleDistribution should be coercible to Vector. + td2 = TupleDistribution(Range(0, 1), Range(0, 1)) + td3 = TupleDistribution(Range(0, 1), Range(0, 1), Range(0, 1)) + + # Can be treated as Vector-compatible random values. + assert canCoerce(td2, Vector) + + v2 = coerce(td2, Vector) + v3 = coerce(td3, Vector) + # Special-case in coerce() should produce Vectors, not wrapper distributions. + assert isinstance(v2, Vector) + assert isinstance(v3, Vector) + + +def test_coerce_tupledist_vector_bad_len(): + # Length-1 and length-4 TupleDistributions should be rejected. + td1 = TupleDistribution(Range(0, 1)) + td4 = TupleDistribution(Range(0, 1), Range(0, 1), Range(0, 1), Range(0, 1)) + + with pytest.raises(TypeError, match="expected vector, got tuple of length 1"): + coerce(td1, Vector) + + with pytest.raises(TypeError, match="expected vector, got tuple of length 4"): + coerce(td4, Vector) + + def test_coerce_distribution_tuple(): y = makeDistWithType(Tuple[float, str]) assert coerce(y, tuple) is y diff --git a/tests/domains/driving/conftest.py b/tests/domains/driving/conftest.py index 8877f6742..fc2e1ddd0 100644 --- a/tests/domains/driving/conftest.py +++ b/tests/domains/driving/conftest.py @@ -10,11 +10,10 @@ mapFolder = Path("assets") / "maps" maps = glob.glob(str(mapFolder / "**" / "*.xodr")) -# TODO fix handling of this problematic map -badmap = str(mapFolder / "opendrive.org" / "sample1.1.xodr") +badmaps = [] # currently all maps are working! map_params = [] for path in maps: - if path == badmap: + if path in badmaps: param = pytest.param( badmap, marks=pytest.mark.xfail( diff --git a/tests/domains/driving/test_network.py b/tests/domains/driving/test_network.py index 4f1394198..b03061cd2 100644 --- a/tests/domains/driving/test_network.py +++ b/tests/domains/driving/test_network.py @@ -23,7 +23,8 @@ def test_network_invalid(): def test_show2D(network): import matplotlib.pyplot as plt - network.show(labelIncomingLanes=True) + network.show(labelIncomingLanes=True, showCurbArrows=True) + plt.show(block=False) plt.close() @@ -34,7 +35,12 @@ def test_element_tolerance(cached_maps, pytestconfig): 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) + top_level_region = drivable.union(network.shoulderRegion).union( + network.sidewalkRegion + ) + outside_top_level = top_level_region.buffer(2 * tol).difference( + top_level_region.buffer(1.5 * tol) + ) road = network.roads[0] nearby = road.buffer(tol).difference(road) rounds = 30 if pytestconfig.getoption("--fast") else 300 @@ -48,9 +54,10 @@ def test_element_tolerance(cached_maps, pytestconfig): assert network.roadAt(pt) is None with pytest.raises(RejectionException): network.roadAt(pt, reject=True) - pt = toofar_noint.uniformPointInner() + pt = outside_top_level.uniformPointInner() assert network.elementAt(pt) is None - assert not network.nominalDirectionsAt(pt) + with pytest.raises(RejectionException): + network.elementAt(pt, reject=True) def test_orientation_consistency(network): @@ -226,3 +233,122 @@ def checkGroup(group): assert rev is not maneuver assert rev.startLane.road is maneuver.endLane.road assert rev.endLane.road is maneuver.startLane.road + + +def test_shoulder(network): + sh = network.shoulders[0] + for _ in range(30): + pt = sh.uniformPointInner() + assert network.shoulderAt(pt) is sh + assert network.elementAt(pt) is sh + so_yaw = sh.orientation[pt].yaw + rd_yaw = network.roadDirection[pt].yaw + assert rd_yaw == pytest.approx(so_yaw) + dirs = network.nominalDirectionsAt(pt) + assert len(dirs) == 1 + assert dirs[0].yaw == pytest.approx(so_yaw) + + +def test_sidewalk(network): + sw = network.sidewalks[0] + for _ in range(30): + pt = sw.uniformPointInner() + assert network.sidewalkAt(pt) is sw + assert network.elementAt(pt) is sw + + +# --- Tests for cached network pickles --- + + +def _read_options_digest(pickled_path: Path) -> bytes: + """Return the optionsDigest bytes from a cached .snet header. + + Header layout: + - 4 bytes: version + - 64 bytes: originalDigest + - 8 bytes: optionsDigest + """ + with open(pickled_path, "rb") as f: + header = f.read(76) # 4 + 64 + 8 + return header[68:76] # skip version (4) + originalDigest (64) + + +def test_dump_pickle_from_pickle(tmp_path, network): + """dumpPickle/fromPickle should rebuild the Network when digests match""" + digest = b"x" * 64 # fake original map digest + options_digest = b"y" * 8 # fake map options digest + path = tmp_path / "net.snet" + + # Write the cache using the new format. + network.dumpPickle(path, digest, options_digest) + + # Read it back with matching digests. + loaded = Network.fromPickle( + path, + originalDigest=digest, + optionsDigest=options_digest, + ) + + # Sanity checks + assert isinstance(loaded, Network) + assert loaded.elements.keys() == network.elements.keys() + + +def test_from_pickle_digest_mismatch(tmp_path, network): + """fromPickle should reject files whose digests don't match.""" + digest = b"x" * 64 + options_digest = b"y" * 8 + path = tmp_path / "net.snet" + + network.dumpPickle(path, digest, options_digest) + + wrong_digest = b"z" * 64 + wrong_options = b"w" * 8 + + # Wrong originalDigest -> DigestMismatchError. + with pytest.raises(Network.DigestMismatchError): + Network.fromPickle( + path, + originalDigest=wrong_digest, + optionsDigest=options_digest, + ) + + # Wrong optionsDigest -> DigestMismatchError. + with pytest.raises(Network.DigestMismatchError): + Network.fromPickle( + path, + originalDigest=digest, + optionsDigest=wrong_options, + ) + + # No digests supplied -> allowed (standalone .snet use case). + Network.fromPickle(path) + + +def test_cache_regenerated_when_options_change(cached_maps): + """Changing map options should invalidate and rewrite the cached .snet file.""" + # Get the temp copy of Town01 from cached_maps. + xodr_loc = cached_maps[str(mapFolder / "CARLA" / "Town01.xodr")] + xodr_path = Path(str(xodr_loc)) + pickled_path = xodr_path.with_suffix(Network.pickledExt) + + # First load: cache with one tolerance value + Network.fromFile( + xodr_path, + useCache=True, + writeCache=True, + tolerance=0.05, + ) + options_digest1 = _read_options_digest(pickled_path) + + # Second load: same map, different tolerance. + Network.fromFile( + xodr_path, + useCache=True, + writeCache=True, + tolerance=0.123, + ) + options_digest2 = _read_options_digest(pickled_path) + + # If the options changed, the cache header should also change. + assert options_digest1 != options_digest2 diff --git a/tests/formats/opendrive/test_opendrive.py b/tests/formats/opendrive/test_opendrive.py index 5929df446..62b011c59 100644 --- a/tests/formats/opendrive/test_opendrive.py +++ b/tests/formats/opendrive/test_opendrive.py @@ -1,12 +1,20 @@ import glob import os from pathlib import Path +import xml.etree.ElementTree as ET import matplotlib.pyplot as plt import pytest from scenic.core.geometry import TriangulationError from scenic.formats.opendrive import OpenDriveWorkspace +from scenic.formats.opendrive.xodr_parser import ( + Cubic, + OpenDriveWarning, + ParamCubic, + RoadMap, + makeCurve, +) oldDir = os.getcwd() os.chdir(Path("tests") / "formats" / "opendrive") @@ -30,3 +38,143 @@ def test_map(path, runLocally, pytestconfig): odw.show(plt) plt.show(block=False) plt.close() + + +def write_xodr(tmp_path, plan_view): + path = tmp_path / "test.xodr" + path.write_text( + f""" + + + {plan_view} + + + +
+ +
+ + + + + +
+
+
+
+""" + ) + return path + + +def test_inconsistent_planview_length_warns(tmp_path): + path = write_xodr( + tmp_path, + """ + + + + + + + """, + ) + + road_map = RoadMap() + + with pytest.warns( + OpenDriveWarning, + match="planView of road 7 has inconsistent length", + ): + road_map.parse(path) + + road = road_map.roads[7] + assert len(road.ref_line) == 2 + assert road.ref_line[0].length == pytest.approx(10.0) + assert road.ref_line[1].length == pytest.approx(10.0) + + +def test_empty_planview_rejected(tmp_path): + path = write_xodr(tmp_path, "") + + road_map = RoadMap() + + with pytest.raises(ValueError, match="road 7 has an empty planView"): + road_map.parse(path) + + +def test_planview_must_start_at_zero(tmp_path): + path = write_xodr( + tmp_path, + """ + + + + """, + ) + + road_map = RoadMap() + + with pytest.raises( + ValueError, match="reference line of road 7 does not start at s=0" + ): + road_map.parse(path) + + +def test_planview_must_be_in_order(tmp_path): + path = write_xodr( + tmp_path, + """ + + + + + + + """, + ) + + road_map = RoadMap() + + with pytest.raises(ValueError, match="planView of road 7 is not in order"): + road_map.parse(path) + + +def test_make_curve_poly3(): + curve_elem = ET.fromstring('') + + curve, susp = makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem) + + assert isinstance(curve, Cubic) + assert curve.length == pytest.approx(10.0) + assert not susp + + +def test_make_curve_param_poly3(): + curve_elem = ET.fromstring( + '' + ) + + curve, susp = makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem) + + assert isinstance(curve, ParamCubic) + assert curve.length == pytest.approx(10.0) + assert not susp + + +def test_make_curve_param_poly3_rejects_arc_length(): + curve_elem = ET.fromstring( + '' + ) + + with pytest.raises(NotImplementedError, match="unsupported pRange for paramPoly3"): + makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem) + + +def test_make_curve_rejects_unknown_geometry_type(): + curve_elem = ET.fromstring("") + + with pytest.raises(NotImplementedError, match="unhandled OpenDRIVE geometry type"): + makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem) diff --git a/tests/simulators/carla/test_actions.py b/tests/simulators/carla/test_actions.py index f0aede475..d4d2a23cb 100644 --- a/tests/simulators/carla/test_actions.py +++ b/tests/simulators/carla/test_actions.py @@ -1,3 +1,4 @@ +import math import os from pathlib import Path import signal @@ -43,19 +44,21 @@ def getCarlaSimulator(getAssetPath): f"bash {CARLA_ROOT}/CarlaUE4.sh -RenderOffScreen", shell=True ) - for _ in range(30): + for _ in range(180): if isCarlaServerRunning(): break time.sleep(1) + else: + pytest.fail("Unable to connect to CARLA.") # Extra 5 seconds to ensure server startup - time.sleep(5) + time.sleep(10) base = getAssetPath("maps/CARLA") def _getCarlaSimulator(town): path = os.path.join(base, f"{town}.xodr") - simulator = CarlaSimulator(map_path=path, carla_map=town) + simulator = CarlaSimulator(map_path=path, carla_map=town, timeout=180) return simulator, town, path yield _getCarlaSimulator @@ -76,7 +79,7 @@ def test_throttle(getCarlaSimulator): 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 @@ -109,8 +112,8 @@ def test_brake(getCarlaSimulator): do DriveWithThrottle() for 2 steps do Brake() for 6 steps - ego = new Car at (369, -326), - with blueprint 'vehicle.toyota.prius', + ego = new Car at (369, -326), + with blueprint 'vehicle.toyota.prius', with behavior DriveThenBrake record final ego.speed as CarSpeed terminate after 8 steps @@ -120,3 +123,100 @@ def test_brake(getCarlaSimulator): simulation = simulator.simulate(scene) finalSpeed = simulation.result.records["CarSpeed"] assert finalSpeed == pytest.approx(0.0, abs=1e-1) + + +def test_reverse(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 DriveInReverse(): + while True: + take SetReverseAction(True), SetThrottleAction(1) + + ego = new Car at (369, -326), with behavior DriveInReverse + record initial ego.heading as Heading + record final ego.velocity as Vel + terminate after 5 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + h = simulation.result.records["Heading"] + fwd_x, fwd_y = -math.sin(h), math.cos(h) + vx, vy, _ = simulation.result.records["Vel"] + proj = vx * fwd_x + vy * fwd_y + assert proj < -0.02, f"Expected reverse velocity (negative proj), got {proj}" + + +def test_steer(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 TurnRight(): + while True: + take SetThrottleAction(0.5), SetSteerAction(1) + + # Ego facing west + ego = new Car at (300, -55), with behavior TurnRight + + record initial ego.heading as InitialHeading + record final ego.heading as FinalHeading + terminate after 3 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + sim = simulator.simulate(scene) + initial_heading = sim.result.records["InitialHeading"] + final_heading = sim.result.records["FinalHeading"] + assert ( + initial_heading > final_heading + ), "Positive steer should turn right (heading must decrease)." + + +def test_track_waypoints(getCarlaSimulator): + simulator, town, mapPath = getCarlaSimulator("Town01") + target_speed = 6.0 + + code = f""" + param map = r'{mapPath}' + param carla_map = '{town}' + param time_step = 1.0/10 + + model scenic.simulators.carla.model + + # Short straight segment, starting from a known-good spawn point. + waypoints = [(-2, -13), (-2, -23), (-2, -33)] + + behavior FollowPath(): + while True: + take TrackWaypointsAction(waypoints, cruising_speed={target_speed}) + + ego = new Car at waypoints[0], + with behavior FollowPath + + record initial (distance from ego to waypoints[-1]) as InitialDist + record final (distance from ego to waypoints[-1]) as FinalDist + record final ego.speed as FinalSpeed + + terminate after 20 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + sim = simulator.simulate(scene) + + initial_dist = sim.result.records["InitialDist"] + final_dist = sim.result.records["FinalDist"] + final_speed = sim.result.records["FinalSpeed"] + + assert final_dist < initial_dist + assert final_speed == pytest.approx(target_speed, abs=2.0) diff --git a/tests/simulators/carla/test_blueprints.py b/tests/simulators/carla/test_blueprints.py index 4b7dcba25..bd9534e89 100644 --- a/tests/simulators/carla/test_blueprints.py +++ b/tests/simulators/carla/test_blueprints.py @@ -7,49 +7,63 @@ 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 scenic.simulators.carla import blueprints as bp from tests.utils import compileScenic, sampleScene +# Map class name -> ids dict key +CATEGORY_TO_CLASS = { + "carModels": "Car", + "bicycleModels": "Bicycle", + "motorcycleModels": "Motorcycle", + "truckModels": "Truck", + "vanModels": "Van", + "busModels": "Bus", + "trashModels": "Trash", + "coneModels": "Cone", + "debrisModels": "Debris", + "vendingMachineModels": "VendingMachine", + "chairModels": "Chair", + "busStopModels": "BusStop", + "advertisementModels": "Advertisement", + "garbageModels": "Garbage", + "containerModels": "Container", + "tableModels": "Table", + "barrierModels": "Barrier", + "plantpotModels": "PlantPot", + "mailboxModels": "Mailbox", + "gnomeModels": "Gnome", + "creasedboxModels": "CreasedBox", + "caseModels": "Case", + "boxModels": "Box", + "benchModels": "Bench", + "barrelModels": "Barrel", + "atmModels": "ATM", + "kioskModels": "Kiosk", + "ironplateModels": "IronPlate", + "trafficwarningModels": "TrafficWarning", + "walkerModels": "Pedestrian", +} + +# Build the (modelType, modelName) pairs from bp.ids +PARAMS = [ + (model_type, model_name) + for key, model_type in CATEGORY_TO_CLASS.items() + for model_name in bp.ids.get(key, []) # empty lists are fine; they just add no params +] + def model_blueprint(simulator, mapPath, town, modelType, modelName): code = f""" - param map = r'{mapPath}' - param carla_map = '{town}' - param time_step = 1.0/10 + 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 - """ + model scenic.simulators.carla.model + ego = new {modelType} at (369, -326), + with blueprint '{modelName}', + with regionContainedIn None + terminate after 1 steps + """ scenario = compileScenic(code, mode2D=True) scene = sampleScene(scenario) simulation = simulator.simulate(scene) @@ -57,42 +71,7 @@ def model_blueprint(simulator, mapPath, town, modelType, modelName): 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], -) +@pytest.mark.parametrize("modelType, modelName", PARAMS) def test_model_blueprints(getCarlaSimulator, modelType, modelName): simulator, town, mapPath = getCarlaSimulator("Town01") model_blueprint(simulator, mapPath, town, modelType, modelName) diff --git a/tests/simulators/metadrive/basic.scenic b/tests/simulators/metadrive/basic.scenic new file mode 100644 index 000000000..612fd146b --- /dev/null +++ b/tests/simulators/metadrive/basic.scenic @@ -0,0 +1,13 @@ +param map = localPath('../../../assets/maps/CARLA/Town01.xodr') +param sumo_map = localPath('../../../assets/maps/CARLA/Town01.net.xml') + +model scenic.simulators.metadrive.model + +ego = new Car in intersection + +ego = new Car on ego.lane.predecessor + +new Pedestrian on visible sidewalk + +third = new Car on visible ego.road +require abs((apparent heading of third) - 180 deg) <= 30 deg diff --git a/tests/simulators/metadrive/test_metadrive.py b/tests/simulators/metadrive/test_metadrive.py new file mode 100644 index 000000000..1c1ea9ad1 --- /dev/null +++ b/tests/simulators/metadrive/test_metadrive.py @@ -0,0 +1,493 @@ +from importlib.metadata import PackageNotFoundError, distribution, version +import json +import math +import os +from urllib.error import URLError + +import numpy as np +import pytest + +try: + import metadrive + from metadrive.pull_asset import pull_asset + + # Make sure MetaDrive assets are available. + # On CI, if the asset server is down or unreachable, skip this whole file. + try: + pull_asset(update=False) + except URLError as e: + pytest.skip( + f"MetaDrive assets could not be pulled ({e}); skipping MetaDrive tests", + allow_module_level=True, + ) + + from scenic.simulators.metadrive import MetaDriveSimulator +except ModuleNotFoundError: + pytest.skip("MetaDrive package not installed", allow_module_level=True) + +from tests.utils import compileScenic, pickle_test, sampleScene, tryPickling + +WINDOW_ERR = "Could not open window" + + +def metadrive_installed_from_git(): + # Git installs still report version "0.4.3"; PEP 610 direct_url.json lets us tell PyPI vs GitHub. + try: + direct = distribution("metadrive-simulator").read_text("direct_url.json") + except PackageNotFoundError: + return False + if not direct: + return False + try: + return "vcs_info" in json.loads(direct) + except json.JSONDecodeError: + return False + + +metadrive_pkg_version = version("metadrive-simulator") + +xfail_md_brake_bug = pytest.mark.xfail( + metadrive_pkg_version == "0.4.3" and not metadrive_installed_from_git(), + reason="Known MetaDrive 0.4.3 (PyPI) braking/handbrake bug; install MetaDrive from GitHub for the fix.", + strict=False, +) + + +# Helper to run a simulation but skip cleanly on CI. +# MetaDrive (Panda3D) tries to open a window on GitHub runners +# and fails with "Could not open window", while Newtonian (pygame) works fine. +# We still attempt the run (in case it succeeds or gets fixed), but if we hit +# that specific error we skip instead of failing the whole CI job. +def simulate_or_skip(simulator, scene): + try: + return simulator.simulate(scene) + except Exception as e: + if WINDOW_ERR in str(e): + pytest.skip("MetaDrive (Panda3D) cannot open a window on this platform/CI") + else: + raise + + +def test_basic(loadLocalScenario): + scenario = loadLocalScenario("basic.scenic", mode2D=True) + scenario.generate(maxIterations=1000) + + +@pickle_test +@pytest.mark.slow +def test_pickle(loadLocalScenario): + scenario = tryPickling(loadLocalScenario("basic.scenic", mode2D=True)) + tryPickling(sampleScene(scenario, maxIterations=1000)) + + +@pytest.fixture(scope="package") +def getMetadriveSimulator(getAssetPath): + base = getAssetPath("maps/CARLA") + + def _getMetadriveSimulator(town, *, render=False, render3D=False, **kwargs): + openDrivePath = os.path.join(base, f"{town}.xodr") + sumoPath = os.path.join(base, f"{town}.net.xml") + simulator = MetaDriveSimulator( + sumo_map=sumoPath, + xodr_map=openDrivePath, + render=render, + render3D=render3D, + **kwargs, + ) + return simulator, openDrivePath, sumoPath + + yield _getMetadriveSimulator + + +def test_throttle(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.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) + speeds = simulation.result.records["CarSpeed"] + assert speeds[len(speeds) // 2][1] < speeds[-1][1] + + +@xfail_md_brake_bug +def test_brake(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.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 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) + + +def test_reverse_and_brake(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + behavior Reverse(): + while True: + take SetReverseAction(True), SetThrottleAction(1), SetBrakeAction(0) + + behavior Brake(): + while True: + take SetThrottleAction(0), SetBrakeAction(1) + + behavior ReverseThenBrake(): + do Reverse() for 3 steps + do Brake() for 10 steps + + ego = new Car at (369, -326), with behavior ReverseThenBrake + + record initial ego.heading as Heading + record ego.velocity as Vel + record final ego.speed as Speed + + terminate after 13 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + + h = simulation.result.records["Heading"] + fwd = (-math.sin(h), math.cos(h)) + vx, vy, _ = simulation.result.records["Vel"][2][1] + proj = vx * fwd[0] + vy * fwd[1] + assert proj < -0.02, f"Expected reverse velocity (negative proj), got {proj}" + + finalSpeed = simulation.result.records["Speed"] + assert finalSpeed == pytest.approx(0.0, abs=0.5) + + +@xfail_md_brake_bug +def test_handbrake(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + behavior HandbrakeAndThrottle(): + while True: + take SetHandBrakeAction(True), SetThrottleAction(1) + + ego = new Car at (369, -326), with behavior HandbrakeAndThrottle + record initial ego.position as InitialPos + record final ego.position as FinalPos + record final ego.speed as Speed + terminate after 6 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + + p0 = simulation.result.records["InitialPos"] + p1 = simulation.result.records["FinalPos"] + finalSpeed = simulation.result.records["Speed"] + + assert p0 == pytest.approx(p1, abs=0.05) + assert finalSpeed == pytest.approx(0.0, abs=0.1) + + +def test_set_position(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + behavior Teleport(): + wait + take SetPositionAction(Vector(120, -56)) + + ego = new Car at (30, 2), with behavior Teleport + record initial ego.position as InitialPos + record final ego.position as FinalPos + terminate after 2 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + p0 = simulation.result.records["InitialPos"] + p1 = simulation.result.records["FinalPos"] + + assert p0 != p1 + assert p1 == pytest.approx((120, -56, 0), abs=0.01) + + +def test_initial_velocity_movement(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + # Car should move 5 m/s west + ego = new Car at (30, 2), with velocity (-5, 0) + record initial ego.position as InitialPos + record final ego.position as FinalPos + terminate after 1 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + initialPos = simulation.result.records["InitialPos"] + finalPos = simulation.result.records["FinalPos"] + dx = finalPos[0] - initialPos[0] + assert dx < -0.1, f"Expected car to move west (negative dx), but got dx = {dx}" + + +def test_pedestrian_movement(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + behavior WalkForward(): + while True: + take SetWalkingDirectionAction(self.heading), SetWalkingSpeedAction(0.5) + + behavior StopWalking(): + while True: + take SetWalkingSpeedAction(0) + + behavior WalkThenStop(): + do WalkForward() for 2 steps + do StopWalking() for 2 steps + + ego = new Car at (30, 2) + pedestrian = new Pedestrian at (50, 6), with behavior WalkThenStop + + record pedestrian.position as Pos + terminate after 4 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + series = simulation.result.records["Pos"] + initialPos = series[0][1] + finalPos = series[-1][1] + assert initialPos != finalPos + + +@pytest.mark.slow +@pytest.mark.graphical +def test_static_pedestrian(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator( + "Town01", render=True, render3D=True + ) + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + ego = new Car at (266.21, -59.57) + pedestrian = new Pedestrian at (275, -59.57), with regionContainedIn None + record initial pedestrian.position as InitialPos + record final pedestrian.position as FinalPos + terminate after 5 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulate_or_skip(simulator, scene) + + initialPos = simulation.result.records["InitialPos"] + finalPos = simulation.result.records["FinalPos"] + assert initialPos == finalPos, ( + f"Expected pedestrian to remain stationary (default speed=0), " + f"but moved from {initialPos} to {finalPos}" + ) + + +@pytest.mark.graphical # TODO temporary until MetaDrive issue fixed in new release +def test_duplicate_sensor_names(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + ego = new Car at (369, -326), + with sensors {{ + "front_rgb": RGBSensor(offset=(1.6, 0, 1.7), width=64, height=64) + }} + + # ensure a different view so frames shouldn't match + other = new Car at (385, -326), + with sensors {{ + "front_rgb": RGBSensor(offset=(1.6, 0, 1.7), rotation=(45, 0, 0), width=64, height=64) + }} + + record ego.observations["front_rgb"] as EgoRGB + record other.observations["front_rgb"] as OtherRGB + terminate after 3 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulate_or_skip(simulator, scene) + + ego_series = simulation.result.records["EgoRGB"] + other_series = simulation.result.records["OtherRGB"] + assert len(ego_series) > 0 and len(other_series) > 0 + + img0 = ego_series[-1][1] + img1 = other_series[-1][1] + assert img0.shape == (64, 64, 3) + assert img1.shape == (64, 64, 3) + assert not np.array_equal(img0, img1) + + +def test_steer(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + model scenic.simulators.metadrive.model + + behavior TurnRight(): + while True: + take SetThrottleAction(0.5), SetSteerAction(1) + + # Ego facing west + ego = new Car at (300, -55), with behavior TurnRight + + record initial ego.heading as InitialHeading + record final ego.heading as FinalHeading + terminate after 3 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + sim = simulator.simulate(scene) + initial_heading = sim.result.records["InitialHeading"] + final_heading = sim.result.records["FinalHeading"] + assert ( + initial_heading > final_heading + ), "Positive steer should turn right (heading must decrease)." + + +def test_composed_scenario(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + model scenic.simulators.metadrive.model + + scenario Sub(): + setup: + other = new Car at (190, -196), with behavior FollowLaneBehavior() + terminate after 3 steps + + scenario Main(): + setup: + ego = new Car at (129, -196), with behavior FollowLaneBehavior() + compose: + do Sub() + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulation = simulator.simulate(scene) + traj = simulation.result.trajectory + + assert len(traj[0]) == 2, f"expected 2 objects, got {len(traj[0])}" + assert traj[0][0] != traj[-1][0], f"ego car did not move." + assert traj[0][1] != traj[-1][1], f"subscenario car did not move." + + +def test_render_2D_saves_gif(getMetadriveSimulator, tmp_path): + outdir = tmp_path / "md_gifs" + outdir.mkdir() + + simulator, openDrivePath, sumoPath = getMetadriveSimulator( + "Town01", + render=True, + screen_record=True, + screen_record_filename="render2d.gif", + screen_record_path=str(outdir), + ) + + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + ego = new Car at (30, 2) + terminate after 2 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario) + simulator.simulate(scene) + + assert (outdir / "render2d.gif").exists() + + +def test_follow_lane(getMetadriveSimulator): + # Exercise MetaDrive's getLaneFollowingControllers via FollowLaneBehavior: + # car should stay on a lane and actually accelerate. + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + ego = new Car with behavior FollowLaneBehavior(target_speed=8) + + record final (ego._lane is not None) as OnLane + record final ego.speed as FinalSpeed + terminate after 8 steps + """ + scenario = compileScenic(code, mode2D=True) + scene = sampleScene(scenario, maxIterations=1000) + simulation = simulator.simulate(scene) + assert simulation.result.records[ + "OnLane" + ], "Vehicle left the lane under FollowLaneBehavior." + assert ( + simulation.result.records["FinalSpeed"] > 0.2 + ), "Vehicle did not accelerate along the lane." diff --git a/tests/simulators/newtonian/test_newtonian.py b/tests/simulators/newtonian/test_newtonian.py index b714479e2..98561c527 100644 --- a/tests/simulators/newtonian/test_newtonian.py +++ b/tests/simulators/newtonian/test_newtonian.py @@ -6,7 +6,7 @@ from scenic.domains.driving.roads import Network from scenic.simulators.newtonian import NewtonianSimulator -from tests.utils import pickle_test, sampleScene, tryPickling +from tests.utils import compileScenic, pickle_test, sampleScene, tryPickling def test_basic(loadLocalScenario): @@ -59,3 +59,57 @@ def test_pickle(loadLocalScenario): simulation = simulator.simulate(scene, maxSteps=100) egoPos, otherPos = simulation.result.trajectory[-1] assert egoPos.distanceTo(otherPos) < 1 + + +def test_pedestrian_movement(getAssetPath): + mapPath = getAssetPath("maps/CARLA/Town01.xodr") + + code = f""" + param map = r'{mapPath}' + param render = False + model scenic.simulators.newtonian.driving_model + + behavior Walk(): + take SetWalkingDirectionAction(self.heading), SetWalkingSpeedAction(1) + + ped = new Pedestrian with regionContainedIn None, + with behavior Walk() + + record initial ped.position as InitialPos + record final ped.position as FinalPos + terminate after 8 steps + """ + scenario = compileScenic(code, mode2D=True) + scene, _ = scenario.generate(maxIterations=1) + simulator = NewtonianSimulator() + simulation = simulator.simulate(scene, maxSteps=8) + init = simulation.result.records["InitialPos"] + fin = simulation.result.records["FinalPos"] + assert init.distanceTo(fin) > 0.1, "Pedestrian did not move." + + +def test_pedestrian_velocity_vector(getAssetPath): + mapPath = getAssetPath("maps/CARLA/Town01.xodr") + + code = f""" + param render = False + param map = r'{mapPath}' + model scenic.simulators.newtonian.driving_model + + ped = new Pedestrian on sidewalk, with velocity (1, 1) + + record initial ped.position as InitialPos + record final ped.position as FinalPos + terminate after 8 steps + """ + scenario = compileScenic(code, mode2D=True) + scene, _ = scenario.generate(maxIterations=1) + simulator = NewtonianSimulator() + simulation = simulator.simulate(scene, maxSteps=8) + init = simulation.result.records["InitialPos"] + fin = simulation.result.records["FinalPos"] + dx = fin[0] - init[0] + dy = fin[1] - init[1] + # Expect movement northeast (positive dx and dy) + assert dx > 0.1, f"Expected positive x movement (east), got dx = {dx}" + assert dy > 0.1, f"Expected positive y movement (north), got dy = {dy}" diff --git a/tests/simulators/webots/dynamic/dynamic.scenic b/tests/simulators/webots/dynamic/dynamic.scenic new file mode 100644 index 000000000..96c897102 --- /dev/null +++ b/tests/simulators/webots/dynamic/dynamic.scenic @@ -0,0 +1,27 @@ +""" +Create a Webots cube in air and have it drop +""" + +model scenic.simulators.webots.model + +class Floor(Object): + width: 5 + length: 5 + height: 0.01 + position: (0,0,0) + color: [0.785, 0.785, 0.785] + +class Block(WebotsObject): + webotsAdhoc: {'physics': True} + shape: BoxShape() + width: 0.2 + length: 0.2 + height: 0.2 + density: 100 + color: [1, 0.502, 0] + +floor = new Floor +ego = new Block at (0, 0, 0.5) #above floor by 0.5 + +terminate when ego.z < 0.1 +record (ego.z) as BlockPosition \ No newline at end of file diff --git a/tests/simulators/webots/dynamic/webots_data/controllers/scenic_supervisor/scenic_supervisor.py b/tests/simulators/webots/dynamic/webots_data/controllers/scenic_supervisor/scenic_supervisor.py new file mode 100644 index 000000000..8e875c6a8 --- /dev/null +++ b/tests/simulators/webots/dynamic/webots_data/controllers/scenic_supervisor/scenic_supervisor.py @@ -0,0 +1,30 @@ +import os + +from controller import Supervisor + +import scenic +from scenic.simulators.webots import WebotsSimulator + +WEBOTS_RESULT_FILE_PATH = f"{os.path.dirname(__file__)}/../../../results.txt" + + +def send_results(data): + with open(WEBOTS_RESULT_FILE_PATH, "w") as file: + file.write(data) + + +supervisor = Supervisor() +simulator = WebotsSimulator(supervisor) + +path = supervisor.getCustomData() +print(f"Loading Scenic scenario {path}") +scenario = scenic.scenarioFromFile(path) + +scene, _ = scenario.generate() +simulation = simulator.simulate(scene, verbosity=2) +block_movements = simulation.result.records["BlockPosition"] +first_block_movement = block_movements[0] +last_block_movement = block_movements[-1] +blocks = [first_block_movement, last_block_movement] +supervisor.simulationQuit(status="finished") +send_results(str(blocks)) diff --git a/tests/simulators/webots/dynamic/webots_data/protos/ScenicObject.proto b/tests/simulators/webots/dynamic/webots_data/protos/ScenicObject.proto new file mode 100644 index 000000000..605321782 --- /dev/null +++ b/tests/simulators/webots/dynamic/webots_data/protos/ScenicObject.proto @@ -0,0 +1,28 @@ +#VRML_SIM R2023a utf8 + +PROTO ScenicObject [ + field SFVec3f translation 0 0 0 + field SFRotation rotation 0 0 1 0 + field SFString name "solid" + field SFVec3f angularVelocity 0 0 0 + field MFString url "" +] +{ + Solid { + translation IS translation + rotation IS rotation + name IS name + angularVelocity IS angularVelocity + children [ + CadShape { + url IS url + castShadows FALSE + } + ] + boundingObject Shape { + geometry Mesh { + url IS url + } + } + } +} diff --git a/tests/simulators/webots/dynamic/webots_data/protos/ScenicObjectWithPhysics.proto b/tests/simulators/webots/dynamic/webots_data/protos/ScenicObjectWithPhysics.proto new file mode 100644 index 000000000..cf1d42934 --- /dev/null +++ b/tests/simulators/webots/dynamic/webots_data/protos/ScenicObjectWithPhysics.proto @@ -0,0 +1,34 @@ +#VRML_SIM R2023a utf8 + +PROTO ScenicObjectWithPhysics [ + field SFVec3f translation 0 0 0 + field SFRotation rotation 0 0 1 0 + field SFString name "solid" + field SFVec3f angularVelocity 0 0 0 + field MFString url "" + field SFFloat density 1000 # kg / m^3 +] +{ + Solid { + translation IS translation + rotation IS rotation + name IS name + angularVelocity IS angularVelocity + children [ + CadShape { + url IS url + castShadows FALSE + } + ] + boundingObject Shape { + geometry Mesh { + url IS url + } + } + physics Physics { + # density will be set by the simulator + density IS density + mass -1 + } + } +} diff --git a/tests/simulators/webots/dynamic/webots_data/worlds/world.wbt b/tests/simulators/webots/dynamic/webots_data/worlds/world.wbt new file mode 100644 index 000000000..0ab0be278 --- /dev/null +++ b/tests/simulators/webots/dynamic/webots_data/worlds/world.wbt @@ -0,0 +1,34 @@ +#VRML_SIM R2023a utf8 + +EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2023a/projects/objects/backgrounds/protos/TexturedBackground.proto" +EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2023a/projects/objects/floors/protos/Floor.proto" +IMPORTABLE EXTERNPROTO "../protos/ScenicObject.proto" +IMPORTABLE EXTERNPROTO "../protos/ScenicObjectWithPhysics.proto" + +WorldInfo { + gravity 3 + basicTimeStep 16 + contactProperties [ + ContactProperties { + coulombFriction [ + 0.8 + ] + } + ] +} +Viewpoint { + orientation -0.19729161510865992 0.07408415124219735 0.977541588446517 2.4380019050245862 + position 6.683615307234937 -5.5006366813466805 4.16419445995153 +} +TexturedBackground { +} +Floor { + name "FLOOR" + size 5 5 +} +Robot { + name "Supervisor" + controller "scenic_supervisor" + customData "../../../dynamic.scenic" + supervisor TRUE +} diff --git a/tests/simulators/webots/test_webots.py b/tests/simulators/webots/test_webots.py index e7904532a..838884b6c 100644 --- a/tests/simulators/webots/test_webots.py +++ b/tests/simulators/webots/test_webots.py @@ -1,7 +1,52 @@ +import os +import subprocess + import pytest from tests.utils import pickle_test, sampleScene, tryPickling +WEBOTS_RESULTS_FILE_PATH = f"{os.path.dirname(__file__)}/dynamic/results.txt" +WEBOTS_WORLD_FILE_PATH = ( + f"{os.path.dirname(__file__)}/dynamic/webots_data/worlds/world.wbt" +) + + +def receive_results(): + with open(WEBOTS_RESULTS_FILE_PATH, "r") as file: + content = file.read() + return content + + +def cleanup_results(): + command = f"rm -f {WEBOTS_RESULTS_FILE_PATH}" + subprocess.run(command, shell=True) + + +def test_dynamics_scenarios(launchWebots): + WEBOTS_ROOT = launchWebots + cleanup_results() + + timeout_seconds = 300 + + command = f"bash {WEBOTS_ROOT}/webots --no-rendering --minimize --batch {WEBOTS_WORLD_FILE_PATH}" + + try: + subprocess.run(command, shell=True, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + pytest.fail( + f"Webots test exceeded the timeout of {timeout_seconds} seconds and failed." + ) + + data = receive_results() + assert data != None + start_z = float(data.split(",")[1].strip(" )]")) + end_z = float(data.split(",")[3].strip(" )]")) + assert start_z == 0.5 + assert start_z > end_z + expected_value = 0.09 + tolerance = 0.01 + assert end_z == pytest.approx(expected_value, abs=tolerance) + def test_basic(loadLocalScenario): scenario = loadLocalScenario("basic.scenic") diff --git a/tests/syntax/test_assignments.py b/tests/syntax/test_assignments.py new file mode 100644 index 000000000..920741738 --- /dev/null +++ b/tests/syntax/test_assignments.py @@ -0,0 +1,56 @@ +# Minimal semantics tests for multiple assignment and LHS unpacking. + +import pytest + +from tests.utils import compileScenic, sampleParamP, sampleParamPFrom + + +def test_multiple_assignment_swap(): + p = sampleParamPFrom( + """ + a = 1 + b = 2 + a, b = b, a + param p = (a, b) + """ + ) + assert p == (2, 1) + + +def test_lhs_tuple_unpack(): + p = sampleParamPFrom( + """ + x, y = (3, 4) + param p = (x, y) + """ + ) + assert p == (3, 4) + + +def test_unpack_from_distribution_pair(): + scenario = compileScenic( + """ + pair = (Uniform(0, 1), Uniform(0, 1)) + x, y = pair + param p = (x, y) + """ + ) + outs = [sampleParamP(scenario) for _ in range(30)] + assert len(set(outs)) > 1 + for x, y in outs: + assert x == 0 or x == 1 + assert y == 0 or y == 1 + + +@pytest.mark.parametrize( + "rhs", + ["(1, 2, 3)", "(1,)"], # too many; not enough +) +def test_unpack_arity_mismatch_exec_raises(rhs): + with pytest.raises(ValueError): + sampleParamPFrom( + f""" + x, y = {rhs} + param p = x + """ + ) diff --git a/tests/syntax/test_basic.py b/tests/syntax/test_basic.py index dd022b208..8c3d01483 100644 --- a/tests/syntax/test_basic.py +++ b/tests/syntax/test_basic.py @@ -56,6 +56,28 @@ def test_ego_nonobject(): compileScenic("ego = dict()") +def test_ego_subclass_point(): + with pytest.raises(TypeError): + compileScenic( + """ + class Foo(Point): + pass + ego = new Foo + """ + ) + + +def test_ego_subclass_orientedpoint(): + with pytest.raises(TypeError): + compileScenic( + """ + class Bar(OrientedPoint): + pass + ego = new Bar + """ + ) + + def test_ego_undefined(): with pytest.raises(InvalidScenarioError): compileScenic("x = ego\n" "ego = new Object") @@ -65,11 +87,48 @@ def test_no_ego(): compileScenic("new Object") +def test_new_in_list_expression(): + scenario = compileScenic( + """ + objs = [new Object with allowCollisions True] + ego = new Object with allowCollisions True + """ + ) + assert len(scenario.objects) == 2 + scene = sampleScene(scenario, maxIterations=1) + assert len(scene.objects) == 2 + + def test_ego_complex_assignment(): with pytest.raises(ScenicSyntaxError): compileScenic("(ego, thing1), thing2 = ((new Object at 1@1), 2), 3") +def test_list_new(): + scenario = compileScenic("objs = [new Object at 10@10, new Object at 20@20]") + assert len(scenario.objects) == 2 + scene = sampleScene(scenario, maxIterations=1) + assert len(scene.objects) == 2 + pos = [obj.position.x for obj in scene.objects] + assert pos == [10, 20] + + +def test_dict_new(): + scenario = compileScenic( + """ + objs = { + 'a': new Object at 10@10, + 'b': new Object at 20@20 + } + """ + ) + assert len(scenario.objects) == 2 + scene = sampleScene(scenario, maxIterations=1) + assert len(scene.objects) == 2 + pos = [obj.position.x for obj in scene.objects] + assert pos == [10, 20] + + def test_noninterference(): scenario = compileScenic("ego = new Object") assert len(scenario.objects) == 1 @@ -172,6 +231,17 @@ def test_mutate_nonobject(): ) +def test_mutate_occupiedSpace(): + scenario = compileScenic( + """ + ego = new Object at (1,2,3), with foo Range(1,2) + mutate ego + """ + ) + ego = sampleEgo(scenario) + assert tuple(ego.position) == pytest.approx(tuple(ego.occupiedSpace.mesh.center_mass)) + + def test_verbose(): for verb in range(4): setDebuggingOptions(verbosity=verb) @@ -330,3 +400,14 @@ class Foo(Bar): obj = sampleEgoFrom(program, mode2D=True) assert obj.heading == obj.parentOrientation.yaw == 0.56 + + +def test_simulator_name_binding_executes(): + scenario = compileScenic( + """ + simulator = 7 + ego = new Object with foo simulator + """ + ) + ego = sampleEgo(scenario) + assert ego.foo == 7 diff --git a/tests/syntax/test_compiler.py b/tests/syntax/test_compiler.py index 1af4dbf9a..597b85b55 100644 --- a/tests/syntax/test_compiler.py +++ b/tests/syntax/test_compiler.py @@ -1150,6 +1150,110 @@ def test_wait(self): case _: assert False + def test_wait_for_seconds(self): + node, _ = compileScenicAST(WaitFor(Seconds(Constant(1))), inBehavior=True) + match node: + case [ + Expr( + value=YieldFrom( + value=Call( + func=Attribute( + value=Name(id="_Scenic_current_behavior", ctx=Load()), + attr="_invokeSubBehavior", + ctx=Load(), + ), + args=[ + Name(id="self", ctx=Load()), + Tuple(elts=[], ctx=Load()), + ast.Call( + func=ast.Name("Modifier"), + args=[ + ast.Constant("for"), + Constant(1), + ast.Constant("seconds"), + ], + keywords=[], + ), + ], + keywords=[], + ) + ) + ), + checkInvariants, + ]: + self.assert_invocation_check_invariants(checkInvariants) + case _: + assert False + + def test_wait_for_steps(self): + node, _ = compileScenicAST(WaitFor(Steps(Constant(3))), inBehavior=True) + match node: + case [ + Expr( + value=YieldFrom( + value=Call( + func=Attribute( + value=Name(id="_Scenic_current_behavior", ctx=Load()), + attr="_invokeSubBehavior", + ctx=Load(), + ), + args=[ + Name(id="self", ctx=Load()), + Tuple(elts=[], ctx=Load()), + ast.Call( + func=ast.Name("Modifier"), + args=[ + ast.Constant("for"), + Constant(3), + ast.Constant("steps"), + ], + keywords=[], + ), + ], + keywords=[], + ) + ) + ), + checkInvariants, + ]: + self.assert_invocation_check_invariants(checkInvariants) + case _: + assert False + + def test_wait_until(self): + node, _ = compileScenicAST(WaitUntil(Name("condition")), inBehavior=True) + match node: + case [ + Expr( + value=YieldFrom( + value=Call( + func=Attribute( + value=Name(id="_Scenic_current_behavior", ctx=Load()), + attr="_invokeSubBehavior", + ctx=Load(), + ), + args=[ + Name(id="self", ctx=Load()), + Tuple(elts=[], ctx=Load()), + ast.Call( + func=ast.Name("Modifier"), + args=[ + ast.Constant("until"), + Lambda(body=Name("condition")), + ], + keywords=[], + ), + ], + keywords=[], + ) + ) + ), + checkInvariants, + ]: + self.assert_invocation_check_invariants(checkInvariants) + case _: + assert False + def test_terminate(self): node, _ = compileScenicAST(Terminate(lineno=1), inBehavior=True) match node: @@ -1337,7 +1441,9 @@ def test_do_until(self): assert False def test_do_choose(self): - node, _ = compileScenicAST(DoChoose([Constant("foo"), Constant("bar")])) + node, _ = compileScenicAST( + DoChoose([Constant("foo"), Constant("bar")]), inBehavior=True + ) match node: case [ Expr( @@ -1365,8 +1471,10 @@ def test_do_choose(self): case _: assert False - def test_do_choose(self): - node, _ = compileScenicAST(DoShuffle([Constant("foo"), Constant("bar")])) + def test_do_shuffle(self): + node, _ = compileScenicAST( + DoShuffle([Constant("foo"), Constant("bar")]), inBehavior=True + ) match node: case [ Expr( @@ -1461,6 +1569,42 @@ def test_record(self): case _: assert False + def test_record_clauses(self): + node, requirements = compileScenicAST( + Record( + Name("C", lineno=2), + period=Seconds(Constant(0.2)), + delay=Steps(Constant(10)), + recorder=Name("file"), + lineno=2, + ) + ) + match node: + case Expr( + Call( + Name("record"), + [ + Constant(0), # reqId + Call(Name("AtomicProposition"), [Lambda(arguments(), Name("C"))]), + Constant(2), # lineno + Constant(None), # name + ], + [ + keyword("recorder", Name("file")), + keyword("period", Tuple([Constant(0.2), Constant("seconds")])), + keyword("delay", Tuple([Constant(10), Constant("steps")])), + ], + ) + ): + assert True + case _: + assert False + match requirements: + case [Name("C")]: + assert True + case _: + assert False + def test_record_initial(self): node, requirements = compileScenicAST( RecordInitial(Name("C", lineno=2), lineno=2) diff --git a/tests/syntax/test_distributions.py b/tests/syntax/test_distributions.py index c45572988..fbfa81073 100644 --- a/tests/syntax/test_distributions.py +++ b/tests/syntax/test_distributions.py @@ -510,6 +510,31 @@ def test_list_filtered_empty_2(): assert sum(v == 2 for v in vs) >= 85 +def test_filter_lambda_random_global(): + scenario = compileScenic( + """ + thresh = Range(0, 1) + mylist = [Range(-1, 1), Range(2, 3)] + filtered = filter(lambda v: v > thresh, mylist) + ego = new Object with foo Uniform(*filtered) + """ + ) + with pytest.raises(RandomControlFlowError): + sampleEgo(scenario) + + +def test_filter_lambda_random_threshold(): + scenario = compileScenic( + """ + mylist = [Range(-1, 1), Range(3, 4)] + filtered = filter(lambda v: v > Range(0, 1), mylist) + ego = new Object with foo Uniform(*filtered) + """ + ) + with pytest.raises(RandomControlFlowError): + sampleEgo(scenario) + + def test_tuple(): scenario = compileScenic("ego = new Object with foo tuple([3, Uniform(1, 2)])") ts = [sampleEgo(scenario).foo for i in range(60)] @@ -668,18 +693,54 @@ def test_reproducibility(): assert iterations == baseIterations +def test_reproducibility_lazy_interior(): + """Regression test for a reproducibility issue involving lazy region computations. + + In this test, an interior point of the objects' shape is computed on demand + during the first sample, then cached. The code for doing so used NumPy's PRNG, + meaning that a second sample with the same random seed could differ. + """ + scenario = compileScenic( + """ + import numpy + from scenic.core.distributions import distributionFunction + @distributionFunction + def gen(arg): + return numpy.random.random() + + region = BoxRegion().difference(BoxRegion(dimensions=(0.1, 0.1, 0.1))) + shape = MeshShape(region.mesh) # Shape which does not contain its center + other = new Object with shape shape + ego = new Object at (Range(0.9, 1.1), 0), with shape shape + param foo = ego intersects other # trigger computation of interior point + param bar = gen(ego) # generate number using NumPy's PRNG + """ + ) + seed = random.randint(0, 1000000000) + random.seed(seed) + numpy.random.seed(seed) + s1 = sampleScene(scenario, maxIterations=60) + random.seed(seed) + numpy.random.seed(seed) + s2 = sampleScene(scenario, maxIterations=60) + assert s1.params["bar"] == s2.params["bar"] + assert s1.egoObject.x == s2.egoObject.x + + @pytest.mark.slow def test_reproducibility_3d(): scenario = compileScenic( - "ego = new Object\n" - "workspace = Workspace(SpheroidRegion(dimensions=(25,15,10)))\n" - "region = BoxRegion(dimensions=(25,15,0.1))\n" - "obj_1 = new Object in workspace, facing Range(0, 360) deg, with width Range(0.5, 1), with length Range(0.5,1)\n" - "obj_2 = new Object in workspace, facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg)\n" - "obj_3 = new Object in workspace, on region\n" - "param foo = Uniform(1, 4, 9, 16, 25, 36)\n" - "x = Range(0, 1)\n" - "require x > 0.8" + """ + ego = new Object + workspace = Workspace(SpheroidRegion(dimensions=(25,15,10))) + region = BoxRegion(dimensions=(25,15,0.1)) + obj_1 = new Object in workspace, facing Range(0, 360) deg, with width Range(0.5, 1), with length Range(0.5,1) + obj_2 = new Object in workspace, facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + obj_3 = new Object in workspace, on region + param foo = ego intersects obj_2 + x = Range(0, 1) + require x > 0.8 + """ ) seeds = [random.randint(0, 100) for i in range(10)] for seed in seeds: diff --git a/tests/syntax/test_dynamics.py b/tests/syntax/test_dynamics.py index 0bd724b03..eb587956b 100644 --- a/tests/syntax/test_dynamics.py +++ b/tests/syntax/test_dynamics.py @@ -162,6 +162,32 @@ def test_behavior_list_actions(): assert tuple(actions) == ((1, 4, 9), (5,)) +def test_behavior_take_none(): + scenario = compileScenic( + """ + behavior Foo(): + take None + take 7 + ego = new Object with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=2) + assert tuple(actions) == (None, 7) + + +def test_behavior_take_empty_tuple(): + scenario = compileScenic( + """ + behavior Foo(): + take () + take 7 + ego = new Object with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=2) + assert tuple(actions) == (None, 7) + + # Various errors @@ -221,7 +247,7 @@ def test_behavior_create_object(): def test_behavior_define_param(): with pytest.raises(ScenicSyntaxError): - scenario = compileScenic( + compileScenic( """ behavior Bar(): param foo = 3 @@ -229,7 +255,6 @@ def test_behavior_define_param(): ego = new Object with behavior Bar """ ) - sampleResultOnce(scenario) def test_behavior_illegal_yield(): @@ -471,6 +496,21 @@ def test_behavior_lazy_nested(): assert tuple(actions) == (pytest.approx((1.5, -0.25)), pytest.approx((-0.5, -0.25))) +def test_obj_equals_self_inside_behavior(): + scenario = compileScenic( + """ + behavior Foo(): + for obj in simulation().objects: + take (obj == self, obj is self) + + ego = new Object with behavior Foo + other = new Object at 10@10 + """ + ) + actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) + assert tuple(actions) == ((True, True), (False, False)) + + # Termination @@ -668,6 +708,56 @@ def test_subbehavior_misplaced_modifier(): ) +def test_subbehavior_wait_for_steps(): + scenario = compileScenic( + """ + behavior Foo(): + wait for 3 steps + take 2 + ego = new Object with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert tuple(actions) == (None, None, None, 2) + + +def test_subbehavior_wait_for_time(): + scenario = compileScenic( + """ + behavior Foo(): + wait for 3 seconds + take 2 + ego = new Object with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=7, timestep=0.5) + assert tuple(actions) == (None, None, None, None, None, None, 2) + + +def test_subbehavior_wait_until(): + scenario = compileScenic( + """ + behavior Foo(): + wait until simulation().currentTime == 2 + take 2 + ego = new Object with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert tuple(actions) == (None, None, 2, None) + + +def test_subbehavior_wait_incompatible_modifiers(): + with pytest.raises(ScenicSyntaxError): + compileScenic( + """ + behavior Foo(): + wait for 5 steps until False + ego = new Object with behavior Foo + """ + ) + + def test_behavior_invoke_mistyped(): scenario = compileScenic( """ @@ -693,6 +783,22 @@ def test_behavior_invoke_multiple(): ) +def test_behavior_tuple_invalid(): + scenario = compileScenic( + """ + behavior Foo(): + take 1 + behavior Bar(): + take 2 + behavior Baz(): + do (Foo(), Bar()) + ego = new Object with behavior Baz + """ + ) + with pytest.raises(TypeError): + sampleEgoActions(scenario, maxSteps=1) + + def test_behavior_calls(): """Ordinary function calls inside behaviors should still work.""" scenario = compileScenic( @@ -983,6 +1089,42 @@ def test_invariant_rejection_shuffle(): assert result.records["test_val"] == (1, 0) +def test_precondition_multiline(): + scenario = compileScenic( + """ + behavior Foo(): + precondition: ( + self.position.x > 0 + and self.position.y == 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) + assert actions[0] > 0 + + +def test_invariant_multiline(): + scenario = compileScenic( + """ + behavior Foo(): + invariant: ( + self.position.x > 0 + and self.position.y == 0 + ) + while True: + 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) + assert actions[1] > 0 + + # Random selection of sub-behaviors @@ -1727,6 +1869,27 @@ def test_interrupt_except_else(): assert tuple(actions) == (1, 2, 3, 1, 1, 5, None) +def test_interrupt_invariant_after_actions(): + scenario = compileScenic( + """ + behavior Foo(): + invariant: ego.bar == 0 + try: + for i in range(3): + take 1 + interrupt when simulation().currentTime == 1: + take 2 + ego.bar = 1 + except InvariantViolation: + ego.bar = 0 + take 4 + ego = new Object with bar 0, with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert tuple(actions) == (1, 2, 4, None) + + # Nesting @@ -2086,6 +2249,42 @@ def test_record(): (3, (6, 0, 0)), ) +def test_record_keys(): + scenario = compileScenic( + """ + behavior Foo(): + for i in range(3): + self.position = self.position + 2@0 + wait + ego = new Object with behavior Foo + for i in range(2): + obj = new Object with behvior Foo + record obj.position as f"position_{i}" + terminate when ego.position.x >= 6 + """ + ) + result = sampleResult(scenario, maxSteps=4) + assert "position_0" in result.records + assert "position_1" in result.records + +def test_record_keys(): + scenario = compileScenic( + """ + behavior Foo(): + for i in range(3): + self.position = self.position + 2@0 + wait + ego = new Object with behavior Foo + for i in range(2): + obj = new Object with behvior Foo + record obj.position as f"position_{i}" + terminate when ego.position.x >= 6 + """ + ) + result = sampleResult(scenario, maxSteps=4) + assert "position_0" in result.records + assert "position_1" in result.records + ## lastActions Property def test_lastActions(): diff --git a/tests/syntax/test_errors.py b/tests/syntax/test_errors.py index f51ad6e3b..7ed31b7d0 100644 --- a/tests/syntax/test_errors.py +++ b/tests/syntax/test_errors.py @@ -1,7 +1,6 @@ import os.path import subprocess import sys -from tokenize import TokenError import pytest @@ -131,9 +130,9 @@ def test_undefined_specifier(): def test_unmatched_parentheses(): - with pytest.raises(TokenError): + with pytest.raises(ScenicSyntaxError): compileScenic("(") - with pytest.raises(TokenError): + with pytest.raises(ScenicSyntaxError): compileScenic("x = (3 + 4") with pytest.raises(ScenicSyntaxError): compileScenic(")") @@ -142,9 +141,9 @@ def test_unmatched_parentheses(): def test_incomplete_multiline_string(): - with pytest.raises(TokenError): + with pytest.raises(ScenicSyntaxError): compileScenic('"""foobar') - with pytest.raises(TokenError): + with pytest.raises(ScenicSyntaxError): compileScenic( ''' x = """foobar @@ -153,6 +152,28 @@ def test_incomplete_multiline_string(): ) +def test_bad_indentation(): + with pytest.raises(ScenicSyntaxError): + compileScenic( + """ + behavior foo(): + x = 1 + y = 2 + ego = new Object with behavior foo + """ + ) + + +def test_fstring_error_uses_filename(tmpdir): + path = os.path.join(tmpdir, "bad_fstring.scenic") + with open(path, "w") as f: + f.write('x = f"{foo"\n') + + with pytest.raises(ScenicSyntaxError) as excinfo: + scenic.scenarioFromFile(path) + assert excinfo.value.filename == path + + def test_incomplete_infix_operator(): """Binary infix operator with too few arguments.""" with pytest.raises(ScenicSyntaxError): @@ -374,7 +395,10 @@ def checkException(e, lines, program, bug, path, output, topLevel=True): chained = bool(e.__cause__ or (e.__context__ and not e.__suppress_context__)) assert bool(remainingLines) == chained if remainingLines: - mid = loc - 5 if topLevel else loc - 2 + if topLevel: + mid = loc - 6 if sys.version_info >= (3, 13) else loc - 5 + else: + mid = loc - 2 assert len(output) >= -(mid - 1) if e.__cause__: assert ( diff --git a/tests/syntax/test_functions.py b/tests/syntax/test_functions.py index f2ea885eb..b4a8fd8e7 100644 --- a/tests/syntax/test_functions.py +++ b/tests/syntax/test_functions.py @@ -21,19 +21,37 @@ def test_max_min(): def test_str(): - ego = sampleEgoFrom("ego = new Object with foo str(Range(12, 17))") + ego = sampleEgoFrom( + """ + assert isinstance("blob", str) + assert not isinstance(42, str) + ego = new Object with foo str(Range(12, 17)) + """ + ) assert isinstance(ego.foo, str) assert 12 <= float(ego.foo) <= 17 def test_float(): - ego = sampleEgoFrom('ego = new Object with foo float(Uniform("1.5", "-0.5"))') + ego = sampleEgoFrom( + """ + assert isinstance(3.14, float) + assert not isinstance(3, float) + ego = new Object with foo float(Uniform("1.5", "-0.5")) + """ + ) assert isinstance(ego.foo, float) assert float(ego.foo) in (1.5, -0.5) def test_int(): - ego = sampleEgoFrom("ego = new Object with foo int(Range(12, 14.9))") + ego = sampleEgoFrom( + """ + assert isinstance(42, int) + assert not isinstance(3.14, int) + ego = new Object with foo int(Range(12, 14.9)) + """ + ) assert isinstance(ego.foo, int) assert ego.foo in (12, 13, 14) diff --git a/tests/syntax/test_modular.py b/tests/syntax/test_modular.py index a7a89903e..21f1a3425 100644 --- a/tests/syntax/test_modular.py +++ b/tests/syntax/test_modular.py @@ -1,9 +1,11 @@ """Tests for modular scenarios.""" import inspect +import signal import pytest +import scenic.core.dynamics as dynamics from scenic.core.dynamics import InvariantViolation, PreconditionViolation from scenic.core.errors import InvalidScenarioError, ScenicSyntaxError, SpecifierError from scenic.core.simulators import DummySimulator, TerminationType @@ -13,6 +15,7 @@ sampleEgo, sampleEgoActions, sampleEgoFrom, + sampleParamPFrom, sampleResult, sampleResultOnce, sampleScene, @@ -72,6 +75,38 @@ def test_requirement(): assert all(2 < w <= 3 for w in ws) +def test_requirement_local_main(): + scenario = compileScenic( + """ + scenario Main(): + ego = new Object + other = new Object at (10, Range(1, 2)) + require other.position.y > 1.5 + other = 42 # should not affect requirement above + """ + ) + ys = [sampleScene(scenario, maxIterations=60).objects[1].y for i in range(60)] + assert all(1.5 < y <= 2 for y in ys) + + +def test_requirement_local_sub(): + scenario = compileScenic( + """ + scenario Main(): + compose: + do Sub() + + scenario Sub(): + ego = new Object + other = new Object at (10, Range(1, 2)) + require other.position.y > 1.5 + other = 42 # should not affect requirement above + """ + ) + ys = [sampleTrajectory(scenario, maxIterations=60)[0][1][1] for i in range(60)] + assert all(1.5 < y <= 2 for y in ys) + + def test_soft_requirement(): scenario = compileScenic( """ @@ -86,13 +121,129 @@ def test_soft_requirement(): assert 255 <= count < 350 +def test_param(): + p = sampleParamPFrom( + """ + scenario Main(): + setup: + param p = Range(3, 5) + """ + ) + assert 3 <= p <= 5 + + +def test_param_top_level(): + scenario = compileScenic( + """ + param bar = 1 + + class Foo: + fizz: globalParameters.bar + Range(0,1) + + scenario Main(): + setup: + ego = new Foo + """ + ) + ego = sampleEgo(scenario) + assert 1 <= ego.fizz <= 2 + + +def test_ego_top_level(): + scenario = compileScenic( + """ + ego = new Object with foo 42 + + scenario Main(): + setup: + assert ego.foo == 42 + compose: + assert ego.foo == 42 + do Sub() + + scenario Sub(): + assert ego.foo == 42 + """ + ) + sampleResult(scenario, maxSteps=1) + + +def test_workspace_top_level(): + scenario = compileScenic( + """ + workspace = Workspace(CircularRegion(0@0, 2)) + + scenario Main(): + setup: + assert workspace.region.radius == 2 + ego = new Object + compose: + assert workspace.region.radius == 2 + do Sub() + + scenario Sub(): + assert workspace.region.radius == 2 + """ + ) + sampleResult(scenario, maxSteps=1) + + +def test_dynamic_object_creation(): + scenario = compileScenic( + """ + scenario Main(): + compose: + wait + do Sub() + + scenario Sub(): + new Object with behavior Foo() + + behavior Foo(): + take 42 + """ + ) + actions = sampleEgoActions(scenario, maxSteps=2) + assert tuple(actions) == (None, 42) + + +def test_object_creation_in_compose(): + with pytest.raises(InvalidScenarioError): + scenario = compileScenic( + """ + scenario Main(): + compose: + wait + new Object + """ + ) + sampleResultOnce(scenario) + + +@pytest.mark.skipif(not hasattr(signal, "SIGALRM"), reason="need SIGALRM") +@pytest.mark.slow +def test_scenario_stuck(monkeypatch): + scenario = compileScenic( + """ + import time + scenario Main(): + compose: + time.sleep(1.5) + wait + """ + ) + monkeypatch.setattr(dynamics, "stuckBehaviorWarningTimeout", 1) + with pytest.warns(dynamics.StuckBehaviorWarning): + sampleResultOnce(scenario) + + def test_invalid_scenario_name(): with pytest.raises(ScenicSyntaxError): compileScenic( """ scenario 101(): ego = new Object - """ + """ ) @@ -104,7 +255,7 @@ def test_scenario_inside_behavior(): scenario Bar(): new Object at 10@10 ego = new Object - """ + """ ) @@ -205,7 +356,7 @@ def test_malformed_precondition(): precondition hello: True setup: ego = new Object - """ + """ ) @@ -217,7 +368,7 @@ def test_malformed_invariant(): invariant hello: True setup: ego = new Object - """ + """ ) @@ -232,7 +383,7 @@ def test_parallel_composition(): do Sub(1), Sub(5) scenario Sub(x): ego = new Object at (x, 1, 2) - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -252,7 +403,7 @@ def test_sequential_composition(): scenario Sub(x): ego = new Object at x @ 0 terminate after 1 steps - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario, maxSteps=3) @@ -276,7 +427,7 @@ def test_subscenario_for_steps(): scenario Sub(x): ego = new Object at x @ 0 terminate after 3 steps - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario, maxSteps=3) @@ -299,7 +450,7 @@ def test_subscenario_for_time(): scenario Sub(x): ego = new Object at x @ 0 terminate after 3 steps - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario, maxSteps=3, timestep=0.5) @@ -321,7 +472,7 @@ def test_subscenario_until(): scenario Sub(x): ego = new Object at x @ 0 terminate after 3 steps - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario, maxSteps=3) @@ -333,6 +484,69 @@ def test_subscenario_until(): assert tuple(trajectory[3][1]) == (5, 0, 0) +def test_subscenario_wait_for_steps(): + scenario = compileScenic( + """ + scenario Main(): + compose: + wait for 2 steps + do Sub(5) + scenario Sub(x): + ego = new Object at x @ 0 + terminate after 3 steps + """, + scenario="Main", + ) + trajectory = sampleTrajectory(scenario, maxSteps=3) + assert len(trajectory) == 4 + assert len(trajectory[0]) == len(trajectory[1]) == 0 + assert len(trajectory[2]) == len(trajectory[3]) == 1 + for i in range(2, 4): + assert tuple(trajectory[i][0]) == (5, 0, 0) + + +def test_subscenario_wait_for_time(): + scenario = compileScenic( + """ + scenario Main(): + compose: + wait for 1 seconds + do Sub(5) + scenario Sub(x): + ego = new Object at x @ 0 + terminate after 3 steps + """, + scenario="Main", + ) + trajectory = sampleTrajectory(scenario, maxSteps=3, timestep=0.5) + assert len(trajectory) == 4 + assert len(trajectory[0]) == len(trajectory[1]) == 0 + assert len(trajectory[2]) == len(trajectory[3]) == 1 + for i in range(2, 4): + assert tuple(trajectory[i][0]) == (5, 0, 0) + + +def test_subscenario_wait_until(): + scenario = compileScenic( + """ + scenario Main(): + compose: + wait until simulation().currentTime == 2 + do Sub(5) + scenario Sub(x): + ego = new Object at x @ 0 + terminate after 3 steps + """, + scenario="Main", + ) + trajectory = sampleTrajectory(scenario, maxSteps=3) + assert len(trajectory) == 4 + assert len(trajectory[0]) == len(trajectory[1]) == 0 + assert len(trajectory[2]) == len(trajectory[3]) == 1 + for i in range(2, 4): + assert tuple(trajectory[i][0]) == (5, 0, 0) + + def test_subscenario_require_eventually(): """Test that 'require eventually' must be satisfied before the scenario ends. @@ -358,6 +572,24 @@ def test_subscenario_require_eventually(): assert result is None +def test_subscenario_require_eventually_2(): + """Variant of the above test using `terminate when` instead of `terminate after`.""" + scenario = compileScenic( + """ + scenario Main(): + compose: + do Sub() + wait + scenario Sub(): + ego = new Object + require eventually simulation().currentTime == 2 + terminate when simulation().currentTime == 1 + """ + ) + result = sampleResultOnce(scenario, maxSteps=2) + assert result is None + + def test_subscenario_require_monitor(): """Test that monitors invoked in subscenarios terminate with the subscenario.""" scenario = compileScenic( @@ -381,8 +613,42 @@ def test_subscenario_require_monitor(): assert len(result.trajectory) == 4 +def test_subscenario_record(): + scenario = compileScenic( + """ + scenario Main(): + setup: + record initial simulation().currentTime as mainInitial + record final simulation().currentTime as mainFinal + record simulation().currentTime as mainTime + compose: + wait for 2 steps + do Sub() + wait + scenario Sub(): + ego = new Object + record initial -simulation().currentTime as subInitial + record final -simulation().currentTime as subFinal + record -simulation().currentTime as subNegTime + terminate after 2 steps + """ + ) + result = sampleResult(scenario, maxSteps=5) + records = result.records + assert records["mainInitial"] == 0 + assert records["mainFinal"] == 5 + assert tuple(records["mainTime"]) == ((0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)) + assert records["subInitial"] == -2 + assert records["subFinal"] == -4 + assert tuple(records["subNegTime"]) == ((2, -2), (3, -3), (4, -4)) + + def test_subscenario_terminate_when(): - """Test that 'terminate when' and 'require' are properly handled.""" + """Test that 'terminate when' is properly handled. + + In particular, this catches a bug where `terminate when` in a subscenario was + interpreted as defining a requirement instead of a termination condition. + """ scenario = compileScenic( """ scenario Main(): @@ -391,12 +657,12 @@ def test_subscenario_terminate_when(): wait scenario Sub(): ego = new Object - require eventually simulation().currentTime == 2 terminate when simulation().currentTime == 1 """ ) - result = sampleResultOnce(scenario, maxSteps=2) - assert result is None + result = sampleResultOnce(scenario, maxSteps=3) + assert result is not None + assert len(result.trajectory) == 3 def test_subscenario_terminate_with_parent(): @@ -463,6 +729,34 @@ def test_subscenario_terminate_compose(): assert len(trajectory[2]) == 1 +def test_subscenario_global_param(): + scenario = compileScenic( + """ + param foo = 10 + + scenario Main(): + setup: + param bar = 20 + + compose: + do Sub1() for 1 steps + do Sub2() for 1 steps + + scenario Sub1(): + new Object at (globalParameters.foo, 0) + + scenario Sub2(): + new Object at (globalParameters.bar, 0) + """ + ) + trajectory = sampleTrajectory(scenario, maxSteps=3) + assert len(trajectory) == 3 + assert len(trajectory[0]) == 1 + assert trajectory[0][0][0] == 10 + assert len(trajectory[1]) == 2 + assert trajectory[1][1][0] == 20 + + def test_initial_scenario_basic(): scenario = compileScenic( """ @@ -474,7 +768,7 @@ def test_initial_scenario_basic(): if initial scenario: ego = new Object new Object left of ego by 5 - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -495,7 +789,7 @@ def test_initial_scenario_setup(): if initial scenario: ego = new Object new Object left of ego by 5 - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -514,7 +808,7 @@ def test_initial_scenario_parallel(): if initial scenario: ego = new Object new Object left of ego by x - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -532,7 +826,7 @@ def test_choose_1(): precondition: simulation().currentTime >= x setup: ego = new Object at x @ 0 - """, + """, scenario="Main", ) xs = [sampleTrajectory(scenario, maxSteps=1)[1][0][0] for i in range(30)] @@ -549,7 +843,7 @@ def test_choose_2(): precondition: simulation().currentTime >= x setup: ego = new Object at x @ 0 - """, + """, scenario="Main", ) xs = [sampleTrajectory(scenario, maxSteps=1)[1][0][0] for i in range(30)] @@ -567,7 +861,7 @@ def test_choose_3(): scenario Sub(x): setup: ego = new Object at x @ 0 - """, + """, scenario="Main", ) xs = [sampleTrajectory(scenario, maxSteps=1)[1][0][0] for i in range(200)] @@ -585,7 +879,7 @@ def test_choose_deadlock(): precondition: simulation().currentTime >= x setup: ego = new Object at x @ 0 - """, + """, scenario="Main", ) result = sampleResultOnce(scenario) @@ -603,7 +897,7 @@ def test_shuffle_1(): setup: ego = new Object at x @ 0 terminate after 1 steps - """, + """, scenario="Main", ) for i in range(30): @@ -624,7 +918,7 @@ def test_shuffle_2(): scenario Sub(x): ego = new Object at x @ 0 terminate after 1 steps - """, + """, scenario="Main", ) x1s = [] @@ -653,7 +947,7 @@ def test_shuffle_3(): setup: ego = new Object at x @ 0 terminate after 1 steps - """, + """, scenario="Main", ) xs = [sampleTrajectory(scenario, maxSteps=3)[2][0][0] for i in range(200)] @@ -672,13 +966,27 @@ def test_shuffle_deadlock(): setup: ego = new Object at x @ 0 terminate after 1 steps - """, + """, scenario="Main", ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None +def test_compose_no_invocations(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object + compose: + pass + """ + ) + with pytest.raises(InvalidScenarioError): + sampleResult(scenario) + + def test_compose_illegal_statement(): with pytest.raises(ScenicSyntaxError): compileScenic( @@ -688,7 +996,7 @@ def test_compose_illegal_statement(): ego = new Object compose: model scenic.domains.driving.model - """ + """ ) @@ -701,7 +1009,7 @@ def test_compose_illegal_yield(): ego = new Object compose: yield 5 - """ + """ ) with pytest.raises(ScenicSyntaxError): compileScenic( @@ -711,7 +1019,7 @@ def test_compose_illegal_yield(): ego = new Object compose: yield from [] - """ + """ ) @@ -724,7 +1032,7 @@ def test_compose_illegal_action(): ego = new Object compose: take 1 - """ + """ ) @@ -737,8 +1045,8 @@ def test_compose_nested_definition(): ego = new Object compose: scenario Foo(): - Object at 10@10 - """ + new Object at 10@10 + """ ) with pytest.raises(ScenicSyntaxError): compileScenic( @@ -749,7 +1057,7 @@ def test_compose_nested_definition(): compose: behavior Foo(): wait - """ + """ ) @@ -773,7 +1081,7 @@ def test_override_basic(): behavior Bar(): while True: take self.foo - """, + """, scenario="Main", ) actions = sampleEgoActions(scenario, maxSteps=3) @@ -803,7 +1111,7 @@ def test_override_behavior(): while True: take x x -= 1 - """, + """, scenario="Main", ) actions = sampleEgoActions(scenario, maxSteps=4) @@ -887,7 +1195,7 @@ def test_override_dynamic(): setup: ego = new Object override ego with position 5@5 - """ + """ ) @@ -899,7 +1207,7 @@ def test_override_nonexistent(): setup: ego = new Object override ego with blob 'hello!' - """ + """ ) @@ -911,7 +1219,7 @@ def test_override_non_object(): setup: ego = new Object override True with blob 'hello!' - """ + """ ) @@ -923,7 +1231,7 @@ def test_override_malformed(): setup: ego = new Object override 101 with blob 'hello!' - """ + """ ) @@ -935,7 +1243,7 @@ def test_override_no_specifiers(): setup: ego = new Object override ego - """ + """ ) @@ -952,7 +1260,7 @@ def test_shared_scope_read(): do Sub(y) scenario Sub(x): ego = new Object at (x, 1, 2) - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -972,7 +1280,7 @@ def test_shared_scope_write(): do Sub(y) scenario Sub(x): ego = new Object at (x, 1, 2) - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -992,7 +1300,7 @@ def test_shared_scope_del(): do Sub() scenario Sub(): ego = new Object - """, + """, scenario="Main", ) sampleTrajectory(scenario) @@ -1012,7 +1320,7 @@ def test_delayed_local_argument(): s1 = Bar() s2 = Foo(s1.ego, s1.y) do s1, s2 - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario) @@ -1034,7 +1342,7 @@ def test_delayed_local_interrupt(): abort scenario Sub(): ego = new Object at (1, 0) - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario, maxSteps=2) @@ -1051,7 +1359,7 @@ def test_delayed_local_until(): do sc until sc.ego.position.x >= 1 scenario Sub(): ego = new Object at (1, 0) - """, + """, scenario="Main", ) trajectory = sampleTrajectory(scenario, maxSteps=2) @@ -1072,7 +1380,7 @@ def test_independent_requirements(): scenario Sub(start, dest): ego = new Object at start @ 0, with behavior Foo require eventually ego.position.x >= dest - """, + """, scenario="Main", ) for i in range(30): diff --git a/tests/syntax/test_parser.py b/tests/syntax/test_parser.py index da5f80545..b259b2ad0 100644 --- a/tests/syntax/test_parser.py +++ b/tests/syntax/test_parser.py @@ -168,6 +168,20 @@ def test_workspace_assign(self): assert False +class TestAssign: + def test_tuple_lhs_parses(self): + mod = parse_string_helper("a, b = 1, 2") + stmt = mod.body[0] + match stmt: + case Assign( + targets=[Tuple([Name("a"), Name("b")])], + value=Tuple([Constant(1), Constant(2)]), + ): + assert True + case _: + assert False + + class TestInitialScenario: def test_initial_scenario(self): mod = parse_string_helper("initial scenario") @@ -871,6 +885,47 @@ def test_wait(self): case _: assert False + def test_wait_for_seconds(self): + mod = parse_string_helper("wait for 3 seconds") + stmt = mod.body[0] + match stmt: + case WaitFor(Seconds(Constant(3))): + assert True + case _: + assert False + + def test_wait_for_steps(self): + mod = parse_string_helper("wait for 3 steps") + stmt = mod.body[0] + match stmt: + case WaitFor(Steps(Constant(3))): + assert True + case _: + assert False + + def test_wait_for_unitless(self): + with pytest.raises(ScenicSyntaxError) as e: + parse_string_helper("wait for 3") + assert "duration must specify a unit" in e.value.msg + + def test_wait_for_expression(self): + mod = parse_string_helper("wait for 3 + 3 steps") + stmt = mod.body[0] + match stmt: + case WaitFor(Steps(BinOp(Constant(3), Add(), Constant(3)))): + assert True + case _: + assert False + + def test_wait_until(self): + mod = parse_string_helper("wait until condition") + stmt = mod.body[0] + match stmt: + case WaitUntil(Name("condition")): + assert True + case _: + assert False + class TestTerminate: def test_basic(self): @@ -1025,6 +1080,15 @@ def test_basic(self): case _: assert False + def test_chained_comparison(self): + mod = parse_string_helper("require a < b < c") + stmt = mod.body[0] + match stmt: + case Require(Compare(Name("a"), [Lt(), Lt()], [Name("b"), Name("c")])): + assert True + case _: + assert False + def test_comparison(self): mod = parse_string_helper("require X > Y") stmt = mod.body[0] @@ -1144,6 +1208,63 @@ def test_record_named(self): case _: assert False + def test_record_recorder(self): + mod = parse_string_helper("record x to file") + stmt = mod.body[0] + match stmt: + case Record(Name("x"), recorder=Name("file")): + assert True + case _: + assert False + + def test_record_delay(self): + mod = parse_string_helper("record x after 5 seconds") + stmt = mod.body[0] + match stmt: + case Record(Name("x"), delay=Seconds(Constant(5))): + assert True + case _: + assert False + + mod = parse_string_helper("record x after 5 steps") + stmt = mod.body[0] + match stmt: + case Record(Name("x"), delay=Steps(Constant(5))): + assert True + case _: + assert False + + def test_record_period(self): + mod = parse_string_helper("record x every 0.2 seconds") + stmt = mod.body[0] + match stmt: + case Record(Name("x"), period=Seconds(Constant(0.2))): + assert True + case _: + assert False + + mod = parse_string_helper("record x every 3 steps") + stmt = mod.body[0] + match stmt: + case Record(Name("x"), period=Steps(Constant(3))): + assert True + case _: + assert False + + def test_record_combined(self): + mod = parse_string_helper("record x every 0.2 seconds after 5 seconds to file") + stmt = mod.body[0] + match stmt: + case Record( + Name("x"), + period=Seconds(Constant(0.2)), + delay=Seconds(Constant(5)), + recorder=Name("file"), + ): + assert True + case _: + assert False + def test_record_initial(self): mod = parse_string_helper("record initial x") stmt = mod.body[0] @@ -1411,6 +1532,44 @@ def test_missing_new(self): parse_string_helper("Object facing x") assert "forgot 'new'" in e.value.msg + def test_in_operator_not_missing_new(self): + # Regression test for issue #356: "in" is also a specifier keyword, but + # `x in [0]` must not trigger the "forgot 'new'" error message. + with pytest.raises(ScenicSyntaxError) as e: + parse_string_helper( + """ + x = 0 + x in [0] + = + """ + ) + err = e.value + assert "forgot 'new'" not in err.msg + assert "invalid syntax" in err.msg + assert err.lineno == 3 + + def test_invalid_specifier_line_number(self): + # Regression test for issue #345: a malformed position specifier like + # `offset left` should not trigger the "forgot 'new'" error or be attributed + # to an earlier valid `... until self in ...` expression. + with pytest.raises(ScenicSyntaxError) as e: + parse_string_helper( + """ + behavior AdvBehavior(): + do CrossingBehavior(ego) until self in ego.lane + while True: + take SetWalkingSpeedAction(0) + + SHIFT = Vector(1, 2) + AdvAgent = new Pedestrian at Truck offset left Truck.heading by SHIFT, + with heading Truck.heading + """ + ) + err = e.value + assert "forgot 'new'" not in err.msg + assert "invalid syntax" in err.msg + assert err.lineno == 7 + def test_invalid_specifier(self): with pytest.raises(ScenicSyntaxError) as e: parse_string_helper("new Object blobbing") diff --git a/tests/syntax/test_requirements.py b/tests/syntax/test_requirements.py index 0c7699fe0..d81eeb672 100644 --- a/tests/syntax/test_requirements.py +++ b/tests/syntax/test_requirements.py @@ -19,6 +19,108 @@ def test_requirement(): assert all(0 <= x <= 10 for x in xs) +def test_chained_comparison_allows(): + sampleSceneFrom( + """ + require 1 < 2 < 3 + ego = new Object + """, + maxIterations=1, + ) + + +def test_chained_comparison_rejects(): + with pytest.raises(RejectionException): + sampleSceneFrom( + """ + require 1 < 2 < 1.5 + ego = new Object + """, + maxIterations=1, + ) + + +def test_requirement_in_loop(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ Range(-10, 10) + for i in range(2): + require ego.position[i] >= 0 + """ + ) + poss = [sampleEgo(scenario, maxIterations=150).position for i in range(60)] + assert all(0 <= pos.x <= 10 and 0 <= pos.y <= 10 for pos in poss) + + +def test_requirement_in_function(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ Range(-10, 10) + def f(i): + require ego.position[i] >= 0 + for i in range(2): + f(i) + """ + ) + poss = [sampleEgo(scenario, maxIterations=150).position for i in range(60)] + assert all(0 <= pos.x <= 10 and 0 <= pos.y <= 10 for pos in poss) + + +def test_requirement_in_function_helper(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ Range(-10, 10) + m = 0 + def f(): + assert m == 0 + return ego.y + m + def g(): + require ego.x < f() + g() + m = -100 + """ + ) + poss = [sampleEgo(scenario, maxIterations=60).position for i in range(60)] + assert all(pos.x < pos.y for pos in poss) + + +def test_requirement_in_function_random_local(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ 0 + def f(): + local = Range(0, 1) + require ego.x < local + f() + """ + ) + xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] + assert all(-10 <= x <= 1 for x in xs) + + +def test_requirement_in_function_random_cell(): + scenario = compileScenic( + """ + ego = new Object at Range(-10, 10) @ 0 + def f(i): + def g(): + return i + return g + g = f(Range(0, 1)) # global function with a cell containing a random value + def h(): + local = Uniform(True, False) + def inner(): # local function likewise + return local + require (g() >= 0) and ((ego.x < -5) if inner() else (ego.x > 5)) + h() + """ + ) + xs = [sampleEgo(scenario, maxIterations=150).position.x for i in range(60)] + assert all(x < -5 or x > 5 for x in xs) + assert any(x < -5 for x in xs) + assert any(x > 5 for x in xs) + + def test_soft_requirement(): scenario = compileScenic( """ diff --git a/tests/syntax/test_specifiers.py b/tests/syntax/test_specifiers.py index 274e875e1..2fd7c127c 100644 --- a/tests/syntax/test_specifiers.py +++ b/tests/syntax/test_specifiers.py @@ -87,6 +87,21 @@ def test_lazy_value_in_requirement_2(): sampleScene(scenario, maxIterations=1) +def test_default_velocity_depends_on_speed_and_orientation(): + # Heading convention in Scenic: 0 deg = +Y, 90 deg = -X + ego = sampleEgoFrom("ego = new Object with speed 10, facing 90 deg") + assert tuple(ego.velocity) == pytest.approx((-10, 0, 0)) + + # Order independence (dependency graph should resolve the same way) + ego = sampleEgoFrom("ego = new Object facing 90 deg, with speed 10") + assert tuple(ego.velocity) == pytest.approx((-10, 0, 0)) + + # Orientation derived from another specifier (not a literal heading) + ego = sampleEgoFrom("ego = new Object with speed 10, facing toward -1 @ 1") + s = 10 * math.sqrt(0.5) + assert tuple(ego.velocity) == pytest.approx((-s, s, 0)) + + ## Value normalization diff --git a/tests/test_docs.py b/tests/test_docs.py index c2a0f33a3..4b4598517 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,12 +1,38 @@ import os -import socket import subprocess +from urllib.error import URLError +from urllib.request import Request, urlopen import pytest +from docs.intersphinx_config import iter_intersphinx_urls + pytest.importorskip("sphinx") +def _check_intersphinx_connectivity(timeout=10.0): + """Check that Intersphinx sites are reachable before building docs.""" + problems = [] + + for url in iter_intersphinx_urls(): + # Some docs hosts don't like the default Python-urllib user-agent. + req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + try: + with urlopen(req, timeout=timeout): + pass + except (URLError, TimeoutError) as e: + # We've seen slow HTTPS responses raise a bare TimeoutError from the + # SSL layer instead of being wrapped in URLError, so catch both here + # to avoid flaky failures when docs hosts are slow or unresponsive. + problems.append(f"{url} ({e})") + + if problems: + pytest.skip( + "Some Intersphinx sites are not reachable; skipping docs build:\n" + + "\n".join(problems) + ) + + @pytest.mark.slow def test_build_docs(): """Test that the documentation builds, and run doctests. @@ -14,10 +40,7 @@ def test_build_docs(): We do this in a subprocess since the Sphinx configuration file activates the veneer and has other side-effects that aren't reset afterward. """ - try: - socket.getaddrinfo("docs.python.org", 80) - except OSError: - pytest.skip("cannot connect to python.org for Intersphinx") + _check_intersphinx_connectivity() oldDirectory = os.getcwd() try: diff --git a/tests/utils.py b/tests/utils.py index 3ae1cf8c1..d8b284fcf 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -504,7 +504,14 @@ def ignorable(attr): cache, debug, ignoreCacheAttrs=True, - extraIgnores=("__module__",), + extraIgnores=( + "__module__", + "_abc_impl", + "_abc_registry", + "_abc_cache", + "_abc_negative_cache", + "_abc_negative_cache_version", + ), ): fail() return False diff --git a/tools/benchmarking/ci/distributions.scenic b/tools/benchmarking/ci/distributions.scenic new file mode 100644 index 000000000..3c24c4273 --- /dev/null +++ b/tools/benchmarking/ci/distributions.scenic @@ -0,0 +1,20 @@ +a = Range(0, 1) +b = Uniform(-a, a) +c = Normal(b, 1) +d = TruncatedNormal(min(max(c, -5), 5), a+1, -10, 10) +e = (Orientation.fromEuler(1, 2, 3) + a).yaw + +x = DiscreteRange(0, 2) +y = Discrete({x: 3, x*x: 5}) + +l = Uniform([a, b, c], [a, b, c, d], [a, b, c, d, e]) +elem = Uniform(*l[x:]) + +vf = VectorField("foo", lambda pos: pos.x) + +ego = new Object in RectangularRegion((d, y), elem, 10, 10), + facing a + (e relative to vf) + +vecs = [ego.position, ego.position.rotatedBy(a)] +shuf = Uniform(vecs, vecs[::-1]) +param p = Uniform(*shuf).x + shuf[0].distanceTo(shuf[1]) diff --git a/tools/benchmarking/ci/driving.scenic b/tools/benchmarking/ci/driving.scenic new file mode 100644 index 000000000..decc541f8 --- /dev/null +++ b/tools/benchmarking/ci/driving.scenic @@ -0,0 +1,19 @@ + +from utils import getAssetPath + +param map = getAssetPath("maps/CARLA/Town01.xodr") +param use2DMap = True +param map_options = dict(useCache=False, writeCache=False) +model scenic.domains.driving.model + +selected_road = Uniform(*network.roads) +selected_lane = Uniform(*selected_road.lanes) +ego = new Car on selected_lane.centerline + +new Pedestrian on visible sidewalk + +rightCurb = ego.laneGroup.curb +spot = new OrientedPoint on visible rightCurb +badAngle = Uniform(1.0, -1.0) * Range(10, 20) deg +parkedCar = new Car left of spot by 0.5, + facing badAngle relative to roadDirection diff --git a/tools/benchmarking/ci/driving_2d.scenic b/tools/benchmarking/ci/driving_2d.scenic new file mode 100644 index 000000000..e3c8cdeb8 --- /dev/null +++ b/tools/benchmarking/ci/driving_2d.scenic @@ -0,0 +1,20 @@ +# option: mode2D=True + +from utils import getAssetPath + +param map = getAssetPath("maps/CARLA/Town01.xodr") +param use2DMap = True +param map_options = dict(useCache=False, writeCache=False) +model scenic.domains.driving.model + +selected_road = Uniform(*network.roads) +selected_lane = Uniform(*selected_road.lanes) +ego = new Car on selected_lane.centerline + +new Pedestrian on visible sidewalk + +rightCurb = ego.laneGroup.curb +spot = new OrientedPoint on visible rightCurb +badAngle = Uniform(1.0, -1.0) * Range(10, 20) deg +parkedCar = new Car left of spot by 0.5, + facing badAngle relative to roadDirection diff --git a/tools/benchmarking/ci/many_boxes_2d.scenic b/tools/benchmarking/ci/many_boxes_2d.scenic new file mode 100644 index 000000000..c6a0b1a90 --- /dev/null +++ b/tools/benchmarking/ci/many_boxes_2d.scenic @@ -0,0 +1,5 @@ + +workspace = Workspace(RectangularRegion((0, 0), 0, 100, 100)) + +for _ in range(10): + new Object in workspace, facing toward (0, 0, 0) \ No newline at end of file diff --git a/tools/benchmarking/ci/many_boxes_3d.scenic b/tools/benchmarking/ci/many_boxes_3d.scenic new file mode 100644 index 000000000..3a8c04dff --- /dev/null +++ b/tools/benchmarking/ci/many_boxes_3d.scenic @@ -0,0 +1,5 @@ + +workspace = Workspace(BoxRegion(dimensions=(100, 100, 100))) + +for _ in range(10): + new Object in workspace, facing toward (0, 0, 0) \ No newline at end of file diff --git a/tools/benchmarking/ci/many_planes.scenic b/tools/benchmarking/ci/many_planes.scenic new file mode 100644 index 000000000..7d43a95e3 --- /dev/null +++ b/tools/benchmarking/ci/many_planes.scenic @@ -0,0 +1,11 @@ +from utils import getAssetPath + +workspace = Workspace(RectangularRegion(0@0, 0, 100, 100)) + +plane_shape = MeshShape.fromFile( + getAssetPath("meshes/classic_plane.obj.bz2"), + initial_rotation=(-90 deg, 0, -10 deg) +) + +for i in range(4): + new Object at Range(-40, 40) @ Range(-40, 40), with shape plane_shape \ No newline at end of file diff --git a/tools/benchmarking/ci/mars.scenic b/tools/benchmarking/ci/mars.scenic new file mode 100644 index 000000000..b05733d9c --- /dev/null +++ b/tools/benchmarking/ci/mars.scenic @@ -0,0 +1,98 @@ +"""Reduced version of the Webots Mars rover example.""" + +model scenic.simulators.webots.model + +from utils import getAssetPath + +# Set up workspace +width = 10 +length = 10 +workspace = Workspace(RectangularRegion(0 @ 0, 0, width, length)) + +# types of objects + +class MarsGround(Ground): + width: width + length: length + color: (0.863 , 0.447, 0.0353) + gridSize: 20 + +class MarsHill(Hill): + position: new Point in workspace + width: Range(1,2) + length: Range(1,2) + height: Range(0.1, 0.3) + spread: Range(0.2, 0.3) + regionContainedIn: everywhere + +class Goal(WebotsObject): + """Flag indicating the goal location.""" + width: 0.1 + length: 0.1 + webotsType: 'GOAL' + color: (0.035 , 0.639, 0.784) + +class Rover(WebotsObject): + """Mars rover.""" + width: 0.5 + length: 0.7 + height: 0.4 + webotsType: 'ROVER' + rotationOffset: (90 deg, 0, 0) + +class Debris(WebotsObject): + """Abstract class for debris scattered randomly in the workspace.""" + # Recess things into the ground slightly by default + baseOffset: (0, 0, -self.height/3) + +class BigRock(Debris): + """Large rock.""" + shape: MeshShape.fromFile(getAssetPath("meshes/webots_rock_large.obj.bz2")) + yaw: Range(0, 360 deg) + webotsType: 'ROCK_BIG' + positionOffset: Vector(0,0, -self.height/2) + +class Rock(Debris): + """Small rock.""" + shape: MeshShape.fromFile(getAssetPath("meshes/webots_rock_small.obj.bz2")) + yaw: Range(0, 360 deg) + webotsType: 'ROCK_SMALL' + positionOffset: Vector(0,0, -self.height/2) + +class Pipe(Debris): + """Pipe with variable length.""" + width: 0.2 + length: Range(0.5, 1.5) + height: self.width + shape: CylinderShape(initial_rotation=(90 deg, 0, 90 deg)) + yaw: Range(0, 360 deg) + webotsType: 'PIPE' + rotationOffset: (90 deg, 0, 90 deg) + + def startDynamicSimulation(self): + # Apply variable length + self.webotsObject.getField('height').setSFFloat(self.length) + + +# Ground with random gaussian hills +ground = new MarsGround on (0,0,0), with terrain [new MarsHill for _ in range(15)] + +# Ego and goal on ground +ego = new Rover at (0, -3), on ground, with controller 'sojourner' +goal = new Goal at (Range(-1, 1), Range(2, 3)), on ground, facing (0,0,0) + +# Bottleneck made of two pipes with a rock in between +bottleneck = new OrientedPoint at ego offset by Range(-0.5, 0.5) @ Range(0.5, 1.5), facing Range(-30, 30) deg +require abs((angle to goal) - (angle to bottleneck)) <= 10 deg +new BigRock at bottleneck, on ground + +gap = 1.2 * ego.width +halfGap = gap / 2 + +leftEdge = new OrientedPoint left of bottleneck by halfGap, + facing Range(60, 120) deg relative to bottleneck.heading +rightEdge = new OrientedPoint right of bottleneck by halfGap, + facing Range(-120, -60) deg relative to bottleneck.heading + +new Pipe ahead of leftEdge, with length Range(1, 2), on ground, facing leftEdge, with parentOrientation 0 +new Pipe ahead of rightEdge, with length Range(1, 2), on ground, facing rightEdge, with parentOrientation 0 diff --git a/tools/benchmarking/ci/nearby_nonconvex.scenic b/tools/benchmarking/ci/nearby_nonconvex.scenic new file mode 100644 index 000000000..794add576 --- /dev/null +++ b/tools/benchmarking/ci/nearby_nonconvex.scenic @@ -0,0 +1,4 @@ +region = BoxRegion().difference(BoxRegion(dimensions=(0.1, 0.1, 0.1))) +shape = MeshShape(region.mesh) +ego = new Object with shape shape +other = new Object at (Range(0.9, 1.1), 0), with shape shape \ No newline at end of file diff --git a/tools/benchmarking/ci/run_benchmarks.py b/tools/benchmarking/ci/run_benchmarks.py new file mode 100644 index 000000000..c392adfda --- /dev/null +++ b/tools/benchmarking/ci/run_benchmarks.py @@ -0,0 +1,160 @@ +import argparse +import math +from pathlib import Path +import sys +import time +import warnings + +import pyperf + +import scenic +from scenic.core.distributions import RejectionException + +scenic.setDebuggingOptions(fullBacktrace=True, debugExceptions=False) + + +def normalizeOptionValue(value): + try: + return int(value) + except ValueError: + pass + try: + return float(value) + except ValueError: + pass + if value == "True": + return True + if value == "False": + return False + return value + + +def parseOptions(path): + prefix = "# option: " + prefixLen = len(prefix) + options = {} + with path.open() as f: + for i, line in enumerate(f): + if not line.startswith(prefix): + break + rest = line[prefixLen:] + name, sep, value = rest.rpartition("=") + if sep != "=": + raise RuntimeError(f"benchmark option line {i} is malformed") + if name in options: + raise RuntimeError( + f"benchmark option '{name}' is specified multiple times" + ) + options[name] = normalizeOptionValue(value) + return options + + +def sampleForIterations(loops, path, iterations): + options = parseOptions(path) + times = [] + + for _ in range(loops): + # Recompile each time to reset caches, checker state, etc. + scenario = scenic.scenarioFromFile(path, **options) + + n = iterations + t0 = time.perf_counter() + + try: + while n > 0: + scene, its = scenario.generate(maxIterations=n) + n -= its + except RejectionException: + pass + + times.append(time.perf_counter() - t0) + + return math.fsum(times) + + +def generateScenes(loops, path, count): + options = parseOptions(path) + times = [] + + for _ in range(loops): + # Recompile each time to reset caches, checker state, etc. + scenario = scenic.scenarioFromFile(path, **options) + + t0 = time.perf_counter() + + for _ in range(count): + scenario.generate() + + times.append(time.perf_counter() - t0) + + return math.fsum(times) + + +benchmarkTypes = { + "compile": ("bench_func", scenic.scenarioFromFile), + "sample10": ("bench_time_func", sampleForIterations, 10), + "sample100": ("bench_time_func", sampleForIterations, 100), + "scene10": ("bench_time_func", generateScenes, 10), + "scene100": ("bench_time_func", generateScenes, 100), +} +defaultTypes = ("compile", "scene10") +for ty in defaultTypes: + assert ty in benchmarkTypes + +description = """\ +Run benchmarks. By default, runs all .scenic files in this directory, but +particular benchmark files can be specified instead. For each file, several +types of benchmarks are available, e.g. compilation or scene generation time. +The default choice of types can be overridden using the --types option. For +example, to measure compile time and the time to sample 100 scenes from the +`tight_fit_2d.scenic` scenario, you can run: + +python run_benchmarks.py tight_fit_2d.scenic --types compile scene100 + +Many additional options are inherited from `pyperf`: see its documentation +for details. +""" + +if __name__ == "__main__": + + def add_cmdline_args(cmd, args): + cmd.extend(args.benchmarks) + if args.types: + cmd.append("--types") + cmd.extend(args.types) + + runner = pyperf.Runner(add_cmdline_args=add_cmdline_args) + runner.argparser.description = description + runner.argparser.formatter_class = argparse.RawDescriptionHelpFormatter + types = ["all"] + types.extend(sorted(benchmarkTypes)) + runner.argparser.add_argument( + "--types", + nargs="+", + action="extend", + metavar="TYPE", + choices=types, + help=f"types of benchmark to run (options: {types}; default: {defaultTypes})", + ) + runner.argparser.add_argument( + "benchmarks", nargs="*", type=Path, help="benchmarks to run (default all)" + ) + runner.parse_args() + benchmarks = runner.args.benchmarks + if not benchmarks: + benchmarks = sorted(Path(__file__).parent.glob("*.scenic")) + types = runner.args.types + if not types: + types = defaultTypes + elif "all" in types: + types = tuple(benchmarkTypes) + + if not sys.warnoptions: + warnings.simplefilter("ignore") + + for path in benchmarks: + name = path.stem + for ty in types: + benchTy, func, *args = benchmarkTypes[ty] + method = getattr(runner, benchTy) + method(f"{name}_{ty}", func, path, *args) diff --git a/tools/benchmarking/ci/tight_fit_2d.scenic b/tools/benchmarking/ci/tight_fit_2d.scenic new file mode 100644 index 000000000..a141c8e9e --- /dev/null +++ b/tools/benchmarking/ci/tight_fit_2d.scenic @@ -0,0 +1,3 @@ +workspace = Workspace(RectangularRegion(0@0, 0, 1.1, 1.1)) + +new Object in workspace diff --git a/tools/benchmarking/ci/tight_fit_3d.scenic b/tools/benchmarking/ci/tight_fit_3d.scenic new file mode 100644 index 000000000..6814d5e85 --- /dev/null +++ b/tools/benchmarking/ci/tight_fit_3d.scenic @@ -0,0 +1,3 @@ +workspace = Workspace(BoxRegion(dimensions=(1.2, 1.2, 1.2))) + +new Object in workspace diff --git a/tools/benchmarking/ci/utils.py b/tools/benchmarking/ci/utils.py new file mode 100644 index 000000000..49c096c03 --- /dev/null +++ b/tools/benchmarking/ci/utils.py @@ -0,0 +1,7 @@ +import os.path +from pathlib import Path + + +def getAssetPath(relpath): + base = Path(__file__).parent.parent.parent.parent / "assets" + return os.path.join(base, relpath) diff --git a/tools/benchmarking/ci/vacuum.scenic b/tools/benchmarking/ci/vacuum.scenic new file mode 100644 index 000000000..0e3bddd42 --- /dev/null +++ b/tools/benchmarking/ci/vacuum.scenic @@ -0,0 +1,157 @@ +"""Reduced version of the Webots vacuum example.""" + +model scenic.simulators.webots.model + +import numpy as np +import trimesh +import random +from pathlib import Path + +from utils import getAssetPath + +param numToys = 1 +param duration = 10 + +## Class Definitions ## + +class Vacuum(WebotsObject): + webotsName: "IROBOT_CREATE" + shape: CylinderShape() + width: 0.335 + length: 0.335 + height: 0.07 + customData: str(random.getrandbits(32)) # Random seed for robot controller + +# Floor uses builtin Webots floor to keep Vacuum Sensors from breaking +# Not actually linked to WebotsObject because Webots floor is 2D +class Floor(Object): + width: 5 + length: 5 + height: 0.01 + position: (0,0,-0.005) + #color: [200, 200, 200] + +class Wall(WebotsObject): + webotsAdhoc: {'physics': False} + width: 5 + length: 0.04 + height: 0.5 + #color: [160, 160, 160] + +class DiningTable(WebotsObject): + webotsAdhoc: {'physics': True} + shape: MeshShape.fromFile(getAssetPath("meshes/dining_table.obj.bz2")) + width: Range(0.7, 1.5) + length: Range(0.7, 1.5) + height: 0.75 + density: 670 # Density of solid birch + #color: [103, 71, 54] + +class DiningChair(WebotsObject): + webotsAdhoc: {'physics': True} + shape: MeshShape.fromFile(getAssetPath("meshes/dining_chair.obj.bz2"), initial_rotation=(180 deg, 0, 0)) + width: 0.4 + length: 0.4 + height: 1 + density: 670 # Density of solid birch + positionStdDev: (0.05, 0.05 ,0) + orientationStdDev: (10 deg, 0, 0) + #color: [103, 71, 54] + +class Couch(WebotsObject): + webotsAdhoc: {'physics': False} + shape: MeshShape.fromFile(getAssetPath("meshes/couch.obj.bz2"), initial_rotation=(-90 deg, 0, 0)) + width: 2 + length: 0.75 + height: 0.75 + positionStdDev: (0.05, 0.5 ,0) + orientationStdDev: (5 deg, 0, 0) + #color: [51, 51, 255] + +# class CoffeeTable(WebotsObject): +# webotsAdhoc: {'physics': False} +# shape: MeshShape.fromFile(getAssetPath("meshes/coffee_table.obj.bz2")) +# width: 1.5 +# length: 0.5 +# height: 0.4 +# positionStdDev: (0.05, 0.05 ,0) +# orientationStdDev: (5 deg, 0, 0) +# #color: [103, 71, 54] + +class Toy(WebotsObject): + webotsAdhoc: {'physics': True} + shape: Uniform(BoxShape(), CylinderShape(), ConeShape(), SpheroidShape()) + width: 0.1 + length: 0.1 + height: 0.1 + density: 100 + #color: [255, 128, 0] + +class BlockToy(Toy): + shape: BoxShape() + +## Scene Layout ## + +# Create room region and set it as the workspace +room_region = RectangularRegion(0 @ 0, 0, 5.09, 5.09) +workspace = Workspace(room_region) + +# Create floor and walls +floor = new Floor +wall_offset = floor.width/2 + 0.04/2 + 1e-4 +right_wall = new Wall at (wall_offset, 0, 0.25), facing toward floor +left_wall = new Wall at (-wall_offset, 0, 0.25), facing toward floor +front_wall = new Wall at (0, wall_offset, 0.25), facing toward floor +back_wall = new Wall at (0, -wall_offset, 0.25), facing toward floor + +# Place vacuum on floor +ego = new Vacuum on floor + +# Create a "safe zone" around the vacuum so that it does not start stuck +safe_zone = CircularRegion(ego.position, radius=1) + +# Create a dining room region where we will place dining room furniture +dining_room_region = RectangularRegion(1.25 @ 0, 0, 2.5, 5).difference(safe_zone) + +# Place a table with 3 chairs around it, and one knocked over on the floor +dining_table = new DiningTable contained in dining_room_region, on floor, + facing Range(0, 360 deg) + +chair_1 = new DiningChair behind dining_table by -0.1, on floor, + facing toward dining_table, with regionContainedIn dining_room_region +# chair_2 = new DiningChair ahead of dining_table by -0.1, on floor, +# facing toward dining_table, with regionContainedIn dining_room_region +# chair_3 = new DiningChair left of dining_table by -0.1, on floor, +# facing toward dining_table, with regionContainedIn dining_room_region + +fallen_orientation = Uniform((0, -90 deg, 0), (0, 90 deg, 0), (0, 0, -90 deg), (0, 0, 90 deg)) + +chair_4 = new DiningChair contained in dining_room_region, facing fallen_orientation, + on floor, with baseOffset(0,0,-0.2) + +# Add some noise to the positions and yaw of the chairs around the table +mutate chair_1#, chair_2, chair_3 + +# Create a living room region where we will place living room furniture +living_room_region = RectangularRegion(-1.25 @ 0, 0, 2.5, 5).difference(safe_zone) + +couch = new Couch ahead of left_wall by 0.335, + on floor, facing away from left_wall + +# coffee_table = new CoffeeTable ahead of couch by 0.336, +# on floor, facing away from couch + +# Add some noise to the positions of the couch and coffee table +mutate couch#, coffee_table + +toy_stack = new BlockToy on floor +toy_stack = new BlockToy on toy_stack +#toy_stack = new BlockToy on toy_stack + +# Spawn some toys +for _ in range(globalParameters.numToys): + new Toy on floor + +## Simulation Setup ## +terminate after globalParameters.duration * 60 seconds +record (ego.x, ego.y) as VacuumPosition diff --git a/tools/benchmarking/ci/visibility_2d.scenic b/tools/benchmarking/ci/visibility_2d.scenic new file mode 100644 index 000000000..4521810a5 --- /dev/null +++ b/tools/benchmarking/ci/visibility_2d.scenic @@ -0,0 +1,14 @@ +workspace = Workspace(RectangularRegion((0, 0), 0, 10, 10)) + +ego = new Object in workspace, + facing Range(0, 360) deg, + with visibleDistance 4, + with viewAngles (90 deg, 90 deg), + with showVisibleRegion True + +target = new Object ahead of ego by (0, Range(2, 8)), + with positionStdDev (0.2, 0.2, 0.2), + with color (0, 0, 1), + with requireVisible True + +mutate target diff --git a/tools/benchmarking/ci/visibility_occlusion.scenic b/tools/benchmarking/ci/visibility_occlusion.scenic new file mode 100644 index 000000000..acaf10c16 --- /dev/null +++ b/tools/benchmarking/ci/visibility_occlusion.scenic @@ -0,0 +1,26 @@ +workspace = Workspace(BoxRegion(dimensions=(10, 10, 10))) +innerRegion = BoxRegion(dimensions=(7, 7, 7)) + +region = BoxRegion().difference(BoxRegion(dimensions=(0.5, 0.5, 0.5))) +shape = MeshShape(region.mesh) + +obstacle = new Object contained in innerRegion, + with shape shape, + with width 2, with height 2, with length 2, + with positionStdDev (1, 1, 1), + with name "obstacle" + +ego = new Object contained in workspace, + facing directly toward obstacle, + with visibleDistance 20, + with viewAngles (90 deg, 90 deg), + with showVisibleRegion True, + with name "ego" + +target = new Object beyond obstacle by 2, + with positionStdDev (0.2, 0.2, 0.2), + with color (0, 0, 1), + with requireVisible True, + with name "target" + +mutate obstacle, target diff --git a/tools/benchmarking/collisions/benchmark_collisions.py b/tools/benchmarking/collisions/benchmark_collisions.py index bfb36be7f..b68fcc349 100644 --- a/tools/benchmarking/collisions/benchmark_collisions.py +++ b/tools/benchmarking/collisions/benchmark_collisions.py @@ -71,6 +71,9 @@ def run_benchmark(path, params): results[(str((benchmark, benchmark_params)), param)] = results_val + # for setup, subresults in results.items(): + # print(f"{setup}: {subresults[0][1]:.2f} s") + # Plot times import matplotlib.pyplot as plt diff --git a/tools/benchmarking/collisions/city_intersection.scenic b/tools/benchmarking/collisions/city_intersection.scenic index e24170e04..268648e4c 100644 --- a/tools/benchmarking/collisions/city_intersection.scenic +++ b/tools/benchmarking/collisions/city_intersection.scenic @@ -15,7 +15,7 @@ from pathlib import Path class EgoCar(WebotsObject): webotsName: "EGO" - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "bmwx5_hull.obj.bz2", initial_rotation=(90 deg, 0, 0)) + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "bmwx5_hull.obj.bz2", initial_rotation=(90 deg, 0, 0)) positionOffset: Vector(-1.43580750, 0, -0.557354985).rotatedBy(Orientation.fromEuler(*self.orientationOffset)) cameraOffset: Vector(-1.43580750, 0, -0.557354985) + Vector(1.72, 0, 1.4) orientationOffset: (90 deg, 0, 0) diff --git a/tools/benchmarking/collisions/narrowGoalNew.scenic b/tools/benchmarking/collisions/narrowGoalNew.scenic index 2d5302098..535a6d443 100644 --- a/tools/benchmarking/collisions/narrowGoalNew.scenic +++ b/tools/benchmarking/collisions/narrowGoalNew.scenic @@ -12,7 +12,7 @@ workspace = Workspace(RectangularRegion(0 @ 0, 0, width, length)) class MarsGround(Ground): width: width length: length - color: (220, 114, 9) + #color: (220, 114, 9) gridSize: 20 class MarsHill(Hill): @@ -28,7 +28,7 @@ class Goal(WebotsObject): width: 0.1 length: 0.1 webotsType: 'GOAL' - color: (9 ,163, 220) + #color: (9 ,163, 220) class Rover(WebotsObject): """Mars rover.""" @@ -45,14 +45,14 @@ class Debris(WebotsObject): class BigRock(Debris): """Large rock.""" - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "webots_rock_large.obj.bz2") + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "webots_rock_large.obj.bz2") yaw: Range(0, 360 deg) webotsType: 'ROCK_BIG' positionOffset: Vector(0,0, -self.height/2) class Rock(Debris): """Small rock.""" - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "webots_rock_small.obj.bz2") + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "webots_rock_small.obj.bz2") yaw: Range(0, 360 deg) webotsType: 'ROCK_SMALL' positionOffset: Vector(0,0, -self.height/2) diff --git a/tools/benchmarking/collisions/vacuum.scenic b/tools/benchmarking/collisions/vacuum.scenic index e1701c948..e9ef1f157 100644 --- a/tools/benchmarking/collisions/vacuum.scenic +++ b/tools/benchmarking/collisions/vacuum.scenic @@ -30,54 +30,54 @@ class Floor(Object): length: 5 height: 0.01 position: (0,0,-0.005) - color: [200, 200, 200] + #color: [200, 200, 200] class Wall(WebotsObject): webotsAdhoc: {'physics': False} width: 5 length: 0.04 height: 0.5 - color: [160, 160, 160] + #color: [160, 160, 160] class DiningTable(WebotsObject): webotsAdhoc: {'physics': True} - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "dining_table.obj.bz2") + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "dining_table.obj.bz2") width: Range(0.7, 1.5) length: Range(0.7, 1.5) height: 0.75 density: 670 # Density of solid birch - color: [103, 71, 54] + #color: [103, 71, 54] class DiningChair(WebotsObject): webotsAdhoc: {'physics': True} - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "dining_chair.obj.bz2", initial_rotation=(180 deg, 0, 0)) + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "dining_chair.obj.bz2", initial_rotation=(180 deg, 0, 0)) width: 0.4 length: 0.4 height: 1 density: 670 # Density of solid birch positionStdDev: (0.05, 0.05 ,0) orientationStdDev: (10 deg, 0, 0) - color: [103, 71, 54] + #color: [103, 71, 54] class Couch(WebotsObject): webotsAdhoc: {'physics': False} - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "couch.obj.bz2", initial_rotation=(-90 deg, 0, 0)) + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "couch.obj.bz2", initial_rotation=(-90 deg, 0, 0)) width: 2 length: 0.75 height: 0.75 positionStdDev: (0.05, 0.5 ,0) orientationStdDev: (5 deg, 0, 0) - color: [51, 51, 255] + #color: [51, 51, 255] class CoffeeTable(WebotsObject): webotsAdhoc: {'physics': False} - shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "tools" / "meshes" / "coffee_table.obj.bz2") + shape: MeshShape.fromFile(Path(localPath(".")).parent.parent.parent / "assets" / "meshes" / "coffee_table.obj.bz2") width: 1.5 length: 0.5 height: 0.4 positionStdDev: (0.05, 0.05 ,0) orientationStdDev: (5 deg, 0, 0) - color: [103, 71, 54] + #color: [103, 71, 54] class Toy(WebotsObject): webotsAdhoc: {'physics': True} @@ -86,7 +86,7 @@ class Toy(WebotsObject): length: 0.1 height: 0.1 density: 100 - color: [255, 128, 0] + #color: [255, 128, 0] class BlockToy(Toy): shape: BoxShape() diff --git a/tools/carla/README.md b/tools/carla/README.md new file mode 100644 index 000000000..68f96e4ff --- /dev/null +++ b/tools/carla/README.md @@ -0,0 +1,30 @@ +# Dynamic CARLA blueprints + +This folder has two scripts that **collect CARLA blueprint IDs/dimensions** and **generate** the auto-generated data module used by +`scenic.simulators.carla.blueprints`. + +## Quick Start + + # From tools/carla: + python snapshot_blueprints.py # capture a snapshot from the running CARLA server + python make_blueprints.py # build src/scenic/simulators/carla/_blueprintData.py + +## What the scripts do + +### `snapshot_blueprints.py` + +Connects to CARLA (starts it headless if needed, requires `CARLA_ROOT`). +Make sure the CARLA server and Python API versions match. +Groups blueprints into categories, measures bounding-box dimensions, and writes a compressed snapshot: +`tools/carla/snapshots/blueprints_.json.gz` + +(The `blueprints_*.json.gz` files are gzip-compressed JSON; you can decompress +one with `gunzip -k blueprints_0.9.15.json.gz` if you want to inspect it.) + +Run this once for each CARLA version you want to support. + +### `make_blueprints.py` + +Reads all `tools/carla/snapshots/blueprints_*.json.gz`, sorts versions semantically (newest → oldest), normalizes category/ID ordering for stable diffs, and writes: +`src/scenic/simulators/carla/_blueprintData.py` + diff --git a/tools/carla/make_blueprints.py b/tools/carla/make_blueprints.py new file mode 100644 index 000000000..a374700b0 --- /dev/null +++ b/tools/carla/make_blueprints.py @@ -0,0 +1,86 @@ +"""Build scenic.simulators.carla._blueprintData from JSON snapshots. + +- Reads tools/carla/snapshots/blueprints_*.json.gz +- Sorts versions semantically (newest → oldest) +- Normalizes IDs/dims (sorted for stable diffs) +- Writes src/scenic/simulators/carla/_blueprintData.py with _IDS/_DIMS. + +Usage: python tools/carla/make_blueprints.py +Output: src/scenic/simulators/carla/_blueprintData.py +""" + +import gzip +import json +from pathlib import Path +import sys + +CARLA_TOOLS_DIR = Path(__file__).resolve().parent # .../Scenic/tools/carla +SNAPSHOT_DIR = CARLA_TOOLS_DIR / "snapshots" # .../Scenic/tools/carla/snapshots +PROJECT_ROOT = CARLA_TOOLS_DIR.parents[1] # .../Scenic +SNAPSHOT_PATTERN = "blueprints_*.json.gz" +OUT_PATH = PROJECT_ROOT / "src" / "scenic" / "simulators" / "carla" / "_blueprintData.py" + + +# Template for src/scenic/simulators/carla/_blueprintData.py +TEMPLATE = """\ +# AUTO-GENERATED. Do not edit by hand. +# Built from tools/carla/make_blueprints.py + +_IDS = {IDS_JSON} + +_DIMS = {DIMS_JSON} +""" + + +def _verkey(s: str): + """Turn a version string like '0.9.15' into a sortable (major, minor, patch) tuple.""" + parts = [int(p) for p in s.split(".")] + parts += [0] * (3 - len(parts)) + return tuple(parts[:3]) + + +def load_versions(): + files = list(SNAPSHOT_DIR.glob(SNAPSHOT_PATTERN)) + if not files: + sys.exit("No input JSONs found.") + + entries = [] + for jf in files: + with gzip.open(jf, "rt", encoding="utf-8") as f: + obj = json.load(f) + ver = str(obj["server_version"]) + ids = obj["ids"] + dims = obj["dims"] + entries.append((ver, {"ids": ids, "dims": dims})) + + entries.sort(key=lambda x: _verkey(x[0]), reverse=True) # newest → oldest + return {ver: data for ver, data in entries} + + +def normalize(versions): + """Return (ids_map, dims_map) with categories, blueprint IDs, and dims sorted.""" + ids_map, dims_map = {}, {} + for ver, data in versions.items(): + ids = data["ids"] + dims = data["dims"] + ids_map[ver] = {cat: sorted(bp_ids) for cat, bp_ids in sorted(ids.items())} + dims_map[ver] = dict(sorted(dims.items())) + return ids_map, dims_map + + +def build_source(versions): + ids_map, dims_map = normalize(versions) + ids_json = json.dumps(ids_map, indent=2) + dims_json = json.dumps(dims_map, indent=2) + return TEMPLATE.format(IDS_JSON=ids_json, DIMS_JSON=dims_json) + + +def main(): + src = build_source(load_versions()) + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUT_PATH.write_text(src, encoding="utf-8") + print(f"[OK] Wrote {OUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/tools/carla/snapshot_blueprints.py b/tools/carla/snapshot_blueprints.py new file mode 100644 index 000000000..2f2118c16 --- /dev/null +++ b/tools/carla/snapshot_blueprints.py @@ -0,0 +1,277 @@ +"""Collect CARLA blueprint snapshots. + +- Connects to (or launches) CARLA. +- Measures dimensions for all ``vehicle.*``, ``walker.pedestrian.*``, and + ``static.prop.*`` blueprints. +- Categorizes blueprints for Scenic. +- Writes snapshots/blueprints_.json.gz with: + {"server_version", "ids" (by category), "dims" (per blueprint id)}. + +Requires: CARLA_ROOT, HOST/PORT reachable. +Usage: python tools/carla/Snapshot_blueprints.py +Output: tools/carla/snapshots/blueprints_.json.gz +""" + +import gzip +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + +SNAPSHOT_DIR = Path(__file__).resolve().parent / "snapshots" +HOST = "127.0.0.1" +PORT = 2000 + +SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + +# ---- server helpers ---------------------------------------------------------- + + +def is_server_running(host=HOST, port=PORT): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + try: + s.connect((host, port)) + return True + except Exception: + return False + + +def wait_until_ready(max_seconds=120): + for _ in range(max_seconds): + if is_server_running(): + return True + time.sleep(1) + return False + + +def check_carla_path(): + carla_root = os.environ.get("CARLA_ROOT") + if not carla_root: + print("ERROR: CARLA_ROOT is not set.", file=sys.stderr) + sys.exit(2) + return Path(carla_root) + + +def pick_launcher_from_root(root: Path): + if (root / "CarlaUnreal.sh").exists(): # 0.10.x+ + return "CarlaUnreal.sh", "CarlaUnreal-Linux-Shipping" + if (root / "CarlaUE4.sh").exists(): # 0.9.x + return "CarlaUE4.sh", "CarlaUE4-Linux-Shipping" + print( + "ERROR: Could not find CarlaUnreal.sh or CarlaUE4.sh in CARLA_ROOT.", + file=sys.stderr, + ) + sys.exit(2) + + +def start_carla_if_needed(): + if is_server_running(): + return None + root = check_carla_path() + script, server_process_name = pick_launcher_from_root(root) + print("[INFO] Starting CARLA") + subprocess.Popen( + f"bash {root}/{script} -RenderOffScreen", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if not wait_until_ready(): + print("ERROR: Unable to connect to CARLA after starting.", file=sys.stderr) + sys.exit(3) + return server_process_name + + +# ---- categorization ---------------------------------------------------------- + +VEHICLE_BASETYPE_TO_CATEGORY = { + "car": "carModels", + "bicycle": "bicycleModels", + "motorcycle": "motorcycleModels", + "truck": "truckModels", + "van": "vanModels", + "bus": "busModels", +} + +# Explicit fixes for empty base_type on known blueprints +VEHICLE_ID_TO_CATEGORY = { + "vehicle.kawasaki.ninja": "motorcycleModels", # base_type == "" in 0.9.14 + "vehicle.mini.cooper_s": "carModels", # base_type == "" in 0.9.15/0.9.16 + "vehicle.bmw.grandtourer": "carModels", # base_type == "" in 0.9.15/0.9.16 + "vehicle.sprinter.mercedes": "vanModels", # base_type == "" in 0.10.0 +} + +PROP_CATEGORY_RULES = [ + ("trashModels", ["trashcan", "bin"]), + ("coneModels", ["cone"]), + ("debrisModels", ["debris"]), + ("vendingMachineModels", ["vendingmachine"]), + ("chairModels", ["chair"]), + ("busStopModels", ["busstop"]), + ("advertisementModels", ["advertisement", "streetsign"]), + ("garbageModels", ["colacan", "garbage", "plasticbag", "trashbag"]), + ("containerModels", ["container"]), + ("tableModels", ["table"]), + ("barrierModels", ["barrier"]), + ("plantpotModels", ["plantpot"]), + ("mailboxModels", ["mailbox"]), + ("gnomeModels", ["gnome"]), + ("creasedboxModels", ["creasedbox"]), + ("caseModels", ["case"]), + ("boxModels", [".box"]), + ("benchModels", ["bench"]), + ("barrelModels", ["barrel"]), + ("atmModels", ["atm"]), + ("kioskModels", ["kiosk"]), + ("ironplateModels", ["ironplank"]), + ("trafficwarningModels", ["trafficwarning"]), +] + +WALKER_CATEGORY = "walkerModels" + +ALL_CATEGORY_KEYS = ( + list(VEHICLE_BASETYPE_TO_CATEGORY.values()) + + [WALKER_CATEGORY] + + [category for category, _ in PROP_CATEGORY_RULES] +) + +# ---- measuring --------------------------------------------------------------- + + +def get_dimensions_from_spawned(actor): + bb = actor.bounding_box + return { + "width": 2.0 * abs(bb.extent.y), # Y right + "length": 2.0 * abs(bb.extent.x), # X forward + "height": 2.0 * abs(bb.extent.z), # Z up + } + + +def measure_dims(world, bp): + import carla + + # `static.prop.mesh` width/length/height == inf + if bp.id == "static.prop.mesh": + return None + + tf = carla.Transform(carla.Location(x=0.0, y=0.0, z=500.0)) + actor = None + try: + actor = world.try_spawn_actor(bp, tf) + if actor is None: + print( + f"[WARN] Could not spawn '{bp.id}' at x=0, y=0, z=500. No dimensions measured.", + file=sys.stderr, + ) + return None + world.wait_for_tick() + return get_dimensions_from_spawned(actor) + except Exception as e: + print( + f"[WARN] Exception while measuring dims for '{bp.id}': {e}. No dimensions measured.", + file=sys.stderr, + ) + return None + finally: + if actor is not None: + try: + actor.destroy() + except Exception: + pass + + +def categorize_vehicle(bp): + if not bp.has_attribute("base_type"): + print( + f"[WARN] vehicle blueprint '{bp.id}' has no 'base_type' attribute; skipping.", + file=sys.stderr, + ) + return None + + val = bp.get_attribute("base_type").as_str().lower() + category = VEHICLE_BASETYPE_TO_CATEGORY.get(val) + if category: + return category + + # Fallback for the empty/unknown base_type case we know about + if bp.id in VEHICLE_ID_TO_CATEGORY: + return VEHICLE_ID_TO_CATEGORY[bp.id] + print( + f"[WARN] vehicle blueprint '{bp.id}' has unsupported base_type='{val}'; skipping.", + file=sys.stderr, + ) + return None + + +def categorize_prop(bp): + name = bp.id.lower() + for category, keywords in PROP_CATEGORY_RULES: + if any(kw in name for kw in keywords): + return category + return None + + +# ---- main -------------------------------------------------------------------- + + +def main(): + try: + import carla + except Exception: + print("ERROR: 'carla' Python package not installed.", file=sys.stderr) + sys.exit(4) + + # start CARLA if needed; remember the process name so we can stop it later + server_process_name = start_carla_if_needed() + + try: + # connect + client = carla.Client(HOST, PORT) + client.set_timeout(10) + world = client.get_world() + server_version = client.get_server_version() + print(f"[INFO] Connected to CARLA {server_version}") + + # gather blueprints + lib = world.get_blueprint_library() + vehicle_bps = lib.filter("vehicle.*") + walker_bps = lib.filter("walker.pedestrian.*") + prop_bps = lib.filter("static.prop.*") + + ids = {category: [] for category in ALL_CATEGORY_KEYS} + dims = {} + + def add_group(bps, category_for_bp): + for bp in bps: + md = measure_dims(world, bp) + if md: + dims[bp.id] = md + category = category_for_bp(bp) + if category: + ids[category].append(bp.id) + + add_group(vehicle_bps, categorize_vehicle) + add_group(walker_bps, lambda bp: WALKER_CATEGORY) + add_group(prop_bps, categorize_prop) + + out_obj = { + "server_version": server_version, + "ids": ids, + "dims": dims, + } + out_path = SNAPSHOT_DIR / f"blueprints_{server_version}.json.gz" + with gzip.open(out_path, "wt", encoding="utf-8") as f: + json.dump(out_obj, f, indent=2) + print(f"[OK] Wrote {out_path}") + + finally: + if server_process_name: + subprocess.run(f"killall -9 {server_process_name}", shell=True) + + +if __name__ == "__main__": + main() diff --git a/tools/carla/snapshots/blueprints_0.10.0.json.gz b/tools/carla/snapshots/blueprints_0.10.0.json.gz new file mode 100644 index 000000000..796fd3d8a Binary files /dev/null and b/tools/carla/snapshots/blueprints_0.10.0.json.gz differ diff --git a/tools/carla/snapshots/blueprints_0.9.14.json.gz b/tools/carla/snapshots/blueprints_0.9.14.json.gz new file mode 100644 index 000000000..60a494fee Binary files /dev/null and b/tools/carla/snapshots/blueprints_0.9.14.json.gz differ diff --git a/tools/carla/snapshots/blueprints_0.9.15.json.gz b/tools/carla/snapshots/blueprints_0.9.15.json.gz new file mode 100644 index 000000000..604686709 Binary files /dev/null and b/tools/carla/snapshots/blueprints_0.9.15.json.gz differ diff --git a/tools/carla/snapshots/blueprints_0.9.16.json.gz b/tools/carla/snapshots/blueprints_0.9.16.json.gz new file mode 100644 index 000000000..7e35db092 Binary files /dev/null and b/tools/carla/snapshots/blueprints_0.9.16.json.gz differ diff --git a/tox.ini b/tox.ini index e08482b81..19ed9d3f8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] -envlist = py{38,39,310,311,312}{,-extras} +envlist = py{38,39,310,311,312,313,314}{,-extras} labels = - basic = py{38,39,310,311,312} + basic = py{38,39,310,311,312,313,314} [testenv] extras =