physics

Swerve drive physics simulation for pyfrc.

Applies a zero-delay plant model: each cycle, the drive encoder velocity and steer encoder position are set directly from the module's last commanded state. This makes closed-loop PID control, odometry, and Mechanism2d visualization all respond correctly in simulation without tuning a dynamic model.

Robot field pose is integrated from swerve kinematics and fed back to:

  • The Field2d widget (visible in the sim GUI)
  • The NavX gyro sim device (so heading-based field-relative drive works)

macOS notes

Two platform-specific workarounds are applied during simulation on Mac:

  1. CANcoder sim frames unavailable at init — CTRE phoenix6's sim backend on macOS does not deliver CAN frames in time for module initialization. baseline_relative_encoders() in swerve_module.py handles this by defaulting the steer baseline to 0° instead of raising. The physics engine also guards against an empty module list (len(module_states) != 4).

  2. Xbox/PlayStation controller input — Apple's Game Controller framework intercepts these devices, making them invisible to GLFW (the input backend used by the WPILib sim GUI). An SDL2-based bridge (utils/sim/sdl2_controller_bridge) reads controllers via SDL2 and writes their state to the HAL sim. See that module's docstring for setup details.

  1"""
  2Swerve drive physics simulation for pyfrc.
  3
  4Applies a zero-delay plant model: each cycle, the drive encoder velocity
  5and steer encoder position are set directly from the module's last commanded
  6state.  This makes closed-loop PID control, odometry, and Mechanism2d
  7visualization all respond correctly in simulation without tuning a dynamic model.
  8
  9Robot field pose is integrated from swerve kinematics and fed back to:
 10  - The Field2d widget (visible in the sim GUI)
 11  - The NavX gyro sim device (so heading-based field-relative drive works)
 12
 13macOS notes
 14-----------
 15Two platform-specific workarounds are applied during simulation on Mac:
 16
 171. **CANcoder sim frames unavailable at init** — CTRE phoenix6's sim backend
 18   on macOS does not deliver CAN frames in time for module initialization.
 19   ``baseline_relative_encoders()`` in ``swerve_module.py`` handles this by
 20   defaulting the steer baseline to 0° instead of raising.  The physics engine
 21   also guards against an empty module list (``len(module_states) != 4``).
 22
 232. **Xbox/PlayStation controller input** — Apple's Game Controller framework
 24   intercepts these devices, making them invisible to GLFW (the input backend
 25   used by the WPILib sim GUI).  An SDL2-based bridge
 26   (``utils/sim/sdl2_controller_bridge``) reads controllers via SDL2 and writes
 27   their state to the HAL sim.  See that module's docstring for setup details.
 28"""
 29
 30import typing
 31
 32import ntcore
 33import rev
 34import wpilib
 35import wpilib.simulation
 36from pyfrc.physics.core import PhysicsInterface
 37from wpimath.system.plant import LinearSystemId
 38from wpimath.geometry import Pose2d, Rotation2d, Twist2d
 39from wpimath.kinematics import SwerveModuleState
 40from wpimath.system.plant import DCMotor
 41
 42from utils.sim.sdl2_controller_bridge import start_controller_bridge
 43
 44if typing.TYPE_CHECKING:
 45    from robot import MyRobot
 46
 47
 48class PhysicsEngine:
 49    """
 50    No-delay swerve drive physics model.
 51
 52    Drive encoder velocity and steer encoder position are written from
 53    _last_commanded_state each cycle, giving an instantaneous plant
 54    response suitable for basic simulation and visualization.
 55
 56    All encoders are seeded to 0 degrees (wheels forward) in __init__ to
 57    prevent the calibration-based baseline from interfering with optimize()
 58    before the first physics tick runs.
 59    """
 60
 61    def __init__(self, physics_controller: PhysicsInterface, robot: "MyRobot") -> None:
 62        self.physics_controller = physics_controller
 63
 64        modules = robot.container.drivetrain.swerve_modules
 65        if len(modules) != 4:
 66            raise RuntimeError(
 67                f"PhysicsEngine requires exactly 4 swerve modules, got {len(modules)}. "
 68                "CANcoder initialization likely failed — check CAN IDs and phoenix6 sim setup."
 69            )
 70        self.modules = modules
 71        self.drive_sims = [
 72            rev.SparkSim(m.drive_motor, DCMotor.NEO()) for m in modules
 73        ]
 74        self.steer_sims = [
 75            rev.SparkSim(m.steer_motor, DCMotor.NEO()) for m in modules
 76        ]
 77        self.kinematics = robot.container.drivetrain.drive_kinematics
 78
 79        # Seed all encoders to 0° (wheels forward) immediately.
 80        for module, drive_sim, steer_sim in zip(self.modules, self.drive_sims, self.steer_sims):
 81            calibration = module.constants.encoder_calibration
 82
 83            steer_enc = steer_sim.getRelativeEncoderSim()
 84            steer_enc.setPosition(0.0)
 85            steer_enc.setVelocity(0.0)
 86
 87            module.absolute_encoder.sim_state.set_supply_voltage(12.0)
 88            module.absolute_encoder.sim_state.set_raw_position(-calibration)
 89
 90            drive_enc = drive_sim.getRelativeEncoderSim()
 91            drive_enc.setPosition(0.0)
 92            drive_enc.setVelocity(0.0)
 93
 94        # Shooter flywheel simulation using WPILib FlywheelSim
 95        shooter = robot.container.shooter
 96        self._has_shooter = shooter is not None
 97        if self._has_shooter:
 98            self._shooter_lead_sim = rev.SparkSim(shooter.leadMotor, DCMotor.neoVortex())
 99            self._shooter_follower_sim = rev.SparkSim(shooter.followerMotor, DCMotor.neoVortex())
100            # Two NEO Vortex motors, ~0.005 kg·m² moment of inertia (dual 4" wheels), 1:1 gearing
101            self._flywheel_sim = wpilib.simulation.FlywheelSim(
102                LinearSystemId.flywheelSystem(DCMotor.neoVortex(2), 0.005, 1.0),
103                DCMotor.neoVortex(2),
104            )
105
106        # Vision simulation — safe to skip if localization is disabled
107        self._localization = getattr(robot.container, 'localization', None)
108        if self._localization is not None:
109            self._localization.setup_sim()
110
111        # Seed pose from the drivetrain's configured default starting position.
112        self._pose = robot.container.drivetrain.get_default_starting_pose()
113
114        # Ground-truth pose struct publisher (for AdvantageScope ghost overlay)
115        self._gt_pose_pub = ntcore.NetworkTableInstance.getDefault().getStructTopic(
116            "SimGroundTruth/pose", Pose2d
117        ).publish()
118
119        # NavX yaw variable — may be unavailable depending on sim state.
120        self._navx_yaw = None
121        try:
122            navx_sim = wpilib.simulation.SimDeviceSim("navX-Sensor[4]")
123            self._navx_yaw = navx_sim.getDouble("Yaw")
124        except Exception:
125            # NavX sim device or Yaw entry may be unavailable in some sim setups;
126            # ignore errors here and leave _navx_yaw as None.
127            pass
128
129        # On macOS, start the SDL2 controller bridge (see module docstring).
130        start_controller_bridge()
131
132    def update_sim(self, now: float, tm_diff: float) -> None:
133        """
134        Called every simulation tick.
135
136        Reads _last_commanded_state from each module and writes those values
137        directly to the encoder sims (no-delay model), then integrates robot
138        pose from swerve kinematics.
139
140        Using _last_commanded_state instead of SparkSim.getSetpoint() avoids
141        any uncertainty about whether the SparkSim API returns converted units
142        (degrees / m/s) or raw encoder units.
143
144        Args:
145            now: current timestamp in seconds
146            tm_diff: elapsed time since last call in seconds
147        """
148        module_states = []
149
150        for module, drive_sim, steer_sim in zip(
151            self.modules, self.drive_sims, self.steer_sims
152        ):
153            velocity = module._last_commanded_state.speed
154            angle_deg = module._last_commanded_state.angle.degrees()
155
156            steer_angle = angle_deg % 360.0
157            if steer_angle >= 180.0:
158                steer_angle -= 180.0
159
160            drive_enc = drive_sim.getRelativeEncoderSim()
161            drive_enc.setVelocity(velocity)
162            drive_enc.setPosition(drive_enc.getPosition() + velocity * tm_diff)
163
164            steer_enc = steer_sim.getRelativeEncoderSim()
165            steer_enc.setPosition(steer_angle)
166
167            calibration = module.constants.encoder_calibration
168            module.absolute_encoder.sim_state.set_supply_voltage(12.0)
169            module.absolute_encoder.sim_state.set_raw_position(
170                steer_angle / 360.0 - calibration
171            )
172
173            module_states.append(
174                SwerveModuleState(velocity, Rotation2d.fromDegrees(angle_deg))
175            )
176
177        # Integrate chassis speeds into robot pose.
178        if len(module_states) != 4:
179            return
180        speeds = self.kinematics.toChassisSpeeds(tuple(module_states))
181        self._pose = self._pose.exp(
182            Twist2d(
183                speeds.vx * tm_diff,
184                speeds.vy * tm_diff,
185                speeds.omega * tm_diff,
186            )
187        )
188
189        # Feed ground-truth pose to vision sim so cameras see AprilTags
190        if self._localization is not None:
191            self._localization.update_sim(self._pose)
192
193        # Update Field2d widget and struct publisher with the integrated pose.
194        self.physics_controller.field.setRobotPose(self._pose)
195        self._gt_pose_pub.set(self._pose)
196
197        # Feed integrated heading back to the NavX gyro sim device so that
198        # field-relative drive and pose estimation use the correct heading.
199        if self._navx_yaw is not None:
200            self._navx_yaw.set(self._pose.rotation().degrees())
201
202        # Simulate shooter flywheel (if enabled)
203        if self._has_shooter:
204            flywheel_rpm = self._flywheel_sim.getAngularVelocity() * 60.0 / (2 * 3.14159)
205            self._shooter_lead_sim.iterate(flywheel_rpm, 12.0, tm_diff)
206            lead_output = self._shooter_lead_sim.getAppliedOutput()
207            self._flywheel_sim.setInputVoltage(lead_output * 12.0)
208            self._flywheel_sim.update(tm_diff)
209            flywheel_rpm = self._flywheel_sim.getAngularVelocity() * 60.0 / (2 * 3.14159)
210            lead_enc = self._shooter_lead_sim.getRelativeEncoderSim()
211            lead_enc.setVelocity(flywheel_rpm)
212            lead_enc.setPosition(lead_enc.getPosition() + flywheel_rpm / 60.0 * tm_diff)
213            follower_enc = self._shooter_follower_sim.getRelativeEncoderSim()
214            follower_enc.setVelocity(flywheel_rpm)
215            follower_enc.setPosition(lead_enc.getPosition())
class PhysicsEngine:
 49class PhysicsEngine:
 50    """
 51    No-delay swerve drive physics model.
 52
 53    Drive encoder velocity and steer encoder position are written from
 54    _last_commanded_state each cycle, giving an instantaneous plant
 55    response suitable for basic simulation and visualization.
 56
 57    All encoders are seeded to 0 degrees (wheels forward) in __init__ to
 58    prevent the calibration-based baseline from interfering with optimize()
 59    before the first physics tick runs.
 60    """
 61
 62    def __init__(self, physics_controller: PhysicsInterface, robot: "MyRobot") -> None:
 63        self.physics_controller = physics_controller
 64
 65        modules = robot.container.drivetrain.swerve_modules
 66        if len(modules) != 4:
 67            raise RuntimeError(
 68                f"PhysicsEngine requires exactly 4 swerve modules, got {len(modules)}. "
 69                "CANcoder initialization likely failed — check CAN IDs and phoenix6 sim setup."
 70            )
 71        self.modules = modules
 72        self.drive_sims = [
 73            rev.SparkSim(m.drive_motor, DCMotor.NEO()) for m in modules
 74        ]
 75        self.steer_sims = [
 76            rev.SparkSim(m.steer_motor, DCMotor.NEO()) for m in modules
 77        ]
 78        self.kinematics = robot.container.drivetrain.drive_kinematics
 79
 80        # Seed all encoders to 0° (wheels forward) immediately.
 81        for module, drive_sim, steer_sim in zip(self.modules, self.drive_sims, self.steer_sims):
 82            calibration = module.constants.encoder_calibration
 83
 84            steer_enc = steer_sim.getRelativeEncoderSim()
 85            steer_enc.setPosition(0.0)
 86            steer_enc.setVelocity(0.0)
 87
 88            module.absolute_encoder.sim_state.set_supply_voltage(12.0)
 89            module.absolute_encoder.sim_state.set_raw_position(-calibration)
 90
 91            drive_enc = drive_sim.getRelativeEncoderSim()
 92            drive_enc.setPosition(0.0)
 93            drive_enc.setVelocity(0.0)
 94
 95        # Shooter flywheel simulation using WPILib FlywheelSim
 96        shooter = robot.container.shooter
 97        self._has_shooter = shooter is not None
 98        if self._has_shooter:
 99            self._shooter_lead_sim = rev.SparkSim(shooter.leadMotor, DCMotor.neoVortex())
100            self._shooter_follower_sim = rev.SparkSim(shooter.followerMotor, DCMotor.neoVortex())
101            # Two NEO Vortex motors, ~0.005 kg·m² moment of inertia (dual 4" wheels), 1:1 gearing
102            self._flywheel_sim = wpilib.simulation.FlywheelSim(
103                LinearSystemId.flywheelSystem(DCMotor.neoVortex(2), 0.005, 1.0),
104                DCMotor.neoVortex(2),
105            )
106
107        # Vision simulation — safe to skip if localization is disabled
108        self._localization = getattr(robot.container, 'localization', None)
109        if self._localization is not None:
110            self._localization.setup_sim()
111
112        # Seed pose from the drivetrain's configured default starting position.
113        self._pose = robot.container.drivetrain.get_default_starting_pose()
114
115        # Ground-truth pose struct publisher (for AdvantageScope ghost overlay)
116        self._gt_pose_pub = ntcore.NetworkTableInstance.getDefault().getStructTopic(
117            "SimGroundTruth/pose", Pose2d
118        ).publish()
119
120        # NavX yaw variable — may be unavailable depending on sim state.
121        self._navx_yaw = None
122        try:
123            navx_sim = wpilib.simulation.SimDeviceSim("navX-Sensor[4]")
124            self._navx_yaw = navx_sim.getDouble("Yaw")
125        except Exception:
126            # NavX sim device or Yaw entry may be unavailable in some sim setups;
127            # ignore errors here and leave _navx_yaw as None.
128            pass
129
130        # On macOS, start the SDL2 controller bridge (see module docstring).
131        start_controller_bridge()
132
133    def update_sim(self, now: float, tm_diff: float) -> None:
134        """
135        Called every simulation tick.
136
137        Reads _last_commanded_state from each module and writes those values
138        directly to the encoder sims (no-delay model), then integrates robot
139        pose from swerve kinematics.
140
141        Using _last_commanded_state instead of SparkSim.getSetpoint() avoids
142        any uncertainty about whether the SparkSim API returns converted units
143        (degrees / m/s) or raw encoder units.
144
145        Args:
146            now: current timestamp in seconds
147            tm_diff: elapsed time since last call in seconds
148        """
149        module_states = []
150
151        for module, drive_sim, steer_sim in zip(
152            self.modules, self.drive_sims, self.steer_sims
153        ):
154            velocity = module._last_commanded_state.speed
155            angle_deg = module._last_commanded_state.angle.degrees()
156
157            steer_angle = angle_deg % 360.0
158            if steer_angle >= 180.0:
159                steer_angle -= 180.0
160
161            drive_enc = drive_sim.getRelativeEncoderSim()
162            drive_enc.setVelocity(velocity)
163            drive_enc.setPosition(drive_enc.getPosition() + velocity * tm_diff)
164
165            steer_enc = steer_sim.getRelativeEncoderSim()
166            steer_enc.setPosition(steer_angle)
167
168            calibration = module.constants.encoder_calibration
169            module.absolute_encoder.sim_state.set_supply_voltage(12.0)
170            module.absolute_encoder.sim_state.set_raw_position(
171                steer_angle / 360.0 - calibration
172            )
173
174            module_states.append(
175                SwerveModuleState(velocity, Rotation2d.fromDegrees(angle_deg))
176            )
177
178        # Integrate chassis speeds into robot pose.
179        if len(module_states) != 4:
180            return
181        speeds = self.kinematics.toChassisSpeeds(tuple(module_states))
182        self._pose = self._pose.exp(
183            Twist2d(
184                speeds.vx * tm_diff,
185                speeds.vy * tm_diff,
186                speeds.omega * tm_diff,
187            )
188        )
189
190        # Feed ground-truth pose to vision sim so cameras see AprilTags
191        if self._localization is not None:
192            self._localization.update_sim(self._pose)
193
194        # Update Field2d widget and struct publisher with the integrated pose.
195        self.physics_controller.field.setRobotPose(self._pose)
196        self._gt_pose_pub.set(self._pose)
197
198        # Feed integrated heading back to the NavX gyro sim device so that
199        # field-relative drive and pose estimation use the correct heading.
200        if self._navx_yaw is not None:
201            self._navx_yaw.set(self._pose.rotation().degrees())
202
203        # Simulate shooter flywheel (if enabled)
204        if self._has_shooter:
205            flywheel_rpm = self._flywheel_sim.getAngularVelocity() * 60.0 / (2 * 3.14159)
206            self._shooter_lead_sim.iterate(flywheel_rpm, 12.0, tm_diff)
207            lead_output = self._shooter_lead_sim.getAppliedOutput()
208            self._flywheel_sim.setInputVoltage(lead_output * 12.0)
209            self._flywheel_sim.update(tm_diff)
210            flywheel_rpm = self._flywheel_sim.getAngularVelocity() * 60.0 / (2 * 3.14159)
211            lead_enc = self._shooter_lead_sim.getRelativeEncoderSim()
212            lead_enc.setVelocity(flywheel_rpm)
213            lead_enc.setPosition(lead_enc.getPosition() + flywheel_rpm / 60.0 * tm_diff)
214            follower_enc = self._shooter_follower_sim.getRelativeEncoderSim()
215            follower_enc.setVelocity(flywheel_rpm)
216            follower_enc.setPosition(lead_enc.getPosition())

No-delay swerve drive physics model.

Drive encoder velocity and steer encoder position are written from _last_commanded_state each cycle, giving an instantaneous plant response suitable for basic simulation and visualization.

All encoders are seeded to 0 degrees (wheels forward) in __init__ to prevent the calibration-based baseline from interfering with optimize() before the first physics tick runs.

PhysicsEngine( physics_controller: pyfrc.physics.core.PhysicsInterface, robot: robot.MyRobot)
 62    def __init__(self, physics_controller: PhysicsInterface, robot: "MyRobot") -> None:
 63        self.physics_controller = physics_controller
 64
 65        modules = robot.container.drivetrain.swerve_modules
 66        if len(modules) != 4:
 67            raise RuntimeError(
 68                f"PhysicsEngine requires exactly 4 swerve modules, got {len(modules)}. "
 69                "CANcoder initialization likely failed — check CAN IDs and phoenix6 sim setup."
 70            )
 71        self.modules = modules
 72        self.drive_sims = [
 73            rev.SparkSim(m.drive_motor, DCMotor.NEO()) for m in modules
 74        ]
 75        self.steer_sims = [
 76            rev.SparkSim(m.steer_motor, DCMotor.NEO()) for m in modules
 77        ]
 78        self.kinematics = robot.container.drivetrain.drive_kinematics
 79
 80        # Seed all encoders to 0° (wheels forward) immediately.
 81        for module, drive_sim, steer_sim in zip(self.modules, self.drive_sims, self.steer_sims):
 82            calibration = module.constants.encoder_calibration
 83
 84            steer_enc = steer_sim.getRelativeEncoderSim()
 85            steer_enc.setPosition(0.0)
 86            steer_enc.setVelocity(0.0)
 87
 88            module.absolute_encoder.sim_state.set_supply_voltage(12.0)
 89            module.absolute_encoder.sim_state.set_raw_position(-calibration)
 90
 91            drive_enc = drive_sim.getRelativeEncoderSim()
 92            drive_enc.setPosition(0.0)
 93            drive_enc.setVelocity(0.0)
 94
 95        # Shooter flywheel simulation using WPILib FlywheelSim
 96        shooter = robot.container.shooter
 97        self._has_shooter = shooter is not None
 98        if self._has_shooter:
 99            self._shooter_lead_sim = rev.SparkSim(shooter.leadMotor, DCMotor.neoVortex())
100            self._shooter_follower_sim = rev.SparkSim(shooter.followerMotor, DCMotor.neoVortex())
101            # Two NEO Vortex motors, ~0.005 kg·m² moment of inertia (dual 4" wheels), 1:1 gearing
102            self._flywheel_sim = wpilib.simulation.FlywheelSim(
103                LinearSystemId.flywheelSystem(DCMotor.neoVortex(2), 0.005, 1.0),
104                DCMotor.neoVortex(2),
105            )
106
107        # Vision simulation — safe to skip if localization is disabled
108        self._localization = getattr(robot.container, 'localization', None)
109        if self._localization is not None:
110            self._localization.setup_sim()
111
112        # Seed pose from the drivetrain's configured default starting position.
113        self._pose = robot.container.drivetrain.get_default_starting_pose()
114
115        # Ground-truth pose struct publisher (for AdvantageScope ghost overlay)
116        self._gt_pose_pub = ntcore.NetworkTableInstance.getDefault().getStructTopic(
117            "SimGroundTruth/pose", Pose2d
118        ).publish()
119
120        # NavX yaw variable — may be unavailable depending on sim state.
121        self._navx_yaw = None
122        try:
123            navx_sim = wpilib.simulation.SimDeviceSim("navX-Sensor[4]")
124            self._navx_yaw = navx_sim.getDouble("Yaw")
125        except Exception:
126            # NavX sim device or Yaw entry may be unavailable in some sim setups;
127            # ignore errors here and leave _navx_yaw as None.
128            pass
129
130        # On macOS, start the SDL2 controller bridge (see module docstring).
131        start_controller_bridge()
physics_controller
modules
drive_sims
steer_sims
kinematics
def update_sim(self, now: float, tm_diff: float) -> None:
133    def update_sim(self, now: float, tm_diff: float) -> None:
134        """
135        Called every simulation tick.
136
137        Reads _last_commanded_state from each module and writes those values
138        directly to the encoder sims (no-delay model), then integrates robot
139        pose from swerve kinematics.
140
141        Using _last_commanded_state instead of SparkSim.getSetpoint() avoids
142        any uncertainty about whether the SparkSim API returns converted units
143        (degrees / m/s) or raw encoder units.
144
145        Args:
146            now: current timestamp in seconds
147            tm_diff: elapsed time since last call in seconds
148        """
149        module_states = []
150
151        for module, drive_sim, steer_sim in zip(
152            self.modules, self.drive_sims, self.steer_sims
153        ):
154            velocity = module._last_commanded_state.speed
155            angle_deg = module._last_commanded_state.angle.degrees()
156
157            steer_angle = angle_deg % 360.0
158            if steer_angle >= 180.0:
159                steer_angle -= 180.0
160
161            drive_enc = drive_sim.getRelativeEncoderSim()
162            drive_enc.setVelocity(velocity)
163            drive_enc.setPosition(drive_enc.getPosition() + velocity * tm_diff)
164
165            steer_enc = steer_sim.getRelativeEncoderSim()
166            steer_enc.setPosition(steer_angle)
167
168            calibration = module.constants.encoder_calibration
169            module.absolute_encoder.sim_state.set_supply_voltage(12.0)
170            module.absolute_encoder.sim_state.set_raw_position(
171                steer_angle / 360.0 - calibration
172            )
173
174            module_states.append(
175                SwerveModuleState(velocity, Rotation2d.fromDegrees(angle_deg))
176            )
177
178        # Integrate chassis speeds into robot pose.
179        if len(module_states) != 4:
180            return
181        speeds = self.kinematics.toChassisSpeeds(tuple(module_states))
182        self._pose = self._pose.exp(
183            Twist2d(
184                speeds.vx * tm_diff,
185                speeds.vy * tm_diff,
186                speeds.omega * tm_diff,
187            )
188        )
189
190        # Feed ground-truth pose to vision sim so cameras see AprilTags
191        if self._localization is not None:
192            self._localization.update_sim(self._pose)
193
194        # Update Field2d widget and struct publisher with the integrated pose.
195        self.physics_controller.field.setRobotPose(self._pose)
196        self._gt_pose_pub.set(self._pose)
197
198        # Feed integrated heading back to the NavX gyro sim device so that
199        # field-relative drive and pose estimation use the correct heading.
200        if self._navx_yaw is not None:
201            self._navx_yaw.set(self._pose.rotation().degrees())
202
203        # Simulate shooter flywheel (if enabled)
204        if self._has_shooter:
205            flywheel_rpm = self._flywheel_sim.getAngularVelocity() * 60.0 / (2 * 3.14159)
206            self._shooter_lead_sim.iterate(flywheel_rpm, 12.0, tm_diff)
207            lead_output = self._shooter_lead_sim.getAppliedOutput()
208            self._flywheel_sim.setInputVoltage(lead_output * 12.0)
209            self._flywheel_sim.update(tm_diff)
210            flywheel_rpm = self._flywheel_sim.getAngularVelocity() * 60.0 / (2 * 3.14159)
211            lead_enc = self._shooter_lead_sim.getRelativeEncoderSim()
212            lead_enc.setVelocity(flywheel_rpm)
213            lead_enc.setPosition(lead_enc.getPosition() + flywheel_rpm / 60.0 * tm_diff)
214            follower_enc = self._shooter_follower_sim.getRelativeEncoderSim()
215            follower_enc.setVelocity(flywheel_rpm)
216            follower_enc.setPosition(lead_enc.getPosition())

Called every simulation tick.

Reads _last_commanded_state from each module and writes those values directly to the encoder sims (no-delay model), then integrates robot pose from swerve kinematics.

Using _last_commanded_state instead of SparkSim.getSetpoint() avoids any uncertainty about whether the SparkSim API returns converted units (degrees / m/s) or raw encoder units.

Args: now: current timestamp in seconds tm_diff: elapsed time since last call in seconds