Motivation
As an FRC coach and mentor, I wanted a guide to shooting on the move that a student who has never done it before could follow from start to finish. So it starts from the parts of the robot and builds the maths up as it goes, and the diagrams are interactive where that helps.
The tutorial has four parts:
- The parts, and how they fit: the turret versus the hood, and the loop connecting them.
- Aiming the turret: an exact formula, derived from scratch.
- Choosing the shot: where the clean answers run out.
- Four refinements, and the one measurement that decides them.
Terms with a dotted underline have definitions: hover over them, tap them, or tab to them. There is also a full glossary at the end.
Groundwork: how the pieces fit together
Everything below rests on one distinction, so it is worth nailing down before any maths. A shooting robot aims with two separate mechanisms, and the two halves of the problem map onto them exactly one-to-one.
The roadmap, and why the two jobs get tangled
A robot shooting while driving has two independent jobs, owned by two different mechanisms, with two completely different characters.
The first job is pointing the turret, and it is pure geometry. There is no physics in it at all, just where things are and how fast they're moving. It has one exact answer, a short two-term formula, which I derive in full in Part One.
The second job is picking the hood angle and the flywheel speed, and that one is physics: air, spin, energy transfer, a ball that squishes. There is no exact answer here, only a tradeoff between trusting measurements and trusting a model. That tradeoff is what Part Two works through.
A useful way to hold the whole document is that there are three things in play. The situation, meaning where you are and how you are moving, is the question. The turret answers which way, with geometry. The shooter answers how hard, with physics. And the shooter's answer needs two separate pieces of knowledge, which is why Part Two runs longer than Part One: what the mechanism does, and what the ball does after it leaves.
The two are constantly discussed as one tangled subject because both jobs get called "aiming". They are really different problems, and separating them gives you most of the clarity for free.
How motion is described: speed, velocity, acceleration
Speed, velocity and acceleration
These are three words that get used interchangeably in ordinary talk and mean strictly different things here.
| term | what it tells you | units | can be negative? |
|---|---|---|---|
| speed | how fast: magnitude only, direction discarded | m/s | no |
| velocity | how fast and which way | m/s | its components can |
| acceleration | how fast the velocity is changing | m/s² | yes |
Speed is simply the size of velocity with the direction thrown away. A speedometer reads 30 whether you are heading up the field or reversing down it; that is a speed. An arrow on a map is a velocity.
The word that misleads is "acceleration". It does not mean "speeding up". It means any change in velocity, and velocity includes direction. So speeding up is acceleration, slowing down is acceleration, and turning at perfectly constant speed is also acceleration. A robot circling the goal at a steady 3 m/s has an unchanging speed and a continuously changing velocity. It is accelerating the whole way round. That acceleration is what you feel pushing you sideways in a car on a roundabout.
Each is the rate of change of the one before, and the rotational versions run exactly parallel, which is why they keep appearing in pairs below:
position ──d/dt──▶ velocity ──d/dt──▶ acceleration m m/s m/s² angle θ ──d/dt──▶ ang. velocity ω ──d/dt──▶ ang. acceleration α rad rad/s rad/s²
So how is a velocity actually written down? In two numbers, because it is two-dimensional. But there are two different pairs you can use, and speed is one number of one pair, never a component of the other.
components v = (vx, vy) both m/s — neither one is the speed polar v = (|v|, θ) the speed, and a direction as an angle
Direction in two dimensions takes exactly one number, which is why both forms need exactly two. The animation in Part Two shows the same velocity in both: (2.41, 0.97) m/s is (2.60 m/s, 22°), since √(2.41² + 0.97²) = 2.60 and atan2(0.97, 2.41) = 21.9°, the heading the slider is set to. Converting runs both ways:
|v| = √(vx² + vy²) vx = |v|·cos θ θ = atan2(vy, vx) vy = |v|·sin θ
There is a third form worth recognising, because the turret code uses it: a magnitude times a unit vector, an arrow of length one that carries direction alone. r = d · r̂ is exactly that, and it is why v∥ = v · r̂ needs no trigonometry. Dotting with a unit vector pulls out how much of a vector lies along it.
That little dot has a name. v · r̂ is a dot product: multiply the matching components and add them up, v · r̂ = vx·r̂x + vy·r̂y, giving one number out of two vectors. When the second one is a unit vector it answers exactly one question: how much of this vector lies along that direction? The answer is signed, and goes negative when the vector leans the opposite way.
Components are what the code sticks to. Angles have to be wrapped at ±180°, and adding two velocities in polar form is genuinely fiddly, whereas in components it is adding two pairs of numbers.
"Differentiate the turret angle", when it comes up later, means taking one step along that chain: you have θ written out as a formula, and you want θ̇.
It also matters for the feedforward, the turret-rate formula this tutorial builds in Part One. θ̇turret = −(ω + v⊥/d) is built entirely from velocities. The moment the robot accelerates, the turret rate it demands is itself changing, and a velocity feedforward has nothing to say about that, so it lags. That is a real limitation of the approach, separate from the 1/d problem, and it is why hard, jerky driving costs you more accuracy than driving that is fast but smooth.
The two splits that organise everything below
"The turret's half" does not mean the turret cancels it
A turret only rotates on the chassis. It cannot move the robot, so it never undoes any velocity. The split is by which subsystem's problem each component creates, and it happens that the sideways component creates rotation problems.
Sideways motion changes where the goal is from your point of view. Slide past a goal and the direction you must point swings round, even though the goal never moved. A changing direction is answered by turning, and turning is what a turret does. Driving straight at a goal, by contrast, changes the direction not at all, so the turret has nothing to do while the range changes underneath you, which is the shooter's to fix.
The same allocation turns up a second time, from a completely different cause. The ball inherits the robot's velocity when it leaves. Split that inherited velocity along the same two directions. The sideways part makes the ball drift left or right of where you pointed, and the turret fixes that by aiming off-target. The toward-or-away part makes it land long or short, and the shooter fixes that by re-solving hood and flywheel.
So each component reaches its subsystem twice over. The tangential half counts twice because your bearing is moving and the ball drifts sideways. The radial half counts twice because your range is changing and the ball carries that speed with it.
A note on the word: v⊥ is not a speed
A speed is a magnitude and can never be negative. v⊥ can. Strictly it is a signed component, the number you get by projecting your velocity onto the sideways direction. It is one number, but it carries a sign, and the sign is doing real work:
- v⊥ positive: you are circling the goal one way, and the turret tracks that way
- v⊥ negative: circling the other way, and the turret tracks the other way
- v⊥ zero: driving straight at or away from the goal, and the term vanishes entirely
If it were an unsigned speed, θ̇turret = −(ω + v⊥/d) would command the same rotation whichever way you drove past, and the turret would swing the wrong way half the time. The same holds for the radial component, and the convention follows from r = target − robotPose, which points from you to the goal: positive means closing on it, negative means receding.
"Tangential velocity" is the usual shorthand. It is fine, so long as you read it as the component of velocity along a stated direction, a single signed number rather than a vector or a speed.
All four component numbers are one operation
The dot product from the primer above now pays off. The field components and the goal components are the same operation four times, differing only in the direction projected onto:
vx = v · x̂ vy = v · ŷ axes bolted to the field v∥ = v · r̂ v⊥ = v · p̂ axes pinned to the goal
So v∥ is not a different kind of thing from vx, and radial is not "the x part" of your velocity. It is the same kind of part, taken along a different direction. Each pair is perpendicular, so each rebuilds the same speed:
vx² + vy² = v∥² + v⊥² = |v|²
The one real difference is that x̂ and ŷ never move, while r̂ and p̂ swivel as you drive, which the animation in Part Two makes visible.
How the two problems connect
They are separate problems, but not independent ones. They're chained together, and the chain loops back on itself, which is the single most important thing to understand before reading further.
The easiest thing to get wrong
There are three mechanisms here, but only two problems, and so two answers rather than three.
It's natural to read "turret, hood, flywheel" as three mechanisms, and therefore three formulas. That isn't how it works out. Hood and flywheel are never worked out separately, because at any distance a high-and-slow shot and a low-and-fast shot both score, so the correct hood angle depends entirely on which speed you picked, and vice versa. There is no hood formula.
What exists is one solver that returns both numbers together as a matched pair. Change one and the other must change too, or you miss. That's why the storage question is about polynomial surfaces and lookup tables, since both store pairs. It's also the entire reason "which pair should we pick?" is a real question, which is what Part Two is about.
Part One: Aiming the turret
This is job one, and it is geometry only. It works out to a formula with exactly two terms.
First, angular velocity
There are two completely different kinds of "fast", and every formula below depends on not confusing them.
- Linear velocity, how fast you get somewhere. Metres per second.
- Angular velocity, also called rotational speed and written ω, how fast you turn. Radians per second.
They're independent: a robot spinning on the spot has zero linear velocity and plenty of angular velocity.
The word for splitting something this way is "decomposition"
It appears throughout this tutorial, and it means something simple: breaking one thing into separate parts that add back up to it.
Rather than "walk diagonally over there", you say "three blocks east, then two blocks north". It is the same journey, but now it is two easy moves instead of one awkward one. The parts must reassemble into the original, and that is what makes it a decomposition rather than merely two facts about the trip.
Two of them run through everything below:
- Velocity splits into radial and tangential: one arrow becomes two, and the two add back to it.
- Motion splits into translation and rotation: any rigid body's movement, however involved, is exactly a slide plus a spin.
The reason it earns its keep is that the parts end up handled by different machinery. The tangential half goes to the turret and the radial half to the shot solver; the rotation gives ω and the translation gives v⊥. You cannot hand two subsystems different halves of a problem until you have split it into halves that genuinely add back up.
The word for that first one is "translation"
It appears throughout this tutorial, and it is the geometry word, nothing to do with language. Translation means sliding without turning. The whole robot shifts, every point on it moves in the same direction at the same speed, and its facing never changes. It comes from Latin trans + latus, "carried across": language translation carries meaning across, and geometric translation carries an object across.
Rotation is its counterpart, and every possible rigid motion is some mixture of exactly the two:
- driving straight ahead, nose forward: translation only
- spinning on the spot: rotation only
- driving while spinning: both at once
That pairing is why the turret formula ends up with exactly two terms: v⊥ comes from the translation and ω from the rotation. And it's worth noting that a tank drive can only translate along the way it faces, so to move sideways it has to turn first. Swerve can translate in any direction while facing any direction, which is precisely why the two can be commanded, and reasoned about, separately on your robot.
That symbol is a Greek omega, not a "w"
It is worth saying, because in most sans-serif fonts ω and w are nearly indistinguishable at body-text size. Every ω in this tutorial is the Greek letter, and it always means the same thing: angular velocity.
You will also see it spelled out in code, and that inconsistency is deliberate:
- Prose and equations use
ω, which is compact, and it's the universal convention across physics and robotics, so every other source you read will match. - Code uses
omega,OMEGA_MAX, or in WPILib's verbose styleangularVelocityRadiansPerSecond. Greek letters are legal in Java identifiers but a bad idea: awkward to type, fragile across editors, and they invite exactly the ω/w confusion this note exists to prevent. Spelling it out and appending the units is the usual FRC convention.
A trap: OMEGA_MAX is uppercase because it is a constant (SCREAMING_SNAKE_CASE is the Java convention), not because it is a capital Ω. Different reason entirely.
Capital Ω is a different symbol, not a louder ω
In this notation, upper and lowercase Greek letters are treated as separate symbols rather than as one symbol in two sizes.
- ω, angular velocity, radians per second. What this tutorial means throughout.
- Ω, electrical resistance, in ohms. An unrelated quantity. Writing
Ω = 3 rad/swould read to anyone in the field as a resistance.
This page already contains a matching pair that proves the rule: σ means a standard deviation in the weighted metric of Part Three, while Σ means "add all of these up" in the integration formula above. Same letter, two cases, entirely different jobs. Δ (a finite change, as in Δt) versus δ (an infinitesimal one) works the same way.
Outside this note, Ω is used nowhere in the document, correctly, since nothing here is electrical. You will meet it elsewhere in FRC though: wire resistance, battery internal resistance, and brownout calculations are all measured in ohms.
Why every point shares one ω, even though they all move differently
Because they all sweep the same angle in the same time. If the robot turns 90°, then every point on it has turned 90°, the one near the axis and the one out at the corner alike. The outer point simply travelled a longer arc to get through that same 90°.
Rigidity is what forces this. The parts can't move relative to each other, so they can't get out of angular step. A minute hand is the everyday version: its tip and a point halfway along both go round once an hour, so they share one ω, but the tip covers far more ground. Likewise everyone on a merry-go-round completes a turn together, while the riders at the edge are moving fastest.
And v is that ground covered: the linear speed of one particular point, in metres per second, which is arc length per second. Since arc = r × angle, dividing both sides by time gives v = r × ω directly. It is the same relationship as Fig 10, used in the opposite direction.
Two different things are called "v" in this tutorial
They are worth separating, because they are genuinely different quantities that happen to share a letter:
- v = ω × r, the speed of a point on the spinning robot, caused by the robot's own rotation. This is the quantity Fig 4 is about. It never appears in the turret formula.
- v⊥ in the turret formula, the motion of the whole robot translating across the field, taking only the part perpendicular to the line of sight. It is a signed number rather than a speed: its sign says which way round the goal you are travelling. It has nothing to do with the robot spinning.
In the turret maths the robot is treated as a single point that has a velocity and a spin rate. Its size, and the speeds of individual bits of it, never enter the calculation at all.
ω itself has no radius in it
v = ω × r is a conversion, not the definition of ω. Angular velocity needs no radius at all: it is simply how many radians per second something turns. Your gyro measures it directly and has no idea how large your robot is. For a rigid body ω is the same about every reference point, which is precisely why the gyro can be bolted anywhere on the chassis and still read the same number.
The radius only appears when you ask about one specific point, and then r is the distance from the axis to that point. It is never "the radius of the object":
- dead centre of a spinning robot:
r = 0, sov = 0 - a point 0.3 m out:
v = 0.3ω - the corner, 0.45 m out:
v = 0.45ω
One ω, three different linear speeds, because three different questions were asked.
In the turret formula, d is not the robot's radius
This is the easiest place to go wrong, because the formula contains a division by a distance and it is tempting to assume that distance is something about the robot. It isn't. There are two separate rotations in play, about two different axes:
| What is rotating | About what axis | Its rate | Radius needed |
|---|---|---|---|
| the chassis | the robot's own centre | ω | none, read straight off the gyro |
| the line of sight | the target | v⊥ ÷ d | d, distance to the target |
The second row is the one that matters. When the robot slides sideways it is, for that instant, orbiting the target, and the radius of that orbit is how far away the target is. Fig 10 draws it literally: the arc is a circle centred on the goal, with the robot out on its rim.
So the robot's own dimensions never enter this formula anywhere. A 30 cm robot and a 90 cm robot, at the same distance and the same speed, need exactly the same turret rate.
The unit zoo
Most of the confusion around angular velocity is really confusion about units. There are three in common use and they all mean the same thing:
| Unit | Reads as | Where you meet it | Scale |
|---|---|---|---|
| rad/s | radians per second | all the maths, ω, gyro output | 1 rad/s ≈ 57 °/s |
| deg/s | degrees per second | dashboards and human-facing readouts | familiar, but never used in formulas |
| RPM | revolutions per minute | flywheel and motor specs | 4000 RPM ≈ 419 rad/s |
Where does 2π come from, if the circumference is 2πr?
That's a fair objection, and the answer is the key to the whole unit. An angle in radians is a ratio, defined as the arc length divided by the radius:
And because it cancels, 2π is the answer for a circle of any size. A coin and a stadium both contain exactly 2π radians in one turn. So 2π and 360 are simply two names for one full revolution, except that 360 is arbitrary (Babylonian, roughly the days in a year, and pleasantly divisible) while 2π falls straight out of the definition.
Why the units in the formula work
Because a radian is metres ÷ metres, it is dimensionless, a pure number carrying no units at all.
Which is exactly what lets the earlier units check come out right: v⊥ ÷ d is (m/s) ÷ m = 1/s, and that is rad/s. The "rad" is free to appear from nowhere precisely because it weighs nothing. It's the same reason arc = r × θ holds only in radians, since that formula is just the definition above, rearranged.
One revolution = 360° = 2π ≈ 6.28 radians. Radians are the native unit for a specific reason: arc = radius × angle is only true in radians. Work in degrees and you end up sprinkling conversion factors through every equation, which is exactly the derivation coming up in Fig 10.
Why the two terms of the formula can be added at all
Check the units on each piece:
ω is in rad/s. · v⊥ ÷ d is (m/s) ÷ m = 1/s = rad/s.
Identical units, so they add. That isn't luck: dividing a linear speed by a radius converts it into an angular velocity, which is just v = ω × r rearranged. The formula is never mixing a rotation with a translation; it's adding two angular velocities that happen to arise from different causes. If your units ever fail this check, the formula is wrong.
And second, what a derivative is
You just met one. ω is a derivative, the rate at which heading changes. Since the whole tutorial runs on derivatives, and the word puts people off far more than the idea deserves, let me set it out plainly.
A derivative is a rate of change: how much the output moves when you nudge the input a little. There are three ways of holding the same idea.
- It's the slope of a graph. A steep curve means a big derivative, and a flat curve means a derivative near zero.
- It's the word "per". Miles per hour. Degrees per second. Centimetres of miss per degree of hood error.
- It's the speedometer. Given a formula for where something is, the derivative tells you how fast it's moving.
θ̇, means specifically "per second". The curly ∂ means "several inputs exist; wiggle this one and hold the rest still".Both halves of this tutorial are derivatives, and in both the derivative is the answer
In Part One you have a formula for where the turret should point. Its derivative is how fast to drive the turret, which is exactly the feedforward. θ̇ reads as "radians of turret per second".
In Part Two the derivative is the robustness score. ∂e/∂θ reads as "centimetres of miss per degree of hood error", so a shot with a small one is a forgiving shot. When you reach the two curves in Fig 21, the fragile one is steep and the robust one is shallow, and you will be looking directly at two derivatives.
In neither case is the derivative an intermediate step on the way to something else. It is the quantity you actually wanted.
Is that just algebra, then?
It's a reasonable thing to assume, and worth heading off, because the answer is no, though for a subtle reason.
Algebra rewrites, while differentiation transforms. Turning 2x + 6 = 10 into x = 2 is a rearrangement: the same statement in different clothes, nothing lost, and you can walk it back. Turning y = x² into 2x is nothing like that, because those two are not equal. You get a brand-new function answering a different question about the original. It no longer tells you the value; it tells you the steepness.
Why it nevertheless looks like algebra
The procedure genuinely is symbol-shuffling: apply the power rule, apply the chain rule, out comes an answer. That impression isn't wrong.
The catch is that those rules are cached results. Somebody did the underlying limit once for xn, got n·xn−1, and wrote it down so nobody would ever have to redo it. Applying pre-computed answers is exactly why it feels mechanical, but the mechanism is a shortcut rather than the definition.
You have already done the real thing
Underneath every rule is one idea, rise ÷ run, as the run shrinks towards nothing, which is the right-hand panel above.
That is precisely what happens in Step 2 of the derivation further down: take a tiny slice of time Δt, work out how much the angle moved, divide by Δt. It isn't a simplified stand-in for calculus. That is the definition, applied directly, and the only thing missing was the name.
The other half of calculus: integration
It's worth meeting now, because both halves are already inside your shooter and it's easier to keep them apart than to untangle them later.
- Differentiate when you have the whole thing and you want the rate. Zoom in. "How fast is this changing right now?"
- Integrate when you have the rate and you want the accumulation. Zoom out. "How much has piled up so far?"
They are inverses of each other, and both work by chopping things into tiny slices. The only difference is what they do with the slice:
differentiate: Δy ÷ Δt divide by the slice integrate: Σ y · Δt multiply by the slice, then add them all up
Where each of them lives in your shooter
The flight model is integration. That twenty-line loop in Part Two is numerical integration: you know the acceleration from gravity, drag and Magnus, and you want position, so you accumulate. vx += a·dt integrates acceleration into velocity, and x += vx·dt integrates velocity into position. That's two integrations chained. Nobody usually labels it that way, but that is exactly what it is.
The robustness metric is differentiation: miss distance in, rate-of-change-per-degree out.
So the pipeline runs integrate, then differentiate. Simulate the flight to find the miss, then take the slope of that miss to find the sensitivity.
Why integration is the hard one, and why it matters here
Differentiation is mechanical, since rules exist that always work. Integration frequently has no closed-form answer at all.
That explains something that comes up later. There is no closed-form range equation once drag is included, because that integral has no elementary solution. The tidy v²·sin(2θ)/g exists only because a vacuum happens to make the integral solvable. Add air resistance and you are forced to step it numerically, which is precisely what the loop does.
One more connection is worth having. Integration always needs a starting value, the "+C" of school calculus. When you integrate acceleration into velocity, that constant is the initial velocity, so the empirically measured exit velocity is literally the constant of integration for the entire flight. Without it, the integration has nowhere to begin.
Let's start with the easy case. The robot is parked. The target is a fixed point on the field. The turret points at it. Nothing changes, nothing moves, and no math is required.
Now drive. Two completely separate things start ruining your aim, and it's worth being very clear that they are separate, because that's the whole insight behind the formula.
Cause 1: the robot's own rotation
The turret is bolted to the chassis, and its angle is measured relative to the chassis. So if the chassis rotates 40° counterclockwise and the turret motor doesn't move at all, the turret has physically swung 40° counterclockwise in the real world, and lost the target.
The fix is obvious. Turn the turret backwards at exactly the rate the robot is turning. If the robot spins at ω radians per second, the turret must spin at −ω. That's the first term, and it's the easy one.
Cause 2: sideways motion, and where the division comes from
This one is less obvious, and it's where the / distanceToTargetMeters in your snippet comes from.
First, split the robot's velocity into two pieces relative to the target:
- Radial is the part moving straight toward or straight away from the target. This changes your distance, which matters enormously for the shot solution (Part Two), but it does not change the direction you're pointing. Driving directly at a target never requires turning.
- Tangential (perpendicular) is the part sliding sideways across the target. This is the piece that forces the turret to sweep.
Both are defined relative to the goal, not to the field and not to your robot
Which means the same physical motion changes category depending on where you happen to be standing:
- goal to the north, you drive north → radial
- goal to the east, you drive north → tangential
- goal to the north-east, you drive north → both
Nothing about your driving changed in those three lines. Your position did.
So the split shifts continuously as you drive. Travel in a straight line past a goal at constant speed and your velocity never changes, yet the decomposition changes the entire way: mostly radial while approaching, purely tangential at the moment you are abreast of it (distance momentarily unchanging, bearing swinging fastest), then mostly radial again as you open up.
Fig 13 shows this happening live. Press play with the spin slider at zero and watch v⊥ swell to a peak as the robot passes the goal and fade at both ends, while the speed slider never moves. It is also why the shot solver takes radial velocity as a continuous input rather than a mode. The question isn't "am I approaching or strafing"; it's a number that slides smoothly between the two as you drive.
Now, how fast must it sweep? Picture the sideways motion as briefly tracing a tiny arc on a circle centred on the target, of radius d. Arc length equals radius times angle, so angle equals arc length over radius:
So does the code compute an arc length? No, it cancels
The arc is scaffolding for the derivation rather than part of the answer. Watch it disappear:
sideways distance in time Δt = v⊥ · Δt ← the "arc" angle swept = arc ÷ radius = (v⊥ · Δt) ÷ d divide through by Δt = v⊥ ÷ d ← Δt gone, arc gone
What the robot actually evaluates each cycle is only this, a subtraction, a length, a projection and a division:
r = target − robotPose // vector to the goal d = |r| // distance v⊥ = part of the chassis velocity perpendicular to r FF = -(omega + v⊥ / d)
There are no arc lengths anywhere.
Which distance is d? The flat one
Everything in that snippet happens in two dimensions, flat on the floor. It is worth saying plainly, because it is easy to reach for the wrong distance and get a turret that is quietly wrong everywhere.
A robot on a floor cannot roll, pitch or fly, so its entire motion is three numbers, (vx, vy, ω), two of translation and one of rotation. That is the chassis decomposition written in coordinates, and it is the reason the turret formula has two terms and no third. WPILib calls this a ChassisSpeeds.
The turret rotates about a vertical axis, so it only ever sees that flat projection. d is therefore the horizontal distance, from the robot to the point on the floor directly beneath the goal, and not the slant range to the hoop. For a goal 2.5 m up and 4 m away across the floor, the slant range is 4.7 m; feed that in and every second term is 15% too small, so the turret consistently under-leads. v⊥ is the floor-plane component for the same reason.
The ball is the exception. The moment it leaves the shooter it is in three dimensions, which is where gravity, drag and Magnus do their work. So the two jobs live in two different planes. The turret's geometry lies flat on the floor, while the shooter's physics stands upright in the vertical slice containing the shot, which is precisely why the shot solver is handed a distance and hands back a hood angle and a flywheel speed.
One honest wrinkle about that arc
The robot doesn't really travel along an arc. It moves in a straight line, which is a chord rather than a curve. The exact angle swept is arctan(v⊥·Δt ÷ d), and the arc formula is the small-angle approximation of it.
That approximation becomes exact only as Δt shrinks to nothing, which is precisely why this ends up a derivative rather than a finite sum. Over a genuine instant, chord and arc are indistinguishable. It is the same "shrink the slice to nothing" idea as Fig 8.
The one place a radius does show up in real code
It isn't in the physics. It's in the gear ratio between the turret motor and the turret ring. You work out the turret's angular velocity in rad/s, then convert it into motor rotations for the controller. That is a mechanical unit conversion sitting downstream of everything here, and it is the only point at which the turret's physical size matters at all.
Put the two together
Add the two effects, flip the sign because the turret has to cancel them rather than follow them, and you have the whole formula:
Both terms are angular velocities, of different things
The two terms aren't an arbitrary pair. Any rigid body's motion splits into exactly two pieces, a translation of some reference point, plus a rotation about that point, and a swerve drive lets you command each independently. The formula's shape is that decomposition applied to aiming.
There's a neater way to read it, though. Both terms describe something rotating:
- ω is the chassis turning underneath the turret.
- v⊥ ÷ d is the line of sight swinging round, from robot to goal.
Note which side of the equation the turret is on. Its own rotation never appears on the right, because it is the thing you are solving for:
| symbol | where it comes from | what is rotating | |
|---|---|---|---|
ω | the chassis's rotation | the chassis | input |
v⊥ ÷ d | the chassis's translation, divided by distance | the line of sight | input |
θ̇turret | what you command the motor | the turret | output |
So the sum isn't "chassis rotation plus turret rotation". Both inputs come from the chassis: its rotation supplies ω directly, and its linear velocity supplies v⊥, which only becomes an angular velocity once divided by d. Translation and rotation are the chassis's only two parts, which is why there is no third term to look for.
Translating the robot doesn't rotate the chassis at all, but it certainly rotates the line of sight. That is a real angular velocity; it simply belongs to an imaginary line rather than a piece of metal. So the turret is never adding "a rotation to a translation". It is adding two angular velocities and cancelling their sum. That's the physical meaning behind the units argument from earlier: dividing by d doesn't just make the units agree, it computes how fast the line of sight swings.
The two panels of Fig 9 are exactly these two rotations, one each.
One ω, many linear velocities
This is worth restating, since it's the idea underneath all of this: angular velocity belongs to the body, linear velocity belongs to a point. A spinning chassis has exactly one ω, but every point on it moves at a different speed, zero at the axis and faster the further out you go, related by v = ω × r as in Fig 4. Both describe the same motion, and they answer different questions about it.
Why every point shares it, and where that stops being true
The reason is rigidity. A solid body cannot change its own internal angles. Pick any two points on the chassis: the distance between them is fixed, so if one rotated faster than the other the angle between them would have to change, which means the metal bent. A minute hand is the everyday case, since tip and midpoint both go round once an hour; for the tip to go faster the hand would have to stretch.
The contrast makes it sharp. A whirlpool has no single ω, because inner water circles faster than outer. That is differential rotation, and rigidity is exactly what forbids it. Sharing one ω is not a property of rotation but a property of being solid.
Which is why "the robot" is the wrong unit here. A turreted robot is two rigid bodies joined at a bearing, so they do not share an ω at all:
chassis points → ω turret points → ω + θ̇_turret
That isn't a quibble, because it is where the formula comes from. The turret's rotation in the world is the sum of the two, and you want that sum to track the bearing:
ω + θ̇_turret = φ̇ = −v⊥/d
θ̇_turret = −( ω + v⊥/d )
The two-term formula is precisely the statement that the turret is a separate rigid body riding on a rotating one. Gyro readings are unaffected by any of this, incidentally, because the gyro is bolted to the chassis: one body, one ω, and the same number wherever you mount it.
"But ω = v ÷ r, so surely it changes with where the point is?"
That's the most natural objection, and the answer is that v and r change together. Take a chassis spinning at 2 rad/s:
| point | r | v = ω·r | v ÷ r |
|---|---|---|---|
| near the axis | 0.15 m | 0.30 m/s | 2.0 |
| middle | 0.30 m | 0.60 m/s | 2.0 |
| corner | 0.45 m | 0.90 m/s | 2.0 |
The denominator grows, but the numerator grows by exactly the same factor. The ratio staying fixed is what sharing one ω means. The reading to avoid is "r varies, so v/r varies"; the right one is "v varies in precisely the way that keeps v/r fixed", and rigidity is what forces it to.
One detail settles it. At the axis r = 0 and v = 0, so v/r is 0/0, undefined, yet the axis is certainly turning at 2 rad/s. So ω = v/r cannot be the definition of ω, or ω would not exist at the centre. It is a way to compute ω from one point's motion. ω belongs to the body; v = ω·r describes how each point expresses it.
The trap worth naming: v ÷ r appears twice here, meaning different things
| what is on top and bottom | behaviour | |
|---|---|---|
ω = v ÷ ra rigid body | a point's speed, and its distance from the axis, locked together | ratio constant · one ω |
v⊥ ÷ dthe line of sight | the chassis sliding, and the distance to the goal, entirely independent | ratio changes constantly |
In the turret formula nothing ties v⊥ to d, since you can be far away and fast, or close and slow. That is exactly why the second term swings about as you drive while the first sits still. Same-looking ratio, opposite behaviour, because of what is on top and bottom.
That is precisely this line:
double turretFFRadiansPerSecond =
-(angularVelocityRadiansPerSecond + tangentialVelocityMetersPerSecond / distanceToTargetMeters);
The same idea, phrase by phrase
Stated compactly, the whole approach is one long sentence with six pieces of jargon in it. Every one of them now has a definition, so let's take it apart:
To compensate for motion, run the turret with a velocity feedforward angular velocity equal and opposite to the rotational speed of the robot plus the tangential velocity of the robot about the target divided by the distance to the target.
| The words | Symbol | What it means |
|---|---|---|
| "to compensate for motion" | — | Keep the turret locked on target while the robot drives and spins. |
| "running the turret with a velocity feedforward" | θ̇ command | Command the turret a speed, worked out in advance, rather than just a position for the PID to chase. |
| "equal (and opposite) to" | the minus sign | Cancel the motion rather than follow it. |
| "the rotational speed of the robot" | ω | Term one, the chassis turning underneath the turret. Fig 9, left panel. |
| "the tangential velocity of the robot about the target" | v⊥ | The sideways part of your velocity, the only part that changes the bearing. Signed: which way round the goal you are going. Fig 9, right panel. |
| "divided by the distance to the target" | ÷ d | Converts that sideways component into an angular rate. This is v = ω × r rearranged. Figs 3 and 5. |
| "seem like a reasonable solution?" | — | Yes, and better than reasonable. See below. |
Assemble those pieces and you get −( ω + v⊥ ÷ d ), the formula above, and the line of code shown earlier. Stripped of jargon, the question is simply: "should I spin the turret backwards at exactly the rate the world appears to rotate around it?"
Answering your first question
Yes, and it's stronger than "reasonable". This isn't an approximation or a heuristic that happens to work. Derive the turret angle as a function of robot pose and differentiate it, and this is exactly what falls out. There is no better answer to give the turret. It's the correct closed form, and I find that satisfying.
What "feedforward" means, if you're new to it
A feedback controller (a PID) watches the error and reacts after the turret has already fallen behind. A feedforward is a prediction you add on top: "I know the robot is spinning at 2 rad/s, so I know the turret needs −2 rad/s before any error even appears." Feedforward handles the part you can predict, and the PID mops up whatever the model got wrong. You want both, since the FF alone will drift and the PID alone will always lag.
Where the formula actually comes from
Calling the formula "exact" is a strong claim, so let me show you the machinery behind it. It is a two-step recipe, and both steps are less intimidating than their names.
Step 1: write the turret angle as a function of robot pose
Pose is just the three numbers that say where the robot is: x, y, and heading θrobot. "As a function of pose" means writing an equation whose inputs are those three numbers and whose output is the turret angle.
Doing that is pure angle bookkeeping. The turret has to point along the bearing to the target, but turret angles are measured relative to the chassis rather than the field, so you subtract the robot's own heading:
atan2 is nothing more exotic than "what direction is that point from me".Why you subtract the heading: field-centric versus robot-centric
It helps to dwell on that subtraction for a moment, because it is a frame conversion, and frames are where most sign bugs live. There are two ways to describe any direction on a field:
- Field-centric (also called field-relative), measured from the field. "Downfield" points the same way no matter how the robot is turned. Like a top-down video game.
- Robot-centric, measured from the robot. "Forward" means wherever the nose happens to be pointing right now. Like driving a car.
(3.00, 0.00) into (2.30, −1.93). Neither is more correct; they answer different questions. Mixing them up in one equation is the classic robot-code bug.Where the numbers actually come from
The formula wants v⊥, ω and d. Nothing on the robot measures any of them directly; each is assembled from sensors, and knowing how is what makes the failure modes predictable.
The velocity arrives in three steps. Every swerve module reports two things: a drive encoder giving that wheel's ground speed, and a steering encoder giving which way the module points. Speed plus direction is a small velocity vector, polar form, exactly as above. Forward kinematics then folds all four of those, together with the known geometry of where the modules sit, into three numbers for the whole chassis: (vx, vy, ω). WPILib spells this SwerveDriveKinematics.toChassisSpeeds(...), and note that ω drops out of the very same calculation, because modules disagreeing slightly with one another is what rotation looks like from the wheels' point of view.
That result is robot-relative, where x means "out the front of the robot". The turret formula needs field-relative, so the last step rotates it by the gyro heading, which is precisely what getLatestMeasuredFieldRelativeChassisSpeeds() is doing in code like the snippet below. Skipping it is the bug discussed further down.
drive encoders ─┐ steer encoders ─┼─▶ kinematics ─▶ (vx, vy, ω) robot-relative ─▶ + gyro ─▶ field-relative v module geometry ─┘
The distance comes from somewhere else entirely: the pose estimator, which is odometry from those same wheels fused with vision of the field markers, differenced against the goal's surveyed position.
Three things spoil it, and all three matter more the harder you drive. The velocity is measured rather than commanded; you could feed in the velocity you asked for, but the robot lags its own command, which is why the method name says Measured. Wheel slip breaks the whole chain, because encoders report wheel rotation rather than ground motion: spin the wheels and the robot confidently believes it is moving when it is not. And gyro drift corrupts the last step, rotating a perfectly good robot-relative velocity into a wrong field-relative one.
The driver's-eye version
Driving robot-centric is like driving a car. Push the stick forward and the robot goes wherever its nose points, so if it's facing you, left and right feel reversed.
Driving field-centric is like a top-down video game. Push the stick away from you and the robot travels away from you, whatever direction it happens to be facing. The robot works out the conversion internally. That's why nearly every swerve team drives field-centric.
Why this decides the shape of the formula
Look again at θturret = φ − θrobot. The bearing φ is field-centric, since it's a direction on the field. But the turret is bolted to the chassis, so the angle you command it is robot-centric. Subtracting the heading is literally the conversion from one frame to the other, a change of address rather than a fudge factor.
The same requirement shows up in the code: getLatestMeasuredFieldRelativeChassisSpeeds() is used because v⊥ has to be compared against φ, a field-frame angle. Feed it robot-relative speeds and you'd be mixing frames, the very mistake the suspected bug below is an example of.
There is one convenient exception: ω is the same in both frames. How fast you're spinning doesn't depend on which way you measure from, which is why the rotation term needs no conversion at all while the velocity term does.
Step 2: differentiate it
Differentiating means finding the rate of change, the same operation as going from a formula for position-over-time to a formula for speed.
That is exactly the bridge needed here. The feedforward wants a velocity; Step 1 produced a position. Differentiating converts one into the other. Nothing else in this tutorial depends on knowing how to do it by hand, but here's the argument with no calculus at all. Over a tiny slice of time Δt, two things happen:
- The chassis rotates by
ω·Δt. Since the turret angle is measured from the chassis, that subtracts directly. - The robot slides sideways by
v⊥·Δt, which swings the line of sight by(v⊥·Δt) ÷ dradians, the arc argument from Fig 10. And it swings backwards: move to the left, and the target appears to drift to the right.
So Δθturret = −ω·Δt − (v⊥/d)·Δt. Divide both sides by Δt and you have the rate:
The same thing with the calculus written out
Differentiate θturret = atan2(ry, rx) − θrobot, where r = T − P is the robot-to-target vector.
The second term is immediate: d/dt(θrobot) = ω, contributing −ω.
For the first, the standard result is d/dt·atan2(ry, rx) = (rxṙy − ryṙx) / d². The target is bolted to the field, so only the robot moves and ṙ = −v. Substituting:
= −(rxvy − ryvx) / d²
That numerator is the cross product r × v, which equals d · v⊥, the length of r times the perpendicular component of v. So the whole term collapses to −d·v⊥/d² = −v⊥/d, and
θ̇turret = −v⊥/d − ω = −(ω + v⊥/d)
Note where v⊥ is defined as the component of v along r̂ rotated by +90°. Getting that rotation backwards is precisely the suspected bug discussed below.
See it move
Drive the robot past the target and watch the two terms trade off. Notice that v⊥ peaks when the robot is directly abreast of the target and falls to zero at the far ends, where the motion is almost purely radial. Notice too how the total demand spikes when d gets small.
A frame-mixing bug worth knowing about
So the formula is right. But a correct formula fed a wrong input is still a wrong turret command, and there is one mistake in computing v⊥ that is easy to make and hard to spot. It is worth walking through, because it fails quietly rather than obviously. Here is the version with the bug in it:
var targetFrameToRobot = new Translation2d(robotSpeeds.vxMetersPerSecond, robotSpeeds.vyMetersPerSecond)
.rotateBy(robotToTargetTranslation.getAngle());
double tangentialVelocityMetersPerSecond = targetFrameToRobot.getY();
Here's the idea being attempted. The velocity is in field coordinates, where x is downfield and y is to the left. You want to know how much of it is sideways relative to the target. The trick is to rotate your whole coordinate system so the target lies straight ahead on the x-axis; once you've done that, the y component is the sideways part, for free.
To rotate the world so that a direction sitting at angle φ ends up on the x-axis, you rotate by −φ. The code rotates by +φ, which spins it the wrong way and lands the target at 2φ instead of 0.
−vx·sinφ + vy·cosφ; the code computes vx·sinφ + vy·cosφ. These agree only when sinφ = 0, that is, when the target happens to be directly up or down the field. Everywhere else it's a different number, not merely a flipped sign.Worth verifying before you trust it
Whether this is a live bug in any particular codebase hinges on the sign convention robotToTargetTranslation.getAngle() uses, and it is possible that gets negated upstream. The test: in simulation, drive a straight line past a stationary target at an angle that is not parallel to the field x-axis, and check for steady-state turret error. If the bug is real, the error grows with sideways speed and vanishes when you drive straight up the field. The fix, if needed, is .rotateBy(robotToTargetTranslation.getAngle().unaryMinus()).
So is the turret solution robust?
Worth answering carefully, because robust means two different things and they give different answers.
Is the maths sound? Yes, it's exact, as the derivation above shows. There is no fitting, no approximation, and therefore no modelling error to be fragile about.
Is it robust to imperfect inputs? Mostly, though there is one genuine structural hazard, and it's worth knowing before you trust the feedforward everywhere on the field.
Because the formula is exact, every error in its output comes from an error in its three inputs: ω, v⊥ and d. And d sits in a denominator. Differentiating the second term with respect to distance gives −v⊥/d², and that squared d means sensitivity grows explosively as you close in.
1/d. The demand rises past what the turret can physically deliver, and the sensitivity to pose error rises faster still. The two error bars represent the identical ±10 cm of distance uncertainty. Out at 5 m it's negligible; at 1 m it's twenty-five times worse.Two distinct close-range failures
The first is saturation. At 1 m and 3 m/s sideways, the formula demands about 3 rad/s, roughly 172 °/s of turret sweep. Many turrets simply cannot move that fast, so the feedforward asks for something impossible and tracking breaks down no matter how correct the maths is.
The second is amplified pose error. The same ±10 cm of distance uncertainty produces ±0.012 rad/s of feedforward error at 5 m, and ±0.30 rad/s at 1 m. Nothing changed about your vision system, only the divisor.
There is a partial consolation: up close the goal subtends a wider angle, so you can tolerate more aiming error. But that relief scales as 1/d while the error grows as 1/d², so it softens the problem without curing it.
The honest scorecard
Mathematically correct: yes, exactly so.
Robust across most of the field: yes.
Robust at close range: no; the 1/d term both saturates the turret and amplifies pose error.
Complete: no; it tracks the goal rather than the virtual goal, which is the next section.
Verified: no; the rotateBy sign above is still unconfirmed.
The redeeming feature of an exact formula is that every failure mode lives in the inputs. Better pose estimation and proper latency compensation improve it directly, and the position PID underneath catches whatever the feedforward misses, so it degrades gracefully rather than falling off a cliff. A sensible guard is to clamp the feedforward below some minimum distance and accept that shooting on the move from very close in is a different problem.
What to do about the close-range problem
Diagnosing it is only half a job. There are real fixes, and the best of them comes straight out of the formula itself.
1. The turret is not your only actuator
Look again at what's demanded: −(ω + v⊥/d). That's a sum, and ω, the chassis rotation rate, is something you also command. Nothing says the turret has to absorb the whole thing.
So split the work. Ask the drivetrain to rotate as far towards −v⊥/d as it can manage, and hand the turret only what's left over:
omegaChassis = clamp(-vPerp / d, -OMEGA_MAX, +OMEGA_MAX); turretFF = -(omegaChassis + vPerp / d); // whatever the chassis couldn't cover
Your available tracking rate becomes turret maximum + chassis maximum rather than the turret's alone. And in the limit where the drivetrain can do the whole job, the turret feedforward falls to zero: the robot simply rotates to follow the goal and the turret sits still relative to the chassis.
2. Or delete the term altogether
Remember what v⊥ actually is: the tangential part of your velocity. Drive radially (straight towards or away from the goal) and v⊥ = 0, so the entire troublesome term disappears regardless of how close you are.
That is a path-planning constraint rather than a control fix: near the goal, approach along the line of sight. It costs nothing to implement and it's completely effective, which makes it the first thing to try.
3. Cap the damage
- Clamp
dto some minimum before dividing. Kills the singularity outright, at the cost of being slightly wrong very close in, where you were going to be wrong anyway. - Let the aim tolerance grow as you close in. The goal subtends a wider angle up close, so a fixed "within 1° to shoot" threshold is needlessly strict there. Scale the threshold with
1/dand you demand less precision exactly where precision is hardest to get.
4. Improve the inputs
- Feed forward from commanded chassis velocity, not measured. You know what you asked the drivetrain to do before it does it, so there's no sensor lag, and lag hurts most when rates are high, which is exactly the close-range case.
- One genuine consolation: vision gets better up close. Tags resolve larger, ambiguity drops, so σd shrinks as you approach. That partly offsets the
1/d²amplification. It doesn't cancel it, but the close-range case is less dire than the raw formula suggests.
The strategic answer
Shooting on the move earns its keep at range, where flight times are long and stopping costs you real seconds. Up close the shot is easy and stopping costs almost nothing.
"Don't shoot on the move inside 1.5 m" is a perfectly respectable engineering decision. Knowing where a technique stops paying is part of implementing it properly, and it's much cheaper than chasing a singularity you were never going to win.
Putting it together: so how far do you actually rotate the turret?
Everything so far has been about the feedforward, which is a rate. But the obvious question is the other one: how far should the turret turn? Both halves exist in this tutorial; here they are assembled.
The first thing to unlearn is the phrasing, because you never tell a turret "rotate 12°". You tell it where to point, recompute that fifty times a second, and let a controller close whatever gap remains. The "where" is the angle bookkeeping from Fig 11:
targetAngle = atan2(Ty − y, Tx − x) − robotHeading
└──── bearing to the goal ────┘ └ chassis ┘
And the complete motor command is those two ideas added together:
// 1 · WHERE to point — a position
double targetAngle = aimPoint.minus(robotPose.getTranslation()).getAngle()
.minus(robotPose.getRotation()).getRadians();
// 2 · HOW FAR off we are at this instant
double error = angleWrap(targetAngle - currentTurretAngle);
// 3 · close that gap, AND keep pace with where it is heading
double output = pid.calculate(error) + turretFF;
// └─ closes the gap ─┘ └ stops it re-opening ┘
The two halves answer different questions
| Piece | Answers | Kind of quantity |
|---|---|---|
targetAngle → PID | how far to rotate | a position |
turretFF | how fast to keep rotating | a velocity |
The feedforward never tells you how much to turn; it stops you falling behind while you turn. Drop it and the PID lags a moving target permanently; drop the PID and the feedforward drifts, because nothing corrects the error that accumulates. Each is useless without the other, which is why the answer to "how much do I rotate" is really "both of these, added".
Two things that will bite you in the implementation
The first is that atan2 wraps. It jumps from +180° to −180° the moment the robot crosses behind the goal. Subtract two angles across that seam and the error comes out around 360° instead of around 0°, and the turret slams the wrong way at full speed. Every FRC codebase carries an angle-wrapping helper for precisely this; the angleWrap() above is not decoration.
The second is that turrets have hard stops. One with ±200° of travel cannot always take the short route, and sometimes it has to unwind the long way round, which costs time you may not have mid-match. The usual treatment is to track accumulated rotation and pre-unwind during a quiet moment rather than discovering the limit while aiming.
One last thing about aimPoint in that snippet: it should be the virtual goal rather than the real one, the goal shifted back to compensate for the ball's drift. That is the subject of the next section, and the one thing this half of the problem cannot supply for itself.
The one thing Part One cannot do
Everything above tracks the goal. But a robot shooting on the move should not point at the goal. The ball leaves the shooter already carrying the robot's velocity, so it drifts sideways during flight. You have to aim at a virtual goal, the real goal offset backwards by however far the ball will drift.
Why the ball drifts at all
Because it inherits the robot's velocity. The ball leaves carrying exit velocity plus robot velocity, not the shooter's exit velocity alone. Throw a ball straight up on a moving train and it lands back in your hand, because it kept the train's speed the whole time. The same thing happens here, and there is no way to prevent it.
So at 3 m/s sideways with a flight lasting 0.85 s, the ball drifts 3 × 0.85 = 2.55 m before it arrives. Aim at the goal and you miss by that much.
So aim somewhere else
Offset the aim point by exactly that drift, in the opposite direction, and the drift carries the ball onto the target. One vector subtraction:
v × TOF turns the drift from an error into the thing that delivers the ball. And because the fake point sits at a different distance than the real one, the flight time it implies is different too, which is the loop drawn in Fig 3.Two exit velocities, and which one driving changes
Relative to the robot, the exit velocity is unaffected. The flywheel spins at its commanded RPM and grips the ball the same way whether the robot is parked or flat out. This is the one a test stand measures.
Relative to the field, it is entirely affected. It is the vector sum vball,field = vball,robot + vrobot: whatever the shooter gave the ball, plus whatever the robot was already doing.
That is the field-centric versus robot-centric distinction from Fig 12 all over again, and it matters because gravity, drag and the goal all live in field coordinates. The flight model integrates in the field frame, so it needs the field-frame initial velocity, which means there's an addition step that is easy to leave out:
exitVel_robot = map(rpm, hoodAngle) // from the test stand
exitVel_field = exitVel_robot.rotateBy(robotHeading)
.plus(robotVelocity) // ← the robot's motion enters HERE
simulate(exitVel_field)
The virtual goal, seen from the other side
There are two ways to account for the very same fact: add the velocity to the initial conditions and re-solve, or shift the target by v × TOF and solve the stationary problem instead. Same physics, two bookkeeping styles.
And here is why shifting is exact in a vacuum: a ground robot's velocity is purely horizontal, so it never touches the vertical motion, meaning the flight time is unchanged and the landing point moves by precisely v × TOF.
The iteration above isn't a contradiction of that. For one fixed shot the flight time really is unchanged. But aiming at the virtual goal means taking a different shot, at a different distance, which carries its own flight time.
One small real term, usually ignored
If the turret is rotating as the ball leaves, the ball also picks up ωturret × rmuzzle from the muzzle sweeping sideways, the same v = ω × r as ever, applied to the turret this time. It is normally small, a few centimetres per second, but it is a genuine term and it grows with a large turret or a fast sweep. This is the one place the turret's own size does affect the shot.
The assumption hiding inside this trick
Shifting the target treats a moving shot as a stationary shot aimed somewhere else. That is exactly true in a vacuum: with no air resistance, adding a sideways velocity really does just translate the landing point, and the iteration above is the whole answer.
With drag it is only approximately true, because drag acts on the ball's total speed, which now includes your contribution. A ball moving faster overall is slowed differently, so a moving shot is a genuinely different trajectory rather than a displaced stationary one.
That breakdown is the entire argument for the two-input surface later on. If drag is negligible for your game piece, this iteration is sufficient and the bigger project isn't needed.
The offset also depends on your velocity, which is itself changing, so a fully correct feedforward has a third term for how fast the aim point is moving. The two-term formula is the dominant part and correct as far as it goes; it simply isn't the complete picture.
And "how long is the ball in the air?" is a question Part One cannot answer. It has no physics in it. Look back at Fig 3: the turret in box 4 is waiting on a time-of-flight number that only the solver in box 2 can produce. Geometry has taken this as far as geometry goes.
Part Two: Choosing the shot
Job two is box 2 in Fig 3, and it is where the clean answers run out and a genuine design choice appears.
Recall what this box has to produce. Given a distance, it owes you a hood angle and a flywheel speed as a matched pair, plus the time of flight that the turret is waiting on. The two numbers are not independent, because they are locked to each other, so what comes out is one pair rather than two separate answers.
What you do today, and why it's defensible
The current approach is a lookup table. Park the robot at 3 metres, tune the hood and flywheel by hand until shots go in, write the numbers down. Repeat at 4 m, 5 m, 6 m. At runtime, measure the distance and interpolate between the two nearest rows. A separate table stores time of flight, used to compute the virtual goal offset for moving shots.
Where the table comes from in the first place
There's no clever source. You make it by shooting, on the practice field, by hand:
- Park the robot at a known distance from the goal.
- Pick a hood angle, then sweep the flywheel speed until shots go in reliably, meaning several balls in a row rather than one lucky one.
- Write that distance and its matching pair into the table.
- Move the robot, repeat. Five to ten distances is typical.
- For the time-of-flight map, film the shots and count frames between launch and arrival.
That's the whole method, and it really is an afternoon's work. It is also why the approach earns so much trust: every row is a shot that actually went in.
One practical trick worth knowing
Key the table on what your own vision system reports, rather than on what a tape measure says. If the camera reads 4.05 m where the tape reads 4.00 m, you want the table indexed on 4.05, because at runtime the robot will look up using that same biased number and the two errors cancel. Calibrating against the tape instead introduces an error that wasn't there.
Why this method cannot be stretched to shoot-on-the-move
Building a 1D table means standing still at six distances. Building the 2D table this approach needs would mean shooting at every combination of distance and radial velocity: driving at a controlled 1 m/s, then 2 m/s, then 3 m/s, at each of those six distances, repeatably enough to trust the result. That is thirty-odd cells, each requiring a repeatable moving shot.
That is not an afternoon, and it isn't really achievable at all on a practice field. This is the strongest practical argument for the physics model (equations that predict the ball's flight, unpacked later in this part). The problem isn't that hand-measuring is inaccurate; it's that hand-measuring a two-dimensional grid isn't something you can physically do.
That word interpolate is doing quiet work, so let me spell out exactly what it means. Measuring d is enough because the answers were already measured and written down. You aren't deriving anything; you're blending two rows you already have.
Two related questions
How is d measured at all? It isn't, directly. Vision and odometry give you the robot's pose on the field, and the goal sits at a known fixed coordinate, so d is the distance between two known points, computed from an estimate. That's precisely why it carries error, and suggestion 2 in Part Three works through what can and cannot be done about it, which turns out to be less than you might expect.
What happens once there are two inputs? With distance and radial velocity you need bilinear interpolation, blending between the four surrounding grid cells instead of two rows. Same idea, applied twice.
How inaccurate is that blend, really?
Less than you might expect, and it's worth putting a number on it rather than hand-waving, because the table's real weaknesses turn out to be somewhere else entirely.
Linear interpolation draws a straight chord between two points on a curve that actually bows, and the gap between chord and curve is bounded by (h² ÷ 8) × curvature, where h is the row spacing. Taking the very numbers from the example above, 42°, 38°, 35.5° at one-metre spacing, the curvature works out at 1.5 °/m², putting the worst-case interpolation error at 0.19°. Note that it scales with the square of spacing: halve the gaps and you quarter the error. Of all the problems here, this is the cheapest one to fix, since you just add rows.
| Error source | Typical size | Fixed by |
|---|---|---|
| Interpolation between rows (1 m spacing) | ≈ 0.19° | more rows — cheap |
| Tuning noise baked into each row | ≈ 0.5° | more rows does NOT help |
That second row is the important one. Hand-tuning gets you perhaps ±0.5° per entry, since you find a band of settings that works and pick somewhere near the middle. And because the chord passes exactly through every measured point, each row's error is preserved permanently. Noise outweighs interpolation error by getting on for three to one, and adding rows only adds more noisy rows.
An argument for fitting a curve with no physics whatsoever
A fitted curve passes near the measurements rather than through them, so it averages the noise out instead of preserving it. That is a straight accuracy gain available from the storage choice alone, with no model, no test stand and no physics.
It is the single cheapest improvement discussed anywhere in this tutorial, and it's why the storage question and the data-source question deserve to be decided separately.
Three problems that aren't accuracy at all
- The error is biased rather than random. On a curve that bends one way, the chord always sits on the same side of the truth, so the error between rows is systematic. It never averages out across many shots the way random error would.
- The slope is a staircase. At every row the derivative jumps discontinuously, and between rows it's constant. A table's slope carries almost no information, which is a second and independent reason robustness can't be extracted from one.
- Beyond the last row there is nothing. You don't get an inaccurate answer; you get no answer.
Which reframes the complaint usefully. The table is not badly inaccurate in the middle. It is noisy (fixable by fitting), incomplete at the edges (fixable by fitting, better by a model), and unusable for slopes (fixable only by a model). Three separate problems at three very different prices, bundled together under the single word "accuracy".
This is popular for an excellent reason: it cannot be wrong about physics, because it contains no physics. Ball compression, roller slip, how much energy the flywheel actually transfers, air drag, none of it is modelled, so none of it can be modelled incorrectly. You measured reality and wrote it down. This is usually called trusting in its "correctness", and that instinct is sound.
Where it runs out
There are three limits. It's only valid where you sampled. It can't extrapolate past the ends of the table. And, the one that matters most here, it hands you exactly one answer per distance, with no way to ask whether that answer is a good one.
That third limit is the crux of the entire argument, and it takes two steps to see.
Step one: many different shots all score
At any given distance there isn't one solution, there's an entire family. Shoot high and slow (a lob), shoot low and fast (a line drive), or anything in between. All of them go in. A lookup table picks whichever one you happened to tune that day.
Why any of this matters: the shot is open-loop
Before the next step, here is the fact that makes it necessary: once the ball leaves the shooter, it is ballistic. Nothing on the robot can touch it. There is no controller downstream, no correction, and no second chance.
Everything you can influence happens before release, and that is where all the feedback lives too.
Why this drives everything that follows
Because there's no feedback after release, every error still present at that instant is permanent, whether it's 2° of hood, 100 RPM or 10 cm of pose error. You will never detect it, and you could not fix it if you did.
So the only defence available is to have chosen a shot that tolerates being slightly wrong. That isn't a refinement sitting on top of accuracy; for an open-loop system it is the accuracy strategy, and it's exactly what the next step is about.
Step two: but they are not equally good
Your hood will never be at exactly the commanded angle, and your flywheel will never be at exactly the commanded RPM. So the right question is not "does this shot score when everything is perfect", because everything in Fig 19 does. The question is "does this shot still score when I'm 2° and 100 RPM off?"
Some members of the family are extremely forgiving. Others fall apart. Same nominal accuracy, wildly different real-world hit rate:
The sensitivity metric is exactly this steepness, measured in both directions at once:
Reading ∂e/∂θ if calculus is new
It's just the slope of the left-hand curve in Fig 21, meaning "how many centimetres of miss do I get per degree of hood error." A big number means fragile, and a number near zero means the curve is flat there and small mistakes don't matter. Squaring makes both directions count as bad, and adding them combines the two error sources into one score you can minimise.
Why the table cannot compute that metric
The metric needs two derivatives, and derivatives are slopes. Here is the difficulty: your table stores where the good shots are, while robustness is entirely about the shape around them.
Every row in the table is a success, since it went in. Nothing in it records what happens 2° off, because nobody ever deliberately shot 2° off and measured how far it missed. The table knows the location of the minimum and nothing at all about the terrain surrounding it.
The marked point is real physics, not an illustration
Ignoring air resistance, a projectile's range is v²·sin(2θ)/g. Differentiate with respect to θ and you get 2v²·cos(2θ)/g, which is exactly zero at 45°. That is the classic maximum-range angle, and it is the least sensitive shot available: being a degree off in hood angle costs you almost nothing there, while the same error at 25° or 65° costs real distance.
So the robustness idea isn't an abstraction invented for this pipeline. It falls out of the simplest projectile equation there is. A real shooter adds drag, spin and an elevated goal, which moves the flattest point somewhere less tidy than 45°, and finding that point is what the model is for.
Could you just measure the shape instead?
In principle, yes. In practice the cost is brutal, for a reason that's easy to overlook. You would have to shoot deliberate misses, a grid of perturbed hood and speed settings at every distance, and measure how far each one missed, in centimetres. That is a far harder measurement than "did it go in", because it needs precise landing positions rather than a yes or no.
Five by five perturbations across six distances is already about 150 deliberately-missed shots for the stationary case alone. Add radial velocity as a second input and it runs into the thousands. The alternative is to measure the exit conditions once on a bench and compute the rest.
And no, you don't need calculus to do it
This is worth saying plainly, because the word "derivative" makes this sound like a maths problem when it's really a simulation problem. Robustness is a derivative, in that "how much does the miss grow per degree of error" is a rate of change, so asking the question is already asking for one. But there are three ways to obtain that number, and only one of them involves calculus:
| Method | What you actually do | Calculus needed |
|---|---|---|
| Symbolic | Do the algebra and get a formula for the slope. | yes |
| Finite difference | Simulate at θ, simulate at θ + 0.5°, subtract, divide by 0.5. | no — subtraction and division |
| Monte Carlo | Simulate hundreds of shots with random errors thrown in, measure the spread of the misses. | no — just a standard deviation |
All three answer the same question. The derivative is the name of the quantity, not the method of getting it. In practice you would use finite differences, estimating the derivatives numerically from the table.
There's also a hard reason symbolic isn't on the table: once drag is in the model there is no closed-form range equation. The tidy v²·sin(2θ)/g above exists only in a vacuum. With air resistance the trajectory has to be integrated step by step, so there is nothing to differentiate symbolically even if you wanted to.
So the real requirement is not calculus
It is this: you must be able to evaluate shots you never took.
Finite differences need e(θ + 0.5°), which is an untaken shot. Monte Carlo needs hundreds of them. Symbolic needs a formula, which you only possess if you modelled it in the first place. Every route runs through the same gate.
And that is exactly what a table cannot do. It holds a handful of successes and can say nothing about the outcome of anything else. So the dependency is on simulation rather than on calculus.
First, what "a model" actually means here
The word model gets thrown around loosely, so let's pin it down, because it's narrower and more boring than it sounds. It does not mean the real robot, a robot simulator, a CAD model, or machine learning.
"Physical model" and "physics model" are the same thing
Some write physical model; this tutorial says physics model. They are the same object, the flight equations below. (In ordinary English "physical model" can mean a physical object, a scale mockup, which is not the meaning here.)
Watch out for something subtler though: the approach describes two models and calls both of them "model".
- The empirical model, which maps shooter velocity and hood angle to the ball's exit velocity. Pure measurement, with no physics in it at all.
- The physics model, a second and physical model on top of the first, giving the actual flight path. Equations.
The word physical there is doing real work: it exists to mark the second model as not the first.
Four things in this tutorial look like tables, and only one is about the shooter
They are easy to conflate, and conflating them is the single most common way to get lost in Part Two. Here they are side by side:
| Which one | What it maps | Built by | Used |
|---|---|---|---|
| The map about the SHOOTER |
(RPM, hood angle) → exit velocity, spin | measured on a test stand with a high-speed camera | offline, inside the sweep |
| The lookup table about the situation |
distance → hood angle, RPM | hand-tuned on the practice field | runtime, today |
| The simulation grid a working set, not a deliverable |
every candidate shot → how far it missed exists only inside the sweep |
generated by the model | offline, then discarded |
| The surface about the situation |
(distance, radial velocity) → hood angle, RPM | fitted through the grid's winners | runtime, in the new pipeline |
Only the first is a property of your machine. Bolt the same shooter to a different robot and the map comes with it unchanged, while the other three would all have to be rebuilt. That is the cleanest test for telling them apart.
What the map is, in one line
It is a calibration curve for your shooter: you asked for 3200 RPM at 30°, and the ball actually left at 7.1 m/s spinning at 8 rev/s.
You cannot calculate that number. Between the wheel and the ball sit compression, slip and energy loss. A wheel surface moving at 20 m/s might launch a ball at 12, and the ratio is not even constant, shifting with hood angle and with how worn the ball is. So you measure it once and carry it forever, exactly like discovering that a set of scales reads 4% light.
So a model here is a page of equations you run on a laptop. Essentially all of it is this:
whereDoesItLand(exitSpeed, launchAngle, spin):
x, y = 0, shooterHeight
vx, vy = exitSpeed * cos(launchAngle), exitSpeed * sin(launchAngle)
while y > goalHeight: # step forward in tiny slices
drag = -DRAG_COEFF * speed * (vx, vy) # air resistance
magnus = MAGNUS_COEFF * spin * perpendicular(vx, vy) # lift from backspin
vx += (drag.x + magnus.x) * dt
vy += (drag.y + magnus.y - 9.81) * dt # gravity
x += vx * dt ; y += vy * dt
return x, elapsedTime # where it landed, and how long it took
Step the ball forward in small time slices and watch where it goes. When people say "simulate" here, they mean running that loop, not booting up a robot simulator. Running it a hundred thousand times with different inputs is the grid sweep in the pipeline below, and it takes seconds.
What exit velocity and spin are each doing in there
They are both handed to the loop, but they enter it in quite different ways.
- Exit velocity is used once, on the line that sets
vx, vy. It initialises the integration and is never touched again, so it is the constant of integration mentioned earlier. - Spin is used at every step, inside the Magnus term. It isn't an initial condition at all; it is a parameter of one of the forces, acting for the whole flight.
| Mostly determines | Which output it drives | |
|---|---|---|
| exit velocity | how far the ball goes — the range | the landing point |
| spin | the shape — hang time and how steeply it arrives | the entry angle |
Spin is nowhere near a small correction
For a hooded shooter the ball rolls along the stationary hood on its way out, which gives a spin ratio near 1, meaning spin fast enough that the surface speed roughly matches the ball's travel. Running the numbers for a 270 g, 9.5-inch ball leaving at 11 m/s:
| spin ratio | situation | Magnus force | as a share of weight |
|---|---|---|---|
| 0.5 | heavy slip | 0.68 N | 26% |
| 0.8 | typical | 1.02 N | 38% |
| 1.0 | ideal rolling | 1.19 N | 45% |
What surprised me here is the size of it: somewhere between a quarter and a half of the ball's weight, the rough equivalent of cancelling a third of gravity for the duration of the flight. That is not a refinement term you can drop.
Why measuring only exit velocity would not do
With exit velocity alone you would get the range approximately right and the shape badly wrong. Wrong shape means wrong entry angle, and wrong entry angle means the wrong acceptance window. The acceptance window is, as Part Three works out, the single channel through which choosing a different shot buys any tolerance to pose error at all.
So the tape-and-high-speed-video measurement is not an optional extra alongside the velocity measurement. It is the half that determines the trajectory's shape.
"Flight path" and "time of flight" are not the same thing
Both appear throughout this tutorial, so let's separate them. The flight path is the shape, the whole curve through the air, where the ball is at every instant. The time of flight is the duration, a single number, in seconds. One is the route, the other is the four hours.
The loop above computes the path. Time of flight is then simply read off it: the moment at which that path reaches goal height, one fact extracted from the whole story.
That framing is worth holding onto, because a single path yields three numbers, each consumed somewhere different:
| Read off the path | Used by |
|---|---|
| landing point | scoring the shot — did it go in, and by how far did it miss? |
| time of flight | the virtual goal offset, v × TOF — the aim point back in Part One |
| entry angle | the hard constraint on shallow shots, and the calibration cross-check |
Which quietly favours computing over measuring. Today you obtain time of flight by filming shots and counting frames. That works, and needs no path at all, but it yields exactly one number. Compute the path and the other two arrive free from the same run, since you cannot extract a landing point from a stopwatch.
The two constants, and why there are only two
Those two capitalised names are the model's only unknowns. Each one bundles several messy physical properties into a single number, so you never have to know them separately:
| Constant | What it captures | Where its value comes from |
|---|---|---|
| DRAG_COEFF how hard air fights the ball |
Air density, the ball's cross-sectional area, its mass and its surface roughness, all rolled into one number. | Neither is calculated. You take perhaps twenty stationary shots at known distances, then adjust the two numbers until the model's predicted landing points match where the balls actually went. Two knobs against twenty data points, which is very well determined. |
| MAGNUS_COEFF how much lift the spin generates |
The same properties, plus how effectively the ball's surface grips and drags air around with it. |
Why this makes the whole approach tractable
Everything else in that loop is either measured on the test stand (exit velocity, spin) or exactly known (gravity, goal height, hood geometry). Only two quantities are genuinely unknown. That is a small enough number to pin down confidently from a modest amount of shooting, which is what makes "build a physics model" a weekend's work rather than a research project.
Naming note: the k prefix you'll see in most write-ups (kd, km) just means "constant". They're spelled out here because d already means distance-to-target back in Part One, and the collision causes exactly the confusion it sounds like.
Model, simulation and measurement
Model, simulation and measurement are three different things, and discussions swap between them freely. They're easy to keep straight once you separate them:
| Word | What it actually is | Where it happens |
|---|---|---|
| Model | The equations, a description of how a ball behaves, which is the code block above. It's a noun: a thing that sits in a file. | on a laptop |
| Simulation | Running that model with some numbers to see what comes out. It's a verb: the act of using the model. | on a laptop |
| Measurement | Shooting an actual ball and recording what actually happened. | real hardware |
Model is to simulation as a recipe is to cooking. One is the description, the other is the act of running it.
Two senses of the word "simulation" in this tutorial
Trajectory simulation means running the ball-flight equations above, which is what Part Two means throughout.
Robot-code simulation means running your actual robot program against a fake robot, which is how you'd test for the turret bug back in Part One.
Both of them mean "run it on a computer instead of on the field", but they simulate completely different things. It's the same word covering two unrelated activities.
So where does the real robot come in?
Crucially, model and measurement aren't rivals. Measurement is what makes the model trustworthy. Those two constants don't come from a textbook; they come from your own test stand and stationary shots. The real cycle is: measure a little → calibrate the model → simulate a lot → check against more measurements. The model doesn't replace testing. It multiplies a small amount of testing into answers about a hundred thousand shots you never took.
Testing is still essential, and it simply has a different job to do. The split is deliberate, and it's the cleverest part of the approach.
Where to run that measurement
Run it stationary, and it barely matters whether that's a purpose-built stand or the robot itself. Never while driving.
Moving isn't merely unnecessary, it is strictly worse. What you are measuring is the robot-frame exit velocity, and driving doesn't affect that at all. Moving therefore adds a quantity you already know how to compute, and then obliges you to subtract it back out, which requires knowing the robot's velocity precisely at the instant of release, with all the error that carries. You get the same signal with more noise and no upside.
| A test stand | The robot, parked | |
|---|---|---|
| Repeatability | better: fixed camera, no drivetrain drawing current | fine |
| Iteration speed | much faster | slower, and it ties up the robot |
| Risk | mismatch with the real shooter | none, since it is the shooter |
The only thing that genuinely matters is mechanical identity. This map exists to capture compression, slip and energy transfer, so a stand that squeezes the ball even slightly differently produces numbers that are wrong in a way nothing downstream can detect. If you have a spare identical shooter, use a stand. If not, use the real robot parked. Do not build an approximate stand.
One design choice that makes the whole map robust
Key it on measured RPM, never on commanded RPM, voltage or duty cycle:
map: (measured RPM, hood angle) → (exit velocity, spin)
Do that and battery state, voltage sag and motor-to-motor variation all drop out. 3200 RPM produces the same exit velocity whether the battery reads 12.6 V or 11.4 V, because the wheel is genuinely turning at 3200 either way. Key it on voltage instead and you have quietly baked the condition of one particular battery into your shooter model.
Testing on a moving robot serves a different purpose
Keep two activities firmly apart:
- Calibration, meaning the map plus the two flight constants. Stationary. Fewer variables means cleaner numbers.
- Validation, meaning: does the whole pipeline actually score while driving? Moving, obviously. This is the end-to-end test, and it is where a frame-conversion bug, a latency problem, or drag mattering more than you assumed would first show itself.
Calibrating while moving conflates the two, and then a failure is impossible to localise: you cannot tell a bad map from a bad drag constant from a sign error in the velocity compensation.
What "calibration" actually involves
There are two calibrations, done in that order, and they are different kinds of measurement. That's worth being clear about, because they get bundled under one word.
Measured once, read a hundred thousand times
The most common way to lose the plot here is to picture the whole procedure happening physically. It does not. Only a small, fixed amount of real shooting takes place, and it happens once:
| What | Where | How many times |
|---|---|---|
| the map, RPM and hood → exit velocity | bench, high-speed camera | once · about 25 shots |
| the two flight constants | field, measured landings | once · about 20 shots |
| the sweep: try, perturb, score | a laptop | ~180,000 evaluations |
| the surface fit | a laptop | once |
That's roughly fifty-five physical shots in total, ever. Everything afterwards reads those fifty-five shots' worth of numbers over and over.
Why it could not be done empirically instead
Suppose you tried to run the search physically, actually shooting every candidate rather than simulating it:
180 grid cells × ~200 candidate shots × ~5 perturbations each ≈ 180,000 shots
At even ten seconds a ball that is five hundred hours of continuous shooting, with a fresh battery every few minutes. This isn't a preference for computers over hardware; the experiment simply cannot be run.
Which is the entire reason the model exists. It does not replace testing, it multiplies about fifty-five measured shots into answers about a hundred and eighty thousand imagined ones. The empirical part is the foundation of the whole thing, and it is just small, and done once.
Step 1, the map, is measured directly. You point a high-speed camera at the muzzle and see the answer: this RPM and this hood angle produced that speed and that spin. Nothing is inferred.
Step 2, the two constants, are fitted instead. You cannot photograph a drag coefficient. They are inferred by asking which values would have produced the flights you actually observed:
for each candidate (kd, km):
total = 0
for each recorded shot:
exitVel, spin = map(shot.rpm, shot.hood) # needs step 1 already done
predicted = simulate(exitVel, spin, kd, km)
total += (predicted - shot.actualLanding)²
remember total
pick the (kd, km) with the smallest total
With only two parameters, a plain 2D grid search is enough: sweep both across a plausible range and take the minimum, no optimiser required. It also makes plain why the map has to come first, since the model cannot simulate anything without knowing where the ball started.
Measure where shots land, not whether they scored
"It went in" is one bit of information. "It landed 34 cm long" is a number you can fit against. Shooting at a wall or a tarp and marking the impact point gives far more calibration value per ball than shooting at the goal, the same asymmetry as the sensitivity discussion earlier. Around twenty shots is comfortable for two parameters, keeping a few back to validate against.
How many shots is actually enough?
The question is worth reframing slightly. Statistical significance belongs to hypothesis testing, which asks whether there is an effect at all, and that is not what is being asked here. You are estimating parameters, so the real question is how precise they need to be, and the answer is: precise enough that their error disappears underneath the noise you cannot remove anyway.
Precision improves as the square root of the shot count, which is unforgiving. Take a shooter with roughly ±20 cm of shot-to-shot scatter:
| shots | uncertainty in the fit | share of the scatter | extra balls per further cm |
|---|---|---|---|
| 5 | 8.9 cm | 45% | — |
| 10 | 6.3 cm | 32% | 2 |
| 20 | 4.5 cm | 22% | 5 |
| 40 | 3.2 cm | 16% | 15 |
| 80 | 2.2 cm | 11% | 43 |
| 160 | 1.6 cm | 8% | 122 |
Twenty shots is the knee of that curve. By then the model's systematic error sits near 4.5 cm, comfortably beneath the ±20 cm of scatter that nothing removes. Pushing on to eighty costs sixty more balls to gain two centimetres which are invisible against that scatter, and the final column shows the price climbing 2 → 5 → 15 → 43 balls per centimetre. That column is the argument for stopping.
Coverage beats count, and it is not close
Twenty shots spread across hood angles and distances are worth more than sixty taken at similar settings. That is the degeneracy shown below: near-identical shots stack their constraints along the same valley without ever narrowing it. Given forty balls to spend, spend them on variety rather than repetition.
The bench measurement is a different problem
There you are not fitting two parameters, you are mapping a function, so what matters is covering the input space rather than statistical power. Two arrangements cost about the same:
- 5 × 5 grid, one ball each. 25 balls, dense coverage, noisier at each point.
- 3 × 3 grid, three balls each. 27 balls, coarser, but averaged.
Either serves, because you interpolate or fit through the results afterwards, which averages the remaining noise either way. Keep a few measurements out of that fit as well, the same held-back trick as everywhere else in this tutorial, and the thing that tells you whether the shape you chose actually describes your shooter.
A stopping rule that answers the question directly
Do not fix the number in advance. Fit with five shots, then ten, then fifteen, then twenty, and watch whether the constants are still moving. Still drifting at twenty means take more. Settled by twelve means you were finished a while ago.
It costs nothing extra, uses data you already have, and answers an empirical question empirically rather than by rule of thumb.
Hold back about a quarter of the shots regardless. Five validation shots are worth more than five additional fitting shots, because they reveal whether the model generalises, something no amount of extra fitting data can ever tell you.
What if the constants drift between ball batches?
That's a fair worry, and it splits into two cases that want different responses.
Random variation within a batch is simply noise. It is already inside the ±20 cm scatter, it averages away across twenty shots, and there is nothing to be done about it.
Systematic drift between batches is the dangerous case, where practice balls behave differently from competition balls. Your constants are then correct for the balls you calibrated on and confidently wrong for the ones you actually compete with.
A new batch invalidates both measurements, not just one
The map is (RPM, hood) → exit velocity, and that depends on how the ball compresses and grips. Different balls, different map. So a genuine recalibration is around forty-five balls, not twenty.
You already own the tool for deciding whether it matters
This is what ∂e/∂v is for. You computed it for the robustness metric; now put it to a second use, converting a percentage of drift into centimetres of miss:
batch variation in exit velocity ≈ 3% of 11 m/s = 0.33 m/s
∂e/∂v at that shot ≈ 2.24 m per m/s
──────────────────────────
cost of the drift ≈ 74 cm of miss
And you do not have to compute that sensitivity specially, because the sweep already produced it for every cell as a by-product of scoring. Fig 27 shows it being read off four neighbouring grid cells by central difference. In the no-drag case you can also get it straight from ∂R/∂v = 2v·sin(2θ)/g, which is where the 2.24 above comes from, or empirically, by shooting two groups a hundred RPM apart and measuring the gap between their landing points.
Run your own numbers rather than these. If it lands at 5 cm, ignore the whole issue. If it lands at 70 cm, it dominates everything else in this tutorial and deserves attention before the robustness project rather than after it.
You can optimise against batch spread rather than worrying about it
Batch spread is just another source of uncertainty, so it belongs in the weighted metric alongside the others:
So batch variation is an input to the method rather than a threat to it. The machinery already handles the problem, provided you tell it the problem exists.
Three practical habits
- Spot-check rather than recalibrating blindly. Five shots at a known setting will tell you whether anything has moved. Do the full forty-five only when it has.
- Calibrate on competition balls whenever you can get hold of them.
- Keep one reference ball sealed away, so you can tell batch-to-batch drift apart from ordinary wear on your practice set.
And here is the point worth holding onto. Without a model, batch drift simply appears as unexplained misses. With one, it appears as a measurable discrepancy you can name and put a price on. The model makes the problem visible, which is the opposite of the worry.
So vary your hood angles deliberately
What separates the two constants is that Magnus scales with spin and drag does not. Different hood angles produce different spin-to-speed ratios, which is precisely the lever that tells them apart. Recording the entry angle helps as well, since backspin steepens the descent far more than drag does.
Calibrating from a narrow band of similar shots is the classic way to end up with a model that fits your test data beautifully and then behaves strangely everywhere else. Unlike most mistakes in this tutorial, it leaves no obvious symptom at the time.
How you'd actually record an entry angle
It is easier than the exit-velocity measurement, for a reason that is not obvious: angles need no scale calibration. Measuring a velocity requires something of known length in frame to convert pixels into metres. An angle is atan(rise ÷ run), and if both are in pixels the units simply cancel. A phone at 120–240 fps, placed square-on to the shot plane and as far back as practical with the zoom in, is the entire rig.
Two ways to get the slope wrong
Don't difference two frames. At 240 fps a ball at 8 m/s advances about 3 cm per frame, so a ±1 cm tracking error across a 3 cm baseline is roughly 17° of noise, far worse than the effect you are trying to detect.
Don't fit a long straight line either. The path is curving under gravity, so a straight fit across a long baseline systematically reports the descent as shallower than it is.
Instead, fit a short parabola across ten to fifteen points and take its derivative at the arrival point. That averages the position noise down without inheriting the curvature bias, and lands within about 2–3° with reasonable care. The open-source tool Tracker does frame-by-frame tracking and curve fitting for free if you would rather not write it.
How entry angle breaks the degeneracy
The entry angle enters the fit as a second residual term:
total += (predictedLanding - actualLanding)²
+ w * (predictedEntryAngle - actualEntryAngle)² # w balances metres against degrees
Two constants, and now two independent observations per shot instead of one. Drag and Magnus can no longer be traded against one another, because any swap that leaves the landing point alone will change the descent angle, so the long valley in the figure above closes up into a bowl.
The crude free version: noting whether shots drop in or rattle out is ordinal rather than numeric, so it's useless for fitting a coefficient, but a perfectly good sanity check that your model's predicted entry angles are not nonsense.
Is it necessary? No.
That's worth saying plainly before the case for it, because the degeneracy problem has a cheaper fix that comes first: simply vary your hood angles. That alone breaks most of it, costs nothing and requires no extra measurement at all. Entry angle is the second lever, not the only one.
| What to do | Effort | What it buys | |
|---|---|---|---|
| 1 | Vary hood angles across your calibration shots | free | the biggest single win on conditioning |
| 2 | Record where shots land, not just whether they scored | near-free | turns one bit into a number |
| 3 | Measure entry angle as well | some video work | refinement, plus a structural check |
Do the first two and you have a serviceable calibration. Skip the third and nothing collapses. And keep the proportion in view: the physics model itself isn't necessary either, which is what the drag measurement in Part Three decides. Entry angle is a refinement of a refinement, and if you are still at "get shoot-on-the-move working at all", it is not where the next hour should go.
When it does earn its place
Chiefly when you are already filming for exit velocity, so the marginal cost is close to zero, or when something is wrong and you cannot tell whether the constants are mis-tuned or the model is inadequate. The last item below is the one that matters most.
- It pulls in a different direction. This is not merely more data, it is data of a different kind. Extra landing points from similar shots produce constraints that are nearly parallel in parameter space, so they stack up along the same valley without narrowing it. Entry angle constrains a different direction, because it responds to drag and to spin in quite different proportions than range does.
- Fewer balls for the same confidence. Every shot now yields two numbers rather than one, so twenty shots become forty constraints. Shoot less, or fit better.
- It is the sharpest measurement for spin in particular. Magnus mostly steepens the descent; drag mostly shortens the range. For a light hollow game piece carrying heavy backspin, Magnus is the constant you most need right, and entry angle is the observable that speaks most directly to it.
- It makes the downstream constraint trustworthy. Suggestion 3 in Part Three argues entry angle should be a hard filter on candidate shots. But you can only enforce a constraint you can predict accurately, and if the model's entry angles were never checked against reality, that filter is decoration. Measuring it is what earns the right to use it.
- And it is nearly free. No scale calibration, same session, often the same footage.
It can tell you the model is wrong, not merely mis-tuned
With one observable and two free constants, the fit will essentially always succeed. There is enough freedom in two parameters to match a set of landing points even when the physics is missing something real. A good fit therefore tells you nothing about whether the model is right.
With two observables the system becomes genuinely over-determined. If no combination of kd and km can match the landing point and the descent angle at the same time, that is the model reporting that its structure is inadequate rather than a tuning failure. Something is absent: spin decaying during flight, lift varying with speed, a shooter effect nobody modelled.
A single-observable fit can never surface that. It will hand you plausible-looking constants and a hidden problem, and you will not find out until the shots start missing in ways nobody can explain. That possibility is what makes the second observable worth the trouble.
Fit on it, or validate against it?
Both, on different shots. Including entry angle in the residual improves conditioning, while holding it back makes it an independent test the model can fail. Fit the constants on one subset and check the predicted descent angles against another, the same held-out idea as Fig 36, applied one stage earlier, at calibration rather than at the end.
The one force in there that isn't obvious: the Magnus effect
Gravity needs no explanation and drag is just air resistance. The third term is the one people skip, and skipping it is what makes a shooter model badly wrong rather than slightly wrong.
The Magnus effect is why a spinning ball curves. It's the same physics as a baseball curveball, a topspin tennis shot dipping inside the baseline, or a backspun golf ball floating instead of dropping.
The mechanism is simpler than its reputation. A spinning ball drags a thin layer of air around with it, which makes it fling its wake off to one side. Push air one way and you get pushed the other way, which is Newton's third law and nothing more exotic. Backspin flings air downward, so the ball gets pushed upward.
Why spin cannot be left out of the model
A flywheel-and-hood shooter grips the ball on one side only, so it cannot avoid imparting heavy backspin, which is a side effect of the mechanism rather than a choice. And because the game piece is light and hollow, the Magnus force is a large fraction of its weight rather than a rounding error.
So a model that leaves spin out has the wrong shape entirely, and it will be confidently wrong, which is the worst failure mode in the table below. This is exactly why the approach measures spin with a tape marker and high-framerate video: the model needs that number, and no other method gets it.
Why the approach splits into two stages
The two-stage design confines the trust problem to one stage. The empirical stage is pure measurement (compression, slip, energy transfer), so it raises none of the "you would have to understand the physics" worry at all. Only the second stage rests on understanding.
And the second stage is the easy physics. A ball flying through air is something physics genuinely handles well; a squishy ball meeting a spinning roller is not. So the architecture hands the badly-understood half to measurement and keeps only the well-understood half as equations, which is exactly the split drawn in Fig 23 above.
Worth remembering when you reach the objection later on: "any approach that requires a physical model…" is aimed only at that second stage. Nobody disputes the first half.
Why bother with a model at all
Those twenty lines can answer "what if the hood were 1° higher?" for a shot nobody ever took, and that question is the only way to find the flat, forgiving shots from Fig 21. No amount of measuring gets you there, because you'd have to physically take every shot you wanted to compare.
The proposed pipeline
To compute that slope you need to be able to ask what-if questions, such as "what if the hood were 1° higher?" A lookup table can't answer that; it only knows the shots you actually took. A physics model can answer it for free, and that is the entire reason to want a model.
Step 1 is the clever part of the approach and worth calling out. Instead of trying to model ball compression, roller slip and energy transfer (genuinely hard, and the usual reason physics models fail on real robots), you measure the map from (flywheel RPM, hood angle) to (exit velocity, spin) directly on a test stand. All the messy contact mechanics gets absorbed into a measurement. The physics model then only has to handle a ball flying through air, which is the part physics is actually good at.
How the robustness number is actually computed
The step is usually stated as something like: compute the robustness of every shot using the metric (derivative of distance error with respect to hood angle)² + (derivative of distance error with respect to exit velocity)², estimating the derivatives numerically from the table. Two things are worth untangling before the algorithm.
A typo in the metric, and the word "table" again
Both terms as written say hood angle. Stated carefully, the second one is shooter velocity; otherwise you would simply be squaring the same quantity twice.
And "using table" does not mean the lookup table. It means the simulation grid generated in the previous step. That distinction matters, because it resolves something that otherwise looks contradictory:
| Which table | What's in it | Can you differentiate it? |
|---|---|---|
| The lookup table measured on the field | A handful of settings that scored. | no — misses were never recorded |
| The simulation grid generated by the model | Every candidate shot and how far it missed. | yes — every cell holds a value |
You can't take a slope from the first because it records only successes. You can from the second because every cell, including all the bad ones, carries a computed miss distance.
The whole idea before any notation
For every situation the robot could be in, meaning some distance and some closing speed, try a great many possible shots. For each one, nudge the hood and the flywheel slightly and see how far off the ball lands. Keep the shot that is least affected by the nudge. Then fit a smooth equation through all the winners so the robot can look one up in microseconds.
The move that is easy to miss sits in the word perturbation: "optimal" is defined by what happens when you are wrong. Every valid shot has zero error when everything is perfect, so the winner cannot be the shot with the least error. It is the shot with the least error after being knocked off.
| At 4 m, closing at 1 m/s | nudge hood ±1° | nudge speed ±50 RPM | |
|---|---|---|---|
| Shot A at 55°, 3200 RPM | lands ±8 cm off | ±11 cm | keep |
| Shot B at 35°, 4100 RPM | ±31 cm | ±19 cm | discard |
Both score perfectly when executed perfectly. Only one of them survives being executed imperfectly, and that is the entire selection rule.
Two things that sentence leaves vague
The first is which perturbation is meant, how large it is, and in which direction. Nudge the hood upward or downward? Both variables at once? None of that is specified, and it is exactly what the full metric settles: (∂e/∂θ)² + (∂e/∂v)² folds both variables and both directions into a single number.
The second is "distance from the centre of the hub", which is quietly load-bearing as well. Measuring distance from the centre rather than asking "did it go in" gives a continuous quantity, and that is the only reason any of this can be differentiated. But it also means "optimal" bundles two things at once: a shot landing dead centre has more room to drift before it misses at all. Accuracy and margin end up folded into one number.
The algorithm, for one distance and radial velocity
# 1 · fill the grid
for θ in 20°..70° step 0.5°:
for v in 8..20 m/s step 0.1:
e[θ][v] = simulate(θ, v).landing − goal # signed miss, in metres
# 2 · central differences on the interior cells
dEdθ = (e[θ+Δθ][v] − e[θ−Δθ][v]) / (2·Δθ)
dEdv = (e[θ][v+Δv] − e[θ][v−Δv]) / (2·Δv)
# 3 · score every cell
S = dEdθ² + dEdv²
# 4 · choose — but ONLY among shots that actually go in
candidates = cells where |e| < tolerance
best = argmin(S) over candidates
Why step 4 is not optional
Minimising S on its own selects a shot that is beautifully insensitive and does not go in. Flatness is only meaningful among shots that score, so this is a constrained optimisation: minimise sensitivity subject to the shot landing in the goal. In practice you walk the zero contour and take the flattest point along it.
Two refinements worth making
It's worth using central differences rather than forward ones. (right − left) / 2Δ has error proportional to Δ², while (next − this) / Δ is only proportional to Δ, so you get a noticeably better answer at identical cost.
You can then consider dropping the division entirely. Pick the step size to match the error your hardware actually has (if the hood holds ±1°, use Δθ = 1°) and use the raw difference:
Δe = e(θ + 1°, v) − e(θ, v) # what a real 1° error costs you, in metres
That has two advantages over a true derivative. It answers the question you care about directly, in centimetres of miss rather than metres-per-radian. And near the optimum, where ∂e/∂θ approaches zero and stops discriminating between candidates, a finite perturbation of realistic size still registers the curvature, which is exactly what separates two shots that both look flat to first order.
What that last step means by "fit a polynomial surface"
This phrase gets used a lot without ever being unpacked, so here it is. A lookup table and a polynomial fit do the same job: given a distance, hand back a hood angle. They differ in what they actually store.
- A lookup table stores the measurements themselves, six rows of numbers. To answer for 3.5 m it finds the two neighbouring rows and splits the difference.
- A polynomial fit stores an equation drawn through those measurements, something like
hood = 51.2 − 4.7·d + 0.21·d². Three coefficients replace the entire table, and 3.5 m is computed rather than looked up.
So why is it called a surface?
It is purely a matter of how many inputs you have.
With one input, distance, the answer graphs as a curve: a line drawn on a flat page, which is exactly Fig 28. That's your current table.
Shooting on the move adds a second input: radial velocity, how fast you're closing on or backing away from the goal. Two inputs graph as a surface, a landscape where the horizontal position stands for a situation and the height above it is the hood angle for that situation.
A grid of situations, not a map of the field
The words "grid", "surface" and "landscape" all sound spatial, so this is worth stating outright: the two axes are not two directions you can drive in. If you drew this grid on the field it would mean nothing.
They are two independent numbers describing your situation: how far away you are, and how fast that distance is changing. Position and its rate of change are independent quantities even along a single line. A ball at 5 m height might be rising at 3 m/s or falling at 3 m/s: identical position, opposite velocity, entirely different future. Knowing one tells you nothing about the other, and that is the whole second dimension.
So at one distance there isn't one right answer. There is a row of them:
| distance | radial velocity | hood | flywheel |
|---|---|---|---|
| 4.0 m | −3 m/s backing away | 52° | 3450 |
| 4.0 m | 0 pure sideways | 48° | 3200 |
| 4.0 m | +3 m/s closing | 44° | 2980 |
Same distance, three different shots, because radial velocity is a head start. Close at 3 m/s and the ball already carries 3 m/s toward the goal before the flywheel touches it, so it needs less help. Back away and the ball is being dragged the wrong way, so it needs more.
Which is precisely why the current table cannot do this: it has one row per distance, and there is nowhere to put the second number. Adding that column is the second dimension, meaning a second thing you must know before you can answer rather than a second direction on the field.
But why radial velocity, and why isn't it three inputs?
A fair question, since the robot's velocity has two components and only one of them appears. The answer is that they are consumed by different subsystems, and only one survives as far as the solver.
What a negative radial velocity does to the shooter
Negative means receding, so you are driving away from the goal and the range is opening. The turret does not care in the slightest, since v∥ never enters θ̇turret = −(ω + v⊥/d) and radial motion swings no bearing. The whole consequence lands on the shooter, as three effects that all push the same way:
- The range is opening. d grows during the shot, so the solver has to target a longer shot than the one you are standing at.
- The ball is robbed of speed. It leaves carrying your velocity, so receding at 2 m/s takes 2 m/s off its horizontal speed toward the goal. A shot that was perfect from a standstill now falls short, and recovering it costs more exit velocity: higher RPM, with the hood angle re-solved to match.
- The flight lasts longer, since the shot is farther and effectively slower. That inflates the virtual-goal correction and everything hanging off the time of flight.
So the shooting envelope is lopsided. Driving at the goal, all three effects reverse: the range closes, the ball gets a free boost, the flight shortens, and the shot can need less flywheel than standing still. Driving away, they compound. Back up briskly enough and the exit velocity the solver asks for exceeds what the flywheel can deliver, at which point no solution exists at all, so the shot is infeasible rather than merely harder. You can shoot while advancing at speeds you could not shoot while retreating.
vx and vy never budge, and the top two bars are frozen for the whole run. Yet the bottom two move continuously, and v∥ even changes sign as the robot passes the goal. Nothing about the driving changed; the axes did. The faint orange and teal lines at the robot are those axes, and you can watch them swivel. Both components are built from both vx and vy (v∥ = vx·r̂x + vy·r̂y and v⊥ = vx·p̂x + vy·p̂y), so neither one is ever "just x" or "just y", except by coincidence when the goal happens to line up with a field axis. Once the robot is past the goal, v∥ turns negative and its orange arrow flips to point away from the goal; that reversal is what a negative component looks like, rather than a second arrow. Drag the heading slider to change how you drive: the top pair jumps to new values and freezes again, while the bottom pair carries on moving regardless.Which way the causation runs
It is easy to read that animation backwards and conclude that your speed somehow emerges from the two components. It works the other way about. Speed is the input; the split is the consequence. How you drive fixes your speed, where the goal sits decides how that speed is apportioned, and the apportioning can never change the total:
√(v∥² + v⊥²) = |v| — always, exactly
Pythagoras, because the two axes are perpendicular. Over the run above v∥ swings by more than 4 m/s and reverses sign, while √(v∥² + v⊥²) sits at 2.60 m/s from first frame to last.
There is a genuine interplay, though it runs between the two components rather than producing the speed. Because their sum in quadrature is pinned, one can only grow if the other shrinks. (Adding in quadrature just means squaring each, adding, and taking the square root: a ⊕ b = √(a² + b²). It is how perpendicular or independent quantities combine, and its useful habit is that the largest term dominates, so combining 10 with 1 gives 10.05 and the small one is very nearly free.) Picture a stick of fixed length casting shadows on two perpendicular walls: rotate the walls and the shadows trade off, while the stick is unmoved.
What the split really decides is the division of labour. Abeam the goal, v⊥ is near its maximum and the turret is working hardest while the shot solver barely notices. Driving straight in, v⊥ falls to zero, term two vanishes, and the whole problem belongs to the shot solver. The speed is identical throughout while the demands are completely different, which is the sharper way to say why shooting on the move is hard: the difficulty comes from where the speed is pointed rather than from the speed itself.
Strictly an approximation: the virtual goal sits at a slightly different bearing from the real one, so "radial" measured toward it differs a little from "radial" toward the goal itself. The difference is second-order and normally ignored.
And why split the work that way at all?
The most immediate reason is that each component is fixed by the mechanism that has authority over it. Sideways drift is a direction error, cured by pointing differently. A distance change is an energy error, cured by shooting differently. You cannot fix a sideways drift by shooting harder, nor a distance change by pointing sideways, so the division is dictated by the hardware rather than chosen.
The deeper reason is a symmetry
Gravity points straight down, and drag does not care which compass direction the ball is travelling. So the whole problem is symmetric about the vertical axis: rotate a shot about that axis and the trajectory's shape is entirely unchanged, with only its direction differing.
Tangential velocity tilts the shot plane, and that is precisely such a rotation. It is therefore absorbed by a symmetry the problem already possesses, which makes it free and exact with nothing to solve. You simply point along the new plane.
Radial velocity changes things within the plane. No symmetry absorbs it, so it has to be solved for. That is why one component goes to geometry and the other to physics, and it comes down to which one the symmetry can swallow rather than to any convention.
Three practical consequences follow from that, and they all point the same way.
| Why it matters | |
|---|---|
| Cost | A third axis would take the grid from roughly 180 cells to about 1,620 (nine times the simulation, nine times the fitting data, and a 3D surface to fit) in order to re-derive something the turret already supplies exactly. |
| Exactness | The turret's correction is pure geometry: exact, no calibration, no model. The grid's needs the physics model: approximate, calibration-dependent, and the part capable of being confidently wrong. Hand as much as possible to the exact mechanism and only the irreducible remainder to the approximate one. |
| Diagnosability | Keep them apart and a miss carries information: left or right points at aiming, frames or latency; short or long points at the shot solution, the model or the calibration. Bundle them into one three-dimensional lookup and a miss tells you only that something is off. |
So the grid is simply every combination of those two inputs:
| Axis | Typical range | Sampled at |
|---|---|---|
| distance | 2 – 8 m | ~20 values |
| radial velocity | −3 to +3 m/snegative = backing away | ~9 values |
Around 180 cells, each one a situation the robot might genuinely be in. Run the optimisation in every cell, keep the winning pair, then fit a surface through the results.
So where does exit velocity sit in all this?
A reasonable thing to lose track of, because it is measured carefully, discussed at length, and then never appears in the finished lookup. It lives inside the offline loop, and only there, but the loop has two levels, and the join between them is where most of the confusion sits.
OFFLINE
for each situation (d, v_radial): # the grid axes. Outer loop.
for each candidate (hood, RPM): # the inner search
exitVel, spin = MAP(RPM, hood) # the map knows only the shooter
v0 = exitVel + v_radial # (1) the situation enters here
landing = SIMULATE(v0, spin)
error = landing - d # (2) and here
keep the flattest candidate whose error is about zero
store that (hood, RPM) at cell (d, v_radial)
The map genuinely does not know about distance or radial velocity, and never needs to, because it describes the shooter rather than the situation. Those two numbers enter the simulation instead, at the points marked above: radial velocity as part of the ball's initial velocity, and distance as the thing the landing point is measured against.
Why the arrows seem to point opposite ways
The map takes (hood, RPM) as its input. The grid produces (hood, RPM) as its output. So how can one feed the other?
The reason is that the grid is searched rather than calculated. You never run the map backwards. You try candidate hood and RPM values, and the map plus the flight model tells you what each one does. The grid simply records which candidate won.
The map acts as the tester rather than the producer. It is rather like finding the key to a lock: you do not compute the key, you try keys and let the lock tell you which one fits.
One cell, worked through with real numbers
Let's take the cell for 4.0 m away, radial velocity zero. First comes the search, hunting for the RPM that lands on target at one particular hood angle:
try 2600 RPM → map says 5.82 m/s → lands 3.46 m short try 2800 RPM → map says 6.27 m/s → lands 4.01 m on target try 3000 RPM → map says 6.72 m/s → lands 4.60 m long
The map was looked up three times, RPM in and exit velocity out. It is worth being exact about what that means, because the shooting experiment is not being repeated. The map was measured once, on a bench, and what happens here is the computer reading that stored measurement again. At no point did it need to know the target was 4 m away. You knew that; the map only ever answered the question "what does 2800 RPM give me?"
Repeat that across hood angles and you are left with survivors, every one of which lands on target:
| hood | RPM | exit velocity from the map | lands | ±1° of hood → |
|---|---|---|---|---|
| 30° | 3000 | 6.72 m/s | 3.99 m | 7.8 cm off |
| 45° | 2800 | 6.27 m/s | 4.01 m | 0.2 cm off |
| 60° | 3000 | 6.72 m/s | 3.99 m | 8.3 cm off |
All three score, and 45° wins because it barely notices a degree of hood error, so the cell (4.0 m, 0 m/s) stores hood 45°, 2800 RPM. That single pair is what the surface will later be fitted through.
Where "the surface" comes from
It is worth stating plainly, because the word sounds far grander than the thing. The surface is the finished product, the only artifact that actually reaches the robot, and physically it is a dozen constants in a file.
The lineage is short:
- The sweep runs over roughly 180 cells.
- Each cell yields one winning (hood, RPM), such as the 45° and 2800 above.
- That leaves 180 rows of
(distance, radial velocity) → (hood, RPM). - Fit a quadratic through those 180 rows.
- Ship the coefficients.
hood = c₀ + c₁·d + c₂·v + c₃·d² + c₄·d·v + c₅·v² rpm = k₀ + k₁·d + k₂·v + k₃·d² + k₄·d·v + k₅·v²
That is six coefficients each, twelve in total. The robot substitutes its measured d and v, performs one line of arithmetic, and has its answer. The surface is simply those 180 winners, compressed.
It is called a surface because when you plot hood angle against the two inputs it forms a curved sheet over a plane. The name describes what it looks like rather than what it is.
Three ingredients go into the surface, and only two of them are empirical
It is worth being fussy here, because it is tempting to say the surface "comes from the exit-velocity experiment". That experiment supplies one ingredient of three.
| Ingredient | Where it comes from | What it supplies |
|---|---|---|
| the map | bench experiment · ~25 balls | where the ball starts |
| the two constants | field shots · ~20 balls | how strongly the air acts |
| the flight equations | physics — not measured | how the ball travels |
On top of those sit two choices that are not measurements of anything at all: which situations to solve for (the grid) and how to choose among the shots that work (the robustness metric).
bench experiment → the map field shots → the two constants → SWEEP → winners → fit → THE SURFACE physics equations → the shape of the flight
So the map is an input to the sweep rather than the producer of the surface. Take away any one of the three and nothing comes out the far end.
Being loose about this invites an obvious and unanswerable reply, "then why do we need the physics at all?", because that framing has quietly dropped the part doing the work. Note too that there are two empirical experiments here rather than one. The bench measurement and the field landings measure different things and feed different parts of the machinery.
The clean version: the experiment tells you where the ball starts, the equations tell you where it goes, the constants tune how much the air interferes, and the metric picks which of the working shots is worth keeping.
Map and surface, side by side
| The map | The surface | |
|---|---|---|
| About | your shooter | your situation |
| Made by | measuring, on a bench | computing, then fitting |
| Takes in | RPM, hood angle | distance, radial velocity |
| Gives out | exit velocity, spin | hood angle, RPM |
| Used | offline, inside the sweep | on the robot, 50× a second |
Notice that the surface outputs what the map takes in. That symmetry is no coincidence, since the sweep sits between them, feeding candidates through the map until it finds the one worth storing. And the surface is what replaces today's lookup table: the same job, better storage, plus a second input.
Where each ingredient entered
Distance was the target the landing point was compared against, and it never touched the map.
Radial velocity was zero in this example; otherwise it is added to the ball's initial velocity before simulating, and it never touched the map either.
The map was consulted once per candidate, purely to answer "what does this RPM produce?"
So the map is about your machine and the grid is about your situation. They meet only inside the simulate-and-compare step, and the map never learns anything at all about where you are standing.
Why exit velocity can be neither an axis nor an output
There is a quiet rule governing the grid. Its inputs must be things you can observe; its outputs must be things you can command.
Exit velocity is neither of those. You cannot ask a shooter for 14.2 m/s. You can only ask for 3200 RPM and let compression and slip decide what actually emerges, so it cannot be an output. And you cannot sense it mid-match, so it cannot be an input either.
That is precisely why the map exists. It is the translator between the quantity you can command and the quantity physics needs, and without it the flight model has no starting condition at all.
This does not mean you only shoot while driving at the goal
It is easy to read it that way, but radial velocity is signed, and the axis spans all three cases:
- Positive, driving toward the goal.
- Zero, moving purely sideways. That is the middle row of the grid, and it is precisely the case Fig 17's worked example uses: sliding past at 3 m/s with the goal 4 m ahead, radial velocity zero the whole way.
- Negative, retreating and shooting back over your shoulder.
Diagonal motion is not a special case either. Drive at 45° to the goal and you simply have both components at once: the tangential part goes to the turret and the aim offset, while the radial part indexes the grid. Nothing extra is needed.
Which is what makes the coverage complete, because any velocity in the plane has exactly two components and both are accounted for, one by the turret and one by the grid axis. For a robot driving on a flat field there is no third kind of motion, so two axes really is the entire space.
Two practical notes on that axis
The first is to size the range to your drivetrain. If it manages 4.5 m/s and the grid only samples −3 to +3, the surface quietly fails exactly where robots most often are, at the extremes.
The second is to pin down the sign convention before writing any of it. Positive-is-closing versus positive-is-receding is a coin flip, and getting it backwards is worse than ignoring the term altogether, because the shot is then wrong by twice the correction: it compensates hard in precisely the wrong direction.
The honest tradeoff
A fit buys you smoothness, tiny storage, noise-averaging, and answers outside the measured range. What it costs is the one guarantee the table had. A table cannot be wrong at a point you measured, whereas a fit can miss everywhere if you picked a shape the data doesn't actually follow: too low a degree and it can't bend enough, too high and it wiggles wildly between points. That is the same trust-versus-smoothness tradeoff as the model-versus-empirical argument, one level down.
Why "second-degree" specifically?
Degree is how many bends the equation is allowed to have. Degree 1 is a straight line and cannot bend at all, degree 2 bends once, and degree 5 can wiggle four times. So the choice is really about how much freedom you give this thing, and both extremes fail.
Degree 2 is the lowest degree that can bend at all, and one bend is about as much structure as the underlying physics has across a normal operating range. In two variables it works out to exactly six terms:
hood = c₀ + c₁·d + c₂·v + c₃·d² + c₄·d·v + c₅·v²
That d·v cross term is the one worth noticing. It encodes the fact that how much your radial velocity matters depends on how far away you are, an interaction a degree-1 fit cannot express at all, no matter how well you tune it.
The deeper reason, particular to this problem
Near a minimum, every smooth function looks like a parabola. At an optimum the linear term vanishes by definition, so the leading behaviour left over is quadratic, which is just Taylor's theorem.
Since this entire pipeline exists to find and describe an optimum, a second-degree model is the natural local description of one. That neatly explains why quadratics turn up twice here: once as the shape of the fitted surface, and once as the reason the sensitivity metric in Fig 21 is measured with second derivatives.
Treat it as a starting guess, not a derived truth
Nothing proves degree 2 is correct for your shooter. The procedure is to fit it and then look at the residuals, meaning what's left over after subtracting the fit. Scattered randomly means degree 2 was enough. Showing structure, such as a systematic bow in one region, means the model is too rigid.
And if it is too rigid, raising the degree is usually the worst available fix. Splitting the domain into regions, using a spline, or keeping a small residual-correction table all buy accuracy without destroying the extrapolation behaviour you adopted a fit for in the first place.
Which comes first, the model or the surface?
It is worth stating plainly, because it's easy to get backwards: the surface is the last step rather than a stepping stone towards the model. The model generates the data, and the surface merely compresses the winning answers so the robot can look them up quickly. There is no path from a surface back to a model, and none is needed.
The surface is a cache, not a source of truth
Because it sits at the end of a one-way chain, the surface is disposable. Recalibrate the model, change the drag constant, adjust the robustness weighting, and you regenerate the surface in seconds. Nothing is lost and nothing has to be re-measured, so committing to a surface commits you to nothing. Which also means fitting one is a genuinely low-risk thing to try first.
Two separate questions, constantly collapsed into one
It is very easy, and it happens constantly, to hear "lookup table vs. polynomial surface" and "empirical vs. physics model" as the same argument. They are two independent choices, and you make both.
- Where do the numbers come from? Measured by actually shooting balls, or computed by simulating trajectories.
- How is the answer stored? As rows in a table, or as a fitted equation.
A polynomial surface is only a storage format. It says nothing about where its numbers came from, and you can fit one straight through hand-measured shots without a line of physics anywhere. All four combinations exist:
Measured versus modelled: the real tradeoff
| Measured + table top-left cell — today | Model + fitted surface bottom-right cell — the new pipeline | Which choice decides this | |
|---|---|---|---|
| Trust | High — it's measured reality | Only as good as your understanding | data source |
| Outside sampled range | Fails | Extrapolates sensibly | storage format |
| Continuity | Piecewise, kinked | Smooth everywhere | storage format |
| Can optimise robustness | No — no gradients available | Yes | data source |
| Effort to build | An afternoon of shooting | Weeks, plus test-stand hardware | data source |
| Fails by | Being silently absent | Being confidently wrong | data source |
Read the right-hand column and you'll see that every property people actually argue about traces back to the data source rather than the storage format. Trust, optimisability, effort, and how it fails are all decided by whether the numbers were measured or simulated. Only smoothness and extrapolation come from choosing a fit over a table, and those are real benefits but cheap ones, available without any physics at all.
The obvious synthesis, a physics model with an empirically calibrated correction term, is the right instinct, and the last section sharpens it.
Why this matters most for drum shooters
A single-flywheel drum shooter has a much narrower window of shots that are accurate than a two-wheel or single-stream design. When the forgiving region is small, picking the right member of the solution family stops being a refinement and becomes the difference between scoring and not. That is the argument for why a team running one should care most.
The strongest objection, and how to answer it
There is one objection to all of this that carries real weight, and it goes roughly like this: any approach requiring a physics model gives up the great virtue of the measured table, that you need not understand the physical system very well, and can trust in its correctness regardless.
Put plainly, the table works because you measured it rather than because you understand why it works, while a model only works if your understanding is right. This is the most serious argument against the whole approach, and it deserves a real answer rather than enthusiasm.
What "trust in its correctness" really means
The table is correct by construction. It is not a claim about the world that could turn out to be false; it is a record of what happened. You wrote 42° at 3 m because balls went in at 42° at 3 m. There is no physics assertion inside it to be wrong about, which is exactly why "correctness" belongs in quotation marks.
A model inverts that. Its authority rests entirely on your understanding being right. Get the drag coefficient wrong, forget that spin decays during flight, or switch to a scuffed ball that grips differently, and the model keeps producing confident numbers everywhere with nothing inside it to signal they are wrong.
So the risk moves rather than shrinking. A table fails by incompleteness, giving no answer beyond where you sampled, and it fails visibly, at the edges. A model fails by wrongness, giving an answer everywhere that may be false, and it fails invisibly, anywhere at all.
The underrated half of the objection
"We don't need to understand the physical system very well" is also an organisational point rather than merely a technical one. A team's roster turns over every year. An approach that works without a resident aerodynamicist survives graduation, while one that depends on somebody remembering why MAGNUS_COEFF is 0.03 may not survive the person who chose it.
That is a legitimate engineering constraint rather than laziness, and it is the part of this objection most likely to be waved away in a technical discussion.
Where the objection can be answered
It rests on one assumption, that a model's correctness has to be taken on faith. It does not have to be, because you already own measurements and you can deliberately hold some of them back.
What this does to the tradeoff
The question stops being "measured data versus trusted physics", which has no resolution, and becomes "does the model pass the exam your measurements set for it?", a question with an answer you can go and get in an afternoon.
It also disposes of the fear of silent wrongness. A model that reproduces held-back shots isn't being trusted on faith, since it has been tested exactly where the table said it couldn't be. And the organisational worry survives intact and should be honoured: whatever you build, the validation script is what lets next year's team re-run the check without understanding the physics themselves.
What the whole pipeline is actually for
Before taking it apart phrase by phrase, it is worth having the thesis, because the pipeline reads as a grab-bag of unrelated tasks until you see what they are all in service of.
The whole thing in one paragraph
The traditional way to pick shots is tuning until they work. That gives one answer per distance and no idea whether it is a good answer. Replace it with: measure what the shooter actually does, build a physics model of the ball's flight on top of that measurement, then use the model to try every possible shot in every possible situation and keep the ones that still score when you are slightly off. Store the winners as an equation.
That is five clauses, and everything else in Part Two is detail hanging off one of them.
The thesis underneath all five is that the entire pipeline is scaffolding for a single capability, being able to evaluate a shot you never took. Every component exists to serve that, and the chain from physics down to workshop task is tighter than it first appears, since each step below is forced by the one above it.
Two things that make the passage hard to read
The first is that it is written in reverse. The destination comes first, with optimal trajectories and a quadratic fit, and the prerequisites come last. The build order is the exact opposite of the reading order, which is why it feels like it starts in the middle.
The second is that it is not really about accuracy. The shots already go in when everything is right. It is about them continuing to go in when something is slightly wrong, which, for a system with no feedback after release, is the only kind of accuracy anyone can actually engineer.
Why calibration is not optional
It is tempting to skip the two measurement steps and take the flight constants from a textbook. That fails, and fails invisibly. Published drag coefficients describe smooth rigid spheres in clean flow, whereas a game piece is light, hollow, seamed, scuffed and spinning hard. Book values for kd on a foam ball are not merely imprecise, they are confidently wrong, and the model would hand you plausible-looking numbers that were junk. Calibration is not a refinement of this pipeline; it is what makes the model mean anything at all.
The approach, phrase by phrase
Every concept in Part Two is now on the table, so we can state the whole approach in one pass. Here it is with the technical vocabulary marked:
Use precomputed optimal trajectories rather than a linear interpolation + time of flight map. Compute the optimal trajectory by finding the one whose error given a perturbation in exit velocity or hood angle is least, for every distance / radial velocity pair, then use a second-degree approximation to recover hood angle and shooter velocity. Build the physics model by empirically measuring and mapping shooter velocity and hood angle to the ball's exit velocity, which absorbs ball compression, slip and the rest; measure the backspin with a piece of tape and a high-framerate video. Then construct a second, physical model on top of the empirical one to get the actual flight path.
| The words | What it means | Explained in |
|---|---|---|
| "precomputed optimal trajectories" | Work the answers out offline on a laptop, before the match, rather than on the robot during it. | Fig 38 |
| "rather than linear interpolation + time of flight map" | The current setup: a 1D table blended between rows, plus a separate flight-time table driving the virtual-goal loop. | Fig 18, Fig 38 |
| "error given a perturbation in exit velocity / hood angle" | A perturbation is a deliberate small wiggle. Nudge the hood, nudge the speed, and see how far the ball moves. Pick the shot least disturbed by it. | Fig 21 |
| "for every distance / radial velocity pair" | Repeat for every cell of the two-input grid, which is precisely what makes the result a surface rather than a curve. | Fig 32 |
| "a second-degree approximation" | Fit a degree-2 (quadratic) polynomial through the winning shots. "Degree" is how much the equation is allowed to bend. | Fig 28, 15 |
| "empirically measure and map shooter velocity and hood angle to exit velocity" | Test-stand calibration: put in an RPM and a hood angle, measure the speed the ball actually leaves at. | Fig 23, left |
| "the issue of ball compression, slip, etc." | The messy contact physics nobody models well, so don't model it, measure it. | Fig 23 |
| "measure the backspin using tape and high-framerate video" | Tape gives a visible reference mark; a high frame rate lets you count rotations between frames. The Magnus term needs this number. | Fig 26 |
| "a second (physical) model using the empirical model" | Two stages chained: the empirical stage supplies exit conditions, the physical stage flies the ball from there. | Fig 23 |
What "optimal" means here
It does not mean most accurate. Every candidate shot in Fig 19 is perfectly accurate when the hood and flywheel hit their targets exactly. Optimal here means most forgiving: the shot that still scores when they don't. That reframing, from "find a shot that works" to "find the shot that survives being wrong", is what the entire pipeline is built around.
Unpacking the first clause: where the work happens
"Precomputed rather than linear interpolation + time of flight map" is the densest phrase in the whole approach, and it's really about when and where the thinking gets done.
The deeper change is the one printed at the bottom of the figure. Today, motion is handled as a correction: you work out the stationary answer, then patch it up for the fact that you're moving. Precomputing instead handles motion as an input, because radial velocity is one of the axes the answer was worked out over in the first place, so there is nothing left to iterate towards.
That is also exactly why the table grows from one dimension to two. The extra dimension is the thing the loop was compensating for.
In fairness to the current approach
The iteration is neither expensive nor fragile. It converges in two or three passes and costs almost nothing on a modern roboRIO, so precomputing is not about saving CPU time. The two real objections are that the loop cannot optimise for robustness, and that it quietly assumes a moving shot is just a displaced stationary shot, an assumption that drag breaks. That is the argument in Part Three.
One thing worth separating out
The usual complaint about the table-only approach is that it "leaves a lot to be desired in terms of accuracy and robustness". Those sound like one complaint, but they are two, with different causes and different fixes:
- Accuracy: does it go in when everything is right? This is limited by how much you sampled and how well you can extrapolate, and it's fixed with better data, or a model.
- Robustness: does it still go in when you're 2° off? This requires optimisation, which requires gradients, which requires a model. No amount of extra data delivers it.
Which exposes the useful thing. The change reads as one big yes-or-no, but it is really three separate questions. Two of them are just the axes of the grid from Fig 35, and the third isn't a square on that grid at all.
| The question | Your options | What it costs | Blocked by | |
|---|---|---|---|---|
| 1 | How is the answer stored? | a table / a fitted equation | a weekend | nothing |
| 2 | Where do the numbers come from? | measured / model-generated | weeks, plus test-stand hardware | nothing |
| 3 | What are you optimising for? | any shot that scores / the most forgiving shot | almost nothing | needs Q2 = model |
Questions 1 and 2 are genuinely independent, in that you can answer either one without touching the other. Question 3 isn't a storage or source choice at all; it's a goal, and it simply becomes available once question 2 is answered "model". Here are all three drawn on the grid you've already seen:
So the thing everyone actually wants, Q3, is invisible until you've paid for Q2, which is why the discussion keeps collapsing into a single "is the model worth it?". It also means the sensible order isn't the order it is usually presented in:
- Now: answer Q1 by fitting a surface to the table you already have. It's cheap, reversible, and it commits you to nothing, since the surface is a cache, as Fig 34 shows.
- Next: measure whether drag actually matters for your game piece. That single measurement, suggestion 4 in Part Three, is what decides whether Q2 is worth paying for.
- Then, only if drag matters: answer Q2, and Q3 comes almost for free with it.
The whole pipeline on one page
Here is everything above, assembled, and separated by when each part happens and where.
The single most useful thing to hold onto is that this runs in three phases, and they are wildly different in character. One happens in a workshop with a camera and about forty-five balls. One happens on a laptop, once, and never again. One happens on the robot fifty times a second. Confusing them is what makes the approach feel like a tangle.
The order is forced, so nothing here can be done out of sequence
1. MEASURE bench, ~25 balls → the map
2. MEASURE field, ~20 balls → landing points
3. FIT ① tune DRAG and MAGNUS → a calibrated model
4. SWEEP
4a build the grid — every (cell, candidate) → its miss distance
4b difference it — neighbouring cells give ∂e/∂θ and ∂e/∂v
4c score and select — best candidate per cell → ~180 winners
↳ the grid itself is discarded here
5. FIT ② quadratic through the ~180 winners → THE SURFACE
6. SHIP the coefficients
The grid inside step 4
It's easy to miss, and it has a name. The sweep does not try candidates one at a time and forget them. It builds a grid, every cell paired with every candidate, each entry holding that shot's miss distance, and then works on it. Roughly 180 cells by 200 candidates is about 36,000 rows, a few megabytes, nothing at all.
It has to exist because step 4b needs neighbours. A central difference is (right − left) ÷ 2Δ, so you cannot evaluate one candidate in isolation and discard it: its neighbours must be present at the same moment for a slope to be taken. This is precisely the "table" meant by the remark about numerically estimating the derivatives.
It is not listed as a pipeline artifact because nothing survives it. The map is carried into step 4; the surface is carried out of step 5. The grid is built, used, and thrown away without ever leaving step 4, and only the ~180 winners emerge.
The derivatives are for selecting, not for fitting
This is worth separating, because "derivative" and "fit" sitting near each other suggests one feeds the other. It does not.
∂e/∂θ , ∂e/∂v → decide WHICH ~180 points the fit → draw a curve THROUGH those points
The fit never sees a derivative. It is handed 180 rows of (distance, radial velocity) → (hood, RPM) and performs ordinary least squares. It has no notion that those particular shots were chosen for being forgiving; to the fit they are simply points.
What the derivative decides is which point each cell contributes. Cell (4.0 m, 0) has valid shots at 30°, 45°, 60° and everywhere between, and all of them score. The derivative is what breaks that tie, and 45° is the row that goes forward.
What would happen if you skipped the derivative entirely
You would still get a surface. It would be fitted through arbitrary valid shots instead of forgiving ones, so it would work, it would be worse, and nothing in the fit would tell you. The derivative's whole influence on the finished surface runs through which winners it picked.
Three different derivatives live in this pipeline, and only one is yours
| Derivative | What it measures | Who uses it |
|---|---|---|
| ∂e/∂θ , ∂e/∂v | miss distance against actuator error | you, the robustness metric |
| ∂(residual²)/∂(kd, km) | fit error against the constants | inside fit ①, and only with a gradient method, since a grid search never forms it |
| ∂(residual²)/∂(coefficients) | fit error against the surface coefficients | inside least squares, automatically and invisibly |
Only the first is ever yours to compute or reason about. The other two are internals of solvers you did not write, and whenever this tutorial says "derivative" it means the first.
Each step is blocked on the one before it. You cannot fit the constants without the map, because the model would have no starting velocity. You cannot sweep without calibrated constants, because you would be simulating the wrong physics a hundred and eighty thousand times. And you cannot fit the surface without the sweep's winners, because there would be nothing to fit through.
The two measurements sit at opposite ends of one flight
Steps 1 and 2 are both labelled "measure", which makes them look like the same activity done twice. In fact they point cameras at opposite ends of the ball's journey and answer different questions.
The short version
Measurement ① tells you what your shooter does, and measurement ② tells you what the air does. One is the beginning of the ball's journey, the other is the end, and the gap between what you put in and what came out is the whole of the physics you are trying to pin down.
It's worth noting that ② needs far cheaper equipment: no high-speed camera at all, just a tape measure and something to mark where the ball hits.
What each measurement produces, and what Fit ① does with it
Both measurements produce a small table of rows. Here is what is genuinely in them.
| MEASURE ① — the map · about 25 rows | |||
|---|---|---|---|
| RPM | hood | → exit velocity | spin |
| 2500 | 30° | 5.6 m/s | 6.3 rev/s |
| 2500 | 45° | 5.6 m/s | 6.9 rev/s |
| 3000 | 30° | 6.7 m/s | 7.5 rev/s |
| 3000 | 45° | 6.7 m/s | 8.2 rev/s |
| MEASURE ② — landing records · about 20 rows | |||
|---|---|---|---|
| RPM used | hood used | stood at | → landed at |
| 2800 | 40° | 4.0 m | 4.35 m |
| 3000 | 35° | 5.0 m | 4.80 m |
Notice what is missing from the second table
Exit velocity. You never measure it in the field; all you can record there is what you commanded and what happened. That single absence is what forces the ordering, and it is the clearest way to see why.
So Fit ① walks every landing record through the same three steps:
record #7: RPM 2800, hood 40°, landed at 4.35 m
1 look up the MAP with (2800, 40°) ← measure ① enters here
→ exit velocity 6.27 m/s, spin 7.5 rev/s
2 simulate with those, plus a GUESS at the constants
try kd = 0.010, km = 0.004 → predicted landing 4.51 m
3 compare against what actually happened ← measure ② enters here
residual = 4.51 − 4.35 = +0.16 m
Repeat for all twenty records, square the residuals and add them up. That gives one score for one guess at the pair (kd, km). Then sweep a grid of guesses and keep whichever scored lowest.
So, precisely
What goes into Fit ①: measurement ① supplies the starting conditions for each shot, by way of the map, and measurement ② supplies the truth to compare against, in the form of actual landing points.
What is being fitted is exactly two numbers, DRAG_COEFF and MAGNUS_COEFF. Everything else in the simulation is either measured or exactly known, which is why a plain grid search suffices.
And ① must come first because measurement ② records commands, not physics. The model cannot simulate "2800 RPM"; it needs metres per second. The map is the only thing that converts one into the other, so without it your twenty landing records are unusable: you would know where the balls went, but not what was launched.
Are drag and Magnus really the only things acting on the ball?
No, but they are the two biggest uncertain ones, and that distinction is exactly why the count is two.
| Acting on the ball | In the model? | Carries an unknown? |
|---|---|---|
| Gravity | yes | no, 9.81 is 9.81 |
| Drag | yes | yes · kd |
| Magnus | yes | yes · km |
| Buoyancy | no | — |
| Added mass | no | — |
| Spin decaying during flight | no | — |
| Drag varying with speed | no | — |
| Sidespin curving the shot laterally | no | — |
| Ball scuffing and asymmetry | no | — |
Gravity is modelled but fitted for nothing. So of the three forces actually in the equations, only two carry an unknown, which is where "two constants" comes from.
Some of those omissions are not small
Take a typical game piece, roughly 270 g at 9.5 inches, which works out to a bulk density near 37 kg/m³, about thirty times lighter than water:
- Buoyancy ≈ 3.3% of its weight
- Added mass ≈ 1.7% of its inertia
That's around 5% combined, and neither appears anywhere in the model. For a light hollow ball that is not a rounding error.
How the fit quietly absorbs them
kd and km do not emerge as the true physical coefficients. They emerge as whatever values make the model match reality, which silently includes compensating for everything left out.
Spin decaying in flight weakens Magnus on average, so the fitted km lands below the true instantaneous value. Buoyancy makes the ball fall a little slower, and some of that is absorbed into kd. The model ends up wrong in its details and right on average, which is perfectly serviceable provided you do not extrapolate far outside the conditions you calibrated in.
This is precisely where the two-observable test earns its keep. If absorption is not enough, meaning no pair of constants can match the landing point and the descent angle at once, then the model is reporting that its structure is inadequate rather than its numbers mis-tuned. That is how you would discover spin decay matters without ever having modelled it.
The one omission that cannot be absorbed
Sidespin. If the rollers grip unevenly the ball picks up a spin component that curves it sideways, and a model working only in the shot plane cannot express that at all, since no value of kd or km compensates for a force pointing out of the plane.
It shows up as a persistent left-or-right bias that survives every recalibration. It's worth knowing about, because the natural instinct is to chase it in the aiming code, where it is not.
Why this order is so easy to scramble: the word "fit" means two different things
| Fit ① — calibration | Fit ② — the surface | |
|---|---|---|
| What is fitted | 2 constants | 12 coefficients |
| Fitted to | ~20 measured landings | ~180 computed winners |
| Fitted against | reality | the model's own output |
| Purpose | make the model correct | make the answer fast |
| When | before the sweep | after the sweep |
They sit on opposite sides of the sweep, and they do opposite jobs: one makes the model trustworthy, the other compresses that model's conclusions into something a roboRIO can evaluate. Merge them in your head and the ordering collapses immediately.
A short way to keep them apart: Fit ① faces reality. Fit ② faces the computer.
The three questions this map answers
What is physical? Only the top band, about forty-five balls, once, and then never again.
What is computed? The middle band, once, on a laptop, before the season. Nobody is waiting on it, so it can take hours.
What runs during a match? Only the bottom band: read two numbers off a surface, iterate a couple of times, point the turret, fire. Everything expensive has already happened.
Where the earlier figures sit on this map
Fig 3 is the bottom band alone, drawn larger. Fig 23 is the boundary between the top and middle bands. Fig 31 zooms into the sweep box. Fig 38 contrasts the bottom band as it works today with how it would work once the middle band exists. If any of them felt disconnected, this is the frame they belong to.
Part Three: Four refinements worth making
The pipeline as described is sound. There are four places worth pushing on. The first two make the metric honest, the third stops it optimising something dangerous, and the fourth decides whether to do any of it at all.
1. Weight the sensitivity metric by real uncertainty
The metric as written adds a term measured in miss-per-degree to a term measured in miss-per-metre-per-second. Those units don't combine. Adding them assumes your hood error and flywheel error happen to be numerically comparable, and they aren't. Weight each one by how badly you actually control that axis:
σ is your measured closed-loop tracking error on each axis. This turns a heuristic into actual variance propagation, and it's a one-line change.
2. Handle distance uncertainty, but not by adding a term
On most robots, knowing where you are is a bigger error source than hood or flywheel control. Vision latency, tag ambiguity and odometry drift all feed into it. So it is tempting to add a σd2(∂e/∂d)2 term alongside the other two, since it looks like the obvious way to complete the metric.
Why the distance term does nothing
Work through what a distance error actually does. You believe you are at d̂; you are really at d; the error is δ. The sweep only keeps candidates that land at d̂, because that is the filter it applies. So every surviving candidate lands at d̂, and every one of them misses by δ.
The miss is the same whichever candidate you picked. ∂e/∂d is therefore identical across the whole cell, a constant, and adding a constant to every score leaves the winner exactly where it was. The term is inert.
Which points at a general rule worth having: a term only earns its place in a selection metric if its value differs between candidates. Hood and flywheel sensitivities genuinely do differ, which is the entire basis of the method. Distance sensitivity does not.
None of which makes distance error harmless. It is real, often dominant, and simply cannot be reduced by choosing a different shot, so it does not belong in a metric whose only job is choosing between shots.
Where distance error does belong: the acceptance window
Shot choice cannot shrink the miss caused by a bad range estimate. What it can change is how large a miss the goal will still accept.
A steeply descending ball drops into the opening; a shallow one has to thread it and risks clipping the rim on the way. So the window your miss has to land inside depends on the trajectory you chose, even though the miss itself does not:
That makes entry angle considerably more load-bearing than suggestion 3 alone implies. It does more than reject balls at the rim: it is the only channel through which choosing a different shot buys you any tolerance to pose error at all.
3. Make entry angle a hard constraint rather than part of the cost
A ball arriving too shallow bounces off the rim no matter how insensitive the trajectory is. Don't let a low entry angle be traded away against good sensitivity. Filter the candidate list by minimum entry angle first, then optimise sensitivity over what survives.
4. Lead with drag, the real justification for the 2D table
This one matters most when making the case for the work at all, because without it the whole project is unnecessary.
With no air drag, shooting while moving is exactly equivalent to shooting at a displaced virtual goal. The iterative "guess the flight time, shift the target, re-solve, repeat" trick is essentially exact, and you don't need a 2D table at all. You need the stationary table plus two iterations.
With meaningful drag on a light game piece, that equivalence breaks. A moving shot becomes a genuinely different trajectory, with a different shape and a different entry angle, because drag acts on the ball's total velocity, which now includes the robot's contribution.
The argument to lead with
The reason to build the 2D (distance, radial velocity) surface instead of iterating on the 1D table is drag. If drag is negligible for your game piece, the 2D table buys you almost nothing and the current approach is fine. Measure that first, because it decides whether the entire project is worth doing.
Where this leaves you
Try not to frame it as model versus empirical. That framing is what gets people stuck, and you do not have to pick.
- Fit the model's free parameters, meaning drag coefficient, Magnus coefficient and effective exit velocity per RPM, to your stationary shot data.
- Keep the leftover residuals as a correction surface layered on top.
- Use the model only for the two things a table fundamentally cannot do: smooth extrapolation, and supplying gradients for the robustness optimisation.
The measured table stays ground truth, and the model becomes a well-behaved interpolator with derivatives. That preserves exactly the "you can trust its correctness" property you do not want to give up, while unlocking the optimisation you do, which dissolves the tradeoff instead of picking a side.
Limitations
Part One is exact maths. The formula is derived, and the only open item is verifying the rotation direction in simulation. Part Two is an engineering judgement call with no single right answer: the recommendation above is one opinion, and the drag measurement in point 4 is the cheapest way to find out whether the choice needs making at all.
Glossary
omega / OMEGA_MAX, since identifiers are kept to plain ASCII.