|
HoloLib
High-performance holonomic (X-Drive) control library for VEX V5
|
This is the practical companion to the overview. The front page explains what each system does; this page is about how to actually use it on your robot, with the functions you'll call, code you can copy, and the reasoning behind the knobs you'll end up turning.
If you're just getting started, read the setup section top to bottom. After that, treat the rest like a reference, jump to the part you need.
Everything starts with one Chassis object. You give it the four motors, the IMU, and a ChassisConfig describing the robot, and it owns odometry, PID, and every movement from there.
Get the directions right when you build the motors. The two right-side motors are reversed in the example above, that's normal for an X-drive, but it depends on how yours is wired. If a movement runs away in the wrong direction, a flipped motor is the first thing to check.
Then calibrate once at the start of the match, before you try to drive anything:
calibrate() takes a couple of seconds and the robot has to sit still while the IMU settles. Call it in initialize(), not right before a move.
Before you tune anything, know what the controllers are working on. HoloLib runs three independent PID controllers:
| Controller | Controls | Error is measured in |
|---|---|---|
| X | strafing (left/right) | inches |
| Y | forward/back | inches |
| Theta | heading | degrees |
So "X gains" tune your sideways movement and "Y gains" tune your forward movement. On a symmetric X-drive these usually end up close to each other, but they're separate so you can tune them separately if the robot behaves differently strafing than it does driving straight.
You hand each controller its gains with setXGains, setYGains, and setThetaGains. These don't take a single set of numbers, they take a schedule (more on why in Gain scheduling). For now, the short version: each line is {error, {kP, kI, kD, kF, slew}}, and the controller picks gains based on how far it still has to go.
If you don't want to deal with scheduling yet, just give each one a single line with a threshold of 0.0. That's a plain, fixed PID and it's a perfectly good place to start.
There's no shortcut here, you tune by watching the robot and adjusting. The order that saves the most headaches:
turnToHeading(90) as your test.moveDistance(24) and X with strafeDistance(24).For each one, the loop is the same:
The PID controller has a few extra features once you outgrow the basics: sign-flip reset (dumps the integral when you cross the target so it doesn't carry stale windup), an integral limit, a windup range, and a filtered derivative. You usually don't need to touch these, but they're there in PID.h when you do.
Almost every autonomous function takes a MoveParams as its last real argument. This is how you control speed, accuracy, and when a move is allowed to give up:
A few of these earn their keep:
timeout** is your safety net. If a move can't settle (stuck on a wall, bad tune), it ends here instead of hanging your whole routine. Always set one.exitRange** is the accuracy/speed trade. Tighter means more precise but the robot spends longer fussing at the end.earlyExitRange** lets a move end before it fully settles, handy when you want to flow straight into the next move without stopping dead.async** is covered in The motion handler. Leave it true unless you specifically want the call to block.You can also set one MoveParams as the default for everything with setMoveParams(params) so you're not repeating yourself on every call.
These are the autonomous moves. They all share the same shape: pass a target, optionally pass a MoveParams, and the chassis drives there using the gains you tuned. By default they're asynchronous, they get queued and the call returns right away, which is what lets you line several up in a row.
Drives to an (x, y) coordinate. By default it also rotates to face the point as it goes, which keeps the front of the robot pointed where it's headed. Set angleCorrection to false if you want to hold your current heading and just slide over there instead.
It stops correcting heading in the last couple of inches so the robot isn't spinning while it tries to settle on the spot.
Like moveToPoint, but you also tell it the exact heading to finish at. It works the translation and the rotation at the same time, so it arrives in position and pointing the right way, instead of driving there and then turning.
Use this when the next thing you do depends on your heading, lining up on a goal, scoring, handing off into a turn-free path.
These move relative to where the robot is right now, instead of to an absolute field coordinate. moveRelative takes a forward and a sideways distance at once; moveDistance and strafeDistance are just the straight-line shortcuts.
By default they hold your current heading the whole way (holdHeading = true), so the robot tracks straight instead of drifting off-angle. These are great for short, predictable adjustments where you don't want to think in field coordinates.
turnToHeading rotates in place to an absolute heading. turnToPoint rotates in place until the front faces a coordinate, it figures out the angle for you.
turnToPoint is the one you want for aiming, point at a goal or a target before you shoot or score, without having to do the trig yourself. Both wait for the robot to actually stop rotating before they call it done, so you don't fire off the next move while it's still drifting.
A swing turn holds one side of the drive still and drives the other side, so the robot pivots around the locked side instead of spinning around its center. The turn is tighter and it sweeps the robot through an arc, which is useful when you're tucked against a wall or working in a corner.
Drives a smooth circular arc of a given radius until you hit a target heading. You can let it pick the shorter direction automatically, or force clockwise / counter-clockwise.
A bigger radius is a wider, gentler curve; a smaller radius turns tighter. This is how you round a corner without stopping to turn.
This is the big one. You give it a list of points and it follows the whole path smoothly, instead of stopping at each point like a chain of moveToPoint calls.
It works on a lookahead: instead of aiming at the nearest point, it aims at a point a fixed distance ahead on the path. Picture driving by looking down the road a bit rather than at your own bumper, that's what keeps the motion smooth and stops the robot from snapping corner to corner. That lookahead distance is the second argument, in inches.
The headingMode argument decides where the robot points while it drives the path:
HeadingMode::FollowPath** (default), the front follows the direction of travel, so the robot "drives like a car" along the curve.HeadingMode::HoldAngle**, lock to one heading for the whole path and pass the angle in holdAngleDeg. The robot keeps facing one way while it traces the shape, which an X-drive can do and a tank drive can't.HeadingMode::CustomAngles**, use the theta you stored in each PathPoint, so you control the heading point by point.Set reversed = true (the last argument) to drive the path backwards. A couple of practical notes: tuning the lookahead matters, too short and it wobbles trying to hug the line, too long and it cuts corners. Start around 6-10 inches and adjust. And you need at least two points or it won't run.
You can build paths by hand like above, load them from a file with parsePathData(...), or design them in the simulator (tools/sim_auton.py) and paste the result in.
For the opcontrol period, driveControl maps joystick inputs to the drive. The last real argument turns on field-centric mode, where pushing the stick "forward" always moves toward the same end of the field no matter which way the robot is currently facing.
The DriveCurves shape the stick response, a deadzone kills drift near center, and the curve multiplier makes small inputs gentler for fine control while still letting you floor it. There's also an optional DriveCorrection that holds your heading when you let go of the turn stick, so the robot doesn't slowly rotate off course while you're just translating.
Field-centric relies on the IMU heading. If you skipped calibrate() or the IMU drifts, "forward" will drift with it. Pass a headingOffset if you want to redefine which way "forward" points (for example, to match your driver's view from across the field).
If you ever need raw, unfiltered control with no PID in the way, openLoop sends power straight to the wheels.
Every autonomous move runs through a single background queue, the MotionHandler that lives on chassis.motion. This is what makes the async flag work, and it's worth understanding because it changes how you write routines.
When you call a move with async = true, it gets added to a queue and the call returns immediately. A background task pulls moves off the queue one at a time and runs them in order. With async = false, the call blocks and doesn't return until that move is finished.
Why this matters: async moves let you do other things while the robot drives, run an intake, raise a lift, check a sensor, without waiting for the wheels to stop. You stay in control of timing instead of being stuck in a blocking call.
The functions you'll use to manage it:
waitUntilDone()**, wait for everything queued to finish. This is your main tool for "do these moves, then continue."**waitUntil(distance)**, wait until the current move has covered a certain distance, then keep going. Perfect for firing an action partway through a move:
cancelMotion()**, stop the move that's running right now.cancelAllMotions()**, stop the current move and wipe the queue. Good for a hard reset, say, when a sensor says you've grabbed what you came for.getDistanceTraveled()**, how far the current move has gone, in inches (or meters if you ask for it).The handler also exposes isInMotion(), isQueueEmpty(), and a setOnMotionStart() callback if you want to react to moves starting, useful for logging or kicking off a mechanism automatically.
Because moves queue, calling five moves in a row doesn't run them all at once, it lines them up. If you want a move to replace what's running instead of waiting behind it, cancel first.
Odometry is the robot knowing where it is. The naive way, count wheel rotations and add them up, drifts: a wheel slips, a sensor reads noisy, tiny errors stack, and after a few seconds the robot's idea of its position has wandered off from reality. The PoseEKF (Extended Kalman Filter) fixes that by refusing to trust any one sensor on its own.
The filter tracks three numbers, your X, Y, and heading, and runs two steps over and over, a few hundred times a second:
"How much it trusts each source" is the whole game, and it's set by noise values. Process noise is how much you distrust the motion model's prediction; measurement noise is how much you distrust the sensors. The filter weighs them against each other automatically every tick.
The EKF runs by default. You can turn it off and fall back to plain odometry, which is worth doing while you tune everything else:
If you have dedicated tracking wheels (unpowered wheels on rotation sensors), register them, this is the single biggest accuracy upgrade you can give odometry, since they don't slip the way driven wheels do:
To tune the filter's trust, use setEKFGains:
The EKF is the hardest part of the library to tune, and a badly tuned filter is worse than no filter — it'll confidently report the wrong position. If you're not comfortable with noise values yet, run with setEKFstate(false) and come back to it. Get your movements working on plain odometry first.
One PID tune can't be good at everything. Tune it to sprint across the field and it overshoots on small moves. Tune it to settle gently on small moves and it crawls across long ones. Gain scheduling is the way out: instead of one set of gains, you define several, each tied to a range of error, and the controller blends between them as the robot closes in.
Every entry is {threshold, {kP, kI, kD, kF, slew}}. The threshold is an error level, how far you still are from the target, in inches for X/Y or degrees for theta. The controller looks at the current error and:
That interpolation is the important part, there's no jarring handoff where the robot lurches as gains change. The slew rate scales right along with everything else.
You don't have to sort the entries, the scheduler sorts them by threshold for you. The common setup is aggressive gains far from the target and gentle gains as you settle, which gives you one move that accelerates hard at the start and stops clean at the end. You can flip that if you need extra push at low speed to beat static friction near the target, the mechanism doesn't care which way you go, it just interpolates whatever you give it.
Start simple. A single entry at threshold 0.0 is a plain fixed PID. Get that working, then add a second entry once you can clearly see the "too aggressive up
close" or "too lazy far away" problem you're trying to solve. Scheduling is a refinement, not a starting point.
A good driver run is already a decent autonomous routine, you just have to capture it. HoloLib records where the robot goes while you drive, then re-drives that path on its own later.
While you drive a practice run, logReplayData watches the pose and prints a coordinate line every time the robot has actually moved (more than half an inch or a degree, so you're not flooded with duplicates while it sits still):
Drive your run, then grab the printed x, y, theta lines from the terminal. Those points are your path. Call disableReplayDataLogging() when you want to stop.
Turn the points you captured into a vector<PathPoint> and hand them to runDriverReplay. It splits the run into segments wherever you reversed direction and follows each one, so a back-and-forth run plays back as the same back-and-forth:
You can also keep paths in a file and load them with parsePathData(...) instead of pasting them inline.
Replay records the robot's path, not your button presses. It won't fire your intake, lift, or anything else — that's on purpose, to keep the log small. Drive the path with replay, and script your mechanisms separately around it (the motion handler's waitUntil is handy for timing those). Also make sure setPose matches where you started the recording, or the whole run plays back shifted.
Sometimes the straight line to your target runs through something you can't drive over. You tell HoloLib where the obstacles are, turn avoidance on, and your normal moves bend around them instead of plowing through.
Obstacles are circles, a center and a radius, in the same field units as your poses (inches). Drop them in, then switch avoidance on:
With avoidance on, moveToPoint, moveToPose, moveRelative, and followPath all route around the obstacles automatically, you don't call anything different. For something bigger than a clean circle, stack a few overlapping obstacles to cover the shape.
The live avoidance uses an artificial potential field. Think of it as a tug of war every tick: your target pulls the robot toward it like a magnet, and each nearby obstacle pushes the robot away. Add those forces up and you get a direction to drive. HoloLib also adds a sideways "tangential" nudge so the robot glides around an obstacle instead of stalling head-on against it.
You tune that push-and-pull with setPotentialFieldParams:
ka**, how strongly the target pulls. Too low and obstacles win and the robot stalls; too high and it ignores them and clips through.kr**, how strongly obstacles push back. Raise it to give them a wider berth.influenceRadius**, how close the robot has to get before an obstacle starts pushing at all. Outside this distance, obstacles are ignored and the robot just drives normally.If your robot isn't roughly square, give the avoidance math your real footprint with setRobotDimensionsAvoidance(width, height) so the clearances account for your actual size.
Potential fields are reactive — they're built to keep you from crashing into something you didn't fully plan for. For a tight, repeatable autonomous routine you'll usually get cleaner results designing a path that already goes around the obstacle. Treat avoidance as a safety layer, not a substitute for a good path, and budget time to tune kr and the influence radius before you trust it in a match.
There's also detectCollision(), which flags a sudden impact from an acceleration spike, handy as a trigger if you want to react to actually bumping something.