Examples¶
Each example on this page is the whole of src/main.py. Copy one, change
the port constants at the top to match your robot, and run it.
They all have the same four parts:
from marlin import Robot, Thruster # 1. what you need
THRUSTER = "M1" # 2. which ports your hardware is on
robot = Robot()
robot.configure(
sensors={},
effectors={THRUSTER: Thruster},
)
def driver(robot): # 3. what the robot does, each phase
while robot.running:
...
robot.run(driver=driver) # 4. hand the file to MARLIN
Every example below changes only part 3. Parts 1, 2, and 4 differ just enough to declare the hardware that example uses.
If you have not built a whole robot program yet, read Complete robot program first. It grows one file from a single thruster to both match phases, one step at a time. Come back here for individual patterns to drop into it.
| Example | What it gets you |
|---|---|
| Single thruster driver control | The robot moves when you push the stick |
| Tank drive | Driving and turning with two sticks |
| Dead zone | The robot stops drifting when you let go |
| Speed limit for fine control | A slow mode for lining up precisely |
| Servo on button press | An arm that toggles once per press |
| Trigger and D-pad control | Using the triggers and the D-pad |
| Timed autonomous movement | The robot does something on its own |
| Autonomous step sequence | A multi-step auton routine |
| Gyro turn estimate | Turning by roughly a set angle |
| Beacon seeking | Homing in on the Beacon during auton |
| Using two examples at once | Combining any of the above |
Single thruster driver control¶
from marlin import Robot, Thruster
THRUSTER = "M1"
robot = Robot()
robot.configure(sensors={}, effectors={THRUSTER: Thruster})
def driver(robot):
while robot.running:
robot.thruster(THRUSTER).set_duty(robot.controller.joystick("ONE").value)
robot.run(driver=driver)
Joystick ONE is the left stick's up and down axis. .value gives -100 to 100,
which is exactly the range set_duty() wants, so no conversion is needed.
You should see: pushing the left stick forward spins the thruster, pulling it back reverses it, and centring the stick stops it.
Tank drive¶
from marlin import Robot, Thruster
LEFT = "M8"
RIGHT = "M1"
robot = Robot()
robot.configure(
sensors={},
effectors={LEFT: Thruster, RIGHT: Thruster},
)
def driver(robot):
while robot.running:
left = robot.controller.joystick("ONE").value # left stick, up/down
right = robot.controller.joystick("THREE").value # right stick, up/down
robot.thruster(LEFT).set_duty(left)
robot.thruster(RIGHT).set_duty(right)
robot.run(driver=driver)
You should see: both sticks forward drives the robot straight, both back reverses it, and one forward while the other is pulled back spins it in place.
Each stick owns one side of the boat and nothing is mixed. A stick reads -100 to
100 and set_duty() takes -100 to 100, so each value goes straight through with
no arithmetic of your own and nothing to clamp.
Turning costs you both sticks, which is the trade-off. Some robots use arcade drive instead, where one stick sets speed, another sets turn, and the two are combined before they reach the thrusters. Tank drive keeps each side independent, which is easier to reason about when something misbehaves.
If you later compute a duty rather than passing a stick straight through, see Range clamping for what happens past the ends of the range.
Dead zone¶
A stick that does not sit perfectly centred reports a small value when nobody is touching it, and the robot creeps. Treat small values as zero:
from marlin import Robot, Thruster
THRUSTER = "M1"
robot = Robot()
robot.configure(sensors={}, effectors={THRUSTER: Thruster})
def driver(robot):
while robot.running:
power = robot.controller.joystick("ONE").value
# abs() ignores the sign, so -3 and 3 both count as small.
if abs(power) < 5:
power = 0
robot.thruster(THRUSTER).set_duty(power)
robot.run(driver=driver)
You should see: the robot holds still with the stick released, and starts moving as soon as you push past a light touch.
Raise the 5 if the robot still creeps. Lower it if the stick feels dead near
the centre.
Speed limit for fine control¶
Multiplying the joystick value scales the whole range at once. Hold a trigger for precise, slow movement:
from marlin import Robot, Thruster
THRUSTER = "M1"
robot = Robot()
robot.configure(sensors={}, effectors={THRUSTER: Thruster})
def driver(robot):
while robot.running:
power = robot.controller.joystick("ONE").value
if robot.controller.button("LEFT_TRIGGER").is_down:
power = power * 0.4 # 40% of normal speed while held
robot.thruster(THRUSTER).set_duty(power)
robot.run(driver=driver)
You should see: the robot drives normally. While you hold the left trigger it moves noticeably slower, even at full stick.
Scaling by 0.4 maps the stick's full -100..100 travel onto -40..40. The whole
range of the stick still does something. It just moves the robot more slowly.
Multiplying is the right tool here rather than clamping. Clamping would throw
away everything past 40 and leave most of the stick's travel dead.
Servo on button press¶
from marlin import Robot, Servo
ARM = "M2"
robot = Robot()
robot.configure(sensors={}, effectors={ARM: Servo})
def driver(robot):
previous_a = False
is_open = False
while robot.running:
current_a = robot.controller.button("A").is_down
# True only on the loop where the button went from up to down.
if current_a and not previous_a:
if is_open:
is_open = False
robot.servo(ARM).angle(0)
else:
is_open = True
robot.servo(ARM).angle(90)
previous_a = current_a
robot.run(driver=driver)
You should see: each press of A swings the servo to the other position and it stays there. Holding A down does nothing more.
is_down is a level, not an event. Without the previous_a comparison, a
single half-second press would toggle the servo dozens of times.
is_open remembers which position the servo is in, because the servo itself
cannot tell you.
Trigger and D-pad control¶
The triggers and the D-pad are buttons, read exactly like A and B:
from marlin import Robot, Servo, Thruster
LEFT = "M8"
RIGHT = "M1"
ARM = "M2"
robot = Robot()
robot.configure(
sensors={},
effectors={LEFT: Thruster, RIGHT: Thruster, ARM: Servo},
)
def driver(robot):
while robot.running:
# D-pad up and down nudge the robot at a fixed, slow speed.
forward = 0
if robot.controller.button("UP").is_down:
forward = 25
if robot.controller.button("DOWN").is_down:
forward = -25
robot.thruster(LEFT).set_duty(forward)
robot.thruster(RIGHT).set_duty(forward)
# Triggers hold the arm at one of two positions.
if robot.controller.button("RIGHT_TRIGGER").is_down:
robot.servo(ARM).angle(120)
else:
robot.servo(ARM).angle(0)
robot.run(driver=driver)
You should see: the D-pad nudges the robot slowly forward and back, and the arm sits at 120° while you hold the right trigger and snaps back to 0° when you let go.
Timed autonomous movement¶
import time
from marlin import Robot, Thruster
LEFT = "M8"
RIGHT = "M1"
robot = Robot()
robot.configure(sensors={}, effectors={LEFT: Thruster, RIGHT: Thruster})
def autonomous(robot):
start = time.monotonic()
while robot.running:
elapsed = time.monotonic() - start
if elapsed < 2.0:
robot.thruster(LEFT).set_duty(30)
robot.thruster(RIGHT).set_duty(30)
else:
robot.thruster(LEFT).set_duty(0)
robot.thruster(RIGHT).set_duty(0)
robot.run(autonomous=autonomous)
You should see: on Start Auton, the robot drives straight for two seconds, then stops and holds still until the period ends.
time.monotonic() returns seconds as a decimal number. Take one reading before
the loop, then subtract it inside the loop to get elapsed time.
The Autonomous Period is 20 seconds
A routine whose deadlines add up past 20 seconds never finishes. And in a Qualification Match your Alliance partner's robot is in the Pool running its own routine at the same time, so do not assume you have the Pool to yourself. See Alliance.
Do not use time.sleep() to time a movement, such as time.sleep(2) to drive
for two seconds. While your code is sleeping it cannot read a sensor, react to
the controller, or change what the robot is doing. Measure elapsed time with
time.monotonic() instead, as above. Your loop then keeps running throughout.
Your loop needs no wait of its own at all: reading robot.running paces it to
fifty times a second, matching the rate MARLIN talks to the robot. See
running.
Autonomous step sequence¶
Several timed steps in a row follow the same shape: compare the elapsed time against a growing list of deadlines.
import time
from marlin import Robot, Thruster
LEFT = "M8"
RIGHT = "M1"
robot = Robot()
robot.configure(sensors={}, effectors={LEFT: Thruster, RIGHT: Thruster})
def drive(robot, left, right):
robot.thruster(LEFT).set_duty(left)
robot.thruster(RIGHT).set_duty(right)
def autonomous(robot):
start = time.monotonic()
while robot.running:
elapsed = time.monotonic() - start
if elapsed < 2.0:
drive(robot, 30, 30) # forward
elif elapsed < 3.0:
drive(robot, 30, -30) # turn
elif elapsed < 5.0:
drive(robot, 30, 30) # forward again
else:
drive(robot, 0, 0) # stop and hold
robot.run(autonomous=autonomous)
You should see: forward for two seconds, a one-second turn, forward again for two seconds, then stop. That is five seconds of movement inside the 20-second period.
The deadlines are cumulative: the turn runs from 2.0 s to 3.0 s, so it lasts one
second. The drive() helper keeps each step to a single readable line.
Gyro turn estimate¶
Advanced
This example uses maths on a sensor reading, and the result is only an estimate. Get the earlier examples working first. A timed turn, like the one in Autonomous step sequence, is simpler and often good enough.
MARLIN's IMU has no heading, bearing, or compass field. What it does report is
gyro_z, a rotation rate in degrees per second. To estimate how far the robot
has turned, integrate gyro_z over time: multiply each loop's rate by the time
since the last loop (dt) and add it to a running total.
The units line up on purpose: gyro_z is in degrees per second, and
time.monotonic() returns seconds, so gyro_z * dt gives you degrees. If you
swap in a timer that reports milliseconds (like time.time() * 1000) without
converting, the math still runs but the result is wrong.
Note that imu() is a method here, not a property - it needs the trailing
(). If you forget it, you'll get a confusing AttributeError instead of an
ImuReading; see Troubleshooting
if that happens to you.
import time
from marlin import IMU, Robot, Thruster
LEFT = "M8"
RIGHT = "M1"
IMU_PORT = "S1"
robot = Robot()
robot.configure(
sensors={IMU_PORT: IMU},
effectors={LEFT: Thruster, RIGHT: Thruster},
)
def autonomous(robot):
turned_deg = 0.0
last_time = time.monotonic()
while robot.running:
now = time.monotonic()
dt = now - last_time # seconds, since time.monotonic() counts in seconds
last_time = now
# imu() is a method, not a property, so the () is required.
reading = robot.imu(IMU_PORT).read()
if not reading.ok:
robot.thruster(LEFT).set_duty(0)
robot.thruster(RIGHT).set_duty(0)
continue
turned_deg += reading.gyro_z * dt
if turned_deg >= 90:
robot.thruster(LEFT).set_duty(0)
robot.thruster(RIGHT).set_duty(0)
else:
robot.thruster(LEFT).set_duty(25)
robot.thruster(RIGHT).set_duty(-25)
robot.run(autonomous=autonomous)
You should see: the robot spins in place until it has turned roughly a quarter circle, then stops. Expect it to overshoot or undershoot, because this is only an estimate.
This estimate drifts: small errors in each sample accumulate every loop, so
turned_deg slowly wanders away from the robot's true turn. It is not a
compass reading and it does not reset itself. Use it only to estimate a single
short turn, not to track heading over a whole match.
Beacon seeking¶
The Beacon is a Link Lockdown Scoring Element: a yellow float that broadcasts a radio signal. The game manual describes exactly this approach. Navigate to the Beacon by moving in the direction where the signal gets stronger. See Game Manual.
Do not assume the Beacon holds still. It is untethered by default, and the Event Partner may or may not weight it down, so it can drift during your auton. A routine that drives to a memorised position will miss it; seek the signal every loop.
RadioBeacon reports signal strength (rssi) in dBm. It is a negative number:
-60 is a stronger signal than -90. It is not a distance, so use it only to
tell "stronger" from "weaker".
from marlin import RadioBeacon, Robot, Thruster
LEFT = "M8"
RIGHT = "M1"
BEACON = "S6"
robot = Robot()
robot.configure(
sensors={BEACON: RadioBeacon},
effectors={LEFT: Thruster, RIGHT: Thruster},
)
def autonomous(robot):
while robot.running:
beacon = robot.radiobeacon(BEACON).read()
if not beacon.ok:
# No fresh reading: hold still rather than spin blind.
robot.thruster(LEFT).set_duty(0)
robot.thruster(RIGHT).set_duty(0)
elif beacon.rssi >= -80:
# Strong enough: stop turning.
robot.thruster(LEFT).set_duty(0)
robot.thruster(RIGHT).set_duty(0)
else:
# Weak: spin in place and keep looking.
robot.thruster(LEFT).set_duty(30)
robot.thruster(RIGHT).set_duty(-30)
robot.run(autonomous=autonomous)
You should see: the robot spinning in place while the signal is weak, and stopping once it is pointed somewhere the signal reads stronger than -80 dBm.
Check beacon.ok first. A missing reading reports rssi as 0.0, which would
otherwise look like the strongest possible signal.
Using two examples at once¶
Each example replaces the body of one callback. To combine two, put both bodies
in the same loop and declare every port they use in one configure() call.
Here are tank drive, the dead zone, and the servo toggle in one file:
from marlin import Robot, Servo, Thruster
LEFT = "M8"
RIGHT = "M1"
ARM = "M2"
robot = Robot()
robot.configure(
sensors={},
effectors={LEFT: Thruster, RIGHT: Thruster, ARM: Servo}, # every port, once
)
def driver(robot):
previous_a = False
is_open = False
while robot.running:
# --- from Tank drive ---
left = robot.controller.joystick("ONE").value
right = robot.controller.joystick("THREE").value
# --- from Dead zone ---
if abs(left) < 5:
left = 0
if abs(right) < 5:
right = 0
robot.thruster(LEFT).set_duty(left)
robot.thruster(RIGHT).set_duty(right)
# --- from Servo on button press ---
current_a = robot.controller.button("A").is_down
if current_a and not previous_a:
is_open = not is_open
robot.servo(ARM).angle(90 if is_open else 0)
previous_a = current_a
robot.run(driver=driver)
Three things to watch when you merge:
- Variables that live across loops, such as
previous_aandis_open, go above thewhile, not inside it. Put them inside and they reset every pass, which stops the press detection from working. - Declare every port once. One
configure()call lists all the hardware the merged file uses. - Do not command the same effector twice in one pass. If two examples both
call
set_duty()onLEFT, the last call wins and the first one silently does nothing. Combine the values into one call instead, the way the dead zone adjustsleftbefore it reachesset_duty().
Combining an autonomous example with a driver example is easier: they are
separate functions, so keep both and pass both to run():
What to read next¶
Complete robot program builds all of this into one file,
one step at a time, and adds an IMU and an initialize check.
For the rules behind these patterns, read Effector commands and safety and State and sensor data.