Simulator Design Research
View SourceOverview
This document covers the design of Drone.Adapters.Sim, the simulator adapter for ex_drone. The simulator must be usable without any hardware and must enforce the same safety rules as real adapters.
Why Simulator-First
- Testable without hardware: All tests run on any machine without a drone
- Safe development: Catch safety violations before connecting to real hardware
- Deterministic testing: The simulator's state is exact, enabling reproducible tests
- Mission validation: Run mission scripts in simulation before flying them
- Educational: Students learn and experiment without risk
- Documentation examples: All examples work in simulation
Simulator Architecture
The simulator implements the Drone.Adapter behaviour, replacing UDP communication with an in-process state machine.
Drone.Vehicle (GenServer)
|
v
Drone.Adapter behaviour
|
v
Drone.Adapters.Sim (in-process state machine)No UDP, no network, no external dependencies. The GenServer calls the adapter module directly.
Simulator State
defstruct [
x: 0, # cm from launch point
y: 0, # cm from launch point
z: 0, # cm altitude
yaw: 0, # degrees (0-360)
flying: false,
battery: 100, # percent
speed: 0, # current speed in cm/s
state: :idle, # :idle | :sdk_mode | :flying | :emergency
last_command: nil,
command_history: [],
config: %{} # simulator configuration
]State Transitions
:idle --("command")--> :sdk_mode --("takeoff")--> :flying --("land")--> :sdk_mode
^ | |
+-------("emergency")<-----------------------------+ |
+<-------("emergency")<---------------------+------+-------+
|
:sdk_mode --("emergency")--> :idle:idle-- Drone is powered on but not in SDK mode:sdk_mode-- In SDK mode, accepting commands, but not flying:flying-- In the air, accepting movement and query commands:emergency-- Emergency stop (transitions to:idle)
Position Tracking
The simulator tracks exact position, something real drones cannot do reliably:
takeoff: Setszto 30 (Tello default hover height in cm),flyingto trueup x: Increaseszbyxdown x: Decreaseszbyx(minimum 20)forward x: Calculates new position based on currentyaw, movesxcm in that directionback x: Movesxcm opposite toyawleft x: Movesxcm perpendicular toyaw(left)right x: Movesxcm perpendicular toyaw(right)cw x: Increasesyawbyx(mod 360)ccw x: Decreasesyawbyx(mod 360)land: Setszto 0,flyingto false
Position calculation for forward/back/left/right:
# Forward direction based on yaw (0 = north, increases clockwise)
radians = yaw * :math.pi() / 180
# Forward
new_x = x + distance * :math.sin(radians)
new_y = y + distance * :math.cos(radians)
# Back
new_x = x - distance * :math.sin(radians)
new_y = y - distance * :math.cos(radians)
# Left
new_x = x - distance * :math.cos(radians)
new_y = y + distance * :math.sin(radians)
# Right (opposite of left)
new_x = x + distance * :math.cos(radians)
new_y = y - distance * :math.sin(radians)Wait -- this is a simplified model. The Tello SDK spec defines:
forward/back: movement along the front/back axis (the direction the camera faces)left/right: strafing left/rightup/down: vertical movement
The coordinate system should be:
- X: right (from drone's initial forward direction)
- Y: forward (from drone's initial forward direction)
- Z: up
After rotation, the axes of movement rotate with the drone. But for the simulator's purpose, we can use a simpler model initially:
- Movement commands are relative to the drone's current heading
- We track absolute position (x, y, z) in a fixed frame
- Yaw rotations change the drone's heading
Battery Simulation
Battery should drain to enable testing of low-battery scenarios:
- Default drain rate: configurable percentage per command
- Movement commands drain more than queries
- Takeoff and landing drain a fixed amount
- Battery cannot go below 0
# Per command battery drain (configurable)
# Default: movement commands cost 0.5%, queries cost nothing
# Takeoff costs 2%, landing costs 1%Simulated Failures
The simulator should support configurable failure injection:
%{
failure_rate: 0.0, # 0.0 to 1.0, probability of random failure
fail_commands: [], # list of command types that will always fail
fail_after_n: nil, # fail after N successful commands
failure_pattern: nil # function (command, state) -> :ok | :error
}Failure modes:
{:error, :simulated_failure}-- Random failures based on rate{:error, :command_not_supported}-- Specific command types configured to fail{:error, :simulated_disconnect}-- No response (timeout from client's perspective){:error, :simulated_low_battery}-- Battery drops below threshold
Adapter Implementation
defmodule Drone.Adapters.Sim do
@behaviour Drone.Adapter
@impl Drone.Adapter
def connect(opts) do
state = %Drone.Adapters.Sim.State{
battery: Keyword.get(opts, :battery, 100),
config: %{
battery_drain_per_move: Keyword.get(opts, :battery_drain_per_move, 0.5),
battery_drain_per_takeoff: Keyword.get(opts, :battery_drain_per_takeoff, 2.0),
battery_drain_per_land: Keyword.get(opts, :battery_drain_per_land, 1.0),
failure_rate: Keyword.get(opts, :failure_rate, 0.0),
fail_commands: Keyword.get(opts, :fail_commands, [])
}
}
{:ok, state}
end
@impl Drone.Adapter
def command(state, %Drone.Command{} = cmd) do
# 1. Check if we should simulate a failure
# 2. Validate command against state machine
# 3. Apply command to state
# 4. Drain battery
# 5. Return result and new state
end
@impl Drone.Adapter
def telemetry(state) do
{:ok, %{
x: state.x,
y: state.y,
z: state.z,
yaw: state.yaw,
battery: state.battery,
flying: state.flying,
speed: state.speed
}, state}
end
@impl Drone.Adapter
def disconnect(_state) do
:ok
end
endTesting with the Simulator
Deterministic Mission Tests
test "simulator tracks position correctly" do
{:ok, drone} = Drone.connect(:sim, name: :test_drone)
Drone.connect_sdk(drone) # enter SDK mode
Drone.takeoff(drone)
Drone.move(drone, :forward, 100)
Drone.rotate(drone, :cw, 90)
Drone.move(drone, :forward, 100)
assert %{x: 100, y: 100, z: 30} = Drone.telemetry(drone)
endSafety Validation Tests
test "simulator respects max altitude" do
{:ok, drone} = Drone.connect(:sim,
name: :test_drone,
safety: [max_altitude_cm: 50]
)
Drone.connect_sdk(drone)
Drone.takeoff(drone)
assert {:error, :safety, :max_altitude} = Drone.move(drone, :up, 50)
endFailure Injection Tests
test "handles simulated command failure" do
{:ok, drone} = Drone.connect(:sim,
name: :test_drone,
failure_rate: 1.0 # always fail
)
Drone.connect_sdk(drone)
assert {:error, :simulated_failure} = Drone.takeoff(drone)
endMission Replay
The simulator should support recording and replaying command sequences:
# Record a mission
{:ok, drone} = Drone.connect(:sim, record: true)
Drone.takeoff(drone)
Drone.move(drone, :forward, 100)
Drone.land(drone)
history = Drone.Adapters.Sim.get_history(drone)
# Replay
{:ok, drone2} = Drone.connect(:sim, replay: history)
# Mission executes automaticallyThis is useful for:
- Testing mission scripts deterministically
- Debugging failed missions
- Creating test fixtures
- Educational demonstrations
Differences Between Sim and Real Adapters
| Aspect | Sim | Tello (Real) |
|---|---|---|
| Communication | In-process | UDP over Wi-Fi |
| Timing | Instant | Real-time (seconds) |
| Position | Exact | Estimated |
| Battery | Mathematical model | Real sensor |
| Failures | Configurable | Unpredictable |
| State transitions | Same state machine | Same state machine |
| Safety rules | Same pipeline | Same pipeline |
The key insight is: the same safety pipeline, command pipeline, and state machine run regardless of the adapter. The adapter only handles communication and response parsing. This means:
- Safety code is tested once, works everywhere
- Missions are tested in sim, fly on real hardware
- State tracking is adapter-independent
Telemetry Snapshots
The simulator should be able to produce telemetry snapshots at any point:
{:ok, telemetry} = Drone.telemetry(drone)
# => %{x: 0, y: 100, z: 30, yaw: 90, battery: 85, flying: true, speed: 50}These snapshots enable:
- Assertions in tests
- Logging and debugging
- Dashboard displays
- Safety validation (checking pre-conditions)
Summary
The simulator must:
- Implement
Drone.Adapterbehaviour - Track position (x, y, z, yaw)
- Track state (idle, sdk_mode, flying, emergency)
- Track battery (with configurable drain)
- Support state machine transitions matching Tello
- Support configurable failure injection
- Support mission recording and replay
- Produce telemetry snapshots
- Enforce the same safety rules as real adapters
- Enable fully deterministic, hardware-free testing
See also
- Guide: Simulator
- Further reading: Geometry and frames