subsystem.mechanisms.shooter.hood

  1# Native imports
  2import math
  3
  4# Third-party imports
  5import rev
  6import wpilib
  7from typing import Callable
  8from commands2 import Command, Subsystem
  9import commands2.cmd
 10from commands2.sysid import SysIdRoutine
 11from ntcore.util import ntproperty
 12from wpilib.sysid import SysIdRoutineLog
 13from wpimath.controller import ArmFeedforward, PIDController
 14
 15# Internal imports
 16from utils.spark_utils import GetSparkSignalsPositionControlConfig
 17
 18
 19class Hood(Subsystem):
 20    """
 21    Subsystem for a hood angle adjustment mechanism.
 22
 23    Uses a single motor with position control to adjust the shooting angle.
 24    Position is measured in degrees via the internal relative encoder with
 25    a configurable conversion factor. Assumes 0 degrees on startup.
 26
 27    The feedforward uses ArmFeedforward to compensate for gravity —
 28    0 degrees is approximately horizontal, positive degrees lift
 29    toward vertical.
 30
 31    Safety interlock: when enabled (default on code load), the hood
 32    automatically stows if the injected shooter's RPM setpoint falls
 33    below a configurable threshold. Safety is always enabled on startup
 34    and must be explicitly disabled at runtime.
 35    """
 36
 37    # Telemetry via ntproperty (published to NT, not persisted)
 38    nt_position = ntproperty('/Hood/position', 0.0, writeDefault=True)
 39    nt_velocity = ntproperty('/Hood/velocity', 0.0, writeDefault=True)
 40    nt_target = ntproperty('/Hood/targetPosition', 0.0, writeDefault=True)
 41    nt_normalized_position = ntproperty(
 42        '/Hood/normalizedPosition', 0.0, writeDefault=True)
 43    nt_normalized_target = ntproperty(
 44        '/Hood/normalizedTarget', 0.0, writeDefault=True)
 45    nt_at_setpoint = ntproperty(
 46        '/Hood/atSetpoint', False, writeDefault=True)
 47    nt_applied_output = ntproperty(
 48        '/Hood/appliedOutput', 0.0, writeDefault=True)
 49    nt_current = ntproperty('/Hood/current', 0.0, writeDefault=True)
 50    nt_bus_voltage = ntproperty(
 51        '/Hood/busVoltage', 0.0, writeDefault=True)
 52    nt_min_soft_limit = ntproperty(
 53        '/Hood/minSoftLimit', 0.0, writeDefault=True)
 54    nt_max_soft_limit = ntproperty(
 55        '/Hood/maxSoftLimit', 0.0, writeDefault=True)
 56
 57    # Safety: persistent configuration
 58    nt_safety_enabled = ntproperty(
 59        '/Hood/safetyEnabled', True, writeDefault=True)
 60    nt_stowed_angle_degrees = ntproperty(
 61        '/Hood/stowedAngleDegrees', 0.0,
 62        writeDefault=False, persistent=True)
 63    nt_safety_rpm_threshold = ntproperty(
 64        '/Hood/safetyRpmThreshold', 5.0,
 65        writeDefault=False, persistent=True)
 66
 67    # Dashboard tuning: set ntControlEnabled=True on dashboard,
 68    # then adjust ntControlAngle to drive hood to a specific angle.
 69    nt_control_enabled = ntproperty(
 70        '/Hood/ntControlEnabled', False, writeDefault=True)
 71    nt_control_angle = ntproperty(
 72        '/Hood/ntControlAngle', 0.0, writeDefault=True)
 73
 74    def __init__(
 75        self,
 76        motor: rev.SparkMax,
 77        position_conversion_factor: float,
 78        max_angle_degrees: float,
 79        pid: tuple,
 80        feedforward: tuple,
 81        inverted: bool = False,
 82        min_angle_degrees: float = 0.0,
 83        horizontal_offset_degrees: float = 0.0,
 84    ) -> None:
 85        """
 86        Creates a new Hood subsystem.
 87
 88        Args:
 89            motor: the SparkMax motor controller driving the hood
 90            position_conversion_factor: encoder conversion factor that
 91                converts raw encoder rotations to degrees
 92            max_angle_degrees: the maximum hood angle in degrees
 93                (forward soft limit)
 94            pid: (P, I, D) gains for the WPILib PIDController
 95            feedforward: (kS, kG, kV, kA) gains for ArmFeedforward
 96            inverted: whether the motor direction is inverted
 97            min_angle_degrees: the minimum hood angle in degrees
 98                (reverse soft limit)
 99            horizontal_offset_degrees: offset from hood 0-position to
100                true horizontal for feedforward calculation
101        """
102        super().__init__()
103        self.motor = motor
104        self.encoder = self.motor.getEncoder()
105
106        self.max_angle_degrees = max_angle_degrees
107        self.min_angle_degrees = min_angle_degrees
108        self._horizontal_offset_degrees = horizontal_offset_degrees
109
110        # Control
111        self.controller = PIDController(*pid)
112        wpilib.SmartDashboard.putData(
113            self.getName() + "/pid", self.controller)
114        self.feedforward = ArmFeedforward(*feedforward)
115
116        # Voltage output limits
117        self._min_output_volts = -12.0
118        self._max_output_volts = 12.0
119
120        # Position tracking
121        self._target_degrees = 0.0
122        self._enabled = True
123
124        # Configure motor
125        config = rev.SparkMaxConfig()
126        (
127            config
128            .inverted(inverted)
129            .setIdleMode(rev.SparkBaseConfig.IdleMode.kBrake)
130            .voltageCompensation(12.0)
131            .smartCurrentLimit(20)
132        )
133
134        # Encoder conversion factors
135        # position: rotations -> degrees (deg/rotation)
136        # velocity: RPM -> deg/s (divide by 60)
137        velocity_conversion_factor_dps = position_conversion_factor / 60.0
138        (
139            config.encoder
140            .positionConversionFactor(position_conversion_factor)
141            .velocityConversionFactor(velocity_conversion_factor_dps)
142        )
143
144        # Soft limits in degrees
145        (
146            config.softLimit
147            .forwardSoftLimit(max_angle_degrees)
148            .forwardSoftLimitEnabled(True)
149            .reverseSoftLimit(min_angle_degrees)
150            .reverseSoftLimitEnabled(True)
151        )
152
153        # Telemetry signals at 20ms
154        GetSparkSignalsPositionControlConfig(config.signals, 20)
155
156        # Apply configuration
157        self.motor.configure(
158            config,
159            rev.ResetMode.kResetSafeParameters,
160            rev.PersistMode.kNoPersistParameters
161        )
162
163        # Assume 0 position on startup
164        self.encoder.setPosition(0)
165
166        # Shooter dependency (optional, injected via setShooter)
167        self._shooter = None
168
169        # Mechanism2d visualization
170        self._configureMechanism2d()
171
172    # -- Setpoint methods --
173
174    def setAngleDegrees(self, degrees: float) -> None:
175        """Set hood target angle in degrees, clamped to valid range."""
176        if not self._enabled:
177            self.enable()
178        self._target_degrees = max(
179            self.min_angle_degrees,
180            min(self.max_angle_degrees, degrees)
181        )
182
183    def setAngleNormalized(self, value: float) -> None:
184        """Set hood target from 0..1 range (0 = min, 1 = max angle)."""
185        value = max(0.0, min(1.0, value))
186        self._target_degrees = value * self.max_angle_degrees
187
188    def setAngleRadians(self, radians: float) -> None:
189        """Set hood target angle in radians, converted to degrees."""
190        self.setAngleDegrees(math.degrees(radians))
191
192    # -- Shooter injection --
193
194    def setShooter(self, shooter) -> None:
195        """Inject a shooter subsystem for safety interlock.
196
197        The shooter must have an `RPM` attribute representing
198        the current setpoint in RPM.
199        """
200        self._shooter = shooter
201
202    def getShooter(self):
203        """Return the injected shooter, or None."""
204        return self._shooter
205
206    # -- Safety --
207
208    def setSafetyEnabled(self, enabled: bool) -> None:
209        """Enable or disable the shooter-based safety interlock."""
210        self.nt_safety_enabled = enabled
211
212    def getSafetyEnabled(self) -> bool:
213        """Return whether the safety interlock is enabled."""
214        return self.nt_safety_enabled
215
216    # -- Getter methods --
217
218    def getAngleDegrees(self) -> float:
219        """Get current hood angle in degrees from encoder."""
220        return self.encoder.getPosition()
221
222    def getAngleNormalized(self) -> float:
223        """Get current hood angle as 0..1 normalized value."""
224        return self.encoder.getPosition() / self.max_angle_degrees
225
226    def getAngleRadians(self) -> float:
227        """Get current hood angle in radians."""
228        return math.radians(self.encoder.getPosition())
229
230    def getTargetDegrees(self) -> float:
231        """Get the current target angle in degrees."""
232        return self._target_degrees
233
234    def atSetpoint(self) -> bool:
235        """Return whether the hood is at its target setpoint."""
236        return self.controller.atSetpoint()
237
238    # -- Lifecycle --
239
240    def onDisabledInit(self) -> None:
241        """Stop the motor when robot is disabled."""
242        self.motor.stopMotor()
243
244    def enable(self) -> None:
245        """Re-enable periodic PID control."""
246        self.controller.reset()
247        self._target_degrees = self.getAngleDegrees()
248        self._enabled = True
249
250    def disable(self) -> None:
251        """Stop motor output and suspend periodic PID control."""
252        self._enabled = False
253        self._target_degrees = self.getAngleDegrees()
254        self.controller.reset()
255        self.motor.stopMotor()
256
257    # -- Periodic --
258
259    def periodic(self) -> None:
260        """
261        Drive PID + feedforward to target, update viz and telemetry.
262        """
263        if not self._enabled:
264            self._updateTelemetry()
265            return
266
267        # Dashboard tuning override
268        if self.nt_control_enabled:
269            self._target_degrees = max(
270                self.min_angle_degrees,
271                min(self.max_angle_degrees, self.nt_control_angle)
272            )
273
274        # Safety interlock: stow hood when shooter setpoint is low
275        if (self.nt_safety_enabled
276                and self._shooter is not None
277                and self._shooter.RPM < self.nt_safety_rpm_threshold):
278            self._target_degrees = self.nt_stowed_angle_degrees
279
280        position_deg = self.encoder.getPosition()
281
282        # PID output (volts)
283        pid_volts = self.controller.calculate(
284            position_deg, self._target_degrees)
285
286        # ArmFeedforward expects angle in radians from horizontal
287        ff_angle_rad = math.radians(
288            position_deg + self._horizontal_offset_degrees)
289        ff_volts = self.feedforward.calculate(ff_angle_rad, 0)
290
291        # Combine and clamp voltage
292        total_volts = pid_volts + ff_volts
293        total_volts = max(self._min_output_volts,
294                          min(self._max_output_volts, total_volts))
295
296        if self.controller.atSetpoint():
297            # Still apply feedforward to hold against gravity
298            self.motor.setVoltage(ff_volts)
299        else:
300            self.motor.setVoltage(total_volts)
301
302        self._updateTelemetry()
303
304    # -- Mechanism2d --
305
306    def _configureMechanism2d(self) -> None:
307        """
308        Create and publish a Mechanism2d widget for hood visualization.
309
310        Sets up a 2D canvas with:
311        - Red arm: current encoder position
312        - Green arm: target setpoint position
313        - Gray arms: static soft limit indicators
314        """
315        self.mech2d = wpilib.Mechanism2d(200, 200)
316        pivot = self.mech2d.getRoot("hood_pivot", 100, 100)
317        self.mech_current_arm = pivot.appendLigament(
318            "current_position", 80, 0, 6,
319            wpilib.Color8Bit(wpilib.Color.kRed)
320        )
321        self.mech_target_arm = pivot.appendLigament(
322            "target_position", 80, 0, 4,
323            wpilib.Color8Bit(wpilib.Color.kGreen)
324        )
325        pivot.appendLigament(
326            "min_limit", 80, self.min_angle_degrees, 2,
327            wpilib.Color8Bit(100, 100, 100)
328        )
329        pivot.appendLigament(
330            "max_limit", 80, self.max_angle_degrees, 2,
331            wpilib.Color8Bit(100, 100, 100)
332        )
333        wpilib.SmartDashboard.putData(
334            self.getName() + "/mechanism", self.mech2d)
335
336    # -- Telemetry --
337
338    def _updateTelemetry(self) -> None:
339        """Publish telemetry via ntproperty and update Mechanism2d."""
340        position_deg = self.encoder.getPosition()
341        velocity_dps = self.encoder.getVelocity()
342
343        self.nt_position = position_deg
344        self.nt_velocity = velocity_dps
345        self.nt_target = self._target_degrees
346        self.nt_normalized_position = position_deg / self.max_angle_degrees
347        self.nt_normalized_target = (
348            self._target_degrees / self.max_angle_degrees)
349        self.nt_at_setpoint = self.atSetpoint()
350        self.nt_applied_output = self.motor.getAppliedOutput()
351        self.nt_current = self.motor.getOutputCurrent()
352        self.nt_bus_voltage = self.motor.getBusVoltage()
353
354        # Soft limits
355        sl = self.motor.configAccessor.softLimit
356        self.nt_min_soft_limit = sl.getReverseSoftLimit()
357        self.nt_max_soft_limit = sl.getForwardSoftLimit()
358
359        # Update mechanism2d arms
360        self.mech_current_arm.setAngle(position_deg)
361        self.mech_target_arm.setAngle(self._target_degrees)
362
363    # -- SysId --
364
365    def _setMotorVoltage(self, volts: float) -> None:
366        """Set motor voltage directly. Used by SysId routines."""
367        self._commanded_volts = volts
368        self.motor.setVoltage(volts)
369
370    def _sysIdLog(self, sys_id_routine: SysIdRoutineLog) -> None:
371        """
372        Log a frame of data for SysId characterization.
373
374        Logs radians (SI units for SysId), plus degrees and 0..1
375        normalized range as custom fields for analysis.
376        """
377        motor_log = sys_id_routine.motor("hood")
378
379        position_deg = self.encoder.getPosition()
380        velocity_dps = self.encoder.getVelocity()
381        position_rad = math.radians(position_deg)
382        velocity_rps = math.radians(velocity_dps)
383
384        (
385            motor_log
386            .angularPosition(position_rad)
387            .angularVelocity(velocity_rps)
388            .current(self.motor.getOutputCurrent())
389            .voltage(getattr(self, '_commanded_volts', 0.0))
390            .value("positionDegrees", position_deg, "deg")
391            .value("velocityDegPerSec", velocity_dps, "deg/s")
392            .value("positionNormalized",
393                   position_deg / self.max_angle_degrees, "")
394        )
395
396    def _sysIdQuasistaticCommand(
397        self,
398        direction: SysIdRoutine.Direction,
399        sysIdRoutine: SysIdRoutine
400    ) -> Command:
401        """Create a quasistatic SysId command for the hood."""
402        return sysIdRoutine.quasistatic(direction)
403
404    def _sysIdDynamicCommand(
405        self,
406        direction: SysIdRoutine.Direction,
407        sysIdRoutine: SysIdRoutine
408    ) -> Command:
409        """Create a dynamic SysId command for the hood."""
410        return sysIdRoutine.dynamic(direction)
411
412    # -- Command generators --
413
414    def autoAngleCommand(self) -> Command:
415        """Command that sets hood angle from shooter's distance lookup."""
416        def _action():
417            if self._shooter is not None:
418                self.setAngleDegrees(
419                    self._shooter.getHoodAngleForDistance(
420                        self._shooter.targetDistance))
421
422        return commands2.cmd.run(_action, self)
423
424    def manualTestCommand(
425        self, analog_input: Callable[[], float]
426    ) -> Command:
427        """Test command: analog input controls hood angle directly.
428
429        Disables safety when input > 0.02, re-enables when released.
430        """
431        def _action():
432            trigger = analog_input()
433            self.setSafetyEnabled(trigger <= 0.02)
434            self.setAngleNormalized(trigger)
435
436        return commands2.cmd.run(_action, self)
437
438
439def createHood(constants, config) -> Hood:
440    """
441    Factory helper to create a Hood subsystem from config objects.
442
443    Args:
444        constants: HoodConstants (motorId, positionConversionFactor,
445            maxAngleDegrees, inverted)
446        config: HoodConfig (hoodPID, hoodFeedforward,
447            horizontalOffsetDegrees)
448
449    Returns:
450        A fully configured Hood subsystem
451    """
452    motor = rev.SparkMax(
453        constants.motorId,
454        rev.SparkLowLevel.MotorType.kBrushless
455    )
456    return Hood(
457        motor=motor,
458        position_conversion_factor=constants.positionConversionFactor,
459        max_angle_degrees=constants.maxAngleDegrees,
460        pid=config.hoodPID,
461        feedforward=config.hoodFeedforward,
462        inverted=constants.inverted,
463        min_angle_degrees=constants.minAngleDegrees,
464        horizontal_offset_degrees=config.horizontalOffsetDegrees,
465    )
class Hood(commands2.subsystem.Subsystem):
 20class Hood(Subsystem):
 21    """
 22    Subsystem for a hood angle adjustment mechanism.
 23
 24    Uses a single motor with position control to adjust the shooting angle.
 25    Position is measured in degrees via the internal relative encoder with
 26    a configurable conversion factor. Assumes 0 degrees on startup.
 27
 28    The feedforward uses ArmFeedforward to compensate for gravity —
 29    0 degrees is approximately horizontal, positive degrees lift
 30    toward vertical.
 31
 32    Safety interlock: when enabled (default on code load), the hood
 33    automatically stows if the injected shooter's RPM setpoint falls
 34    below a configurable threshold. Safety is always enabled on startup
 35    and must be explicitly disabled at runtime.
 36    """
 37
 38    # Telemetry via ntproperty (published to NT, not persisted)
 39    nt_position = ntproperty('/Hood/position', 0.0, writeDefault=True)
 40    nt_velocity = ntproperty('/Hood/velocity', 0.0, writeDefault=True)
 41    nt_target = ntproperty('/Hood/targetPosition', 0.0, writeDefault=True)
 42    nt_normalized_position = ntproperty(
 43        '/Hood/normalizedPosition', 0.0, writeDefault=True)
 44    nt_normalized_target = ntproperty(
 45        '/Hood/normalizedTarget', 0.0, writeDefault=True)
 46    nt_at_setpoint = ntproperty(
 47        '/Hood/atSetpoint', False, writeDefault=True)
 48    nt_applied_output = ntproperty(
 49        '/Hood/appliedOutput', 0.0, writeDefault=True)
 50    nt_current = ntproperty('/Hood/current', 0.0, writeDefault=True)
 51    nt_bus_voltage = ntproperty(
 52        '/Hood/busVoltage', 0.0, writeDefault=True)
 53    nt_min_soft_limit = ntproperty(
 54        '/Hood/minSoftLimit', 0.0, writeDefault=True)
 55    nt_max_soft_limit = ntproperty(
 56        '/Hood/maxSoftLimit', 0.0, writeDefault=True)
 57
 58    # Safety: persistent configuration
 59    nt_safety_enabled = ntproperty(
 60        '/Hood/safetyEnabled', True, writeDefault=True)
 61    nt_stowed_angle_degrees = ntproperty(
 62        '/Hood/stowedAngleDegrees', 0.0,
 63        writeDefault=False, persistent=True)
 64    nt_safety_rpm_threshold = ntproperty(
 65        '/Hood/safetyRpmThreshold', 5.0,
 66        writeDefault=False, persistent=True)
 67
 68    # Dashboard tuning: set ntControlEnabled=True on dashboard,
 69    # then adjust ntControlAngle to drive hood to a specific angle.
 70    nt_control_enabled = ntproperty(
 71        '/Hood/ntControlEnabled', False, writeDefault=True)
 72    nt_control_angle = ntproperty(
 73        '/Hood/ntControlAngle', 0.0, writeDefault=True)
 74
 75    def __init__(
 76        self,
 77        motor: rev.SparkMax,
 78        position_conversion_factor: float,
 79        max_angle_degrees: float,
 80        pid: tuple,
 81        feedforward: tuple,
 82        inverted: bool = False,
 83        min_angle_degrees: float = 0.0,
 84        horizontal_offset_degrees: float = 0.0,
 85    ) -> None:
 86        """
 87        Creates a new Hood subsystem.
 88
 89        Args:
 90            motor: the SparkMax motor controller driving the hood
 91            position_conversion_factor: encoder conversion factor that
 92                converts raw encoder rotations to degrees
 93            max_angle_degrees: the maximum hood angle in degrees
 94                (forward soft limit)
 95            pid: (P, I, D) gains for the WPILib PIDController
 96            feedforward: (kS, kG, kV, kA) gains for ArmFeedforward
 97            inverted: whether the motor direction is inverted
 98            min_angle_degrees: the minimum hood angle in degrees
 99                (reverse soft limit)
100            horizontal_offset_degrees: offset from hood 0-position to
101                true horizontal for feedforward calculation
102        """
103        super().__init__()
104        self.motor = motor
105        self.encoder = self.motor.getEncoder()
106
107        self.max_angle_degrees = max_angle_degrees
108        self.min_angle_degrees = min_angle_degrees
109        self._horizontal_offset_degrees = horizontal_offset_degrees
110
111        # Control
112        self.controller = PIDController(*pid)
113        wpilib.SmartDashboard.putData(
114            self.getName() + "/pid", self.controller)
115        self.feedforward = ArmFeedforward(*feedforward)
116
117        # Voltage output limits
118        self._min_output_volts = -12.0
119        self._max_output_volts = 12.0
120
121        # Position tracking
122        self._target_degrees = 0.0
123        self._enabled = True
124
125        # Configure motor
126        config = rev.SparkMaxConfig()
127        (
128            config
129            .inverted(inverted)
130            .setIdleMode(rev.SparkBaseConfig.IdleMode.kBrake)
131            .voltageCompensation(12.0)
132            .smartCurrentLimit(20)
133        )
134
135        # Encoder conversion factors
136        # position: rotations -> degrees (deg/rotation)
137        # velocity: RPM -> deg/s (divide by 60)
138        velocity_conversion_factor_dps = position_conversion_factor / 60.0
139        (
140            config.encoder
141            .positionConversionFactor(position_conversion_factor)
142            .velocityConversionFactor(velocity_conversion_factor_dps)
143        )
144
145        # Soft limits in degrees
146        (
147            config.softLimit
148            .forwardSoftLimit(max_angle_degrees)
149            .forwardSoftLimitEnabled(True)
150            .reverseSoftLimit(min_angle_degrees)
151            .reverseSoftLimitEnabled(True)
152        )
153
154        # Telemetry signals at 20ms
155        GetSparkSignalsPositionControlConfig(config.signals, 20)
156
157        # Apply configuration
158        self.motor.configure(
159            config,
160            rev.ResetMode.kResetSafeParameters,
161            rev.PersistMode.kNoPersistParameters
162        )
163
164        # Assume 0 position on startup
165        self.encoder.setPosition(0)
166
167        # Shooter dependency (optional, injected via setShooter)
168        self._shooter = None
169
170        # Mechanism2d visualization
171        self._configureMechanism2d()
172
173    # -- Setpoint methods --
174
175    def setAngleDegrees(self, degrees: float) -> None:
176        """Set hood target angle in degrees, clamped to valid range."""
177        if not self._enabled:
178            self.enable()
179        self._target_degrees = max(
180            self.min_angle_degrees,
181            min(self.max_angle_degrees, degrees)
182        )
183
184    def setAngleNormalized(self, value: float) -> None:
185        """Set hood target from 0..1 range (0 = min, 1 = max angle)."""
186        value = max(0.0, min(1.0, value))
187        self._target_degrees = value * self.max_angle_degrees
188
189    def setAngleRadians(self, radians: float) -> None:
190        """Set hood target angle in radians, converted to degrees."""
191        self.setAngleDegrees(math.degrees(radians))
192
193    # -- Shooter injection --
194
195    def setShooter(self, shooter) -> None:
196        """Inject a shooter subsystem for safety interlock.
197
198        The shooter must have an `RPM` attribute representing
199        the current setpoint in RPM.
200        """
201        self._shooter = shooter
202
203    def getShooter(self):
204        """Return the injected shooter, or None."""
205        return self._shooter
206
207    # -- Safety --
208
209    def setSafetyEnabled(self, enabled: bool) -> None:
210        """Enable or disable the shooter-based safety interlock."""
211        self.nt_safety_enabled = enabled
212
213    def getSafetyEnabled(self) -> bool:
214        """Return whether the safety interlock is enabled."""
215        return self.nt_safety_enabled
216
217    # -- Getter methods --
218
219    def getAngleDegrees(self) -> float:
220        """Get current hood angle in degrees from encoder."""
221        return self.encoder.getPosition()
222
223    def getAngleNormalized(self) -> float:
224        """Get current hood angle as 0..1 normalized value."""
225        return self.encoder.getPosition() / self.max_angle_degrees
226
227    def getAngleRadians(self) -> float:
228        """Get current hood angle in radians."""
229        return math.radians(self.encoder.getPosition())
230
231    def getTargetDegrees(self) -> float:
232        """Get the current target angle in degrees."""
233        return self._target_degrees
234
235    def atSetpoint(self) -> bool:
236        """Return whether the hood is at its target setpoint."""
237        return self.controller.atSetpoint()
238
239    # -- Lifecycle --
240
241    def onDisabledInit(self) -> None:
242        """Stop the motor when robot is disabled."""
243        self.motor.stopMotor()
244
245    def enable(self) -> None:
246        """Re-enable periodic PID control."""
247        self.controller.reset()
248        self._target_degrees = self.getAngleDegrees()
249        self._enabled = True
250
251    def disable(self) -> None:
252        """Stop motor output and suspend periodic PID control."""
253        self._enabled = False
254        self._target_degrees = self.getAngleDegrees()
255        self.controller.reset()
256        self.motor.stopMotor()
257
258    # -- Periodic --
259
260    def periodic(self) -> None:
261        """
262        Drive PID + feedforward to target, update viz and telemetry.
263        """
264        if not self._enabled:
265            self._updateTelemetry()
266            return
267
268        # Dashboard tuning override
269        if self.nt_control_enabled:
270            self._target_degrees = max(
271                self.min_angle_degrees,
272                min(self.max_angle_degrees, self.nt_control_angle)
273            )
274
275        # Safety interlock: stow hood when shooter setpoint is low
276        if (self.nt_safety_enabled
277                and self._shooter is not None
278                and self._shooter.RPM < self.nt_safety_rpm_threshold):
279            self._target_degrees = self.nt_stowed_angle_degrees
280
281        position_deg = self.encoder.getPosition()
282
283        # PID output (volts)
284        pid_volts = self.controller.calculate(
285            position_deg, self._target_degrees)
286
287        # ArmFeedforward expects angle in radians from horizontal
288        ff_angle_rad = math.radians(
289            position_deg + self._horizontal_offset_degrees)
290        ff_volts = self.feedforward.calculate(ff_angle_rad, 0)
291
292        # Combine and clamp voltage
293        total_volts = pid_volts + ff_volts
294        total_volts = max(self._min_output_volts,
295                          min(self._max_output_volts, total_volts))
296
297        if self.controller.atSetpoint():
298            # Still apply feedforward to hold against gravity
299            self.motor.setVoltage(ff_volts)
300        else:
301            self.motor.setVoltage(total_volts)
302
303        self._updateTelemetry()
304
305    # -- Mechanism2d --
306
307    def _configureMechanism2d(self) -> None:
308        """
309        Create and publish a Mechanism2d widget for hood visualization.
310
311        Sets up a 2D canvas with:
312        - Red arm: current encoder position
313        - Green arm: target setpoint position
314        - Gray arms: static soft limit indicators
315        """
316        self.mech2d = wpilib.Mechanism2d(200, 200)
317        pivot = self.mech2d.getRoot("hood_pivot", 100, 100)
318        self.mech_current_arm = pivot.appendLigament(
319            "current_position", 80, 0, 6,
320            wpilib.Color8Bit(wpilib.Color.kRed)
321        )
322        self.mech_target_arm = pivot.appendLigament(
323            "target_position", 80, 0, 4,
324            wpilib.Color8Bit(wpilib.Color.kGreen)
325        )
326        pivot.appendLigament(
327            "min_limit", 80, self.min_angle_degrees, 2,
328            wpilib.Color8Bit(100, 100, 100)
329        )
330        pivot.appendLigament(
331            "max_limit", 80, self.max_angle_degrees, 2,
332            wpilib.Color8Bit(100, 100, 100)
333        )
334        wpilib.SmartDashboard.putData(
335            self.getName() + "/mechanism", self.mech2d)
336
337    # -- Telemetry --
338
339    def _updateTelemetry(self) -> None:
340        """Publish telemetry via ntproperty and update Mechanism2d."""
341        position_deg = self.encoder.getPosition()
342        velocity_dps = self.encoder.getVelocity()
343
344        self.nt_position = position_deg
345        self.nt_velocity = velocity_dps
346        self.nt_target = self._target_degrees
347        self.nt_normalized_position = position_deg / self.max_angle_degrees
348        self.nt_normalized_target = (
349            self._target_degrees / self.max_angle_degrees)
350        self.nt_at_setpoint = self.atSetpoint()
351        self.nt_applied_output = self.motor.getAppliedOutput()
352        self.nt_current = self.motor.getOutputCurrent()
353        self.nt_bus_voltage = self.motor.getBusVoltage()
354
355        # Soft limits
356        sl = self.motor.configAccessor.softLimit
357        self.nt_min_soft_limit = sl.getReverseSoftLimit()
358        self.nt_max_soft_limit = sl.getForwardSoftLimit()
359
360        # Update mechanism2d arms
361        self.mech_current_arm.setAngle(position_deg)
362        self.mech_target_arm.setAngle(self._target_degrees)
363
364    # -- SysId --
365
366    def _setMotorVoltage(self, volts: float) -> None:
367        """Set motor voltage directly. Used by SysId routines."""
368        self._commanded_volts = volts
369        self.motor.setVoltage(volts)
370
371    def _sysIdLog(self, sys_id_routine: SysIdRoutineLog) -> None:
372        """
373        Log a frame of data for SysId characterization.
374
375        Logs radians (SI units for SysId), plus degrees and 0..1
376        normalized range as custom fields for analysis.
377        """
378        motor_log = sys_id_routine.motor("hood")
379
380        position_deg = self.encoder.getPosition()
381        velocity_dps = self.encoder.getVelocity()
382        position_rad = math.radians(position_deg)
383        velocity_rps = math.radians(velocity_dps)
384
385        (
386            motor_log
387            .angularPosition(position_rad)
388            .angularVelocity(velocity_rps)
389            .current(self.motor.getOutputCurrent())
390            .voltage(getattr(self, '_commanded_volts', 0.0))
391            .value("positionDegrees", position_deg, "deg")
392            .value("velocityDegPerSec", velocity_dps, "deg/s")
393            .value("positionNormalized",
394                   position_deg / self.max_angle_degrees, "")
395        )
396
397    def _sysIdQuasistaticCommand(
398        self,
399        direction: SysIdRoutine.Direction,
400        sysIdRoutine: SysIdRoutine
401    ) -> Command:
402        """Create a quasistatic SysId command for the hood."""
403        return sysIdRoutine.quasistatic(direction)
404
405    def _sysIdDynamicCommand(
406        self,
407        direction: SysIdRoutine.Direction,
408        sysIdRoutine: SysIdRoutine
409    ) -> Command:
410        """Create a dynamic SysId command for the hood."""
411        return sysIdRoutine.dynamic(direction)
412
413    # -- Command generators --
414
415    def autoAngleCommand(self) -> Command:
416        """Command that sets hood angle from shooter's distance lookup."""
417        def _action():
418            if self._shooter is not None:
419                self.setAngleDegrees(
420                    self._shooter.getHoodAngleForDistance(
421                        self._shooter.targetDistance))
422
423        return commands2.cmd.run(_action, self)
424
425    def manualTestCommand(
426        self, analog_input: Callable[[], float]
427    ) -> Command:
428        """Test command: analog input controls hood angle directly.
429
430        Disables safety when input > 0.02, re-enables when released.
431        """
432        def _action():
433            trigger = analog_input()
434            self.setSafetyEnabled(trigger <= 0.02)
435            self.setAngleNormalized(trigger)
436
437        return commands2.cmd.run(_action, self)

Subsystem for a hood angle adjustment mechanism.

Uses a single motor with position control to adjust the shooting angle. Position is measured in degrees via the internal relative encoder with a configurable conversion factor. Assumes 0 degrees on startup.

The feedforward uses ArmFeedforward to compensate for gravity — 0 degrees is approximately horizontal, positive degrees lift toward vertical.

Safety interlock: when enabled (default on code load), the hood automatically stows if the injected shooter's RPM setpoint falls below a configurable threshold. Safety is always enabled on startup and must be explicitly disabled at runtime.

Hood( motor: rev._rev.SparkMax, position_conversion_factor: float, max_angle_degrees: float, pid: tuple, feedforward: tuple, inverted: bool = False, min_angle_degrees: float = 0.0, horizontal_offset_degrees: float = 0.0)
 75    def __init__(
 76        self,
 77        motor: rev.SparkMax,
 78        position_conversion_factor: float,
 79        max_angle_degrees: float,
 80        pid: tuple,
 81        feedforward: tuple,
 82        inverted: bool = False,
 83        min_angle_degrees: float = 0.0,
 84        horizontal_offset_degrees: float = 0.0,
 85    ) -> None:
 86        """
 87        Creates a new Hood subsystem.
 88
 89        Args:
 90            motor: the SparkMax motor controller driving the hood
 91            position_conversion_factor: encoder conversion factor that
 92                converts raw encoder rotations to degrees
 93            max_angle_degrees: the maximum hood angle in degrees
 94                (forward soft limit)
 95            pid: (P, I, D) gains for the WPILib PIDController
 96            feedforward: (kS, kG, kV, kA) gains for ArmFeedforward
 97            inverted: whether the motor direction is inverted
 98            min_angle_degrees: the minimum hood angle in degrees
 99                (reverse soft limit)
100            horizontal_offset_degrees: offset from hood 0-position to
101                true horizontal for feedforward calculation
102        """
103        super().__init__()
104        self.motor = motor
105        self.encoder = self.motor.getEncoder()
106
107        self.max_angle_degrees = max_angle_degrees
108        self.min_angle_degrees = min_angle_degrees
109        self._horizontal_offset_degrees = horizontal_offset_degrees
110
111        # Control
112        self.controller = PIDController(*pid)
113        wpilib.SmartDashboard.putData(
114            self.getName() + "/pid", self.controller)
115        self.feedforward = ArmFeedforward(*feedforward)
116
117        # Voltage output limits
118        self._min_output_volts = -12.0
119        self._max_output_volts = 12.0
120
121        # Position tracking
122        self._target_degrees = 0.0
123        self._enabled = True
124
125        # Configure motor
126        config = rev.SparkMaxConfig()
127        (
128            config
129            .inverted(inverted)
130            .setIdleMode(rev.SparkBaseConfig.IdleMode.kBrake)
131            .voltageCompensation(12.0)
132            .smartCurrentLimit(20)
133        )
134
135        # Encoder conversion factors
136        # position: rotations -> degrees (deg/rotation)
137        # velocity: RPM -> deg/s (divide by 60)
138        velocity_conversion_factor_dps = position_conversion_factor / 60.0
139        (
140            config.encoder
141            .positionConversionFactor(position_conversion_factor)
142            .velocityConversionFactor(velocity_conversion_factor_dps)
143        )
144
145        # Soft limits in degrees
146        (
147            config.softLimit
148            .forwardSoftLimit(max_angle_degrees)
149            .forwardSoftLimitEnabled(True)
150            .reverseSoftLimit(min_angle_degrees)
151            .reverseSoftLimitEnabled(True)
152        )
153
154        # Telemetry signals at 20ms
155        GetSparkSignalsPositionControlConfig(config.signals, 20)
156
157        # Apply configuration
158        self.motor.configure(
159            config,
160            rev.ResetMode.kResetSafeParameters,
161            rev.PersistMode.kNoPersistParameters
162        )
163
164        # Assume 0 position on startup
165        self.encoder.setPosition(0)
166
167        # Shooter dependency (optional, injected via setShooter)
168        self._shooter = None
169
170        # Mechanism2d visualization
171        self._configureMechanism2d()

Creates a new Hood subsystem.

Args: motor: the SparkMax motor controller driving the hood position_conversion_factor: encoder conversion factor that converts raw encoder rotations to degrees max_angle_degrees: the maximum hood angle in degrees (forward soft limit) pid: (P, I, D) gains for the WPILib PIDController feedforward: (kS, kG, kV, kA) gains for ArmFeedforward inverted: whether the motor direction is inverted min_angle_degrees: the minimum hood angle in degrees (reverse soft limit) horizontal_offset_degrees: offset from hood 0-position to true horizontal for feedforward calculation

nt_position
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_velocity
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_target
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_normalized_position
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_normalized_target
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_at_setpoint
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_applied_output
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_current
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_bus_voltage
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_min_soft_limit
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_max_soft_limit
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_safety_enabled
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_stowed_angle_degrees
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_safety_rpm_threshold
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_control_enabled
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

nt_control_angle
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

motor
encoder
max_angle_degrees
min_angle_degrees
controller
feedforward
def setAngleDegrees(self, degrees: float) -> None:
175    def setAngleDegrees(self, degrees: float) -> None:
176        """Set hood target angle in degrees, clamped to valid range."""
177        if not self._enabled:
178            self.enable()
179        self._target_degrees = max(
180            self.min_angle_degrees,
181            min(self.max_angle_degrees, degrees)
182        )

Set hood target angle in degrees, clamped to valid range.

def setAngleNormalized(self, value: float) -> None:
184    def setAngleNormalized(self, value: float) -> None:
185        """Set hood target from 0..1 range (0 = min, 1 = max angle)."""
186        value = max(0.0, min(1.0, value))
187        self._target_degrees = value * self.max_angle_degrees

Set hood target from 0..1 range (0 = min, 1 = max angle).

def setAngleRadians(self, radians: float) -> None:
189    def setAngleRadians(self, radians: float) -> None:
190        """Set hood target angle in radians, converted to degrees."""
191        self.setAngleDegrees(math.degrees(radians))

Set hood target angle in radians, converted to degrees.

def setShooter(self, shooter) -> None:
195    def setShooter(self, shooter) -> None:
196        """Inject a shooter subsystem for safety interlock.
197
198        The shooter must have an `RPM` attribute representing
199        the current setpoint in RPM.
200        """
201        self._shooter = shooter

Inject a shooter subsystem for safety interlock.

The shooter must have an RPM attribute representing the current setpoint in RPM.

def getShooter(self):
203    def getShooter(self):
204        """Return the injected shooter, or None."""
205        return self._shooter

Return the injected shooter, or None.

def setSafetyEnabled(self, enabled: bool) -> None:
209    def setSafetyEnabled(self, enabled: bool) -> None:
210        """Enable or disable the shooter-based safety interlock."""
211        self.nt_safety_enabled = enabled

Enable or disable the shooter-based safety interlock.

def getSafetyEnabled(self) -> bool:
213    def getSafetyEnabled(self) -> bool:
214        """Return whether the safety interlock is enabled."""
215        return self.nt_safety_enabled

Return whether the safety interlock is enabled.

def getAngleDegrees(self) -> float:
219    def getAngleDegrees(self) -> float:
220        """Get current hood angle in degrees from encoder."""
221        return self.encoder.getPosition()

Get current hood angle in degrees from encoder.

def getAngleNormalized(self) -> float:
223    def getAngleNormalized(self) -> float:
224        """Get current hood angle as 0..1 normalized value."""
225        return self.encoder.getPosition() / self.max_angle_degrees

Get current hood angle as 0..1 normalized value.

def getAngleRadians(self) -> float:
227    def getAngleRadians(self) -> float:
228        """Get current hood angle in radians."""
229        return math.radians(self.encoder.getPosition())

Get current hood angle in radians.

def getTargetDegrees(self) -> float:
231    def getTargetDegrees(self) -> float:
232        """Get the current target angle in degrees."""
233        return self._target_degrees

Get the current target angle in degrees.

def atSetpoint(self) -> bool:
235    def atSetpoint(self) -> bool:
236        """Return whether the hood is at its target setpoint."""
237        return self.controller.atSetpoint()

Return whether the hood is at its target setpoint.

def onDisabledInit(self) -> None:
241    def onDisabledInit(self) -> None:
242        """Stop the motor when robot is disabled."""
243        self.motor.stopMotor()

Stop the motor when robot is disabled.

def enable(self) -> None:
245    def enable(self) -> None:
246        """Re-enable periodic PID control."""
247        self.controller.reset()
248        self._target_degrees = self.getAngleDegrees()
249        self._enabled = True

Re-enable periodic PID control.

def disable(self) -> None:
251    def disable(self) -> None:
252        """Stop motor output and suspend periodic PID control."""
253        self._enabled = False
254        self._target_degrees = self.getAngleDegrees()
255        self.controller.reset()
256        self.motor.stopMotor()

Stop motor output and suspend periodic PID control.

def periodic(self) -> None:
260    def periodic(self) -> None:
261        """
262        Drive PID + feedforward to target, update viz and telemetry.
263        """
264        if not self._enabled:
265            self._updateTelemetry()
266            return
267
268        # Dashboard tuning override
269        if self.nt_control_enabled:
270            self._target_degrees = max(
271                self.min_angle_degrees,
272                min(self.max_angle_degrees, self.nt_control_angle)
273            )
274
275        # Safety interlock: stow hood when shooter setpoint is low
276        if (self.nt_safety_enabled
277                and self._shooter is not None
278                and self._shooter.RPM < self.nt_safety_rpm_threshold):
279            self._target_degrees = self.nt_stowed_angle_degrees
280
281        position_deg = self.encoder.getPosition()
282
283        # PID output (volts)
284        pid_volts = self.controller.calculate(
285            position_deg, self._target_degrees)
286
287        # ArmFeedforward expects angle in radians from horizontal
288        ff_angle_rad = math.radians(
289            position_deg + self._horizontal_offset_degrees)
290        ff_volts = self.feedforward.calculate(ff_angle_rad, 0)
291
292        # Combine and clamp voltage
293        total_volts = pid_volts + ff_volts
294        total_volts = max(self._min_output_volts,
295                          min(self._max_output_volts, total_volts))
296
297        if self.controller.atSetpoint():
298            # Still apply feedforward to hold against gravity
299            self.motor.setVoltage(ff_volts)
300        else:
301            self.motor.setVoltage(total_volts)
302
303        self._updateTelemetry()

Drive PID + feedforward to target, update viz and telemetry.

def autoAngleCommand(self) -> commands2.command.Command:
415    def autoAngleCommand(self) -> Command:
416        """Command that sets hood angle from shooter's distance lookup."""
417        def _action():
418            if self._shooter is not None:
419                self.setAngleDegrees(
420                    self._shooter.getHoodAngleForDistance(
421                        self._shooter.targetDistance))
422
423        return commands2.cmd.run(_action, self)

Command that sets hood angle from shooter's distance lookup.

def manualTestCommand(self, analog_input: Callable[[], float]) -> commands2.command.Command:
425    def manualTestCommand(
426        self, analog_input: Callable[[], float]
427    ) -> Command:
428        """Test command: analog input controls hood angle directly.
429
430        Disables safety when input > 0.02, re-enables when released.
431        """
432        def _action():
433            trigger = analog_input()
434            self.setSafetyEnabled(trigger <= 0.02)
435            self.setAngleNormalized(trigger)
436
437        return commands2.cmd.run(_action, self)

Test command: analog input controls hood angle directly.

Disables safety when input > 0.02, re-enables when released.

def createHood(constants, config) -> Hood:
440def createHood(constants, config) -> Hood:
441    """
442    Factory helper to create a Hood subsystem from config objects.
443
444    Args:
445        constants: HoodConstants (motorId, positionConversionFactor,
446            maxAngleDegrees, inverted)
447        config: HoodConfig (hoodPID, hoodFeedforward,
448            horizontalOffsetDegrees)
449
450    Returns:
451        A fully configured Hood subsystem
452    """
453    motor = rev.SparkMax(
454        constants.motorId,
455        rev.SparkLowLevel.MotorType.kBrushless
456    )
457    return Hood(
458        motor=motor,
459        position_conversion_factor=constants.positionConversionFactor,
460        max_angle_degrees=constants.maxAngleDegrees,
461        pid=config.hoodPID,
462        feedforward=config.hoodFeedforward,
463        inverted=constants.inverted,
464        min_angle_degrees=constants.minAngleDegrees,
465        horizontal_offset_degrees=config.horizontalOffsetDegrees,
466    )

Factory helper to create a Hood subsystem from config objects.

Args: constants: HoodConstants (motorId, positionConversionFactor, maxAngleDegrees, inverted) config: HoodConfig (hoodPID, hoodFeedforward, horizontalOffsetDegrees)

Returns: A fully configured Hood subsystem