utils.position_calibration
1# Native imports 2import math 3from typing import Any, Callable, Optional 4 5# Third-party imports 6import wpilib 7from ntcore.util import ntproperty 8 9 10# Cache of dynamically-created subclasses keyed by mechanism name. 11# Each subclass has ntproperty descriptors with NT paths unique to 12# that mechanism, so multiple PositionCalibration instances with 13# different names get independent persistent storage. 14_calibration_classes = {} 15 16# Valid callback names accepted by the constructor and set_callbacks() 17_VALID_CALLBACKS = { 18 'get_position', 'get_velocity', 'set_position', 19 'set_motor_output', 'stop_motor', 20 'set_current_limit', 'set_soft_limits', 'disable_soft_limits', 21 'save_config', 'restore_config', 22 'get_forward_limit_switch', 'get_reverse_limit_switch', 23 'on_limit_detected', 24 'set_conversion_factor', 25} 26 27# Callbacks that must always be set before homing or calibration 28_CORE_REQUIRED = {'set_motor_output', 'stop_motor', 'set_position'} 29 30 31class PositionCalibration: 32 """ 33 Reusable calibration and homing controller for positional mechanisms. 34 35 Extracts sensorless or sensor homing and two-phase calibration logic 36 from mechanism-specific subsystems into a standalone, motor-agnostic 37 class. All hardware interaction is done through user-provided 38 callbacks, making this compatible with any motor controller. 39 40 For SparkMax users, the ``motor`` convenience parameter auto-populates 41 callbacks via SparkMaxCallbacks. Individual callbacks passed as keyword 42 arguments override the SparkMax defaults. 43 44 Calibration discovers mechanical hard limits by driving into stops, 45 then computes soft limits with a configurable safety margin. Persists 46 discovered limits to NetworkTables via ntproperty so values survive 47 reboots. 48 49 ## Callbacks 50 51 **Core required** (must be set before homing or calibration): 52 53 | Name | Signature | Purpose | 54 |------|-----------|---------| 55 | `set_motor_output` | `(float) -> None` | Drive motor at duty cycle (-1.0 to 1.0) | 56 | `stop_motor` | `() -> None` | Stop motor output | 57 | `set_position` | `(float) -> None` | Reset encoder position | 58 59 **Detection** (at least one required per homing direction): 60 61 | Name | Signature | Purpose | 62 |------|-----------|---------| 63 | `get_velocity` | `() -> float` | Enables stall detection | 64 | `get_forward_limit_switch` | `() -> bool` | Forward limit switch | 65 | `get_reverse_limit_switch` | `() -> bool` | Reverse limit switch | 66 67 **Optional** (enhance safety/features but not required): 68 69 | Name | Signature | Purpose | 70 |------|-----------|---------| 71 | `get_position` | `() -> float` | Required only for calibration phase 2 | 72 | `set_current_limit` | `(float) -> None` | Protective current limit during homing | 73 | `set_soft_limits` | `(min, max) -> None` | Apply soft limits to hardware | 74 | `disable_soft_limits` | `(fwd, rev) -> None` | Disable soft limits for free travel | 75 | `save_config` | `() -> Any` | Snapshot motor config before homing | 76 | `restore_config` | `(Any) -> None` | Restore snapshot after homing | 77 | `on_limit_detected` | `(pos, dir) -> None` | Fires when a hard limit is found | 78 | `set_conversion_factor` | `(float) -> None` | Apply a new encoder position conversion factor | 79 80 Use ``get_callbacks()`` to inspect the current callback dict (returns 81 ``None`` for any callback that has not been set). 82 83 Examples 84 -------- 85 86 SparkMax convenience (auto-generates all callbacks):: 87 88 self.calibration = PositionCalibration( 89 name="Turret", 90 fallback_min=-90, fallback_max=90, 91 motor=self.motor, encoder=self.encoder) 92 93 Pure callbacks with stall detection only:: 94 95 self.calibration = PositionCalibration( 96 name="Elevator", 97 fallback_min=0.0, fallback_max=1.5, 98 set_motor_output=lambda pct: motor.set(pct), 99 stop_motor=lambda: motor.stopMotor(), 100 set_position=lambda v: encoder.setPosition(v), 101 get_velocity=lambda: encoder.getVelocity()) 102 103 Pure callbacks with limit switches only:: 104 105 self.calibration = PositionCalibration( 106 name="Arm", 107 fallback_min=0.0, fallback_max=90.0, 108 set_motor_output=lambda pct: motor.set(pct), 109 stop_motor=lambda: motor.stopMotor(), 110 set_position=lambda v: encoder.setPosition(v), 111 get_forward_limit_switch=lambda: fwd_switch.get(), 112 get_reverse_limit_switch=lambda: rev_switch.get()) 113 114 Deferred setup (set callbacks after construction):: 115 116 cal = PositionCalibration( 117 name="Wrist", 118 fallback_min=-45, fallback_max=45) 119 # ... later, once hardware is ready ... 120 cal.set_callbacks( 121 set_motor_output=motor.set, 122 stop_motor=motor.stopMotor, 123 set_position=encoder.setPosition, 124 get_velocity=encoder.getVelocity) 125 """ 126 127 # -- Callback type declarations -- 128 _cb_get_position: Optional[Callable[[], float]] 129 _cb_get_velocity: Optional[Callable[[], float]] 130 _cb_set_position: Optional[Callable[[float], None]] 131 _cb_set_motor_output: Optional[Callable[[float], None]] 132 _cb_stop_motor: Optional[Callable[[], None]] 133 _cb_set_current_limit: Optional[Callable[[float], None]] 134 _cb_set_soft_limits: Optional[Callable[[float, float], None]] 135 _cb_disable_soft_limits: Optional[Callable[[bool, bool], None]] 136 _cb_save_config: Optional[Callable[[], Any]] 137 _cb_restore_config: Optional[Callable[[Any], None]] 138 _cb_get_forward_limit_switch: Optional[Callable[[], bool]] 139 _cb_get_reverse_limit_switch: Optional[Callable[[], bool]] 140 _cb_on_limit_detected: Optional[Callable[[float, str], None]] 141 _cb_set_conversion_factor: Optional[Callable[[float], None]] 142 143 def __init__( 144 self, 145 name: str, 146 fallback_min: float, 147 fallback_max: float, 148 *, 149 motor=None, 150 encoder=None, 151 **kwargs, 152 ) -> None: 153 """ 154 Create a new PositionCalibration controller. 155 156 Args: 157 name: mechanism name used for NT paths and alerts (e.g. "Turret") 158 fallback_min: reverse soft limit used before calibration data 159 exists. Once calibrated, the persisted hard limits are used 160 instead. The caller is responsible for the far end of travel 161 if only homing (not full calibration) is performed. 162 fallback_max: forward soft limit used before calibration data 163 exists. See fallback_min for details. 164 motor: (optional) rev.SparkMax for convenience callback generation 165 encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) 166 **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys) 167 """ 168 # Assign this instance to a cached subclass with ntproperty 169 # descriptors keyed to this mechanism's name. ntproperty is a 170 # class-level descriptor, so each mechanism name needs its own 171 # class to avoid key collisions. The cache ensures the 172 # ntproperty is created only once per name so defaults don't 173 # overwrite previously persisted values. 174 if name not in _calibration_classes: 175 prefix = f"/{name}/calibration" 176 attrs = { 177 "_nt_hard_limit_min": ntproperty( 178 f"{prefix}/hard_limit_min", float("nan"), 179 writeDefault=False, persistent=True), 180 "_nt_hard_limit_max": ntproperty( 181 f"{prefix}/hard_limit_max", float("nan"), 182 writeDefault=False, persistent=True), 183 "_nt_soft_limit_margin": ntproperty( 184 f"{prefix}/soft_limit_margin", 0.05, 185 writeDefault=False, persistent=True), 186 } 187 # Non-persistent booleans for callback status 188 for cb_name in sorted(_VALID_CALLBACKS): 189 attrs[f"_nt_cb_{cb_name}"] = ntproperty( 190 f"{prefix}/callbacks/{cb_name}", False, 191 writeDefault=True, persistent=False) 192 _calibration_classes[name] = type( 193 f"PositionCalibration_{name}", 194 (PositionCalibration,), 195 attrs, 196 ) 197 self.__class__ = _calibration_classes[name] 198 199 self._name = name 200 201 # Soft limits — initialized to defaults, overwritten by 202 # _load_from_nt() if persisted calibration data exists 203 self._min_soft_limit = fallback_min 204 self._max_soft_limit = fallback_max 205 206 # Calibration state 207 self._hard_limit_min = None 208 self._hard_limit_max = None 209 self._is_calibrated = False 210 self._is_calibrating = False 211 self._calibration_phase = 0 212 self._soft_limit_margin = 0.05 213 self._position_offset = 0.0 214 215 # Homing state 216 self._is_homing = False 217 self._saved_config = None 218 219 # -- Build callbacks -- 220 # Validate kwargs keys 221 unknown = set(kwargs.keys()) - _VALID_CALLBACKS 222 if unknown: 223 raise ValueError( 224 f"Unknown callback(s): {', '.join(sorted(unknown))}" 225 ) 226 227 # If motor provided, generate SparkMax defaults 228 defaults = {} 229 if motor is not None: 230 from utils.spark_max_callbacks import SparkMaxCallbacks 231 defaults = SparkMaxCallbacks(motor, encoder).as_dict() 232 233 # Merge: explicit kwargs override SparkMax defaults 234 merged = {**defaults, **kwargs} 235 236 # Store each callback as self._cb_{name} (None if not provided) 237 for cb_name in _VALID_CALLBACKS: 238 setattr(self, f'_cb_{cb_name}', merged.get(cb_name)) 239 240 # Publish callback status to NT 241 self._publish_callback_status() 242 243 # Attempt to load persisted calibration from NT 244 self._load_from_nt() 245 246 # ---- Public properties ---- 247 248 @property 249 def is_busy(self) -> bool: 250 """True when homing or calibrating (blocks motor commands).""" 251 return self._is_homing or self._is_calibrating 252 253 @property 254 def is_homing(self) -> bool: 255 """Whether a homing routine is active.""" 256 return self._is_homing 257 258 @property 259 def is_calibrating(self) -> bool: 260 """Whether a calibration routine is active.""" 261 return self._is_calibrating 262 263 @property 264 def is_calibrated(self) -> bool: 265 """Whether the full mechanical range has been discovered.""" 266 return self._is_calibrated 267 268 @property 269 def is_zeroed(self) -> bool: 270 """Whether the encoder has been aligned to a reference point.""" 271 return self._hard_limit_min is not None 272 273 @property 274 def min_limit(self): 275 """Hard limit min, or None if uncalibrated.""" 276 return self._hard_limit_min 277 278 @property 279 def max_limit(self): 280 """Hard limit max, or None if uncalibrated.""" 281 return self._hard_limit_max 282 283 @property 284 def min_soft_limit(self) -> float: 285 """Current minimum soft limit.""" 286 return self._min_soft_limit 287 288 @property 289 def max_soft_limit(self) -> float: 290 """Current maximum soft limit.""" 291 return self._max_soft_limit 292 293 @property 294 def soft_limit_margin(self) -> float: 295 """Current safety margin as a fraction of the full range.""" 296 return self._soft_limit_margin 297 298 # ---- Public methods ---- 299 300 def get_callbacks(self) -> dict: 301 """ 302 Return a dict of all callback names and their current values. 303 304 Each key is a callback name from _VALID_CALLBACKS. The value is 305 the callable if set, or None if not set. Useful for inspecting 306 which callbacks are configured:: 307 308 cbs = cal.get_callbacks() 309 for name, func in cbs.items(): 310 print(f"{name}: {'set' if func else 'not set'}") 311 312 Returns: 313 dict mapping callback name -> callable or None 314 """ 315 return { 316 name: getattr(self, f'_cb_{name}', None) 317 for name in sorted(_VALID_CALLBACKS) 318 } 319 320 def set_callbacks(self, **kwargs) -> None: 321 """ 322 Set, override, or clear one or more callbacks by name. 323 324 Pass a callable to set a callback, or None to clear it:: 325 326 # Set a callback 327 cal.set_callbacks(get_velocity=encoder.getVelocity) 328 329 # Clear callbacks that don't apply to this mechanism 330 cal.set_callbacks( 331 get_forward_limit_switch=None, 332 get_reverse_limit_switch=None) 333 334 Args: 335 **kwargs: callback name/value pairs (see _VALID_CALLBACKS). 336 Pass None to clear a callback. 337 """ 338 for key, value in kwargs.items(): 339 if key not in _VALID_CALLBACKS: 340 raise ValueError(f"Unknown callback: {key}") 341 setattr(self, f'_cb_{key}', value) 342 self._publish_callback_status() 343 344 def homing_init( 345 self, 346 max_current: float, 347 max_power_pct: float, 348 max_homing_time: float, 349 homing_forward: bool, 350 min_velocity: float = None, 351 home_position: float = 0.0, 352 max_travel: Optional[float] = None, 353 min_trigger_travel: float = 0.0, 354 ) -> None: 355 """ 356 Initialize the sensorless or sensor homing routine. 357 358 Saves current motor settings (if save_config callback is set), 359 applies homing-specific configuration, and begins driving toward 360 the specified hard stop. 361 362 Args: 363 max_current: current limit in amps during homing 364 max_power_pct: motor duty cycle during homing (0.0-1.0) 365 max_homing_time: maximum time in seconds before timeout 366 homing_forward: True to home toward forward limit, False for reverse 367 min_velocity: stall detection threshold in user units/second. 368 Defaults to 5% of soft limit range over 2 seconds. 369 home_position: encoder value to set when the hard stop is 370 found (default 0.0) 371 max_travel: abort if encoder travels more than this many user 372 units from the start position. Catches a stuck-open limit 373 switch that never triggers. None disables the check — 374 useful when the mechanical range is unknown. Requires 375 ``get_position`` callback to take effect. 376 min_trigger_travel: minimum encoder travel (user units) 377 required before a limit switch trigger is trusted. If 378 the switch fires before this much movement, abort with 379 a stuck-closed-switch alert. 0.0 disables (default). 380 Requires ``get_position`` callback to take effect. 381 """ 382 # Validate callbacks before starting 383 self._validate_homing(homing_forward) 384 385 if min_velocity is None: 386 full_range = self._max_soft_limit - self._min_soft_limit 387 min_velocity = full_range * 0.05 / 2.0 388 389 # Save current motor settings for restoration (if callback exists) 390 if self._cb_save_config is not None: 391 self._saved_config = self._cb_save_config() 392 else: 393 self._saved_config = None 394 395 # Apply homing current limit (if callback exists) 396 if self._cb_set_current_limit is not None: 397 self._cb_set_current_limit(max_current) 398 399 # Disable soft limit in homing direction (if callback exists) 400 if self._cb_disable_soft_limits is not None: 401 if homing_forward: 402 self._cb_disable_soft_limits(True, False) 403 else: 404 self._cb_disable_soft_limits(False, True) 405 406 # Store homing parameters 407 self._homing_forward = homing_forward 408 self._max_power_pct = max_power_pct 409 self._min_velocity = min_velocity 410 self._max_homing_time = max_homing_time 411 self._home_position = home_position 412 self._max_travel = max_travel 413 self._min_trigger_travel = min_trigger_travel 414 415 # Capture start position for travel tracking. Only meaningful if 416 # get_position is wired; otherwise both travel-based guards are 417 # silently no-ops. 418 if self._cb_get_position is not None: 419 self._homing_start_pos = self._cb_get_position() 420 else: 421 self._homing_start_pos = None 422 423 # Set homing state 424 self._is_homing = True 425 self._stall_detected = False 426 427 # Start timeout timer 428 self._homing_timer = wpilib.Timer() 429 self._homing_timer.start() 430 431 # Stall detection timer 432 self._stall_timer = wpilib.Timer() 433 434 # Alerts 435 self._homing_status_alert = wpilib.Alert( 436 f"{self._name}: homing started", 437 wpilib.Alert.AlertType.kInfo 438 ) 439 self._homing_status_alert.set(True) 440 self._homing_error_alert = wpilib.Alert( 441 f"{self._name}: homing failed", 442 wpilib.Alert.AlertType.kError 443 ) 444 self._homing_error_alert.set(False) 445 446 def calibration_init( 447 self, 448 max_current: float, 449 max_power_pct: float, 450 max_homing_time: float, 451 min_velocity: float = None, 452 known_range: float = None, 453 max_travel: Optional[float] = None, 454 min_trigger_travel: float = 0.0, 455 ) -> None: 456 """ 457 Start a calibration routine that discovers the mechanical range. 458 459 Phase 1: Home negative to find the reverse hard limit (set as zero). 460 Phase 2: Home positive to find the forward hard limit (measured). 461 462 If known_range is provided, only phase 1 runs and the forward hard 463 limit is computed as known_range from the zero point. 464 465 Args: 466 max_current: current limit in amps during calibration 467 max_power_pct: motor duty cycle (0.0-1.0) 468 max_homing_time: maximum time per phase before timeout 469 min_velocity: stall detection threshold in user units/second 470 known_range: if provided, skip phase 2 and use this as the 471 full mechanical range from the zero point 472 max_travel: abort a homing phase if encoder travels more 473 than this from its phase start. Defaults to 110% of the 474 current soft-limit range (a sane bound for calibration 475 because the expected range is known). Pass an explicit 476 None via the kwarg to disable the check entirely. 477 min_trigger_travel: minimum travel required before a limit 478 switch trigger is trusted per phase. Guards against a 479 stuck-closed switch firing immediately. 0.0 disables. 480 """ 481 # Validate callbacks before starting 482 self._validate_calibration() 483 484 # Save motor settings for restoration after calibration 485 if self._cb_save_config is not None: 486 self._cal_saved_config = self._cb_save_config() 487 else: 488 self._cal_saved_config = None 489 490 # Disable both soft limits for free travel 491 if self._cb_disable_soft_limits is not None: 492 self._cb_disable_soft_limits(True, True) 493 494 # Default max_travel to 110% of the expected range. Each phase 495 # homes one direction, so the full range is the per-phase ceiling. 496 if max_travel is None: 497 expected_range = abs(self._max_soft_limit - self._min_soft_limit) 498 if expected_range > 0: 499 max_travel = expected_range * 1.1 500 501 # Store calibration parameters for phase transitions 502 self._cal_max_current = max_current 503 self._cal_max_power_pct = max_power_pct 504 self._cal_max_homing_time = max_homing_time 505 self._cal_min_velocity = min_velocity 506 self._cal_known_range = known_range 507 self._cal_max_travel = max_travel 508 self._cal_min_trigger_travel = min_trigger_travel 509 510 self._is_calibrating = True 511 self._calibration_phase = 1 512 513 # Start phase 1: home negative 514 self.homing_init( 515 max_current, max_power_pct, max_homing_time, 516 homing_forward=False, min_velocity=min_velocity, 517 max_travel=max_travel, 518 min_trigger_travel=min_trigger_travel, 519 ) 520 521 self._cal_status_alert = wpilib.Alert( 522 f"{self._name}: calibration phase 1 - homing negative", 523 wpilib.Alert.AlertType.kInfo 524 ) 525 self._cal_status_alert.set(True) 526 527 def periodic(self) -> None: 528 """ 529 Run the active homing or calibration state machine. 530 531 Call this every cycle when is_busy is True. 532 """ 533 if self._is_calibrating: 534 self._calibration_periodic() 535 elif self._is_homing: 536 self._homing_periodic() 537 538 def abort(self) -> None: 539 """Abort any active homing or calibration routine.""" 540 if self._is_calibrating: 541 self._calibration_end(abort=True) 542 elif self._is_homing: 543 self._homing_end(abort=True) 544 545 def set_soft_limit_margin(self, margin_pct: float) -> None: 546 """ 547 Set the soft limit safety margin and apply to the motor controller. 548 549 Args: 550 margin_pct: safety margin as a fraction (e.g. 0.05 for 5%) 551 """ 552 if not self._is_calibrated: 553 return 554 self._soft_limit_margin = margin_pct 555 full_range = self._hard_limit_max - self._hard_limit_min 556 margin = full_range * margin_pct 557 self._min_soft_limit = self._hard_limit_min + margin 558 self._max_soft_limit = self._hard_limit_max - margin 559 560 # Apply to hardware only if callback exists 561 if self._cb_set_soft_limits is not None: 562 self._cb_set_soft_limits( 563 self._min_soft_limit, self._max_soft_limit) 564 565 def compute_conversion_factor( 566 self, 567 current_factor: float, 568 desired_min: float, 569 desired_max: float, 570 ) -> float: 571 """ 572 Compute a corrected encoder conversion factor after calibration. 573 574 After a full calibration discovers the raw mechanical range, this 575 method computes the conversion factor needed so the measured 576 range maps to ``[desired_min, desired_max]``. 577 578 Example: if calibration measured 500 user-units of travel with 579 the initial conversion factor and you want -90..90 (180°), the 580 corrected factor scales the encoder so full travel reads 180°. 581 582 Args: 583 current_factor: the position conversion factor currently set 584 on the encoder 585 desired_min: target minimum position in user units 586 desired_max: target maximum position in user units 587 588 Returns: 589 The corrected conversion factor to apply to the encoder. 590 591 Raises: 592 RuntimeError: if calibration has not completed, or the 593 measured range is zero. 594 """ 595 if not self._is_calibrated: 596 raise RuntimeError( 597 f"{self._name}: cannot compute conversion factor " 598 "before calibration is complete" 599 ) 600 measured_range = self._hard_limit_max - self._hard_limit_min 601 if measured_range == 0: 602 raise RuntimeError( 603 f"{self._name}: measured range is zero, " 604 "calibration data invalid" 605 ) 606 desired_range = desired_max - desired_min 607 return current_factor * (desired_range / measured_range) 608 609 def apply_conversion_factor( 610 self, 611 current_factor: float, 612 desired_min: float, 613 desired_max: float, 614 ) -> float: 615 """ 616 Compute and apply a corrected encoder conversion factor. 617 618 Calls :meth:`compute_conversion_factor` for the new value, then 619 applies it via the ``set_conversion_factor`` callback, resets 620 the encoder to ``desired_min`` (the new zero reference), and 621 rewrites the internal hard/soft limits to match the desired 622 range. 623 624 This is intended to be called immediately after a full 625 calibration completes (encoder at the reverse hard stop, 626 reading 0). A guard rejects the call if the encoder reports 627 more than 5% of the current soft-limit range away from zero — 628 the mechanism has moved since calibration, so the post-rescale 629 encoder zero would not correspond to a known physical position. 630 631 Args: 632 current_factor: the position conversion factor currently set 633 on the encoder 634 desired_min: target minimum position in user units (becomes 635 the new zero reference) 636 desired_max: target maximum position in user units 637 638 Returns: 639 The corrected conversion factor that was applied. 640 641 Raises: 642 ValueError: if the ``set_conversion_factor`` callback is 643 not configured. 644 RuntimeError: if calibration has not produced a zero 645 reference, or the mechanism is not at that zero point. 646 """ 647 if self._cb_set_conversion_factor is None: 648 raise ValueError( 649 "set_conversion_factor callback not set" 650 ) 651 652 if not self.is_zeroed: 653 raise RuntimeError( 654 f"{self._name}: cannot apply conversion factor " 655 "before homing to zero position" 656 ) 657 658 # Verify the mechanism is still at the reverse hard stop 659 # (encoder reads ~0) before rescaling. Without this, the post- 660 # rescale encoder zero corresponds to no known physical point. 661 if self._cb_get_position is not None: 662 pos = abs(self._cb_get_position()) 663 full_range = self._max_soft_limit - self._min_soft_limit 664 tolerance = abs(full_range) * 0.05 665 if pos > tolerance: 666 raise RuntimeError( 667 f"{self._name}: mechanism is not at the zero " 668 f"position (current: {pos:.1f}, tolerance: " 669 f"{tolerance:.1f}). Home the mechanism before " 670 "applying a new conversion factor" 671 ) 672 673 new_factor = self.compute_conversion_factor( 674 current_factor, desired_min, desired_max 675 ) 676 self._cb_set_conversion_factor(new_factor) 677 678 # The encoder unit-scale just changed, so its raw reading no 679 # longer maps to the same physical position. Reseed to 680 # desired_min (we calibrated from the reverse hard stop) and 681 # rewrite limits to match the desired range. 682 self._cb_set_position(desired_min) 683 self._hard_limit_min = desired_min 684 self._hard_limit_max = desired_max 685 self.set_soft_limit_margin(self._soft_limit_margin) 686 self._save_to_nt() 687 688 return new_factor 689 690 def update_telemetry(self, prefix: str) -> None: 691 """ 692 Publish calibration data to SmartDashboard. 693 694 Args: 695 prefix: SmartDashboard key prefix (e.g. "Turret/") 696 """ 697 sd = wpilib.SmartDashboard 698 sd.putBoolean(prefix + "isCalibrated", self._is_calibrated) 699 sd.putBoolean(prefix + "isCalibrating", self._is_calibrating) 700 sd.putBoolean(prefix + "isHoming", self._is_homing) 701 sd.putNumber( 702 prefix + "hardLimitMin", 703 self._hard_limit_min if self._hard_limit_min is not None 704 else 0.0 705 ) 706 sd.putNumber( 707 prefix + "hardLimitMax", 708 self._hard_limit_max if self._hard_limit_max is not None 709 else 0.0 710 ) 711 sd.putNumber(prefix + "positionOffset", self._position_offset) 712 sd.putNumber(prefix + "softLimitMargin", self._soft_limit_margin) 713 714 # ---- Validation ---- 715 716 def _validate_homing(self, homing_forward: bool): 717 """Validate callbacks needed for homing in the given direction.""" 718 missing = [n for n in sorted(_CORE_REQUIRED) 719 if getattr(self, f'_cb_{n}', None) is None] 720 if missing: 721 raise ValueError( 722 f"Required callbacks not set: {', '.join(missing)}" 723 ) 724 725 has_velocity = self._cb_get_velocity is not None 726 if homing_forward: 727 has_limit = self._cb_get_forward_limit_switch is not None 728 else: 729 has_limit = self._cb_get_reverse_limit_switch is not None 730 731 if not has_velocity and not has_limit: 732 direction = "forward" if homing_forward else "reverse" 733 raise ValueError( 734 f"No detection method for {direction} homing: " 735 f"provide get_velocity and/or " 736 f"get_{direction}_limit_switch" 737 ) 738 739 def _validate_calibration(self): 740 """Validate callbacks needed for full calibration.""" 741 if self._cb_get_position is None: 742 raise ValueError( 743 "get_position callback required for calibration" 744 ) 745 # Need detection in both directions 746 self._validate_homing(homing_forward=False) # phase 1 747 self._validate_homing(homing_forward=True) # phase 2 748 749 # ---- Internal methods ---- 750 751 def _publish_callback_status(self) -> None: 752 """Write True/False for each callback to NT via ntproperty.""" 753 for cb_name in _VALID_CALLBACKS: 754 is_set = getattr(self, f'_cb_{cb_name}', None) is not None 755 setattr(self, f'_nt_cb_{cb_name}', is_set) 756 757 def _homing_periodic(self) -> bool: 758 """ 759 Periodic homing logic. Drives the motor, monitors for stall or 760 limit switch, and handles timeout. 761 762 Returns: 763 True when homing is complete (success or failure), False if ongoing 764 """ 765 if not self._is_homing: 766 return True 767 768 # Check timeout first 769 if self._homing_timer.hasElapsed(self._max_homing_time): 770 self._homing_error_alert.setText( 771 f"{self._name}: homing failed - timeout" 772 ) 773 self._homing_end(abort=True) 774 return True 775 776 # Overtravel guard: abort if encoder has moved more than expected. 777 # Catches a stuck-open limit switch that never triggers and a 778 # broken stall-detection path. Requires get_position to take 779 # effect; otherwise it's a silent no-op. 780 if (self._max_travel is not None 781 and self._homing_start_pos is not None 782 and self._cb_get_position is not None): 783 travel = abs(self._cb_get_position() - self._homing_start_pos) 784 if travel > self._max_travel: 785 self._homing_error_alert.setText( 786 f"{self._name}: homing aborted - overtravel " 787 f"({travel:.1f} > {self._max_travel:.1f})" 788 ) 789 self._homing_end(abort=True) 790 return True 791 792 # Check hard limit switch in homing direction (if callback exists) 793 limit_hit = False 794 if (self._homing_forward 795 and self._cb_get_forward_limit_switch is not None): 796 limit_hit = self._cb_get_forward_limit_switch() 797 elif (not self._homing_forward 798 and self._cb_get_reverse_limit_switch is not None): 799 limit_hit = self._cb_get_reverse_limit_switch() 800 801 # Drive motor in homing direction 802 if self._homing_forward: 803 self._cb_set_motor_output(self._max_power_pct) 804 else: 805 self._cb_set_motor_output(-self._max_power_pct) 806 807 # Check for limit switch hit 808 if limit_hit: 809 # Stuck-closed guard: if the switch fires before we've moved 810 # enough to be credible, treat it as a false trigger. 811 if (self._min_trigger_travel > 0.0 812 and self._homing_start_pos is not None 813 and self._cb_get_position is not None): 814 travel = abs( 815 self._cb_get_position() - self._homing_start_pos) 816 if travel < self._min_trigger_travel: 817 self._homing_error_alert.setText( 818 f"{self._name}: homing aborted - limit switch " 819 f"triggered after only {travel:.1f} of " 820 f"{self._min_trigger_travel:.1f} min travel " 821 "(may be stuck closed)" 822 ) 823 self._homing_end(abort=True) 824 return True 825 826 home_position = self._home_position 827 self._cb_set_position(home_position) 828 if self._cb_on_limit_detected is not None: 829 direction = ( 830 "forward" if self._homing_forward else "reverse" 831 ) 832 self._cb_on_limit_detected(home_position, direction) 833 self._homing_status_alert.setText( 834 f"{self._name}: homing complete" 835 ) 836 self._homing_end(abort=False) 837 return True 838 839 # Stall detection only if velocity callback provided 840 if self._cb_get_velocity is not None: 841 velocity = abs(self._cb_get_velocity()) 842 843 if velocity < self._min_velocity: 844 if not self._stall_detected: 845 self._stall_detected = True 846 self._stall_timer.restart() 847 elif self._stall_timer.hasElapsed(0.1): 848 home_position = self._home_position 849 self._cb_set_position(home_position) 850 if self._cb_on_limit_detected is not None: 851 direction = ( 852 "forward" if self._homing_forward 853 else "reverse" 854 ) 855 self._cb_on_limit_detected( 856 home_position, direction) 857 self._homing_status_alert.setText( 858 f"{self._name}: homing complete" 859 ) 860 self._homing_end(abort=False) 861 return True 862 else: 863 self._stall_detected = False 864 self._stall_timer.stop() 865 self._stall_timer.reset() 866 867 return False 868 869 def _homing_end(self, abort: bool) -> None: 870 """ 871 Clean up after homing. Stops motor, restores saved settings, 872 updates alerts. 873 874 Args: 875 abort: True if homing was aborted or failed 876 """ 877 self._cb_stop_motor() 878 879 # Restore saved motor settings (if we saved them) 880 if (self._saved_config is not None 881 and self._cb_restore_config is not None): 882 self._cb_restore_config(self._saved_config) 883 884 # Clear homing state 885 self._is_homing = False 886 self._homing_succeeded = not abort 887 888 # Stop timers 889 self._homing_timer.stop() 890 self._stall_timer.stop() 891 892 # Update alerts 893 if abort: 894 self._homing_error_alert.setText( 895 f"{self._name}: homing aborted" 896 ) 897 self._homing_error_alert.set(True) 898 self._homing_status_alert.set(False) 899 else: 900 self._homing_error_alert.set(False) 901 # After successful homing, if calibration data exists, the 902 # encoder is now aligned so soft limits can be applied to 903 # hardware 904 if (not self._is_calibrating 905 and self._is_calibrated 906 and self._cb_set_soft_limits is not None): 907 self._cb_set_soft_limits( 908 self._min_soft_limit, self._max_soft_limit) 909 910 def _calibration_periodic(self) -> None: 911 """Manage the two-phase calibration state machine.""" 912 if not self._is_calibrating: 913 return 914 915 if self._calibration_phase == 1: 916 done = self._homing_periodic() 917 if done: 918 if self._homing_succeeded: 919 # Phase 1 complete - set encoder to 0 920 self._cb_set_position(0.0) 921 self._hard_limit_min = 0.0 922 923 if self._cal_known_range is not None: 924 self._hard_limit_max = self._cal_known_range 925 self._calibration_end(abort=False) 926 else: 927 self._cal_status_alert.setText( 928 f"{self._name}: calibration phase 2" 929 " - homing positive" 930 ) 931 self._calibration_phase = 2 932 self.homing_init( 933 self._cal_max_current, 934 self._cal_max_power_pct, 935 self._cal_max_homing_time, 936 homing_forward=True, 937 min_velocity=self._cal_min_velocity, 938 max_travel=self._cal_max_travel, 939 min_trigger_travel=( 940 self._cal_min_trigger_travel), 941 ) 942 else: 943 # Homing failed (timeout) - abort calibration 944 self._calibration_end(abort=True) 945 946 elif self._calibration_phase == 2: 947 measured_position = self._cb_get_position() 948 done = self._homing_periodic() 949 if done: 950 if self._homing_succeeded: 951 self._hard_limit_max = measured_position 952 self._calibration_end(abort=False) 953 else: 954 self._calibration_end(abort=True) 955 956 def _calibration_end(self, abort: bool) -> None: 957 """ 958 Clean up after calibration. Restores motor settings and on 959 success computes and applies soft limits. 960 961 Args: 962 abort: True if calibration failed 963 """ 964 self._cb_stop_motor() 965 self._is_calibrating = False 966 self._calibration_phase = 0 967 self._is_homing = False 968 969 if not abort and self._hard_limit_max is not None: 970 self._is_calibrated = True 971 self._cal_status_alert.setText( 972 f"{self._name}: calibration complete" 973 ) 974 self.set_soft_limit_margin(self._soft_limit_margin) 975 self._save_to_nt() 976 else: 977 # Restore original config on abort 978 if (self._cal_saved_config is not None 979 and self._cb_restore_config is not None): 980 self._cb_restore_config(self._cal_saved_config) 981 self._cal_status_alert.setText( 982 f"{self._name}: calibration aborted" 983 ) 984 985 def _save_to_nt(self) -> None: 986 """Persist calibration data to NetworkTables via ntproperty.""" 987 if self._hard_limit_min is not None: 988 self._nt_hard_limit_min = self._hard_limit_min 989 if self._hard_limit_max is not None: 990 self._nt_hard_limit_max = self._hard_limit_max 991 self._nt_soft_limit_margin = self._soft_limit_margin 992 993 def _load_from_nt(self) -> None: 994 """Load persisted calibration data from ntproperty NT entries.""" 995 nt_min = self._nt_hard_limit_min 996 nt_max = self._nt_hard_limit_max 997 if not math.isnan(nt_min) and not math.isnan(nt_max): 998 self._hard_limit_min = nt_min 999 self._hard_limit_max = nt_max 1000 self._soft_limit_margin = self._nt_soft_limit_margin 1001 self._is_calibrated = True 1002 # Compute soft limits in memory only — don't push to hardware 1003 # because encoder position is unknown until homing completes 1004 full_range = self._hard_limit_max - self._hard_limit_min 1005 margin = full_range * self._soft_limit_margin 1006 self._min_soft_limit = self._hard_limit_min + margin 1007 self._max_soft_limit = self._hard_limit_max - margin
32class PositionCalibration: 33 """ 34 Reusable calibration and homing controller for positional mechanisms. 35 36 Extracts sensorless or sensor homing and two-phase calibration logic 37 from mechanism-specific subsystems into a standalone, motor-agnostic 38 class. All hardware interaction is done through user-provided 39 callbacks, making this compatible with any motor controller. 40 41 For SparkMax users, the ``motor`` convenience parameter auto-populates 42 callbacks via SparkMaxCallbacks. Individual callbacks passed as keyword 43 arguments override the SparkMax defaults. 44 45 Calibration discovers mechanical hard limits by driving into stops, 46 then computes soft limits with a configurable safety margin. Persists 47 discovered limits to NetworkTables via ntproperty so values survive 48 reboots. 49 50 ## Callbacks 51 52 **Core required** (must be set before homing or calibration): 53 54 | Name | Signature | Purpose | 55 |------|-----------|---------| 56 | `set_motor_output` | `(float) -> None` | Drive motor at duty cycle (-1.0 to 1.0) | 57 | `stop_motor` | `() -> None` | Stop motor output | 58 | `set_position` | `(float) -> None` | Reset encoder position | 59 60 **Detection** (at least one required per homing direction): 61 62 | Name | Signature | Purpose | 63 |------|-----------|---------| 64 | `get_velocity` | `() -> float` | Enables stall detection | 65 | `get_forward_limit_switch` | `() -> bool` | Forward limit switch | 66 | `get_reverse_limit_switch` | `() -> bool` | Reverse limit switch | 67 68 **Optional** (enhance safety/features but not required): 69 70 | Name | Signature | Purpose | 71 |------|-----------|---------| 72 | `get_position` | `() -> float` | Required only for calibration phase 2 | 73 | `set_current_limit` | `(float) -> None` | Protective current limit during homing | 74 | `set_soft_limits` | `(min, max) -> None` | Apply soft limits to hardware | 75 | `disable_soft_limits` | `(fwd, rev) -> None` | Disable soft limits for free travel | 76 | `save_config` | `() -> Any` | Snapshot motor config before homing | 77 | `restore_config` | `(Any) -> None` | Restore snapshot after homing | 78 | `on_limit_detected` | `(pos, dir) -> None` | Fires when a hard limit is found | 79 | `set_conversion_factor` | `(float) -> None` | Apply a new encoder position conversion factor | 80 81 Use ``get_callbacks()`` to inspect the current callback dict (returns 82 ``None`` for any callback that has not been set). 83 84 Examples 85 -------- 86 87 SparkMax convenience (auto-generates all callbacks):: 88 89 self.calibration = PositionCalibration( 90 name="Turret", 91 fallback_min=-90, fallback_max=90, 92 motor=self.motor, encoder=self.encoder) 93 94 Pure callbacks with stall detection only:: 95 96 self.calibration = PositionCalibration( 97 name="Elevator", 98 fallback_min=0.0, fallback_max=1.5, 99 set_motor_output=lambda pct: motor.set(pct), 100 stop_motor=lambda: motor.stopMotor(), 101 set_position=lambda v: encoder.setPosition(v), 102 get_velocity=lambda: encoder.getVelocity()) 103 104 Pure callbacks with limit switches only:: 105 106 self.calibration = PositionCalibration( 107 name="Arm", 108 fallback_min=0.0, fallback_max=90.0, 109 set_motor_output=lambda pct: motor.set(pct), 110 stop_motor=lambda: motor.stopMotor(), 111 set_position=lambda v: encoder.setPosition(v), 112 get_forward_limit_switch=lambda: fwd_switch.get(), 113 get_reverse_limit_switch=lambda: rev_switch.get()) 114 115 Deferred setup (set callbacks after construction):: 116 117 cal = PositionCalibration( 118 name="Wrist", 119 fallback_min=-45, fallback_max=45) 120 # ... later, once hardware is ready ... 121 cal.set_callbacks( 122 set_motor_output=motor.set, 123 stop_motor=motor.stopMotor, 124 set_position=encoder.setPosition, 125 get_velocity=encoder.getVelocity) 126 """ 127 128 # -- Callback type declarations -- 129 _cb_get_position: Optional[Callable[[], float]] 130 _cb_get_velocity: Optional[Callable[[], float]] 131 _cb_set_position: Optional[Callable[[float], None]] 132 _cb_set_motor_output: Optional[Callable[[float], None]] 133 _cb_stop_motor: Optional[Callable[[], None]] 134 _cb_set_current_limit: Optional[Callable[[float], None]] 135 _cb_set_soft_limits: Optional[Callable[[float, float], None]] 136 _cb_disable_soft_limits: Optional[Callable[[bool, bool], None]] 137 _cb_save_config: Optional[Callable[[], Any]] 138 _cb_restore_config: Optional[Callable[[Any], None]] 139 _cb_get_forward_limit_switch: Optional[Callable[[], bool]] 140 _cb_get_reverse_limit_switch: Optional[Callable[[], bool]] 141 _cb_on_limit_detected: Optional[Callable[[float, str], None]] 142 _cb_set_conversion_factor: Optional[Callable[[float], None]] 143 144 def __init__( 145 self, 146 name: str, 147 fallback_min: float, 148 fallback_max: float, 149 *, 150 motor=None, 151 encoder=None, 152 **kwargs, 153 ) -> None: 154 """ 155 Create a new PositionCalibration controller. 156 157 Args: 158 name: mechanism name used for NT paths and alerts (e.g. "Turret") 159 fallback_min: reverse soft limit used before calibration data 160 exists. Once calibrated, the persisted hard limits are used 161 instead. The caller is responsible for the far end of travel 162 if only homing (not full calibration) is performed. 163 fallback_max: forward soft limit used before calibration data 164 exists. See fallback_min for details. 165 motor: (optional) rev.SparkMax for convenience callback generation 166 encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) 167 **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys) 168 """ 169 # Assign this instance to a cached subclass with ntproperty 170 # descriptors keyed to this mechanism's name. ntproperty is a 171 # class-level descriptor, so each mechanism name needs its own 172 # class to avoid key collisions. The cache ensures the 173 # ntproperty is created only once per name so defaults don't 174 # overwrite previously persisted values. 175 if name not in _calibration_classes: 176 prefix = f"/{name}/calibration" 177 attrs = { 178 "_nt_hard_limit_min": ntproperty( 179 f"{prefix}/hard_limit_min", float("nan"), 180 writeDefault=False, persistent=True), 181 "_nt_hard_limit_max": ntproperty( 182 f"{prefix}/hard_limit_max", float("nan"), 183 writeDefault=False, persistent=True), 184 "_nt_soft_limit_margin": ntproperty( 185 f"{prefix}/soft_limit_margin", 0.05, 186 writeDefault=False, persistent=True), 187 } 188 # Non-persistent booleans for callback status 189 for cb_name in sorted(_VALID_CALLBACKS): 190 attrs[f"_nt_cb_{cb_name}"] = ntproperty( 191 f"{prefix}/callbacks/{cb_name}", False, 192 writeDefault=True, persistent=False) 193 _calibration_classes[name] = type( 194 f"PositionCalibration_{name}", 195 (PositionCalibration,), 196 attrs, 197 ) 198 self.__class__ = _calibration_classes[name] 199 200 self._name = name 201 202 # Soft limits — initialized to defaults, overwritten by 203 # _load_from_nt() if persisted calibration data exists 204 self._min_soft_limit = fallback_min 205 self._max_soft_limit = fallback_max 206 207 # Calibration state 208 self._hard_limit_min = None 209 self._hard_limit_max = None 210 self._is_calibrated = False 211 self._is_calibrating = False 212 self._calibration_phase = 0 213 self._soft_limit_margin = 0.05 214 self._position_offset = 0.0 215 216 # Homing state 217 self._is_homing = False 218 self._saved_config = None 219 220 # -- Build callbacks -- 221 # Validate kwargs keys 222 unknown = set(kwargs.keys()) - _VALID_CALLBACKS 223 if unknown: 224 raise ValueError( 225 f"Unknown callback(s): {', '.join(sorted(unknown))}" 226 ) 227 228 # If motor provided, generate SparkMax defaults 229 defaults = {} 230 if motor is not None: 231 from utils.spark_max_callbacks import SparkMaxCallbacks 232 defaults = SparkMaxCallbacks(motor, encoder).as_dict() 233 234 # Merge: explicit kwargs override SparkMax defaults 235 merged = {**defaults, **kwargs} 236 237 # Store each callback as self._cb_{name} (None if not provided) 238 for cb_name in _VALID_CALLBACKS: 239 setattr(self, f'_cb_{cb_name}', merged.get(cb_name)) 240 241 # Publish callback status to NT 242 self._publish_callback_status() 243 244 # Attempt to load persisted calibration from NT 245 self._load_from_nt() 246 247 # ---- Public properties ---- 248 249 @property 250 def is_busy(self) -> bool: 251 """True when homing or calibrating (blocks motor commands).""" 252 return self._is_homing or self._is_calibrating 253 254 @property 255 def is_homing(self) -> bool: 256 """Whether a homing routine is active.""" 257 return self._is_homing 258 259 @property 260 def is_calibrating(self) -> bool: 261 """Whether a calibration routine is active.""" 262 return self._is_calibrating 263 264 @property 265 def is_calibrated(self) -> bool: 266 """Whether the full mechanical range has been discovered.""" 267 return self._is_calibrated 268 269 @property 270 def is_zeroed(self) -> bool: 271 """Whether the encoder has been aligned to a reference point.""" 272 return self._hard_limit_min is not None 273 274 @property 275 def min_limit(self): 276 """Hard limit min, or None if uncalibrated.""" 277 return self._hard_limit_min 278 279 @property 280 def max_limit(self): 281 """Hard limit max, or None if uncalibrated.""" 282 return self._hard_limit_max 283 284 @property 285 def min_soft_limit(self) -> float: 286 """Current minimum soft limit.""" 287 return self._min_soft_limit 288 289 @property 290 def max_soft_limit(self) -> float: 291 """Current maximum soft limit.""" 292 return self._max_soft_limit 293 294 @property 295 def soft_limit_margin(self) -> float: 296 """Current safety margin as a fraction of the full range.""" 297 return self._soft_limit_margin 298 299 # ---- Public methods ---- 300 301 def get_callbacks(self) -> dict: 302 """ 303 Return a dict of all callback names and their current values. 304 305 Each key is a callback name from _VALID_CALLBACKS. The value is 306 the callable if set, or None if not set. Useful for inspecting 307 which callbacks are configured:: 308 309 cbs = cal.get_callbacks() 310 for name, func in cbs.items(): 311 print(f"{name}: {'set' if func else 'not set'}") 312 313 Returns: 314 dict mapping callback name -> callable or None 315 """ 316 return { 317 name: getattr(self, f'_cb_{name}', None) 318 for name in sorted(_VALID_CALLBACKS) 319 } 320 321 def set_callbacks(self, **kwargs) -> None: 322 """ 323 Set, override, or clear one or more callbacks by name. 324 325 Pass a callable to set a callback, or None to clear it:: 326 327 # Set a callback 328 cal.set_callbacks(get_velocity=encoder.getVelocity) 329 330 # Clear callbacks that don't apply to this mechanism 331 cal.set_callbacks( 332 get_forward_limit_switch=None, 333 get_reverse_limit_switch=None) 334 335 Args: 336 **kwargs: callback name/value pairs (see _VALID_CALLBACKS). 337 Pass None to clear a callback. 338 """ 339 for key, value in kwargs.items(): 340 if key not in _VALID_CALLBACKS: 341 raise ValueError(f"Unknown callback: {key}") 342 setattr(self, f'_cb_{key}', value) 343 self._publish_callback_status() 344 345 def homing_init( 346 self, 347 max_current: float, 348 max_power_pct: float, 349 max_homing_time: float, 350 homing_forward: bool, 351 min_velocity: float = None, 352 home_position: float = 0.0, 353 max_travel: Optional[float] = None, 354 min_trigger_travel: float = 0.0, 355 ) -> None: 356 """ 357 Initialize the sensorless or sensor homing routine. 358 359 Saves current motor settings (if save_config callback is set), 360 applies homing-specific configuration, and begins driving toward 361 the specified hard stop. 362 363 Args: 364 max_current: current limit in amps during homing 365 max_power_pct: motor duty cycle during homing (0.0-1.0) 366 max_homing_time: maximum time in seconds before timeout 367 homing_forward: True to home toward forward limit, False for reverse 368 min_velocity: stall detection threshold in user units/second. 369 Defaults to 5% of soft limit range over 2 seconds. 370 home_position: encoder value to set when the hard stop is 371 found (default 0.0) 372 max_travel: abort if encoder travels more than this many user 373 units from the start position. Catches a stuck-open limit 374 switch that never triggers. None disables the check — 375 useful when the mechanical range is unknown. Requires 376 ``get_position`` callback to take effect. 377 min_trigger_travel: minimum encoder travel (user units) 378 required before a limit switch trigger is trusted. If 379 the switch fires before this much movement, abort with 380 a stuck-closed-switch alert. 0.0 disables (default). 381 Requires ``get_position`` callback to take effect. 382 """ 383 # Validate callbacks before starting 384 self._validate_homing(homing_forward) 385 386 if min_velocity is None: 387 full_range = self._max_soft_limit - self._min_soft_limit 388 min_velocity = full_range * 0.05 / 2.0 389 390 # Save current motor settings for restoration (if callback exists) 391 if self._cb_save_config is not None: 392 self._saved_config = self._cb_save_config() 393 else: 394 self._saved_config = None 395 396 # Apply homing current limit (if callback exists) 397 if self._cb_set_current_limit is not None: 398 self._cb_set_current_limit(max_current) 399 400 # Disable soft limit in homing direction (if callback exists) 401 if self._cb_disable_soft_limits is not None: 402 if homing_forward: 403 self._cb_disable_soft_limits(True, False) 404 else: 405 self._cb_disable_soft_limits(False, True) 406 407 # Store homing parameters 408 self._homing_forward = homing_forward 409 self._max_power_pct = max_power_pct 410 self._min_velocity = min_velocity 411 self._max_homing_time = max_homing_time 412 self._home_position = home_position 413 self._max_travel = max_travel 414 self._min_trigger_travel = min_trigger_travel 415 416 # Capture start position for travel tracking. Only meaningful if 417 # get_position is wired; otherwise both travel-based guards are 418 # silently no-ops. 419 if self._cb_get_position is not None: 420 self._homing_start_pos = self._cb_get_position() 421 else: 422 self._homing_start_pos = None 423 424 # Set homing state 425 self._is_homing = True 426 self._stall_detected = False 427 428 # Start timeout timer 429 self._homing_timer = wpilib.Timer() 430 self._homing_timer.start() 431 432 # Stall detection timer 433 self._stall_timer = wpilib.Timer() 434 435 # Alerts 436 self._homing_status_alert = wpilib.Alert( 437 f"{self._name}: homing started", 438 wpilib.Alert.AlertType.kInfo 439 ) 440 self._homing_status_alert.set(True) 441 self._homing_error_alert = wpilib.Alert( 442 f"{self._name}: homing failed", 443 wpilib.Alert.AlertType.kError 444 ) 445 self._homing_error_alert.set(False) 446 447 def calibration_init( 448 self, 449 max_current: float, 450 max_power_pct: float, 451 max_homing_time: float, 452 min_velocity: float = None, 453 known_range: float = None, 454 max_travel: Optional[float] = None, 455 min_trigger_travel: float = 0.0, 456 ) -> None: 457 """ 458 Start a calibration routine that discovers the mechanical range. 459 460 Phase 1: Home negative to find the reverse hard limit (set as zero). 461 Phase 2: Home positive to find the forward hard limit (measured). 462 463 If known_range is provided, only phase 1 runs and the forward hard 464 limit is computed as known_range from the zero point. 465 466 Args: 467 max_current: current limit in amps during calibration 468 max_power_pct: motor duty cycle (0.0-1.0) 469 max_homing_time: maximum time per phase before timeout 470 min_velocity: stall detection threshold in user units/second 471 known_range: if provided, skip phase 2 and use this as the 472 full mechanical range from the zero point 473 max_travel: abort a homing phase if encoder travels more 474 than this from its phase start. Defaults to 110% of the 475 current soft-limit range (a sane bound for calibration 476 because the expected range is known). Pass an explicit 477 None via the kwarg to disable the check entirely. 478 min_trigger_travel: minimum travel required before a limit 479 switch trigger is trusted per phase. Guards against a 480 stuck-closed switch firing immediately. 0.0 disables. 481 """ 482 # Validate callbacks before starting 483 self._validate_calibration() 484 485 # Save motor settings for restoration after calibration 486 if self._cb_save_config is not None: 487 self._cal_saved_config = self._cb_save_config() 488 else: 489 self._cal_saved_config = None 490 491 # Disable both soft limits for free travel 492 if self._cb_disable_soft_limits is not None: 493 self._cb_disable_soft_limits(True, True) 494 495 # Default max_travel to 110% of the expected range. Each phase 496 # homes one direction, so the full range is the per-phase ceiling. 497 if max_travel is None: 498 expected_range = abs(self._max_soft_limit - self._min_soft_limit) 499 if expected_range > 0: 500 max_travel = expected_range * 1.1 501 502 # Store calibration parameters for phase transitions 503 self._cal_max_current = max_current 504 self._cal_max_power_pct = max_power_pct 505 self._cal_max_homing_time = max_homing_time 506 self._cal_min_velocity = min_velocity 507 self._cal_known_range = known_range 508 self._cal_max_travel = max_travel 509 self._cal_min_trigger_travel = min_trigger_travel 510 511 self._is_calibrating = True 512 self._calibration_phase = 1 513 514 # Start phase 1: home negative 515 self.homing_init( 516 max_current, max_power_pct, max_homing_time, 517 homing_forward=False, min_velocity=min_velocity, 518 max_travel=max_travel, 519 min_trigger_travel=min_trigger_travel, 520 ) 521 522 self._cal_status_alert = wpilib.Alert( 523 f"{self._name}: calibration phase 1 - homing negative", 524 wpilib.Alert.AlertType.kInfo 525 ) 526 self._cal_status_alert.set(True) 527 528 def periodic(self) -> None: 529 """ 530 Run the active homing or calibration state machine. 531 532 Call this every cycle when is_busy is True. 533 """ 534 if self._is_calibrating: 535 self._calibration_periodic() 536 elif self._is_homing: 537 self._homing_periodic() 538 539 def abort(self) -> None: 540 """Abort any active homing or calibration routine.""" 541 if self._is_calibrating: 542 self._calibration_end(abort=True) 543 elif self._is_homing: 544 self._homing_end(abort=True) 545 546 def set_soft_limit_margin(self, margin_pct: float) -> None: 547 """ 548 Set the soft limit safety margin and apply to the motor controller. 549 550 Args: 551 margin_pct: safety margin as a fraction (e.g. 0.05 for 5%) 552 """ 553 if not self._is_calibrated: 554 return 555 self._soft_limit_margin = margin_pct 556 full_range = self._hard_limit_max - self._hard_limit_min 557 margin = full_range * margin_pct 558 self._min_soft_limit = self._hard_limit_min + margin 559 self._max_soft_limit = self._hard_limit_max - margin 560 561 # Apply to hardware only if callback exists 562 if self._cb_set_soft_limits is not None: 563 self._cb_set_soft_limits( 564 self._min_soft_limit, self._max_soft_limit) 565 566 def compute_conversion_factor( 567 self, 568 current_factor: float, 569 desired_min: float, 570 desired_max: float, 571 ) -> float: 572 """ 573 Compute a corrected encoder conversion factor after calibration. 574 575 After a full calibration discovers the raw mechanical range, this 576 method computes the conversion factor needed so the measured 577 range maps to ``[desired_min, desired_max]``. 578 579 Example: if calibration measured 500 user-units of travel with 580 the initial conversion factor and you want -90..90 (180°), the 581 corrected factor scales the encoder so full travel reads 180°. 582 583 Args: 584 current_factor: the position conversion factor currently set 585 on the encoder 586 desired_min: target minimum position in user units 587 desired_max: target maximum position in user units 588 589 Returns: 590 The corrected conversion factor to apply to the encoder. 591 592 Raises: 593 RuntimeError: if calibration has not completed, or the 594 measured range is zero. 595 """ 596 if not self._is_calibrated: 597 raise RuntimeError( 598 f"{self._name}: cannot compute conversion factor " 599 "before calibration is complete" 600 ) 601 measured_range = self._hard_limit_max - self._hard_limit_min 602 if measured_range == 0: 603 raise RuntimeError( 604 f"{self._name}: measured range is zero, " 605 "calibration data invalid" 606 ) 607 desired_range = desired_max - desired_min 608 return current_factor * (desired_range / measured_range) 609 610 def apply_conversion_factor( 611 self, 612 current_factor: float, 613 desired_min: float, 614 desired_max: float, 615 ) -> float: 616 """ 617 Compute and apply a corrected encoder conversion factor. 618 619 Calls :meth:`compute_conversion_factor` for the new value, then 620 applies it via the ``set_conversion_factor`` callback, resets 621 the encoder to ``desired_min`` (the new zero reference), and 622 rewrites the internal hard/soft limits to match the desired 623 range. 624 625 This is intended to be called immediately after a full 626 calibration completes (encoder at the reverse hard stop, 627 reading 0). A guard rejects the call if the encoder reports 628 more than 5% of the current soft-limit range away from zero — 629 the mechanism has moved since calibration, so the post-rescale 630 encoder zero would not correspond to a known physical position. 631 632 Args: 633 current_factor: the position conversion factor currently set 634 on the encoder 635 desired_min: target minimum position in user units (becomes 636 the new zero reference) 637 desired_max: target maximum position in user units 638 639 Returns: 640 The corrected conversion factor that was applied. 641 642 Raises: 643 ValueError: if the ``set_conversion_factor`` callback is 644 not configured. 645 RuntimeError: if calibration has not produced a zero 646 reference, or the mechanism is not at that zero point. 647 """ 648 if self._cb_set_conversion_factor is None: 649 raise ValueError( 650 "set_conversion_factor callback not set" 651 ) 652 653 if not self.is_zeroed: 654 raise RuntimeError( 655 f"{self._name}: cannot apply conversion factor " 656 "before homing to zero position" 657 ) 658 659 # Verify the mechanism is still at the reverse hard stop 660 # (encoder reads ~0) before rescaling. Without this, the post- 661 # rescale encoder zero corresponds to no known physical point. 662 if self._cb_get_position is not None: 663 pos = abs(self._cb_get_position()) 664 full_range = self._max_soft_limit - self._min_soft_limit 665 tolerance = abs(full_range) * 0.05 666 if pos > tolerance: 667 raise RuntimeError( 668 f"{self._name}: mechanism is not at the zero " 669 f"position (current: {pos:.1f}, tolerance: " 670 f"{tolerance:.1f}). Home the mechanism before " 671 "applying a new conversion factor" 672 ) 673 674 new_factor = self.compute_conversion_factor( 675 current_factor, desired_min, desired_max 676 ) 677 self._cb_set_conversion_factor(new_factor) 678 679 # The encoder unit-scale just changed, so its raw reading no 680 # longer maps to the same physical position. Reseed to 681 # desired_min (we calibrated from the reverse hard stop) and 682 # rewrite limits to match the desired range. 683 self._cb_set_position(desired_min) 684 self._hard_limit_min = desired_min 685 self._hard_limit_max = desired_max 686 self.set_soft_limit_margin(self._soft_limit_margin) 687 self._save_to_nt() 688 689 return new_factor 690 691 def update_telemetry(self, prefix: str) -> None: 692 """ 693 Publish calibration data to SmartDashboard. 694 695 Args: 696 prefix: SmartDashboard key prefix (e.g. "Turret/") 697 """ 698 sd = wpilib.SmartDashboard 699 sd.putBoolean(prefix + "isCalibrated", self._is_calibrated) 700 sd.putBoolean(prefix + "isCalibrating", self._is_calibrating) 701 sd.putBoolean(prefix + "isHoming", self._is_homing) 702 sd.putNumber( 703 prefix + "hardLimitMin", 704 self._hard_limit_min if self._hard_limit_min is not None 705 else 0.0 706 ) 707 sd.putNumber( 708 prefix + "hardLimitMax", 709 self._hard_limit_max if self._hard_limit_max is not None 710 else 0.0 711 ) 712 sd.putNumber(prefix + "positionOffset", self._position_offset) 713 sd.putNumber(prefix + "softLimitMargin", self._soft_limit_margin) 714 715 # ---- Validation ---- 716 717 def _validate_homing(self, homing_forward: bool): 718 """Validate callbacks needed for homing in the given direction.""" 719 missing = [n for n in sorted(_CORE_REQUIRED) 720 if getattr(self, f'_cb_{n}', None) is None] 721 if missing: 722 raise ValueError( 723 f"Required callbacks not set: {', '.join(missing)}" 724 ) 725 726 has_velocity = self._cb_get_velocity is not None 727 if homing_forward: 728 has_limit = self._cb_get_forward_limit_switch is not None 729 else: 730 has_limit = self._cb_get_reverse_limit_switch is not None 731 732 if not has_velocity and not has_limit: 733 direction = "forward" if homing_forward else "reverse" 734 raise ValueError( 735 f"No detection method for {direction} homing: " 736 f"provide get_velocity and/or " 737 f"get_{direction}_limit_switch" 738 ) 739 740 def _validate_calibration(self): 741 """Validate callbacks needed for full calibration.""" 742 if self._cb_get_position is None: 743 raise ValueError( 744 "get_position callback required for calibration" 745 ) 746 # Need detection in both directions 747 self._validate_homing(homing_forward=False) # phase 1 748 self._validate_homing(homing_forward=True) # phase 2 749 750 # ---- Internal methods ---- 751 752 def _publish_callback_status(self) -> None: 753 """Write True/False for each callback to NT via ntproperty.""" 754 for cb_name in _VALID_CALLBACKS: 755 is_set = getattr(self, f'_cb_{cb_name}', None) is not None 756 setattr(self, f'_nt_cb_{cb_name}', is_set) 757 758 def _homing_periodic(self) -> bool: 759 """ 760 Periodic homing logic. Drives the motor, monitors for stall or 761 limit switch, and handles timeout. 762 763 Returns: 764 True when homing is complete (success or failure), False if ongoing 765 """ 766 if not self._is_homing: 767 return True 768 769 # Check timeout first 770 if self._homing_timer.hasElapsed(self._max_homing_time): 771 self._homing_error_alert.setText( 772 f"{self._name}: homing failed - timeout" 773 ) 774 self._homing_end(abort=True) 775 return True 776 777 # Overtravel guard: abort if encoder has moved more than expected. 778 # Catches a stuck-open limit switch that never triggers and a 779 # broken stall-detection path. Requires get_position to take 780 # effect; otherwise it's a silent no-op. 781 if (self._max_travel is not None 782 and self._homing_start_pos is not None 783 and self._cb_get_position is not None): 784 travel = abs(self._cb_get_position() - self._homing_start_pos) 785 if travel > self._max_travel: 786 self._homing_error_alert.setText( 787 f"{self._name}: homing aborted - overtravel " 788 f"({travel:.1f} > {self._max_travel:.1f})" 789 ) 790 self._homing_end(abort=True) 791 return True 792 793 # Check hard limit switch in homing direction (if callback exists) 794 limit_hit = False 795 if (self._homing_forward 796 and self._cb_get_forward_limit_switch is not None): 797 limit_hit = self._cb_get_forward_limit_switch() 798 elif (not self._homing_forward 799 and self._cb_get_reverse_limit_switch is not None): 800 limit_hit = self._cb_get_reverse_limit_switch() 801 802 # Drive motor in homing direction 803 if self._homing_forward: 804 self._cb_set_motor_output(self._max_power_pct) 805 else: 806 self._cb_set_motor_output(-self._max_power_pct) 807 808 # Check for limit switch hit 809 if limit_hit: 810 # Stuck-closed guard: if the switch fires before we've moved 811 # enough to be credible, treat it as a false trigger. 812 if (self._min_trigger_travel > 0.0 813 and self._homing_start_pos is not None 814 and self._cb_get_position is not None): 815 travel = abs( 816 self._cb_get_position() - self._homing_start_pos) 817 if travel < self._min_trigger_travel: 818 self._homing_error_alert.setText( 819 f"{self._name}: homing aborted - limit switch " 820 f"triggered after only {travel:.1f} of " 821 f"{self._min_trigger_travel:.1f} min travel " 822 "(may be stuck closed)" 823 ) 824 self._homing_end(abort=True) 825 return True 826 827 home_position = self._home_position 828 self._cb_set_position(home_position) 829 if self._cb_on_limit_detected is not None: 830 direction = ( 831 "forward" if self._homing_forward else "reverse" 832 ) 833 self._cb_on_limit_detected(home_position, direction) 834 self._homing_status_alert.setText( 835 f"{self._name}: homing complete" 836 ) 837 self._homing_end(abort=False) 838 return True 839 840 # Stall detection only if velocity callback provided 841 if self._cb_get_velocity is not None: 842 velocity = abs(self._cb_get_velocity()) 843 844 if velocity < self._min_velocity: 845 if not self._stall_detected: 846 self._stall_detected = True 847 self._stall_timer.restart() 848 elif self._stall_timer.hasElapsed(0.1): 849 home_position = self._home_position 850 self._cb_set_position(home_position) 851 if self._cb_on_limit_detected is not None: 852 direction = ( 853 "forward" if self._homing_forward 854 else "reverse" 855 ) 856 self._cb_on_limit_detected( 857 home_position, direction) 858 self._homing_status_alert.setText( 859 f"{self._name}: homing complete" 860 ) 861 self._homing_end(abort=False) 862 return True 863 else: 864 self._stall_detected = False 865 self._stall_timer.stop() 866 self._stall_timer.reset() 867 868 return False 869 870 def _homing_end(self, abort: bool) -> None: 871 """ 872 Clean up after homing. Stops motor, restores saved settings, 873 updates alerts. 874 875 Args: 876 abort: True if homing was aborted or failed 877 """ 878 self._cb_stop_motor() 879 880 # Restore saved motor settings (if we saved them) 881 if (self._saved_config is not None 882 and self._cb_restore_config is not None): 883 self._cb_restore_config(self._saved_config) 884 885 # Clear homing state 886 self._is_homing = False 887 self._homing_succeeded = not abort 888 889 # Stop timers 890 self._homing_timer.stop() 891 self._stall_timer.stop() 892 893 # Update alerts 894 if abort: 895 self._homing_error_alert.setText( 896 f"{self._name}: homing aborted" 897 ) 898 self._homing_error_alert.set(True) 899 self._homing_status_alert.set(False) 900 else: 901 self._homing_error_alert.set(False) 902 # After successful homing, if calibration data exists, the 903 # encoder is now aligned so soft limits can be applied to 904 # hardware 905 if (not self._is_calibrating 906 and self._is_calibrated 907 and self._cb_set_soft_limits is not None): 908 self._cb_set_soft_limits( 909 self._min_soft_limit, self._max_soft_limit) 910 911 def _calibration_periodic(self) -> None: 912 """Manage the two-phase calibration state machine.""" 913 if not self._is_calibrating: 914 return 915 916 if self._calibration_phase == 1: 917 done = self._homing_periodic() 918 if done: 919 if self._homing_succeeded: 920 # Phase 1 complete - set encoder to 0 921 self._cb_set_position(0.0) 922 self._hard_limit_min = 0.0 923 924 if self._cal_known_range is not None: 925 self._hard_limit_max = self._cal_known_range 926 self._calibration_end(abort=False) 927 else: 928 self._cal_status_alert.setText( 929 f"{self._name}: calibration phase 2" 930 " - homing positive" 931 ) 932 self._calibration_phase = 2 933 self.homing_init( 934 self._cal_max_current, 935 self._cal_max_power_pct, 936 self._cal_max_homing_time, 937 homing_forward=True, 938 min_velocity=self._cal_min_velocity, 939 max_travel=self._cal_max_travel, 940 min_trigger_travel=( 941 self._cal_min_trigger_travel), 942 ) 943 else: 944 # Homing failed (timeout) - abort calibration 945 self._calibration_end(abort=True) 946 947 elif self._calibration_phase == 2: 948 measured_position = self._cb_get_position() 949 done = self._homing_periodic() 950 if done: 951 if self._homing_succeeded: 952 self._hard_limit_max = measured_position 953 self._calibration_end(abort=False) 954 else: 955 self._calibration_end(abort=True) 956 957 def _calibration_end(self, abort: bool) -> None: 958 """ 959 Clean up after calibration. Restores motor settings and on 960 success computes and applies soft limits. 961 962 Args: 963 abort: True if calibration failed 964 """ 965 self._cb_stop_motor() 966 self._is_calibrating = False 967 self._calibration_phase = 0 968 self._is_homing = False 969 970 if not abort and self._hard_limit_max is not None: 971 self._is_calibrated = True 972 self._cal_status_alert.setText( 973 f"{self._name}: calibration complete" 974 ) 975 self.set_soft_limit_margin(self._soft_limit_margin) 976 self._save_to_nt() 977 else: 978 # Restore original config on abort 979 if (self._cal_saved_config is not None 980 and self._cb_restore_config is not None): 981 self._cb_restore_config(self._cal_saved_config) 982 self._cal_status_alert.setText( 983 f"{self._name}: calibration aborted" 984 ) 985 986 def _save_to_nt(self) -> None: 987 """Persist calibration data to NetworkTables via ntproperty.""" 988 if self._hard_limit_min is not None: 989 self._nt_hard_limit_min = self._hard_limit_min 990 if self._hard_limit_max is not None: 991 self._nt_hard_limit_max = self._hard_limit_max 992 self._nt_soft_limit_margin = self._soft_limit_margin 993 994 def _load_from_nt(self) -> None: 995 """Load persisted calibration data from ntproperty NT entries.""" 996 nt_min = self._nt_hard_limit_min 997 nt_max = self._nt_hard_limit_max 998 if not math.isnan(nt_min) and not math.isnan(nt_max): 999 self._hard_limit_min = nt_min 1000 self._hard_limit_max = nt_max 1001 self._soft_limit_margin = self._nt_soft_limit_margin 1002 self._is_calibrated = True 1003 # Compute soft limits in memory only — don't push to hardware 1004 # because encoder position is unknown until homing completes 1005 full_range = self._hard_limit_max - self._hard_limit_min 1006 margin = full_range * self._soft_limit_margin 1007 self._min_soft_limit = self._hard_limit_min + margin 1008 self._max_soft_limit = self._hard_limit_max - margin
Reusable calibration and homing controller for positional mechanisms.
Extracts sensorless or sensor homing and two-phase calibration logic from mechanism-specific subsystems into a standalone, motor-agnostic class. All hardware interaction is done through user-provided callbacks, making this compatible with any motor controller.
For SparkMax users, the motor convenience parameter auto-populates
callbacks via SparkMaxCallbacks. Individual callbacks passed as keyword
arguments override the SparkMax defaults.
Calibration discovers mechanical hard limits by driving into stops, then computes soft limits with a configurable safety margin. Persists discovered limits to NetworkTables via ntproperty so values survive reboots.
Callbacks
Core required (must be set before homing or calibration):
| Name | Signature | Purpose |
|---|---|---|
set_motor_output |
(float) -> None |
Drive motor at duty cycle (-1.0 to 1.0) |
stop_motor |
() -> None |
Stop motor output |
set_position |
(float) -> None |
Reset encoder position |
Detection (at least one required per homing direction):
| Name | Signature | Purpose |
|---|---|---|
get_velocity |
() -> float |
Enables stall detection |
get_forward_limit_switch |
() -> bool |
Forward limit switch |
get_reverse_limit_switch |
() -> bool |
Reverse limit switch |
Optional (enhance safety/features but not required):
| Name | Signature | Purpose |
|---|---|---|
get_position |
() -> float |
Required only for calibration phase 2 |
set_current_limit |
(float) -> None |
Protective current limit during homing |
set_soft_limits |
(min, max) -> None |
Apply soft limits to hardware |
disable_soft_limits |
(fwd, rev) -> None |
Disable soft limits for free travel |
save_config |
() -> Any |
Snapshot motor config before homing |
restore_config |
(Any) -> None |
Restore snapshot after homing |
on_limit_detected |
(pos, dir) -> None |
Fires when a hard limit is found |
set_conversion_factor |
(float) -> None |
Apply a new encoder position conversion factor |
Use get_callbacks() to inspect the current callback dict (returns
None for any callback that has not been set).
Examples
SparkMax convenience (auto-generates all callbacks)::
self.calibration = PositionCalibration(
name="Turret",
fallback_min=-90, fallback_max=90,
motor=self.motor, encoder=self.encoder)
Pure callbacks with stall detection only::
self.calibration = PositionCalibration(
name="Elevator",
fallback_min=0.0, fallback_max=1.5,
set_motor_output=lambda pct: motor.set(pct),
stop_motor=lambda: motor.stopMotor(),
set_position=lambda v: encoder.setPosition(v),
get_velocity=lambda: encoder.getVelocity())
Pure callbacks with limit switches only::
self.calibration = PositionCalibration(
name="Arm",
fallback_min=0.0, fallback_max=90.0,
set_motor_output=lambda pct: motor.set(pct),
stop_motor=lambda: motor.stopMotor(),
set_position=lambda v: encoder.setPosition(v),
get_forward_limit_switch=lambda: fwd_switch.get(),
get_reverse_limit_switch=lambda: rev_switch.get())
Deferred setup (set callbacks after construction)::
cal = PositionCalibration(
name="Wrist",
fallback_min=-45, fallback_max=45)
# ... later, once hardware is ready ...
cal.set_callbacks(
set_motor_output=motor.set,
stop_motor=motor.stopMotor,
set_position=encoder.setPosition,
get_velocity=encoder.getVelocity)
144 def __init__( 145 self, 146 name: str, 147 fallback_min: float, 148 fallback_max: float, 149 *, 150 motor=None, 151 encoder=None, 152 **kwargs, 153 ) -> None: 154 """ 155 Create a new PositionCalibration controller. 156 157 Args: 158 name: mechanism name used for NT paths and alerts (e.g. "Turret") 159 fallback_min: reverse soft limit used before calibration data 160 exists. Once calibrated, the persisted hard limits are used 161 instead. The caller is responsible for the far end of travel 162 if only homing (not full calibration) is performed. 163 fallback_max: forward soft limit used before calibration data 164 exists. See fallback_min for details. 165 motor: (optional) rev.SparkMax for convenience callback generation 166 encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) 167 **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys) 168 """ 169 # Assign this instance to a cached subclass with ntproperty 170 # descriptors keyed to this mechanism's name. ntproperty is a 171 # class-level descriptor, so each mechanism name needs its own 172 # class to avoid key collisions. The cache ensures the 173 # ntproperty is created only once per name so defaults don't 174 # overwrite previously persisted values. 175 if name not in _calibration_classes: 176 prefix = f"/{name}/calibration" 177 attrs = { 178 "_nt_hard_limit_min": ntproperty( 179 f"{prefix}/hard_limit_min", float("nan"), 180 writeDefault=False, persistent=True), 181 "_nt_hard_limit_max": ntproperty( 182 f"{prefix}/hard_limit_max", float("nan"), 183 writeDefault=False, persistent=True), 184 "_nt_soft_limit_margin": ntproperty( 185 f"{prefix}/soft_limit_margin", 0.05, 186 writeDefault=False, persistent=True), 187 } 188 # Non-persistent booleans for callback status 189 for cb_name in sorted(_VALID_CALLBACKS): 190 attrs[f"_nt_cb_{cb_name}"] = ntproperty( 191 f"{prefix}/callbacks/{cb_name}", False, 192 writeDefault=True, persistent=False) 193 _calibration_classes[name] = type( 194 f"PositionCalibration_{name}", 195 (PositionCalibration,), 196 attrs, 197 ) 198 self.__class__ = _calibration_classes[name] 199 200 self._name = name 201 202 # Soft limits — initialized to defaults, overwritten by 203 # _load_from_nt() if persisted calibration data exists 204 self._min_soft_limit = fallback_min 205 self._max_soft_limit = fallback_max 206 207 # Calibration state 208 self._hard_limit_min = None 209 self._hard_limit_max = None 210 self._is_calibrated = False 211 self._is_calibrating = False 212 self._calibration_phase = 0 213 self._soft_limit_margin = 0.05 214 self._position_offset = 0.0 215 216 # Homing state 217 self._is_homing = False 218 self._saved_config = None 219 220 # -- Build callbacks -- 221 # Validate kwargs keys 222 unknown = set(kwargs.keys()) - _VALID_CALLBACKS 223 if unknown: 224 raise ValueError( 225 f"Unknown callback(s): {', '.join(sorted(unknown))}" 226 ) 227 228 # If motor provided, generate SparkMax defaults 229 defaults = {} 230 if motor is not None: 231 from utils.spark_max_callbacks import SparkMaxCallbacks 232 defaults = SparkMaxCallbacks(motor, encoder).as_dict() 233 234 # Merge: explicit kwargs override SparkMax defaults 235 merged = {**defaults, **kwargs} 236 237 # Store each callback as self._cb_{name} (None if not provided) 238 for cb_name in _VALID_CALLBACKS: 239 setattr(self, f'_cb_{cb_name}', merged.get(cb_name)) 240 241 # Publish callback status to NT 242 self._publish_callback_status() 243 244 # Attempt to load persisted calibration from NT 245 self._load_from_nt()
Create a new PositionCalibration controller.
Args: name: mechanism name used for NT paths and alerts (e.g. "Turret") fallback_min: reverse soft limit used before calibration data exists. Once calibrated, the persisted hard limits are used instead. The caller is responsible for the far end of travel if only homing (not full calibration) is performed. fallback_max: forward soft limit used before calibration data exists. See fallback_min for details. motor: (optional) rev.SparkMax for convenience callback generation encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys)
249 @property 250 def is_busy(self) -> bool: 251 """True when homing or calibrating (blocks motor commands).""" 252 return self._is_homing or self._is_calibrating
True when homing or calibrating (blocks motor commands).
254 @property 255 def is_homing(self) -> bool: 256 """Whether a homing routine is active.""" 257 return self._is_homing
Whether a homing routine is active.
259 @property 260 def is_calibrating(self) -> bool: 261 """Whether a calibration routine is active.""" 262 return self._is_calibrating
Whether a calibration routine is active.
264 @property 265 def is_calibrated(self) -> bool: 266 """Whether the full mechanical range has been discovered.""" 267 return self._is_calibrated
Whether the full mechanical range has been discovered.
269 @property 270 def is_zeroed(self) -> bool: 271 """Whether the encoder has been aligned to a reference point.""" 272 return self._hard_limit_min is not None
Whether the encoder has been aligned to a reference point.
274 @property 275 def min_limit(self): 276 """Hard limit min, or None if uncalibrated.""" 277 return self._hard_limit_min
Hard limit min, or None if uncalibrated.
279 @property 280 def max_limit(self): 281 """Hard limit max, or None if uncalibrated.""" 282 return self._hard_limit_max
Hard limit max, or None if uncalibrated.
284 @property 285 def min_soft_limit(self) -> float: 286 """Current minimum soft limit.""" 287 return self._min_soft_limit
Current minimum soft limit.
289 @property 290 def max_soft_limit(self) -> float: 291 """Current maximum soft limit.""" 292 return self._max_soft_limit
Current maximum soft limit.
294 @property 295 def soft_limit_margin(self) -> float: 296 """Current safety margin as a fraction of the full range.""" 297 return self._soft_limit_margin
Current safety margin as a fraction of the full range.
301 def get_callbacks(self) -> dict: 302 """ 303 Return a dict of all callback names and their current values. 304 305 Each key is a callback name from _VALID_CALLBACKS. The value is 306 the callable if set, or None if not set. Useful for inspecting 307 which callbacks are configured:: 308 309 cbs = cal.get_callbacks() 310 for name, func in cbs.items(): 311 print(f"{name}: {'set' if func else 'not set'}") 312 313 Returns: 314 dict mapping callback name -> callable or None 315 """ 316 return { 317 name: getattr(self, f'_cb_{name}', None) 318 for name in sorted(_VALID_CALLBACKS) 319 }
Return a dict of all callback names and their current values.
Each key is a callback name from _VALID_CALLBACKS. The value is the callable if set, or None if not set. Useful for inspecting which callbacks are configured::
cbs = cal.get_callbacks()
for name, func in cbs.items():
print(f"{name}: {'set' if func else 'not set'}")
Returns: dict mapping callback name -> callable or None
321 def set_callbacks(self, **kwargs) -> None: 322 """ 323 Set, override, or clear one or more callbacks by name. 324 325 Pass a callable to set a callback, or None to clear it:: 326 327 # Set a callback 328 cal.set_callbacks(get_velocity=encoder.getVelocity) 329 330 # Clear callbacks that don't apply to this mechanism 331 cal.set_callbacks( 332 get_forward_limit_switch=None, 333 get_reverse_limit_switch=None) 334 335 Args: 336 **kwargs: callback name/value pairs (see _VALID_CALLBACKS). 337 Pass None to clear a callback. 338 """ 339 for key, value in kwargs.items(): 340 if key not in _VALID_CALLBACKS: 341 raise ValueError(f"Unknown callback: {key}") 342 setattr(self, f'_cb_{key}', value) 343 self._publish_callback_status()
Set, override, or clear one or more callbacks by name.
Pass a callable to set a callback, or None to clear it::
# Set a callback
cal.set_callbacks(get_velocity=encoder.getVelocity)
# Clear callbacks that don't apply to this mechanism
cal.set_callbacks(
get_forward_limit_switch=None,
get_reverse_limit_switch=None)
Args: **kwargs: callback name/value pairs (see _VALID_CALLBACKS). Pass None to clear a callback.
345 def homing_init( 346 self, 347 max_current: float, 348 max_power_pct: float, 349 max_homing_time: float, 350 homing_forward: bool, 351 min_velocity: float = None, 352 home_position: float = 0.0, 353 max_travel: Optional[float] = None, 354 min_trigger_travel: float = 0.0, 355 ) -> None: 356 """ 357 Initialize the sensorless or sensor homing routine. 358 359 Saves current motor settings (if save_config callback is set), 360 applies homing-specific configuration, and begins driving toward 361 the specified hard stop. 362 363 Args: 364 max_current: current limit in amps during homing 365 max_power_pct: motor duty cycle during homing (0.0-1.0) 366 max_homing_time: maximum time in seconds before timeout 367 homing_forward: True to home toward forward limit, False for reverse 368 min_velocity: stall detection threshold in user units/second. 369 Defaults to 5% of soft limit range over 2 seconds. 370 home_position: encoder value to set when the hard stop is 371 found (default 0.0) 372 max_travel: abort if encoder travels more than this many user 373 units from the start position. Catches a stuck-open limit 374 switch that never triggers. None disables the check — 375 useful when the mechanical range is unknown. Requires 376 ``get_position`` callback to take effect. 377 min_trigger_travel: minimum encoder travel (user units) 378 required before a limit switch trigger is trusted. If 379 the switch fires before this much movement, abort with 380 a stuck-closed-switch alert. 0.0 disables (default). 381 Requires ``get_position`` callback to take effect. 382 """ 383 # Validate callbacks before starting 384 self._validate_homing(homing_forward) 385 386 if min_velocity is None: 387 full_range = self._max_soft_limit - self._min_soft_limit 388 min_velocity = full_range * 0.05 / 2.0 389 390 # Save current motor settings for restoration (if callback exists) 391 if self._cb_save_config is not None: 392 self._saved_config = self._cb_save_config() 393 else: 394 self._saved_config = None 395 396 # Apply homing current limit (if callback exists) 397 if self._cb_set_current_limit is not None: 398 self._cb_set_current_limit(max_current) 399 400 # Disable soft limit in homing direction (if callback exists) 401 if self._cb_disable_soft_limits is not None: 402 if homing_forward: 403 self._cb_disable_soft_limits(True, False) 404 else: 405 self._cb_disable_soft_limits(False, True) 406 407 # Store homing parameters 408 self._homing_forward = homing_forward 409 self._max_power_pct = max_power_pct 410 self._min_velocity = min_velocity 411 self._max_homing_time = max_homing_time 412 self._home_position = home_position 413 self._max_travel = max_travel 414 self._min_trigger_travel = min_trigger_travel 415 416 # Capture start position for travel tracking. Only meaningful if 417 # get_position is wired; otherwise both travel-based guards are 418 # silently no-ops. 419 if self._cb_get_position is not None: 420 self._homing_start_pos = self._cb_get_position() 421 else: 422 self._homing_start_pos = None 423 424 # Set homing state 425 self._is_homing = True 426 self._stall_detected = False 427 428 # Start timeout timer 429 self._homing_timer = wpilib.Timer() 430 self._homing_timer.start() 431 432 # Stall detection timer 433 self._stall_timer = wpilib.Timer() 434 435 # Alerts 436 self._homing_status_alert = wpilib.Alert( 437 f"{self._name}: homing started", 438 wpilib.Alert.AlertType.kInfo 439 ) 440 self._homing_status_alert.set(True) 441 self._homing_error_alert = wpilib.Alert( 442 f"{self._name}: homing failed", 443 wpilib.Alert.AlertType.kError 444 ) 445 self._homing_error_alert.set(False)
Initialize the sensorless or sensor homing routine.
Saves current motor settings (if save_config callback is set), applies homing-specific configuration, and begins driving toward the specified hard stop.
Args:
max_current: current limit in amps during homing
max_power_pct: motor duty cycle during homing (0.0-1.0)
max_homing_time: maximum time in seconds before timeout
homing_forward: True to home toward forward limit, False for reverse
min_velocity: stall detection threshold in user units/second.
Defaults to 5% of soft limit range over 2 seconds.
home_position: encoder value to set when the hard stop is
found (default 0.0)
max_travel: abort if encoder travels more than this many user
units from the start position. Catches a stuck-open limit
switch that never triggers. None disables the check —
useful when the mechanical range is unknown. Requires
get_position callback to take effect.
min_trigger_travel: minimum encoder travel (user units)
required before a limit switch trigger is trusted. If
the switch fires before this much movement, abort with
a stuck-closed-switch alert. 0.0 disables (default).
Requires get_position callback to take effect.
447 def calibration_init( 448 self, 449 max_current: float, 450 max_power_pct: float, 451 max_homing_time: float, 452 min_velocity: float = None, 453 known_range: float = None, 454 max_travel: Optional[float] = None, 455 min_trigger_travel: float = 0.0, 456 ) -> None: 457 """ 458 Start a calibration routine that discovers the mechanical range. 459 460 Phase 1: Home negative to find the reverse hard limit (set as zero). 461 Phase 2: Home positive to find the forward hard limit (measured). 462 463 If known_range is provided, only phase 1 runs and the forward hard 464 limit is computed as known_range from the zero point. 465 466 Args: 467 max_current: current limit in amps during calibration 468 max_power_pct: motor duty cycle (0.0-1.0) 469 max_homing_time: maximum time per phase before timeout 470 min_velocity: stall detection threshold in user units/second 471 known_range: if provided, skip phase 2 and use this as the 472 full mechanical range from the zero point 473 max_travel: abort a homing phase if encoder travels more 474 than this from its phase start. Defaults to 110% of the 475 current soft-limit range (a sane bound for calibration 476 because the expected range is known). Pass an explicit 477 None via the kwarg to disable the check entirely. 478 min_trigger_travel: minimum travel required before a limit 479 switch trigger is trusted per phase. Guards against a 480 stuck-closed switch firing immediately. 0.0 disables. 481 """ 482 # Validate callbacks before starting 483 self._validate_calibration() 484 485 # Save motor settings for restoration after calibration 486 if self._cb_save_config is not None: 487 self._cal_saved_config = self._cb_save_config() 488 else: 489 self._cal_saved_config = None 490 491 # Disable both soft limits for free travel 492 if self._cb_disable_soft_limits is not None: 493 self._cb_disable_soft_limits(True, True) 494 495 # Default max_travel to 110% of the expected range. Each phase 496 # homes one direction, so the full range is the per-phase ceiling. 497 if max_travel is None: 498 expected_range = abs(self._max_soft_limit - self._min_soft_limit) 499 if expected_range > 0: 500 max_travel = expected_range * 1.1 501 502 # Store calibration parameters for phase transitions 503 self._cal_max_current = max_current 504 self._cal_max_power_pct = max_power_pct 505 self._cal_max_homing_time = max_homing_time 506 self._cal_min_velocity = min_velocity 507 self._cal_known_range = known_range 508 self._cal_max_travel = max_travel 509 self._cal_min_trigger_travel = min_trigger_travel 510 511 self._is_calibrating = True 512 self._calibration_phase = 1 513 514 # Start phase 1: home negative 515 self.homing_init( 516 max_current, max_power_pct, max_homing_time, 517 homing_forward=False, min_velocity=min_velocity, 518 max_travel=max_travel, 519 min_trigger_travel=min_trigger_travel, 520 ) 521 522 self._cal_status_alert = wpilib.Alert( 523 f"{self._name}: calibration phase 1 - homing negative", 524 wpilib.Alert.AlertType.kInfo 525 ) 526 self._cal_status_alert.set(True)
Start a calibration routine that discovers the mechanical range.
Phase 1: Home negative to find the reverse hard limit (set as zero). Phase 2: Home positive to find the forward hard limit (measured).
If known_range is provided, only phase 1 runs and the forward hard limit is computed as known_range from the zero point.
Args: max_current: current limit in amps during calibration max_power_pct: motor duty cycle (0.0-1.0) max_homing_time: maximum time per phase before timeout min_velocity: stall detection threshold in user units/second known_range: if provided, skip phase 2 and use this as the full mechanical range from the zero point max_travel: abort a homing phase if encoder travels more than this from its phase start. Defaults to 110% of the current soft-limit range (a sane bound for calibration because the expected range is known). Pass an explicit None via the kwarg to disable the check entirely. min_trigger_travel: minimum travel required before a limit switch trigger is trusted per phase. Guards against a stuck-closed switch firing immediately. 0.0 disables.
528 def periodic(self) -> None: 529 """ 530 Run the active homing or calibration state machine. 531 532 Call this every cycle when is_busy is True. 533 """ 534 if self._is_calibrating: 535 self._calibration_periodic() 536 elif self._is_homing: 537 self._homing_periodic()
Run the active homing or calibration state machine.
Call this every cycle when is_busy is True.
539 def abort(self) -> None: 540 """Abort any active homing or calibration routine.""" 541 if self._is_calibrating: 542 self._calibration_end(abort=True) 543 elif self._is_homing: 544 self._homing_end(abort=True)
Abort any active homing or calibration routine.
546 def set_soft_limit_margin(self, margin_pct: float) -> None: 547 """ 548 Set the soft limit safety margin and apply to the motor controller. 549 550 Args: 551 margin_pct: safety margin as a fraction (e.g. 0.05 for 5%) 552 """ 553 if not self._is_calibrated: 554 return 555 self._soft_limit_margin = margin_pct 556 full_range = self._hard_limit_max - self._hard_limit_min 557 margin = full_range * margin_pct 558 self._min_soft_limit = self._hard_limit_min + margin 559 self._max_soft_limit = self._hard_limit_max - margin 560 561 # Apply to hardware only if callback exists 562 if self._cb_set_soft_limits is not None: 563 self._cb_set_soft_limits( 564 self._min_soft_limit, self._max_soft_limit)
Set the soft limit safety margin and apply to the motor controller.
Args: margin_pct: safety margin as a fraction (e.g. 0.05 for 5%)
566 def compute_conversion_factor( 567 self, 568 current_factor: float, 569 desired_min: float, 570 desired_max: float, 571 ) -> float: 572 """ 573 Compute a corrected encoder conversion factor after calibration. 574 575 After a full calibration discovers the raw mechanical range, this 576 method computes the conversion factor needed so the measured 577 range maps to ``[desired_min, desired_max]``. 578 579 Example: if calibration measured 500 user-units of travel with 580 the initial conversion factor and you want -90..90 (180°), the 581 corrected factor scales the encoder so full travel reads 180°. 582 583 Args: 584 current_factor: the position conversion factor currently set 585 on the encoder 586 desired_min: target minimum position in user units 587 desired_max: target maximum position in user units 588 589 Returns: 590 The corrected conversion factor to apply to the encoder. 591 592 Raises: 593 RuntimeError: if calibration has not completed, or the 594 measured range is zero. 595 """ 596 if not self._is_calibrated: 597 raise RuntimeError( 598 f"{self._name}: cannot compute conversion factor " 599 "before calibration is complete" 600 ) 601 measured_range = self._hard_limit_max - self._hard_limit_min 602 if measured_range == 0: 603 raise RuntimeError( 604 f"{self._name}: measured range is zero, " 605 "calibration data invalid" 606 ) 607 desired_range = desired_max - desired_min 608 return current_factor * (desired_range / measured_range)
Compute a corrected encoder conversion factor after calibration.
After a full calibration discovers the raw mechanical range, this
method computes the conversion factor needed so the measured
range maps to [desired_min, desired_max].
Example: if calibration measured 500 user-units of travel with the initial conversion factor and you want -90..90 (180°), the corrected factor scales the encoder so full travel reads 180°.
Args: current_factor: the position conversion factor currently set on the encoder desired_min: target minimum position in user units desired_max: target maximum position in user units
Returns: The corrected conversion factor to apply to the encoder.
Raises: RuntimeError: if calibration has not completed, or the measured range is zero.
610 def apply_conversion_factor( 611 self, 612 current_factor: float, 613 desired_min: float, 614 desired_max: float, 615 ) -> float: 616 """ 617 Compute and apply a corrected encoder conversion factor. 618 619 Calls :meth:`compute_conversion_factor` for the new value, then 620 applies it via the ``set_conversion_factor`` callback, resets 621 the encoder to ``desired_min`` (the new zero reference), and 622 rewrites the internal hard/soft limits to match the desired 623 range. 624 625 This is intended to be called immediately after a full 626 calibration completes (encoder at the reverse hard stop, 627 reading 0). A guard rejects the call if the encoder reports 628 more than 5% of the current soft-limit range away from zero — 629 the mechanism has moved since calibration, so the post-rescale 630 encoder zero would not correspond to a known physical position. 631 632 Args: 633 current_factor: the position conversion factor currently set 634 on the encoder 635 desired_min: target minimum position in user units (becomes 636 the new zero reference) 637 desired_max: target maximum position in user units 638 639 Returns: 640 The corrected conversion factor that was applied. 641 642 Raises: 643 ValueError: if the ``set_conversion_factor`` callback is 644 not configured. 645 RuntimeError: if calibration has not produced a zero 646 reference, or the mechanism is not at that zero point. 647 """ 648 if self._cb_set_conversion_factor is None: 649 raise ValueError( 650 "set_conversion_factor callback not set" 651 ) 652 653 if not self.is_zeroed: 654 raise RuntimeError( 655 f"{self._name}: cannot apply conversion factor " 656 "before homing to zero position" 657 ) 658 659 # Verify the mechanism is still at the reverse hard stop 660 # (encoder reads ~0) before rescaling. Without this, the post- 661 # rescale encoder zero corresponds to no known physical point. 662 if self._cb_get_position is not None: 663 pos = abs(self._cb_get_position()) 664 full_range = self._max_soft_limit - self._min_soft_limit 665 tolerance = abs(full_range) * 0.05 666 if pos > tolerance: 667 raise RuntimeError( 668 f"{self._name}: mechanism is not at the zero " 669 f"position (current: {pos:.1f}, tolerance: " 670 f"{tolerance:.1f}). Home the mechanism before " 671 "applying a new conversion factor" 672 ) 673 674 new_factor = self.compute_conversion_factor( 675 current_factor, desired_min, desired_max 676 ) 677 self._cb_set_conversion_factor(new_factor) 678 679 # The encoder unit-scale just changed, so its raw reading no 680 # longer maps to the same physical position. Reseed to 681 # desired_min (we calibrated from the reverse hard stop) and 682 # rewrite limits to match the desired range. 683 self._cb_set_position(desired_min) 684 self._hard_limit_min = desired_min 685 self._hard_limit_max = desired_max 686 self.set_soft_limit_margin(self._soft_limit_margin) 687 self._save_to_nt() 688 689 return new_factor
Compute and apply a corrected encoder conversion factor.
Calls compute_conversion_factor() for the new value, then
applies it via the set_conversion_factor callback, resets
the encoder to desired_min (the new zero reference), and
rewrites the internal hard/soft limits to match the desired
range.
This is intended to be called immediately after a full calibration completes (encoder at the reverse hard stop, reading 0). A guard rejects the call if the encoder reports more than 5% of the current soft-limit range away from zero — the mechanism has moved since calibration, so the post-rescale encoder zero would not correspond to a known physical position.
Args: current_factor: the position conversion factor currently set on the encoder desired_min: target minimum position in user units (becomes the new zero reference) desired_max: target maximum position in user units
Returns: The corrected conversion factor that was applied.
Raises:
ValueError: if the set_conversion_factor callback is
not configured.
RuntimeError: if calibration has not produced a zero
reference, or the mechanism is not at that zero point.
691 def update_telemetry(self, prefix: str) -> None: 692 """ 693 Publish calibration data to SmartDashboard. 694 695 Args: 696 prefix: SmartDashboard key prefix (e.g. "Turret/") 697 """ 698 sd = wpilib.SmartDashboard 699 sd.putBoolean(prefix + "isCalibrated", self._is_calibrated) 700 sd.putBoolean(prefix + "isCalibrating", self._is_calibrating) 701 sd.putBoolean(prefix + "isHoming", self._is_homing) 702 sd.putNumber( 703 prefix + "hardLimitMin", 704 self._hard_limit_min if self._hard_limit_min is not None 705 else 0.0 706 ) 707 sd.putNumber( 708 prefix + "hardLimitMax", 709 self._hard_limit_max if self._hard_limit_max is not None 710 else 0.0 711 ) 712 sd.putNumber(prefix + "positionOffset", self._position_offset) 713 sd.putNumber(prefix + "softLimitMargin", self._soft_limit_margin)
Publish calibration data to SmartDashboard.
Args: prefix: SmartDashboard key prefix (e.g. "Turret/")