Guidance, Navigation and Control. Personal project.
A closed-loop 6-DOF GNC implementation for a pitched baseball. The ball carries one IMU and three reaction wheels, utilizing the magnus effect to steer. Over 0.45s of flight it estimates its state, solves for the spin that puts it on the target, and actuates itself onto that spin. Strike rate goes from 25% to 100%.
Run a single pitch comparing the GNC implementation with a normal pitch, using python in browser.
A 90mph fastball reaches the plate in under half a second. In that time the Magnus force can deflect the ball by more than a foot.
Spin is a usable control input with some limitations: the force is always perpendicular to velocity, its magnitude saturates with spin rate, and it can only change as fast as the angular momentum can be reoriented.
Image source: American Baseball Coaches Association.
The target is a 0.432m by 0.55m box at a plane 16.8m from release. The ball either crosses inside it or it does not, which gives a clean terminal miss distance and a pass/fail metric. Guidance default aim is at the centre of the zone, but can be tuned.
The point of the project was to write every layer of a GNC system myself rather than study them separately.
Guidance never sees truth. It runs on the EKF estimate, and the EKF evaluates its onboard aerodynamic model at its own estimate.
20 pitches with release dispersion in angle, speed, spin rate and release point. Each pitch is flown twice, once torque-free as a baseline and once under the full stack.
The reaction wheels are not sized to a real motor. A 5N·m flywheel does not fit inside a baseball. That one component is idealised so the question under test is whether the estimator and the guidance law work. Mass properties, air density, drag and lift coefficients, sensor noise and bias, filter tuning and integration are all physical.
Image source: Garry Robinson and Ian Robinson.
The aerodynamic model is based off a 2013 paper by Garry Robinson and Ian Robinson, The motion of an arbitrarily rotating spherical projectile and its application to ball games.
Lift uses the inertial spin $\boldsymbol\omega_i = R(\mathbf q)\,\boldsymbol\omega_b$.
Invertible and saturating, so a required $C_L$ maps to a unique spin rate. Aerodynamic torque is zero; spin decay over 0.45s is negligible.
Axes stacked in $A$, inertias $I_w$. $-A\boldsymbol\tau_w$ is the reaction: wheel torque hits the ball with the opposite sign.
White noise plus a bias drawn once per flight. The accelerometer senses specific force, so gravity does not appear.
It sees only the aerodynamic force, which does not depend on position.
Three wheels, $I_w = 3\times10^{-6}\ \mathrm{kg\,m^2}$, saturated at $\pm 5\ \mathrm{N\,m}$.
| Quantity | Value | Source / note |
|---|---|---|
| Mass $m$ | 0.145kg | MLB regulation |
| Diameter | 0.074m | MLB regulation |
| Inertia $I$ | 0.4 m r² | solid sphere |
| Air density $\rho$ | 1.22kg/m³ | 16°C, sea level |
| Drag coefficient $C_D$ | 0.45 | constant over the Re band of a pitch |
| Max lift coefficient | 0.319 | saturating fit, spin-dependent |
| Release point | 16.8m, 1.8m | distance to plate, height |
| Nominal pitch | 90mph, 2200rpm | backspin about $-x$ |
| Wheel inertia $I_w$ | 3 × 10⁻⁶ kg m² | 3 orthogonal wheels |
| Torque limit | ±5N·m | idealised, not motor-sized |
| Gyro noise / bias | 1e-3 / 1e-4 rad/s | 1σ, bias drawn per flight |
| Accel noise / bias | 1e-3 / 1e-3 m/s² | 1σ, bias drawn per flight |
Flown with spin and spinless; the difference is the induced break.
| Pitch | Flight [s] | Horizontal [in] | Vertical [in] | Published range |
|---|---|---|---|---|
| 4-seam fastball, 95mph / 2400rpm | 0.424 | 0.0 | +14.9 | rise 15 to 17in |
| Curveball, 80mph / 2500rpm | 0.504 | 0.0 | −15.5 | drop 10 to 15in |
| Slider, 85mph / 2400rpm | 0.474 | 14.9 | −0.0 | horiz 6 to 15in |
| Gyro spin, 85mph / 2400rpm | 0.474 | −0.8 | 0.0 | no movement (control case) |
Gyro spin producing no movement confirms the Magnus direction. $C_L$ ignores seam orientation, so two-seam is out of scope.
dynamics.pyState pack and unpack, quaternion kinematics and DCM, angular momentum, the state derivative, BallParams, and the drag and Magnus model with its $C_L$ fit and validation harness.imu.py6-DOF IMU with per-flight bias draws.run.pyScenario constants, DOP853 integration, terminal events, and the zero-order-hold GNC driver.DOP853 throughout: $\text{rtol}=10^{-9}$ for the torque-free baseline, $10^{-8}$ across the zero-order-hold steps of a closed-loop flight. Plate and ground are terminal events.
Guidance turns a position error 16.8m away into a body-rate command, against a force perpendicular to velocity, saturating at $C_{L,\max}$, on a slew-limited axis. The law inverts the aerodynamics in closed form: no ODE solves, no Jacobians, no iteration.
Each outer tick, from $(\hat{\mathbf r}, \hat{\mathbf v}, \hat{\mathbf q})$:
Along-track speed is nearly constant.
Two-point boundary value problem.
Gravity and drag act regardless.
The along-track part is unachievable.
Magnus force set to $m\,\mathbf a_\perp$, clipped short of saturation.
Invert the $C_L$ aerodynamics from the physics section.
Direction, from the Magnus cross product:
w_setpoint() in gnc.py: thirty lines, no solver, no state, 50Hz.
The code calls the projected vector a_guid; it is $\mathbf a_\perp$ above.
# gnc.py, the guidance law in full t_go = (target[1] - r_i[1]) / v_i[1] if t_go < 0.09: # terminal freeze return np.zeros(3), np.zeros(3), np.zeros(3) a = 2 * (target - r_i - v_i * t_go) / (t_go**2) # required accel a_cmd = a - a_g - a_d # strip gravity + drag a_guid = a_cmd - v_i * np.dot(v_i, a_cmd) / v_norm2 # strip along-track C_L_req = min(np.linalg.norm(a_guid) * p.m / (q_dyn * p.area), C_L_max*.99) w_cmd_i = np.cross(v_i, a_guid) / v_norm2 # direction w_cmd_i /= np.linalg.norm(w_cmd_i) w_cmd_i *= -np.log(1 - C_L_req/C_L_max) / 2.48E-3 # invert C_L(w)
Cancel the gyroscopic coupling onto the wheel axes.
Drive rate error to zero on a chosen time constant.
Inner loop and EKF at 500Hz, guidance at 50Hz. The 10:1 separation lets guidance treat the inner loop as instantaneous. Torque is zero-order-hold.
inner_loop() in gnc.py.
# gnc.py, the inner loop in full L_b = p.J @ w_b + p.A @ (p.I_w * Om) # wheel momentum included tau_gyro = -p.A.T @ np.cross(w_b, L_b) # cancel the cross coupling w_dot_des = (w_cmd_b - w_b) / t_settle # first-order error decay tau_fb = -p.A.T @ (p.J_eff @ w_dot_des) # torque that produces it tau = np.clip(tau_gyro + tau_fb, -p.tau_limit, p.tau_limit)
RandomState(4000); each trial's IMU biases
and EKF initialisation are seeded from its index. The whole campaign reproduces exactly.20 pitches. Dispersion, 1σ:
Per trial:
| Metric | Uncontrolled | EKF + kinematic GNC | Note |
|---|---|---|---|
| Combined miss, median | 0.416m | 0.009m | distance to target at the plate |
| Combined miss, p90 | 0.536m | 0.069m | the tail |
| Vertical $|\Delta z|$, median | 0.402m | 0.007m | modulate existing backspin |
| Vertical $|\Delta z|$, p90 | 0.526m | 0.063m | |
| Lateral $|\Delta x|$, median | 0.074m | 0.004m | reorient the spin axis |
| Lateral $|\Delta x|$, p90 | 0.195m | 0.026m | |
| Crossing spread $\sigma_z$ | 0.133m | 0.026m | dispersion, not bias |
| Crossing spread $\sigma_x$ | 0.122m | 0.023m | |
| Strike rate | 25% | 100% | inside the 0.432 × 0.55m box |
| Peak wheel torque, median | n/a | 1.83 of 5N·m | no saturation on any trial |
| Terminal EKF position error, median | n/a | 0.007m | dead-reckoned, IMU only |