Skip to content

Headless Core Runs#

This tutorial runs ACRES Core without the game: a batch of environments from Python, the LiDAR, an episode log and ROS 2. The last part compares ACRES Core with the game on the same commands.

One control step of the ACRES Core batch: the command rows go to N environments on the thread pool. Each environment reads its row and runs 12 physics steps: drive-by-wire, steering and powertrain, wheel contacts, driveline solve, farm stamps, rigid body. Then the batch publishes the state arrays. The episode log and the LiDAR scan are optional. One control step of the Core batch, 0.1 s INPUTS EACH ENVIRONMENT, ON ONE THREAD OF THE POOL OUTPUTS Command rows shape (N, 18), ds_dbw units NaN mode: no message Shared world terrain, surface map, soil units, crop patches, obstacle scene read-only for all threads Environment settings soil-water scenario, Polaris parameters FAcresBatch::Step 1 Read the command row new messages, subsystems that the caller holds 12 physics steps of 1/120 s A held command arrives again each 6 steps (20 Hz). 2 Drive-by-wire watchdog, actuators, ULC StepDbw 3 Steering and powertrain road wheels, engine, CVT StepUtvPowertrain 4 Four wheel contacts ray cast, surface, soil water PrepareUtvWheel 5 Driveline solve wheel speeds, tyre forces, fuel SolveUtvDriveline 6 Farm stamps tyre and body marks: crushed crop, ruts 7 Rigid body and energy ledger forces, semi-implicit Euler step AccountChassis 8 Publish the arrays one row for each environment Episode log, optional MCAP, one state message for each physics step State arrays state (N, 49) wheels (N, 4, 13) energy (N, 24) zone_crushed (N, 8) LiDAR scan, optional ray casts from the new poses lidar() The thread pool runs all environments at the same time. One environment uses one thread. The result does not change with the number of threads.
One call of step: each environment reads its command row and runs 12 physics steps on one thread of the pool. Open the diagram

Before You Start#

  • Build ACRES Core. Build ACRES Core gives the procedure.
  • Do all commands in the repository root.
  • Put the Python scripts of this page into the repository root. The scripts use "." as the repository root.
  • Start each Python script with PYTHONPATH=Core/Build python <file>.
  • For the ROS 2 part, build the ROS 2 workspace. Build the ROS 2 Workspace gives the procedure.
  • For the comparison with the game, package the game. Build the Game gives the procedure.

The measured values of this page are from the test workstation: an AMD Ryzen 7 9700X with 16 threads.

Run a Batch with a Controller#

This procedure drives 256 environments to 256 goals. A controller in Python computes the commands for all environments at the same time.

  1. Write this script into the file batch_drive.py.

    """Drive 256 Polaris environments to goals with a simple steering law, and measure the speed."""
    import time
    
    import numpy as np
    import acres_core as ac
    
    N = 256
    b = ac.Batch(".", num_envs=N)
    S = {name: i for i, name in enumerate(ac.STATE_COLUMNS)}
    
    # All environments start at one point in field 14. Each goal is on a circle of 50 m around the start.
    start = np.array([-585.0, 75.0])
    bearing = np.linspace(0.0, 2.0 * np.pi, N, endpoint=False)
    goal = start + 50.0 * np.stack([np.cos(bearing), np.sin(bearing)], axis=1)
    b.reset(None, np.tile([start[0], start[1], np.pi / 2, 0.0], (N, 1)))
    
    state = b.state
    arrival = np.full(N, np.nan)
    t0 = time.perf_counter()
    for k in range(600):                                   # 60 s at most
        to_goal = goal - state[:, [S["x"], S["y"]]]
        distance = np.hypot(to_goal[:, 0], to_goal[:, 1])
        alpha = np.arctan2(to_goal[:, 1], to_goal[:, 0]) - state[:, S["yaw"]]
        alpha = (alpha + np.pi) % (2.0 * np.pi) - np.pi    # the angle to the goal, -pi to pi
        # Steer to a point 6 m ahead in the direction of the goal. Turn fully when the goal is behind.
        curvature = np.where(np.abs(alpha) < np.pi / 2, 2.0 * np.sin(alpha) / 6.0, 0.15 * np.sign(alpha))
        curvature = np.clip(curvature, -0.15, 0.15)
        speed = np.where(distance > 2.0, 3.0, 0.0)
        arrival = np.where(np.isnan(arrival) & (distance <= 2.0), k * 0.1, arrival)
        if not np.isnan(arrival).any():
            break
        state = b.step_curvature_speed(curvature, speed)
    wall = time.perf_counter() - t0
    
    simulated = N * state[0, S["time_s"]]
    print(f"{np.isfinite(arrival).sum()} of {N} environments arrived, mean time {np.nanmean(arrival):.1f} s")
    print(f"crushed crop: mean {state[:, S['crop_crushed_m2']].mean():.1f} m2, collisions {int(state[:, S['collision']].sum())}")
    print(f"{simulated:.0f} s of simulation in {wall:.2f} s of wall time: {simulated / wall:.0f} times real time")
    
  2. Run the script.

    PYTHONPATH=Core/Build python batch_drive.py
    

    Expected Result

    All environments arrive. The crop accounts show the crushed soybean of field 14.

    256 of 256 environments arrived, mean time 21.0 s
    crushed crop: mean 108.4 m2, collisions 0
    6861 s of simulation in 1.89 s of wall time: 3636 times real time
    

The loop has three parts: read the state, compute the commands with NumPy, call step_curvature_speed. One call advances each environment by 0.1 s. The batch uses all threads of the processor. The environments do not see each other. Each environment has its own vehicle, its own crop marks and its own ruts.

The speed changes with the number of environments. One environment uses one thread, thus a small batch does not use all threads. bench.py measures the speed without a controller: 440 to 560 times real time on one thread.

Note

Set farm=False in the constructor when the task does not use the crop marks. The step is then faster.

Use the LiDAR#

The function lidar makes scans from the poses after the last step. This procedure stops the Polaris before a tree.

  1. Write this script into the file lidar_stop.py.

    """Drive to a tree and stop with the planar LiDAR scan. Then make one Helios scan."""
    import numpy as np
    import acres_core as ac
    
    b = ac.Batch(".", num_envs=1)
    S = {name: i for i, name in enumerate(ac.STATE_COLUMNS)}
    home = next(p for p in b.places() if p[0] == "spawn-icsc-garage")
    b.reset(None, np.array([[home[2], home[3], np.radians(45.0), 0.0]]))   # heading north-east
    
    ahead = np.r_[0:21, 340:360]                           # the beams within 20 degrees of forward
    stop = False
    for k in range(300):
        ranges, material = b.lidar()                       # planar: (1, 360), beam k is k degrees to the left
        near = ranges[0, ahead]
        nearest = np.nanmin(near) if np.isfinite(near).any() else np.inf
        if k == 0:
            print(f"start: obstacle {nearest:.2f} m ahead of the sensor, material {material[0, ahead][np.nanargmin(near)]}")
        stop = stop or nearest <= 6.0
        state = b.step_curvature_speed(np.zeros(1), np.array([0.0 if stop else 2.0]))
        if stop and abs(state[0, S["speed"]]) < 0.02:
            break
    
    print(f"stopped after {state[0, S['time_s']]:.1f} s, obstacle {nearest:.2f} m ahead of the sensor, "
          f"collision {int(state[0, S['collision']])}")
    
    ranges, material = b.lidar(None, "helios", noise=True, seed=1)
    cloud = ranges.reshape(1800, 32)                       # [column, ring]
    print(f"helios: {np.isfinite(cloud).sum()} returns of {cloud.size} beams, "
          f"terrain {(material == 254).sum()}, vehicle body {(material == 252).sum()}")
    
  2. Run the script.

    PYTHONPATH=Core/Build python lidar_stop.py
    

    Expected Result

    The first scan shows a tree trunk (material 11, bark) at 23.9 m. The Polaris stops 3.9 m before it.

    start: obstacle 23.88 m ahead of the sensor, material 11
    stopped after 17.6 s, obstacle 3.86 m ahead of the sensor, collision 0
    helios: 38649 returns of 57600 beams, terrain 17030, vehicle body 15910
    

The planar pattern has 360 level beams with a range of 30 m. It is the cheap scan for a learner. The Helios pattern is the sensor of the real Polaris with 57600 beams. A NaN range shows that the beam has no return. One thread makes approximately 16,000 planar scans or 90 Helios scans in one second. Python API gives the beam order and the material codes.

The LiDAR of ACRES Core sees the terrain, the buildings, the bins, the trees and the props. It does not see the crops. Limitations lists the differences from the game.

Record an Episode Log and Examine It#

ACRES Core writes the same episode log as the game. The game can show the log later with all cameras. The glossary names this method render-later.

  1. Write this script into the file record_log.py.

    """Record a Core episode into an episode log and examine the file."""
    import numpy as np
    import acres_core as ac
    from mcap.reader import make_reader
    from mcap_ros2.decoder import DecoderFactory
    
    b = ac.Batch(".", num_envs=1)
    b.reset(None, np.array([[150.0, 190.0, 0.0, 0.0]]))    # field 23, heading east into field 24
    b.start_log(0, "core-episode.mcap", {"seed": "1", "note": "headless tutorial"})
    for k in range(300):                                   # 30 s
        b.step_curvature_speed(np.array([0.02 * np.sin(k / 20.0)]), np.array([3.0]))
    b.stop_log(0)
    
    with open("core-episode.mcap", "rb") as stream:
        reader = make_reader(stream, decoder_factories=[DecoderFactory()])
        summary = reader.get_summary()
        for channel_id, count in sorted(summary.statistics.channel_message_counts.items()):
            if count:
                print(f"{count:5d}  {summary.channels[channel_id].topic}")
        last = None
        for _, channel, _, message in reader.iter_decoded_messages(topics=["/sim/agent_states"]):
            last = message
    agent = last.agents[0]
    print(f"step {last.step}: {agent.name} at x {agent.pose.position.x:.2f} m, y {agent.pose.position.y:.2f} m, "
          f"crushed crop {agent.crop_crushed_m2:.1f} m2")
    
  2. Run the script.

    PYTHONPATH=Core/Build python record_log.py
    

    Expected Result

    The log has one state message for each physics step: 3600 messages for 30 s. The file size is 5.9 MB.

        1  /sim/episode
        1  /sim/agents
     3600  /sim/agent_states
     3600  /sim/farm_stamps
        1  /sim/conditions
        1  /sim/shifts
      600  /polaris/vehicle/steering/cmd
      600  /polaris/vehicle/ulc/cmd
        1  /polaris/vehicle/dbw_enabled
    step 3600: polaris at x 232.13 m, y 170.71 m, crushed crop 134.8 m2
    
  3. Optional: examine the file with the ROS 2 tools. This step needs the ROS 2 environment.

    source ROS/Env/setup_env.sh
    ros2 bag info -s mcap core-episode.mcap
    

    Expected Result

    Bag size:          5.7 MiB
    Storage id:        mcap
    Duration:          30.000000000s
    Messages:          8405
    Topic information: Topic: /polaris/vehicle/dbw_enabled | Type: std_msgs/msg/Bool | Count: 1 | Serialization Format: cdr
                       Topic: /sim/agent_states | Type: acres_interfaces/msg/AgentStates | Count: 3600 | Serialization Format: cdr
                       Topic: /sim/farm_stamps | Type: acres_interfaces/msg/FarmStamps | Count: 3600 | Serialization Format: cdr
    
  4. Show the log in the game. The option -CaptureVideo= writes a video of the replay.

    Packaged/Linux/Acres.sh -VehicleDemo -EpisodeReplay=$PWD/core-episode.mcap -EpisodeReplayExit \
        -RenderOffscreen -ResX=1920 -ResY=1080 -CaptureVideo=$PWD/core-episode.mp4
    

    Expected Result

    The Polaris follows the logged poses. The crop of the game goes down where the log has farm contacts. The game stops at the end of the log. The log of the game contains these lines.

    ACRES_REPLAY_LOADED /home/user/ACRES/core-episode.mcap states=3600 agents=1 farm_events=0 farm_stamps=3600 duration_s=29.99
    ACRES_REPLAY_DONE steps=3600
    ACRES_CAPTURE_DONE /home/user/ACRES/core-episode.mp4: 898 frames (29.9 s at 30 fps), 0 dropped
    

The log contains the poses, the wheel states, the drive-by-wire state, the energy ledger and each farm contact. It contains no images. Replay gives the options of the episode replay. Episode Log gives the format.

Run ACRES Core on ROS 2#

The program core_sim of the package acres_core_sim puts ACRES Core behind the three local sockets of the game. The ROS 2 bridge connects to these sockets. A ROS 2 node then sees the same topics and services as with the game. ACRES Core has no camera, thus the camera topics have no messages.

CAUTION

core_sim uses the default ports of the game. Do not start the game and core_sim at the same time with the default ports.

  1. Open a shell with the ROS 2 environment and the DDS loopback fence.

    source ROS/Env/setup_env.sh
    

    Expected Result

    acres_ros: ROS 2 humble | rmw_cyclonedds_cpp | ROS_DOMAIN_ID=77 | ROS_LOCALHOST_ONLY=1
    
  2. Start ACRES Core and the ROS 2 bridge in real time.

    ros2 launch acres_core_sim core_sim.launch.py
    

    Expected Result

    core_sim loads the world in less than 1 s and prints its ports and the spawn pose.

    [core_sim-1] ACRES_CORE_SIM_READY agents=1 control=5600 lockstep=0 rate=1 world_load_s=0.57 utm_origin=500476.935,4480099.755 grid_rotation_deg=0.054092
    [core_sim-1] ACRES_CORE_SIM_AGENT name=polaris sensor_stream=5601 rl_port=5556 x=-38.710 y=-140.513 yaw_deg=90.00
    
  3. Open a second shell with the ROS 2 environment. Measure the rates of the clock, the odometry and the LiDAR.

    source ROS/Env/setup_env.sh
    ros2 topic hz /clock --window 600
    ros2 topic hz /vehicle/odom --window 600
    ros2 topic hz /lidar/points --window 50
    

    Expected Result

    The clock has one message for each physics step. Stop each command with Ctrl+C.

    average rate: 119.999
    average rate: 100.001
    average rate: 10.000
    

    WARNING

    The next step sends drive-by-wire commands on the topic names of the real Polaris. Send them only from a shell with the DDS loopback fence. Without the fence, the real vehicle can move.

  4. Enable the drive-by-wire and command a speed of 2 m/s for 8 s. Read the speed in a third shell.

    ros2 topic pub --once /vehicle/enable std_msgs/msg/Empty "{}"
    timeout 8 ros2 topic pub -r 20 /vehicle/ulc/cmd ds_dbw_msgs/msg/UlcCmd "{cmd: 2.0, cmd_type: 1, enable: true}"
    
    ros2 topic echo --once /vehicle/odom --field twist.twist.linear
    

    Expected Result

    The Polaris moves. After the 8 s the watchdog of the drive-by-wire releases the ULC and the Polaris coasts.

    x: 2.2925995179429064
    y: -0.001748883209188853
    z: 0.01810863605444945
    
  5. Stop the launch with Ctrl+C. Start it again at five times real time.

    ros2 launch acres_core_sim core_sim.launch.py rate:=5
    

    Expected Result

    The rates are five times the rates of step 3: 600 Hz for /clock, 500 Hz for /vehicle/odom and 50 Hz for /lidar/points. At the stop core_sim prints its speed.

    [core_sim-1] ACRES_CORE_SIM_EXIT steps=24539 sim_s=204.492 wall_s=40.899 speedup=5.00 lidar_dropped=0
    

    CAUTION

    The watchdog of the drive-by-wire counts simulation time. A command stays valid for 0.1 s of simulation time. At a rate factor of 5, a controller must send its commands at more than 50 Hz of wall time. The command of step 4 at 20 Hz does not move the Polaris at this rate factor.

  6. Stop the launch. Start it again in lockstep. The world then advances only when a client requests steps.

    ros2 launch acres_core_sim core_sim.launch.py lockstep:=true
    
  7. In the second shell, read the state and request 120 steps, that is 1 s.

    ros2 service call /get_simulation_state simulation_interfaces/srv/GetSimulationState "{}"
    ros2 service call /step_simulation simulation_interfaces/srv/StepSimulation "{steps: 120}"
    

    Expected Result

    The state 2 is "paused". The step service answers after the 120 steps and their sensor data are on ROS 2.

    simulation_interfaces.srv.GetSimulationState_Response(state=simulation_interfaces.msg.SimulationState(state=2), result=simulation_interfaces.msg.Result(result=1, error_message=''))
    simulation_interfaces.srv.StepSimulation_Response(result=simulation_interfaces.msg.Result(result=1, error_message=''))
    
  8. Record an episode log through the ROS 2 service: start, step 5 s, stop.

    ros2 service call /acres/record acres_interfaces/srv/Record "{action: 1, path: /tmp/core-ros.mcap}"
    ros2 service call /step_simulation simulation_interfaces/srv/StepSimulation "{steps: 600}"
    ros2 service call /acres/record acres_interfaces/srv/Record "{action: 2}"
    

    Expected Result

    The last reply gives the number of messages and the length of the log.

    acres_interfaces.srv.Record_Response(result=simulation_interfaces.msg.Result(result=1, error_message=''), path='/tmp/core-ros.mcap', messages=1205, duration_s=5.0)
    
  9. Stop the launch with Ctrl+C.

The table gives the arguments of the launch file.

Name Type Unit Default Description
lockstep bool false Starts with the world held. Advance it with /step_simulation or /simulate_steps.
rate float 1.0 The rate factor of a free run. 1 is real time. 0 runs as fast as the processor permits.
spawn text the ICSC garage place:<id>, utm:<e>:<n>:<heading deg> or enu:<x>:<y>:<yaw deg>. One value for each agent, with commas between them.
polaris_set text empty Polaris parameters as key=value;key=value.
agents text polaris The names of the agents, with commas between them.
sensor_port int 5601 The port of the sensor stream.
rl_port int 5556 The port of the vehicle bridge.
control_port int 5600 The port of the simulator control channel.
lidar bool true The ray-cast Helios at 10 Hz.
lidar_threads int 4 The number of threads that make the scans.
noise bool true The noise of the INS and of the LiDAR.
episode_log path empty Records an episode log from the start.
rviz bool false Starts RViz.
stats_file path empty The bridge writes its transport statistics into this file at the stop.

In a free run, core_sim runs 12 physics steps at most in one pass, as the game does in one frame. When the processor is too slow for the rate factor, the simulation becomes slower. It does not skip time. When the LiDAR threads are too slow, core_sim drops scans and counts them in lidar_dropped. In lockstep it drops no scan.

Lockstep Stepping gives the lockstep procedure for the game. It applies to core_sim without changes. Nodes and Launch Files gives the nodes of the two packages.

Compare ACRES Core with the Game#

The script Core/Scripts/compare_unreal.py drives ACRES Core with the commands that the game got. This procedure sends a recorded log of the real Polaris to the game, then gives the same commands to ACRES Core. The log is grass_diag: 43 s on grass with a start speed of 0.785 m/s.

  1. Start the game with the Polaris, the vehicle bridge and a session log.

    Packaged/Linux/Acres.sh -VehicleDemo -Vehicle=polaris -RlPort=5556 -SessionLog \
        -VehicleOutput=$HOME/acres-sessions/grass_1/session -RenderOffscreen -ResX=1280 -ResY=720
    
  2. In a second shell, send the commands of the log to the game. The start pose is on a grass lane.

    python Core/Scripts/unreal_dbw_replay.py --run grass_diag_20260731_174757 --speed 0.785 \
        --start 203.32,138.01,90 --port 5556 --out $HOME/acres-sessions/grass_1/replay
    

    Expected Result

    The script runs for approximately 46 s and prints a summary. It sent 2475 messages.

     "t_reset_s": 0.1167,
     "t_first_command_s": 0.1167,
     "messages": 2475,
     "speed_mps": 0.785
    
  3. Stop the game. The session folder now contains tractor.csv, which has the pose of each physics step.

  4. Compare ACRES Core with the session.

    PYTHONPATH=Core/Build python Core/Scripts/compare_unreal.py \
        --session $HOME/acres-sessions/grass_1/session \
        --commands $HOME/acres-sessions/grass_1/replay/commands_sent.csv
    

    Expected Result

    session: open loop 43 s, speed RMSE 0.044 m/s (2 s means 0.035; limit cycle std game 0.053, Core 0.042; mean game 1.206, Core 1.210), yaw rate RMSE 0.0010 rad/s, 1 m apart after inf s, 5 m after inf s; apart 0.206 m at 10 s, 0.195 m at 20 s, 0.191 m at 30 s, 0.191 m at 40 s, at most 0.378 m
        windows 2s: n 3, position median 0.164 m, p90 0.174 m (across 0.001 m, p90 0.001; along 0.164 m); heading median 0.03 deg, p90 0.07 deg; speed median 0.031 m/s
        windows 5s: n 3, position median 0.166 m, p90 0.197 m (across 0.002 m, p90 0.005; along 0.166 m); heading median 0.03 deg, p90 0.07 deg; speed median 0.011 m/s
        windows 10s: n 3, position median 0.196 m, p90 0.263 m (across 0.011 m, p90 0.019; along 0.196 m); heading median 0.01 deg, p90 0.08 deg; speed median 0.006 m/s
    

In this run the two simulators stay less than 0.38 m apart during 43 s. The mean speeds are 1.206 m/s and 1.210 m/s. The difference across the track is 0.011 m after 10 s. Almost all of the difference is along the track.

The game does not repeat this run exactly in real time, because the frame times change. Do steps 1 to 3 a second time with a different session folder to measure this effect. The options --session2 and --commands2 then add the difference between the two sessions of the game. In the test record the two sessions of the game were 0.008 m apart at most. Time Stepping and Determinism gives all measured results and their causes.

The script also reads an episode log or a ROS 2 bag of the game with the option --bag. C++ API gives all options.

Next Steps#