Shooting while driving is two problems wearing one coat. One is about where to point the turret while the robot moves. The other is about how hard to shoot once it is pointed. This tutorial builds both from zero — the geometry, the formula, a bug worth knowing about, and the design trade-off underneath it all.
Terms with a dotted underline have definitions — hover them, tap them, or tab to them.
Everything below rests on one distinction, so it's 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.
A robot shooting while driving has two independent jobs, owned by two different mechanisms, with two completely different characters.
Job one — point the turret. Pure geometry. No physics at all, just where things are and how fast they're moving. It has one exact answer — a short two-term formula, derived in full in Part One.
Job two — pick the hood angle and flywheel speed. 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: there are three things in play. The situation — 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 aren't the same problem, and separating them is most of the clarity.
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²
And how is a velocity actually written down? 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 — 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 θ̇.
And it 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 — 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.
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 — the turret fixes that by aiming off-target. The toward-or-away part makes it land long or short — 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 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. One number, but carrying a sign, and the sign is doing real work:
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, not a vector and not a speed.
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.
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.
Three mechanisms, but only two problems — and two answers, not three.
It's natural to read "turret, hood, flywheel" as three mechanisms, so three formulas. It isn't. 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 — 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.
Job one. Geometry only — and it works out to a formula with exactly two terms.
There are two completely different kinds of "fast", and every formula below depends on not confusing them.
They're independent: a robot spinning on the spot has zero linear velocity and plenty of angular velocity.
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". 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:
The reason it earns its keep is that the parts end up handled by different machinery: the tangential half goes to the turret, the radial half to the shot solver; the rotation gives ω, 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.
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. From Latin trans + latus, "carried across" — language translation carries meaning across, geometric translation carries an object across.
Rotation is its counterpart, and every possible rigid motion is some mixture of exactly the two:
That pairing is why the turret formula ends up with exactly two terms: v⊥ comes from the translation, ω from the rotation. And it's worth noting a tank drive can only translate along the way it faces — 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.
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:
ω — compact, and it's the universal convention across physics and robotics, so every other source you read will match.omega, OMEGA_MAX, or in WPILib's verbose style angularVelocityRadiansPerSecond. 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 — and not because it is a capital Ω. Different reason entirely.
In this notation, upper and lowercase Greek letters are treated as separate symbols rather than as one symbol in two sizes.
Ω = 3 rad/s would 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.
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 — same ω — 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 — arc length per second. Since arc = r × angle, dividing both sides by time gives v = r × ω directly. Same relationship as Fig 10, used in the opposite direction.
Worth separating, because they are genuinely different quantities that happen to share a letter:
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.
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 — and 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":
r = 0, so v = 0v = 0.3ωv = 0.45ωOne ω, three different linear speeds, because three different questions were asked.
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.
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 |
A fair objection, and the answer is the key to the whole unit. An angle in radians is a ratio — it is 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.
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 — 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.
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.
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, here it is plainly.
A derivative is a rate of change: how much the output moves when you nudge the input a little. Three ways of holding the same idea:
θ̇, means specifically "per second". The curly ∂ means "several inputs exist; wiggle this one and hold the rest still".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".
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 — 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.
Reasonable thing to assume, and worth heading off, because the answer is no — though for a subtle reason.
Algebra rewrites. Differentiation transforms. Turning 2x + 6 = 10 into x = 2 is a rearrangement: same statement, different clothes, nothing lost, and you can walk it back. Turning y = x² into 2x is not that at all — those two are not equal. It's a brand-new function answering a different question about the original: not "what is its value" but "how steep is it".
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, not the definition.
Underneath every rule is one idea: rise ÷ run, as the run shrinks towards nothing — the right-hand panel above.
Which 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. That is not a simplified stand-in for calculus. That is the definition, applied directly — the only thing missing was the name.
Worth meeting now, because both halves are already inside your shooter and it's easier to keep them apart than to untangle them later.
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
The flight model is integration. That twenty-line loop in Part Two is numerical integration: you know the acceleration — gravity, drag, Magnus — and you want position, so you accumulate. vx += a·dt integrates acceleration into velocity; x += vx·dt integrates velocity into position. 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.
Differentiation is mechanical: rules exist that always work. Integration frequently has no closed-form answer at all.
Which explains something that comes up later. There is no closed-form range equation once drag is included — 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 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.
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, no math required.
Now drive. Two completely separate things start ruining your aim, and it's worth being very clear that they are separate — that's the whole insight behind the formula.
The turret is bolted to the chassis. 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.
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:
Which means the same physical motion changes category depending on where you happen to be standing:
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 — but 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: it is not "am I approaching or strafing", it is 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:
The arc is scaffolding for the derivation, not 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)
No arc lengths anywhere.
Everything in that snippet happens in two dimensions, flat on the floor. 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.
The robot doesn't really travel along an arc — it moves in a straight line, which is a chord, not 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.
Not in the physics — 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.
Add the two effects, flip the sign because the turret has to cancel them rather than follow them, and you have the whole formula:
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.
But there's a neater way to read it. Both terms describe something rotating:
And note which side of the equation the turret is on. Its own rotation never appears on the right — 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 it is not "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.
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, faster the further out you go, related by v = ω × r as in Fig 4. Both describe the same motion; they answer different questions about it.
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: tip and midpoint both go round once an hour, because for the tip to go faster the hand would have to stretch.
The contrast makes it sharp. A whirlpool has no single ω — 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; it is 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
And that is not a quibble — 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 ω, same number wherever you mount it.
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.
| 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 — 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);
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 here it is taken 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 — not 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?"
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.
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; the PID mops up whatever the model got wrong. You want both — the FF alone will drift, and the PID alone will always lag.
Saying the formula is "exact" is a strong claim, so here's the machinery behind it. It's a two-step recipe, and both steps are less intimidating than their names.
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, not the field, so you subtract the robot's own heading:
atan2 is nothing more exotic than "what direction is that point from me".That subtraction is worth dwelling on, because it's a frame conversion, and frames are where most sign bugs live. There are two ways to describe any direction on a field:
(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.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.
Velocity, 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 — 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, and skipping it is the bug discussed further down.
drive encoders ─┐ steer encoders ─┼─▶ kinematics ─▶ (vx, vy, ω) robot-relative ─▶ + gyro ─▶ field-relative v module geometry ─┘
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. It is measured, not 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.
Robot-centric is 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.
Field-centric is 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.
Look again at θturret = φ − θrobot. The bearing φ is field-centric — 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. That's not a fudge factor, it's a change of address.
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.
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.
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:
ω·Δt. Since the turret angle is measured from the chassis, that subtracts directly.v⊥·Δt, which swings the line of sight by (v⊥·Δt) ÷ d radians — 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:
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.
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 — and notice how the total demand spikes when d gets small.
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 — x is downfield, 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.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()).
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 — but 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² — 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.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.
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.
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.
Diagnosing it is only half a job. There are real fixes, and the best of them comes straight out of the formula itself.
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.
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 isn't a control fix, it's a path-planning constraint: 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.
d to 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.1/d and you demand less precision exactly where precision is hardest to get.1/d² amplification — it doesn't cancel it, but the close-range case is less dire than the raw formula suggests.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.
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. 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 ┘
| 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".
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.
Turrets have hard stops. One with ±200° of travel cannot always take the short route — 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, not the real one — the goal shifted back to compensate for the ball's drift. Which is the subject of the next section, and the one thing this half of the problem cannot supply for itself.
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.
Because it inherits the robot's velocity. The ball doesn't leave carrying only the shooter's exit velocity — it leaves carrying exit velocity plus robot velocity. 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.
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.Relative to the robot: 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: 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)
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.
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.
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 not merely a displaced stationary one; it is a genuinely different trajectory.
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.
Job two — box 2 in Fig 3. This is where the clean answers run out, and a genuine design choice appears.
Recall what this box has to produce: given a distance, a hood angle and a flywheel speed as a matched pair, plus the time of flight the turret is waiting on. Not two independent numbers — one pair, because the two are locked to each other.
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.
There's no clever source. You make it by shooting, on the practice field, by hand:
That's the whole method, and it's genuinely an afternoon's work. It's also why the approach earns so much trust: every row is a shot that actually went in.
Key the table on what your own vision system reports, not 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.
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. Thirty-odd cells, each requiring a repeatable moving shot.
That is not an afternoon; it's not 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) — not that hand-measuring is inaccurate, but that hand-measuring a two-dimensional grid isn't something you can physically do.
That word interpolate is doing quiet work, so here's exactly what it means — the answer is that 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.
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 — blend between the four surrounding grid cells instead of two rows. Same idea, applied twice.
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. It is the cheapest problem here to fix — 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 — 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.
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 — no model, no test stand, 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.
Which reframes the complaint usefully. The table isn't 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.
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.
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.
Before the next step, 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, no second chance.
Everything you can influence happens before release — and that is where all the feedback lives too.
Because there's no feedback after release, every error still present at that instant is permanent — 2° of hood, 100 RPM, 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 is not a refinement on top of accuracy; for an open-loop system it is the accuracy strategy — and it's exactly what the next step is about.
Your hood will never be at exactly the commanded angle. Your flywheel will never be at exactly the commanded RPM. So the right question isn't "does this shot score when everything is perfect" — everything in Fig 19 does. It's "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:
It's just the slope of the left-hand curve in Fig 21 — "how many centimetres of miss do I get per degree of hood error." A big number means fragile; 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.
The metric needs two derivatives. Derivatives are slopes. And 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 — 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.
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.
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.
Worth saying plainly, because "derivative" makes this sound like a maths problem when it's really a simulation problem. Robustness is a derivative — "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.
It is this: you must be able to evaluate shots you never took.
Finite differences need e(θ + 0.5°) — 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. The dependency isn't on calculus — it's on simulation.
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.
Some write physical model; this tutorial says physics model. Same object — the flight equations below. (In ordinary English "physical model" can mean a physical object, a scale mockup. Not the meaning here.)
Watch out for something subtler though: the approach describes two models and calls both of them "model".
The word physical there is doing real work: it exists to mark the second model as not the first.
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; the other three would all have to be rebuilt. That is the cleanest test for telling them apart.
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 a set of scales reads 4% light.
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 boot 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.
They are both handed to the loop, but they enter it in quite different ways.
vx, vy. It initialises the integration and is never touched again — it is the constant of integration mentioned earlier.| 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 |
For a hooded shooter the ball rolls along the stationary hood on its way out, which gives a spin ratio near 1 — 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% |
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.
With exit velocity alone you would get the range approximately right and the shape badly wrong. Wrong shape means wrong entry angle; wrong entry angle means the wrong acceptance window — and 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.
Both appear throughout this tutorial, so: 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. Route versus 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.
And 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. You cannot extract a landing point from a stopwatch.
Those two capitalised names are the model's only unknowns. Each 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, twenty data points — 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. |
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's a small enough number to pin down confidently from a modest amount of shooting — which is the difference between "build a physics model" being a weekend's work and being 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 are three different things, and discussions swap between them freely. They're easy to keep straight once separated:
| Word | What it actually is | Where it happens |
|---|---|---|
| Model | The equations. A description of how a ball behaves — 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.
Trajectory simulation — running the ball-flight equations above. That's what Part Two means throughout.
Robot-code simulation — running your actual robot program against a fake robot, which is how you'd test for the turret bug back in Part One.
Both mean "run it on a computer instead of on the field", but they simulate completely different things. Same word, unrelated activities.
Crucially, model and measurement are not 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.
It's still essential — it just has a different job. The split is deliberate, and it's the cleverest part of the approach.
Stationary — and it barely matters whether that's a purpose-built stand or the robot itself. Never while driving.
The reason moving is not merely unnecessary but strictly worse: what you are measuring is the robot-frame exit velocity, and that is unaffected by driving. 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. Same signal, more noise, 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 — 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.
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.
Keep two activities firmly apart:
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.
Two calibrations, done in that order, and they are different kinds of measurement — which is worth being clear about because they get bundled under one word.
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 |
Roughly fifty-five physical shots in total, ever. Everything afterwards reads those fifty-five shots' worth of numbers over and over.
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. It is not 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 — 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. 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
Two parameters means a plain 2D grid search is enough — sweep both across a plausible range and take the minimum, no optimiser required. And it makes plain why the map has to come first: the model cannot simulate anything without knowing where the ball started.
"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.
Worth reframing the question slightly. Statistical significance belongs to hypothesis testing — "is there an effect at all?" — and that is not what is being asked. 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. Taking 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 is the knee. 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.
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.
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:
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.
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.
And hold back about a quarter of them 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.
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 one — practice balls behaving 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.
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.
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: 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.
Batch spread is just another source of uncertainty, so it belongs in the weighted metric alongside the others:
Batch variation is not a threat to the method. It is an input to it. The machinery already handles the problem, provided you tell it the problem exists.
And 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.
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 — and unlike most mistakes in this tutorial, it leaves no obvious symptom at the time.
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.
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.
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.
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 — useless for fitting a coefficient, but a perfectly good sanity check that your model's predicted entry angles are not nonsense.
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 — that 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.
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.
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 not a tuning failure — it is the model reporting that its structure is inadequate. 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.
Both, on different shots. Including entry angle in the residual improves conditioning; 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.
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, get pushed the other way — Newton's third law, nothing more exotic. Backspin flings air downward, so the ball gets pushed upward.
A flywheel-and-hood shooter grips the ball on one side only, so it cannot avoid imparting heavy backspin — that's a side effect of the mechanism, not a choice. And because the game piece is light and hollow, the Magnus force is a large fraction of its weight, not a rounding error.
So a model that leaves spin out isn't slightly off, it's 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.
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.
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.
To compute that slope you need to be able to ask what-if questions — "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. That's 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.
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.
Both terms 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.
For every situation the robot could be in — some distance, 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. Not the shot with the least error — every valid shot has zero error when everything is perfect. 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 — 55°, 3200 RPM | lands ±8 cm off | ±11 cm | keep |
| Shot B — 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.
"A perturbation" — which one, how large, 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.
"Distance from the centre of the hub" is quietly load-bearing as well. Measuring distance from the centre rather than asking "did it go in" gives a continuous quantity — which 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, folded into one number.
# 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
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.
Use central differences, not forward ones. (right − left) / 2Δ has error proportional to Δ², while (next − this) / Δ is only proportional to Δ. Identical cost, noticeably better answer.
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
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.
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.
hood = 51.2 − 4.7·d + 0.21·d². Three coefficients replace the entire table, and 3.5 m isn't looked up, it's computed.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.
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 — not a second direction on the field, just a second thing you must know before you can answer.
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.
Negative means receding — driving away from the goal, range 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:
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 — the shot is not merely harder, it is infeasible. You can shoot while advancing at speeds you could not shoot while retreating.
vx and vy never budge — 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, not 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.Easy to read that animation backwards and conclude that your speed somehow emerges from the two components. It is 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 — just 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: combining 10 with 1 gives 10.05, so 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. Identical speed throughout; completely different demands. Which is the sharper way to say why shooting on the move is hard — not that speed is the enemy, but that speed pointed the wrong way is.
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. Second-order, and normally ignored.
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, not chosen.
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 — only its direction differs.
Tangential velocity tilts the shot plane, and that is precisely such a rotation. It is therefore absorbed by a symmetry the problem already possesses — free, exact, 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 — not a convention, but a question of which one the symmetry can swallow.
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.
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 — it describes the shooter, not 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.
The map takes (hood, RPM) as its input. The grid produces (hood, RPM) as its output. So how can one feed the other?
Because the grid is not calculated — it is searched. 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 is the tester, not the producer. 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.
Take the cell for 4.0 m away, radial velocity zero. First 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, exit velocity out. Worth being exact about what that means: 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 "what does 2800 RPM give me?"
Repeat 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. 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.
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:
(distance, radial velocity) → (hood, RPM).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²
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, not what 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 |
Plus 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
The map is an input to the sweep, not 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.
| 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 is not a coincidence — 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: same job, better storage, plus a second input.
Distance — the target the landing point was compared against. Never touched the map.
Radial velocity — zero in this example; otherwise added to the ball's initial velocity before simulating. Never touched the map.
The map — consulted once per candidate, purely to answer "what does this RPM produce?"
So: the map is about your machine, 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.
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. 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.
Easy to read it that way, but radial velocity is signed, and the axis spans all three cases:
Diagonal motion isn't 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, the radial part indexes the grid. Nothing extra is needed.
Which is what makes the coverage complete: any velocity in the plane has exactly two components, and both are accounted for — one by the turret, one by the grid axis. For a robot driving on a flat field there is no third kind of motion. Two axes really is the entire space.
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.
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: the shot is then wrong by twice the correction, because it compensates hard in precisely the wrong direction.
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.
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; degree 5 can wiggle four times. So the choice is really "how much freedom do I 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.
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 — that's 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's 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.
Nothing proves degree 2 is correct for your shooter. The procedure is: fit it, then look at the residuals — 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.
Worth stating plainly, because it's easy to get backwards: the surface is the last step, not a stepping stone towards the model. The model generates the data; 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.
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.
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 not. They're two independent choices, and you make both.
A polynomial surface is only a storage format. It says nothing about where its numbers came from — you can fit one straight through hand-measured shots without a line of physics anywhere. All four combinations exist:
| 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: every property people actually argue about traces back to the data source, not 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 — real benefits, but cheap ones, and 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.
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's the argument for why a team running one should care most.
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, not because you understand why it works — and 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.
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, 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 — no answer beyond where you sampled — and it fails visibly, at the edges. A model fails by wrongness — an answer everywhere, possibly false — and it fails invisibly, anywhere at all.
"We don't need to understand the physical system very well" is also an organisational point, not merely a technical one. A team's roster turns over every year. An approach that works without a resident aerodynamicist survives graduation; 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, not laziness — and it is the part of this objection most likely to be waved away in a technical discussion.
It rests on one assumption: that a model's correctness has to be taken on faith. It doesn't — because you already own measurements, and you can deliberately hold some of them back.
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; 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.
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 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.
Five clauses. Everything else in Part Two is detail hanging off one of them.
And the thesis underneath all five: 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. Each step below is forced by the one above it.
It is written in reverse. The destination comes first — optimal trajectories, 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.
And 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.
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; 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 — 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.
Every concept in Part Two is now on the table, so the whole approach can be stated 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 — not 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 |
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 the entire pipeline in four words.
"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 — work out the stationary answer, then patch it up for the fact that you're moving. Precomputing handles motion as an input — 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.
The iteration is neither expensive nor fragile — it converges in two or three passes and costs almost nothing on a modern roboRIO. 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, which is the argument in Part Three.
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're two, with different causes and different fixes:
Which exposes the useful thing: the change reads as one big yes-or-no, but it's really three separate questions. Two of them are just the axes of the grid from Fig 35 — 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 — 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:
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.
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 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.
And 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 — only the ~180 winners emerge.
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 — all of them score. The derivative is what breaks that tie, and 45° is the row that goes forward.
You would still get a surface. It would be fitted through arbitrary valid shots instead of forgiving ones — 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.
| 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 — 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.
Steps 1 and 2 are both labelled "measure", which makes them look like the same activity done twice. They are not — they point cameras at opposite ends of the ball's journey and answer different questions.
Measurement ① tells you what your shooter does. 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.
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.
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 |
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.
What goes into Fit ①: measurement ① supplies the starting conditions for each shot, by way of the map. Measurement ② supplies the truth to compare against, in the form of actual landing points.
What is being fitted: 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 why ① must come first: 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.
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.
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:
Around 5% combined, and neither appears anywhere in the model. For a light hollow ball that is not a rounding error.
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 — perfectly serviceable, provided you do not extrapolate far outside the conditions you calibrated in.
Which is precisely where the two-observable test earns its keep. If absorption is not enough — if no pair of constants can match the landing point and the descent angle at once — 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.
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 — 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. Worth knowing about, because the natural instinct is to chase it in the aiming code, where it is not.
| 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.
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.
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.
The pipeline as described is sound. These are the 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.
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, which they aren't. Weight each 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.
On most robots, knowing where you are is a bigger error source than hood or flywheel control. Vision latency, tag ambiguity, odometry drift. So it is tempting to add a σd2(∂e/∂d)2 term alongside the other two — it looks like the obvious way to complete the metric.
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̂ — 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.
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:
Which makes entry angle considerably more load-bearing than suggestion 3 alone implies. It is not merely a rim-rejection constraint — it is the only channel through which choosing a different shot buys you any tolerance to pose error at all.
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.
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. The moving shot isn't a stationary shot at a shifted target; it's 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 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 — it decides whether the entire project is worth doing.
Don't frame it as model versus empirical. That framing is what gets people stuck, and you do not have to pick.
The measured table stays ground truth. 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.
omega / OMEGA_MAX, since identifiers are kept to plain ASCII.