27. The portable swerve interface — the mid-level component
A drive subsystem is the first interesting component above the leaf: its children are four module
components, each two motors. Its Command_in is a control-intent union, its State is the
fused drive state, and its Command_out is four module setpoints. Working it out shows
seam-granularity for what it is — the altitude at which you draw a component boundary — and reuses the
motor interface wholesale. Part II built the swerve subsystem from
the corpus (ch. 19); this chapter gives it the portable
contract.
They already agree
Put the field’s four reference swerve systems side by side — CTRE Phoenix 6, Team 6328’s AdvantageKit
template, YAGSL, and WPILib’s math — and the apparent diversity collapses. Every one computes module
setpoints with the same WPILib types (SwerveDriveKinematics, SwerveModuleState, ChassisSpeeds,
SwerveDrivePoseEstimator). That is the shared substrate. They differ on exactly two decisions:
where the hardware seam goes and whether there is a control-intent vocabulary. So the design space
is not four architectures; it is one architecture with two open choices — and the field has already
produced the best answer to each. The right swerve is “AdvantageKit’s seam + CTRE’s vocabulary, on
WPILib’s math.”
Five layers, one seam
Two load-bearing rules, inherited from the rubric. Vendor SDK types appear only at L1 — everything L2 and above is WPILib-and-our-own-types only (the D1 rule applied to swerve). And the seam is a data struct, not a method bag — L1’s read side is a serializable inputs struct, which makes it simultaneously the replay seam, the sim-mock seam, and the vendor-swap seam: one cut buys all three.
L1 is just two motors and an encoder
The seam to adopt is AdvantageKit’s ModuleIO/GyroIO — minimal and field-proven: a per-module read
struct plus four write verbs (drive open-loop, drive velocity, steer open-loop, steer position), and a
gyro read struct. But the clean formulation is not a fresh interface. Those four verbs are exactly two
motors’ worth of the motor spec plus an absolute encoder:
ModuleIO ≙ { drive: Motor (velocity tier), steer: Motor (position tier), azimuth: AbsoluteEncoder }
So the swerve component inherits the motor spec’s nullable payloads, capability tiers, and ROS translation
for free — a steer motor is “a Motor whose Command is a position oneof,” nothing new to design.
The flat five-method ModuleIO is a convenience facade over that canonical pair.
The one leak to legislate against: AdvantageKit’s template reuses CTRE’s SwerveModuleConstants as a
constants bag above the seam, because that is what the Tuner X generator emits. We disallow that —
constants cross as our own neutral record (ModuleConstants{ driveGearRatio, wheelRadiusMeters, locationMeters, encoderOffsetRot, … }), populated from TunerConstants by the L1 adapter, never
referenced by type above it. This is the swerve form of generate the constants, own the architecture.
Two altitudes for the seam
Where you cut L1 depends on what sits below it, and the corpus shows both in live elite use:
- Per-module —
ModuleIO+GyroIO, the subsystem owning kinematics and odometry. Correct when you build from motors: the vendor abstraction stops at one drive + one steer + one encoder, four times over. - Per-drivetrain — a single
DriveIOwrapping an entire vendor swerve (CTRE’sCommandSwerveDrivetrain), with the contractSwerveRequestin,SwerveDriveStateout. Correct when you inherit a vendor’s swerve, because CTRE already abstracts the four modules internally. This is the 254/2910 pattern:CommandSwerveDrivetrainis demoted to a plain device (it does notimplements Subsystem) and a hand-ownedDriveSubsystemsits on the seam.
Both are the same seam — data-struct read side, intent write side, vendor below only — cut at different heights. Rule of thumb: own the motors → per-module; inherit a vendor swerve → per-drivetrain.
L4: control is an intent object
AdvantageKit stops at runVelocity(ChassisSpeeds); CTRE’s SwerveRequest is the better idea worth
lifting — control is an intent object you hand the drivetrain each loop, not a method you call —
recast off vendor types as a tagged union, so requests are loggable and replayable like everything
else. The arms are not speculative; the counts are corpus reference frequencies across 683 repos (the
full cloned corpus — see Appendix A), so
the union is the measured ~90% of real usage:
SwerveRequest = oneof {
FieldCentric { vx, vy, omega } // field frame, REP-103 [324×]
FacingAngle { vx, vy; Rotation2d heading } // translate + θ-PID heading [115×]
ApplyChassisSpeeds { ChassisSpeeds; bool fieldRel} // path-follower entry [127×]
RobotCentric { vx, vy, omega } // [80×]
Brake { } // X-lock the wheels [119×]
PointWheelsAt { Rotation2d angle } // [74×]
Characterize { CharMode mode; double value } // plant-response test [~310×]
}
modifiers: DriveRequestType drive = OPEN_LOOP | VELOCITY // lands on the two ModuleIO drive verbs
wheelForceFeedforwardsX/Y // the L3 setpoint generator's per-module forces
(One vintage note: Phoenix 6’s 2025 release split ApplyChassisSpeeds into ApplyRobotSpeeds and
ApplyFieldSpeeds; the counts above use the older class name because most of the corpus predates the
split.)
DriveRequestType is the whole open-vs-closed-loop switch, and it maps straight onto the two
ModuleIO drive verbs — no extra machinery. One naming decision is deliberate: the characterization
arm is Characterize, not CTRE’s SysId. “System identification” collides head-on with software’s
prior claims on both words — getSysId() in any other codebase returns a string handle, not a
feedforward routine — so it fails ch. 26’s
survive-a-change-of-reader rule. The general
policy: import a control term only if the destination domain hasn’t already spent the word. plant,
chassis speeds, kinematics, pose import cleanly; system identification and bare observer do
not.
L3: state out, and the optional planner
State out is one immutable snapshot, modeled field-for-field on CTRE’s SwerveDriveState — which is
itself a flat public-field POD, meaning CTRE independently arrived at the inputs-struct-as-data idea,
just without the replay seam around it. The field that matters: Pose is read 682 times across the
corpus, more than any actuator field on any subsystem — the empirical proof that the drivetrain is
the world-model anchor (ch. 6). Its state snapshot, not its motion
command, is its primary output.
Odometry must be signal-synced — a 250 Hz (CAN FD) / 100 Hz (RIO) thread that samples drive,
steer, and gyro signals together and timestamps them — because timestamped high-rate samples are what
make vision fusion and replay correct; a bare 50 Hz Notifier (YAGSL) is not enough. The thread is a
property of the L1 adapter (it knows the vendor’s signal API) and publishes into the inputs struct, so
L3 stays vendor-neutral. Above the modules sits the optional SwerveSetpointGenerator, a
dynamic-feasibility filter (pure WPILib-and-DCMotor math, no vendor types) that backs a desired
ChassisSpeeds off to one actually reachable this loop without slipping a wheel; prefer
PathPlannerLib’s torque/friction model, and let its per-module force feedforwards flow down as the
arbitrary-feedforward field of the L1 drive command — closing the loop with the motor spec again.
A team climbs capability tiers — drive-only → fused localization → feasibility-planned — over the
same L1, never re-cutting the seam. And the whole stack crosses to ROS cleanly: a swerve drive is a
Twist-in / Odometry-out component with four JointState pairs underneath — the shape a
ros2_control swerve controller takes (cf. diff_drive_controller; swerve controllers exist as
third-party packages) (ch. 31). With the leaf and
mid-level components in hand, the next chapter recovers the two higher seams as components:
RobotState and Superstructure.