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} 25 26# Callbacks that must always be set before homing or calibration 27_CORE_REQUIRED = {'set_motor_output', 'stop_motor', 'set_position'} 28 29 30class PositionCalibration: 31 """ 32 Reusable calibration and homing controller for positional mechanisms. 33 34 Extracts sensorless or sensor homing and two-phase calibration logic 35 from mechanism-specific subsystems into a standalone, motor-agnostic 36 class. All hardware interaction is done through user-provided 37 callbacks, making this compatible with any motor controller. 38 39 For SparkMax users, the ``motor`` convenience parameter auto-populates 40 callbacks via SparkMaxCallbacks. Individual callbacks passed as keyword 41 arguments override the SparkMax defaults. 42 43 Calibration discovers mechanical hard limits by driving into stops, 44 then computes soft limits with a configurable safety margin. Persists 45 discovered limits to NetworkTables via ntproperty so values survive 46 reboots. 47 48 ## Callbacks 49 50 **Core required** (must be set before homing or calibration): 51 52 | Name | Signature | Purpose | 53 |------|-----------|---------| 54 | `set_motor_output` | `(float) -> None` | Drive motor at duty cycle (-1.0 to 1.0) | 55 | `stop_motor` | `() -> None` | Stop motor output | 56 | `set_position` | `(float) -> None` | Reset encoder position | 57 58 **Detection** (at least one required per homing direction): 59 60 | Name | Signature | Purpose | 61 |------|-----------|---------| 62 | `get_velocity` | `() -> float` | Enables stall detection | 63 | `get_forward_limit_switch` | `() -> bool` | Forward limit switch | 64 | `get_reverse_limit_switch` | `() -> bool` | Reverse limit switch | 65 66 **Optional** (enhance safety/features but not required): 67 68 | Name | Signature | Purpose | 69 |------|-----------|---------| 70 | `get_position` | `() -> float` | Required only for calibration phase 2 | 71 | `set_current_limit` | `(float) -> None` | Protective current limit during homing | 72 | `set_soft_limits` | `(min, max) -> None` | Apply soft limits to hardware | 73 | `disable_soft_limits` | `(fwd, rev) -> None` | Disable soft limits for free travel | 74 | `save_config` | `() -> Any` | Snapshot motor config before homing | 75 | `restore_config` | `(Any) -> None` | Restore snapshot after homing | 76 | `on_limit_detected` | `(pos, dir) -> None` | Fires when a hard limit is found | 77 78 Use ``get_callbacks()`` to inspect the current callback dict (returns 79 ``None`` for any callback that has not been set). 80 81 Examples 82 -------- 83 84 SparkMax convenience (auto-generates all callbacks):: 85 86 self.calibration = PositionCalibration( 87 name="Turret", 88 fallback_min=-90, fallback_max=90, 89 motor=self.motor, encoder=self.encoder) 90 91 Pure callbacks with stall detection only:: 92 93 self.calibration = PositionCalibration( 94 name="Elevator", 95 fallback_min=0.0, fallback_max=1.5, 96 set_motor_output=lambda pct: motor.set(pct), 97 stop_motor=lambda: motor.stopMotor(), 98 set_position=lambda v: encoder.setPosition(v), 99 get_velocity=lambda: encoder.getVelocity()) 100 101 Pure callbacks with limit switches only:: 102 103 self.calibration = PositionCalibration( 104 name="Arm", 105 fallback_min=0.0, fallback_max=90.0, 106 set_motor_output=lambda pct: motor.set(pct), 107 stop_motor=lambda: motor.stopMotor(), 108 set_position=lambda v: encoder.setPosition(v), 109 get_forward_limit_switch=lambda: fwd_switch.get(), 110 get_reverse_limit_switch=lambda: rev_switch.get()) 111 112 Deferred setup (set callbacks after construction):: 113 114 cal = PositionCalibration( 115 name="Wrist", 116 fallback_min=-45, fallback_max=45) 117 # ... later, once hardware is ready ... 118 cal.set_callbacks( 119 set_motor_output=motor.set, 120 stop_motor=motor.stopMotor, 121 set_position=encoder.setPosition, 122 get_velocity=encoder.getVelocity) 123 """ 124 125 # -- Callback type declarations -- 126 _cb_get_position: Optional[Callable[[], float]] 127 _cb_get_velocity: Optional[Callable[[], float]] 128 _cb_set_position: Optional[Callable[[float], None]] 129 _cb_set_motor_output: Optional[Callable[[float], None]] 130 _cb_stop_motor: Optional[Callable[[], None]] 131 _cb_set_current_limit: Optional[Callable[[float], None]] 132 _cb_set_soft_limits: Optional[Callable[[float, float], None]] 133 _cb_disable_soft_limits: Optional[Callable[[bool, bool], None]] 134 _cb_save_config: Optional[Callable[[], Any]] 135 _cb_restore_config: Optional[Callable[[Any], None]] 136 _cb_get_forward_limit_switch: Optional[Callable[[], bool]] 137 _cb_get_reverse_limit_switch: Optional[Callable[[], bool]] 138 _cb_on_limit_detected: Optional[Callable[[float, str], None]] 139 140 def __init__( 141 self, 142 name: str, 143 fallback_min: float, 144 fallback_max: float, 145 *, 146 motor=None, 147 encoder=None, 148 **kwargs, 149 ) -> None: 150 """ 151 Create a new PositionCalibration controller. 152 153 Args: 154 name: mechanism name used for NT paths and alerts (e.g. "Turret") 155 fallback_min: reverse soft limit used before calibration data 156 exists. Once calibrated, the persisted hard limits are used 157 instead. The caller is responsible for the far end of travel 158 if only homing (not full calibration) is performed. 159 fallback_max: forward soft limit used before calibration data 160 exists. See fallback_min for details. 161 motor: (optional) rev.SparkMax for convenience callback generation 162 encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) 163 **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys) 164 """ 165 # Assign this instance to a cached subclass with ntproperty 166 # descriptors keyed to this mechanism's name. ntproperty is a 167 # class-level descriptor, so each mechanism name needs its own 168 # class to avoid key collisions. The cache ensures the 169 # ntproperty is created only once per name so defaults don't 170 # overwrite previously persisted values. 171 if name not in _calibration_classes: 172 prefix = f"/{name}/calibration" 173 attrs = { 174 "_nt_hard_limit_min": ntproperty( 175 f"{prefix}/hard_limit_min", float("nan"), 176 writeDefault=False, persistent=True), 177 "_nt_hard_limit_max": ntproperty( 178 f"{prefix}/hard_limit_max", float("nan"), 179 writeDefault=False, persistent=True), 180 "_nt_soft_limit_margin": ntproperty( 181 f"{prefix}/soft_limit_margin", 0.05, 182 writeDefault=False, persistent=True), 183 } 184 # Non-persistent booleans for callback status 185 for cb_name in sorted(_VALID_CALLBACKS): 186 attrs[f"_nt_cb_{cb_name}"] = ntproperty( 187 f"{prefix}/callbacks/{cb_name}", False, 188 writeDefault=True, persistent=False) 189 _calibration_classes[name] = type( 190 f"PositionCalibration_{name}", 191 (PositionCalibration,), 192 attrs, 193 ) 194 self.__class__ = _calibration_classes[name] 195 196 self._name = name 197 198 # Soft limits — initialized to defaults, overwritten by 199 # _load_from_nt() if persisted calibration data exists 200 self._min_soft_limit = fallback_min 201 self._max_soft_limit = fallback_max 202 203 # Calibration state 204 self._hard_limit_min = None 205 self._hard_limit_max = None 206 self._is_calibrated = False 207 self._is_calibrating = False 208 self._calibration_phase = 0 209 self._soft_limit_margin = 0.05 210 self._position_offset = 0.0 211 212 # Homing state 213 self._is_homing = False 214 self._saved_config = None 215 216 # -- Build callbacks -- 217 # Validate kwargs keys 218 unknown = set(kwargs.keys()) - _VALID_CALLBACKS 219 if unknown: 220 raise ValueError( 221 f"Unknown callback(s): {', '.join(sorted(unknown))}" 222 ) 223 224 # If motor provided, generate SparkMax defaults 225 defaults = {} 226 if motor is not None: 227 from utils.spark_max_callbacks import SparkMaxCallbacks 228 defaults = SparkMaxCallbacks(motor, encoder).as_dict() 229 230 # Merge: explicit kwargs override SparkMax defaults 231 merged = {**defaults, **kwargs} 232 233 # Store each callback as self._cb_{name} (None if not provided) 234 for cb_name in _VALID_CALLBACKS: 235 setattr(self, f'_cb_{cb_name}', merged.get(cb_name)) 236 237 # Publish callback status to NT 238 self._publish_callback_status() 239 240 # Attempt to load persisted calibration from NT 241 self._load_from_nt() 242 243 # ---- Public properties ---- 244 245 @property 246 def is_busy(self) -> bool: 247 """True when homing or calibrating (blocks motor commands).""" 248 return self._is_homing or self._is_calibrating 249 250 @property 251 def is_homing(self) -> bool: 252 """Whether a homing routine is active.""" 253 return self._is_homing 254 255 @property 256 def is_calibrating(self) -> bool: 257 """Whether a calibration routine is active.""" 258 return self._is_calibrating 259 260 @property 261 def is_calibrated(self) -> bool: 262 """Whether the full mechanical range has been discovered.""" 263 return self._is_calibrated 264 265 @property 266 def is_zeroed(self) -> bool: 267 """Whether the encoder has been aligned to a reference point.""" 268 return self._hard_limit_min is not None 269 270 @property 271 def min_limit(self): 272 """Hard limit min, or None if uncalibrated.""" 273 return self._hard_limit_min 274 275 @property 276 def max_limit(self): 277 """Hard limit max, or None if uncalibrated.""" 278 return self._hard_limit_max 279 280 @property 281 def min_soft_limit(self) -> float: 282 """Current minimum soft limit.""" 283 return self._min_soft_limit 284 285 @property 286 def max_soft_limit(self) -> float: 287 """Current maximum soft limit.""" 288 return self._max_soft_limit 289 290 @property 291 def soft_limit_margin(self) -> float: 292 """Current safety margin as a fraction of the full range.""" 293 return self._soft_limit_margin 294 295 # ---- Public methods ---- 296 297 def get_callbacks(self) -> dict: 298 """ 299 Return a dict of all callback names and their current values. 300 301 Each key is a callback name from _VALID_CALLBACKS. The value is 302 the callable if set, or None if not set. Useful for inspecting 303 which callbacks are configured:: 304 305 cbs = cal.get_callbacks() 306 for name, func in cbs.items(): 307 print(f"{name}: {'set' if func else 'not set'}") 308 309 Returns: 310 dict mapping callback name -> callable or None 311 """ 312 return { 313 name: getattr(self, f'_cb_{name}', None) 314 for name in sorted(_VALID_CALLBACKS) 315 } 316 317 def set_callbacks(self, **kwargs) -> None: 318 """ 319 Set, override, or clear one or more callbacks by name. 320 321 Pass a callable to set a callback, or None to clear it:: 322 323 # Set a callback 324 cal.set_callbacks(get_velocity=encoder.getVelocity) 325 326 # Clear callbacks that don't apply to this mechanism 327 cal.set_callbacks( 328 get_forward_limit_switch=None, 329 get_reverse_limit_switch=None) 330 331 Args: 332 **kwargs: callback name/value pairs (see _VALID_CALLBACKS). 333 Pass None to clear a callback. 334 """ 335 for key, value in kwargs.items(): 336 if key not in _VALID_CALLBACKS: 337 raise ValueError(f"Unknown callback: {key}") 338 setattr(self, f'_cb_{key}', value) 339 self._publish_callback_status() 340 341 def homing_init( 342 self, 343 max_current: float, 344 max_power_pct: float, 345 max_homing_time: float, 346 homing_forward: bool, 347 min_velocity: float = None, 348 home_position: float = 0.0 349 ) -> None: 350 """ 351 Initialize the sensorless or sensor homing routine. 352 353 Saves current motor settings (if save_config callback is set), 354 applies homing-specific configuration, and begins driving toward 355 the specified hard stop. 356 357 Args: 358 max_current: current limit in amps during homing 359 max_power_pct: motor duty cycle during homing (0.0-1.0) 360 max_homing_time: maximum time in seconds before timeout 361 homing_forward: True to home toward forward limit, False for reverse 362 min_velocity: stall detection threshold in user units/second. 363 Defaults to 5% of soft limit range over 2 seconds. 364 home_position: encoder value to set when the hard stop is 365 found (default 0.0) 366 """ 367 # Validate callbacks before starting 368 self._validate_homing(homing_forward) 369 370 if min_velocity is None: 371 full_range = self._max_soft_limit - self._min_soft_limit 372 min_velocity = full_range * 0.05 / 2.0 373 374 # Save current motor settings for restoration (if callback exists) 375 if self._cb_save_config is not None: 376 self._saved_config = self._cb_save_config() 377 else: 378 self._saved_config = None 379 380 # Apply homing current limit (if callback exists) 381 if self._cb_set_current_limit is not None: 382 self._cb_set_current_limit(max_current) 383 384 # Disable soft limit in homing direction (if callback exists) 385 if self._cb_disable_soft_limits is not None: 386 if homing_forward: 387 self._cb_disable_soft_limits(True, False) 388 else: 389 self._cb_disable_soft_limits(False, True) 390 391 # Store homing parameters 392 self._homing_forward = homing_forward 393 self._max_power_pct = max_power_pct 394 self._min_velocity = min_velocity 395 self._max_homing_time = max_homing_time 396 self._home_position = home_position 397 398 # Set homing state 399 self._is_homing = True 400 self._stall_detected = False 401 402 # Start timeout timer 403 self._homing_timer = wpilib.Timer() 404 self._homing_timer.start() 405 406 # Stall detection timer 407 self._stall_timer = wpilib.Timer() 408 409 # Alerts 410 self._homing_status_alert = wpilib.Alert( 411 f"{self._name}: homing started", 412 wpilib.Alert.AlertType.kInfo 413 ) 414 self._homing_status_alert.set(True) 415 self._homing_error_alert = wpilib.Alert( 416 f"{self._name}: homing failed", 417 wpilib.Alert.AlertType.kError 418 ) 419 self._homing_error_alert.set(False) 420 421 def calibration_init( 422 self, 423 max_current: float, 424 max_power_pct: float, 425 max_homing_time: float, 426 min_velocity: float = None, 427 known_range: float = None 428 ) -> None: 429 """ 430 Start a calibration routine that discovers the mechanical range. 431 432 Phase 1: Home negative to find the reverse hard limit (set as zero). 433 Phase 2: Home positive to find the forward hard limit (measured). 434 435 If known_range is provided, only phase 1 runs and the forward hard 436 limit is computed as known_range from the zero point. 437 438 Args: 439 max_current: current limit in amps during calibration 440 max_power_pct: motor duty cycle (0.0-1.0) 441 max_homing_time: maximum time per phase before timeout 442 min_velocity: stall detection threshold in user units/second 443 known_range: if provided, skip phase 2 and use this as the 444 full mechanical range from the zero point 445 """ 446 # Validate callbacks before starting 447 self._validate_calibration() 448 449 # Save motor settings for restoration after calibration 450 if self._cb_save_config is not None: 451 self._cal_saved_config = self._cb_save_config() 452 else: 453 self._cal_saved_config = None 454 455 # Disable both soft limits for free travel 456 if self._cb_disable_soft_limits is not None: 457 self._cb_disable_soft_limits(True, True) 458 459 # Store calibration parameters for phase transitions 460 self._cal_max_current = max_current 461 self._cal_max_power_pct = max_power_pct 462 self._cal_max_homing_time = max_homing_time 463 self._cal_min_velocity = min_velocity 464 self._cal_known_range = known_range 465 466 self._is_calibrating = True 467 self._calibration_phase = 1 468 469 # Start phase 1: home negative 470 self.homing_init( 471 max_current, max_power_pct, max_homing_time, 472 homing_forward=False, min_velocity=min_velocity 473 ) 474 475 self._cal_status_alert = wpilib.Alert( 476 f"{self._name}: calibration phase 1 - homing negative", 477 wpilib.Alert.AlertType.kInfo 478 ) 479 self._cal_status_alert.set(True) 480 481 def periodic(self) -> None: 482 """ 483 Run the active homing or calibration state machine. 484 485 Call this every cycle when is_busy is True. 486 """ 487 if self._is_calibrating: 488 self._calibration_periodic() 489 elif self._is_homing: 490 self._homing_periodic() 491 492 def abort(self) -> None: 493 """Abort any active homing or calibration routine.""" 494 if self._is_calibrating: 495 self._calibration_end(abort=True) 496 elif self._is_homing: 497 self._homing_end(abort=True) 498 499 def set_soft_limit_margin(self, margin_pct: float) -> None: 500 """ 501 Set the soft limit safety margin and apply to the motor controller. 502 503 Args: 504 margin_pct: safety margin as a fraction (e.g. 0.05 for 5%) 505 """ 506 if not self._is_calibrated: 507 return 508 self._soft_limit_margin = margin_pct 509 full_range = self._hard_limit_max - self._hard_limit_min 510 margin = full_range * margin_pct 511 self._min_soft_limit = self._hard_limit_min + margin 512 self._max_soft_limit = self._hard_limit_max - margin 513 514 # Apply to hardware only if callback exists 515 if self._cb_set_soft_limits is not None: 516 self._cb_set_soft_limits( 517 self._min_soft_limit, self._max_soft_limit) 518 519 def update_telemetry(self, prefix: str) -> None: 520 """ 521 Publish calibration data to SmartDashboard. 522 523 Args: 524 prefix: SmartDashboard key prefix (e.g. "Turret/") 525 """ 526 sd = wpilib.SmartDashboard 527 sd.putBoolean(prefix + "isCalibrated", self._is_calibrated) 528 sd.putBoolean(prefix + "isCalibrating", self._is_calibrating) 529 sd.putBoolean(prefix + "isHoming", self._is_homing) 530 sd.putNumber( 531 prefix + "hardLimitMin", 532 self._hard_limit_min if self._hard_limit_min is not None 533 else 0.0 534 ) 535 sd.putNumber( 536 prefix + "hardLimitMax", 537 self._hard_limit_max if self._hard_limit_max is not None 538 else 0.0 539 ) 540 sd.putNumber(prefix + "positionOffset", self._position_offset) 541 sd.putNumber(prefix + "softLimitMargin", self._soft_limit_margin) 542 543 # ---- Validation ---- 544 545 def _validate_homing(self, homing_forward: bool): 546 """Validate callbacks needed for homing in the given direction.""" 547 missing = [n for n in sorted(_CORE_REQUIRED) 548 if getattr(self, f'_cb_{n}', None) is None] 549 if missing: 550 raise ValueError( 551 f"Required callbacks not set: {', '.join(missing)}" 552 ) 553 554 has_velocity = self._cb_get_velocity is not None 555 if homing_forward: 556 has_limit = self._cb_get_forward_limit_switch is not None 557 else: 558 has_limit = self._cb_get_reverse_limit_switch is not None 559 560 if not has_velocity and not has_limit: 561 direction = "forward" if homing_forward else "reverse" 562 raise ValueError( 563 f"No detection method for {direction} homing: " 564 f"provide get_velocity and/or " 565 f"get_{direction}_limit_switch" 566 ) 567 568 def _validate_calibration(self): 569 """Validate callbacks needed for full calibration.""" 570 if self._cb_get_position is None: 571 raise ValueError( 572 "get_position callback required for calibration" 573 ) 574 # Need detection in both directions 575 self._validate_homing(homing_forward=False) # phase 1 576 self._validate_homing(homing_forward=True) # phase 2 577 578 # ---- Internal methods ---- 579 580 def _publish_callback_status(self) -> None: 581 """Write True/False for each callback to NT via ntproperty.""" 582 for cb_name in _VALID_CALLBACKS: 583 is_set = getattr(self, f'_cb_{cb_name}', None) is not None 584 setattr(self, f'_nt_cb_{cb_name}', is_set) 585 586 def _homing_periodic(self) -> bool: 587 """ 588 Periodic homing logic. Drives the motor, monitors for stall or 589 limit switch, and handles timeout. 590 591 Returns: 592 True when homing is complete (success or failure), False if ongoing 593 """ 594 if not self._is_homing: 595 return True 596 597 # Check timeout first 598 if self._homing_timer.hasElapsed(self._max_homing_time): 599 self._homing_error_alert.setText( 600 f"{self._name}: homing failed - timeout" 601 ) 602 self._homing_end(abort=True) 603 return True 604 605 # Check hard limit switch in homing direction (if callback exists) 606 limit_hit = False 607 if (self._homing_forward 608 and self._cb_get_forward_limit_switch is not None): 609 limit_hit = self._cb_get_forward_limit_switch() 610 elif (not self._homing_forward 611 and self._cb_get_reverse_limit_switch is not None): 612 limit_hit = self._cb_get_reverse_limit_switch() 613 614 # Drive motor in homing direction 615 if self._homing_forward: 616 self._cb_set_motor_output(self._max_power_pct) 617 else: 618 self._cb_set_motor_output(-self._max_power_pct) 619 620 # Check for limit switch hit 621 if limit_hit: 622 home_position = self._home_position 623 self._cb_set_position(home_position) 624 if self._cb_on_limit_detected is not None: 625 direction = ( 626 "forward" if self._homing_forward else "reverse" 627 ) 628 self._cb_on_limit_detected(home_position, direction) 629 self._homing_status_alert.setText( 630 f"{self._name}: homing complete" 631 ) 632 self._homing_end(abort=False) 633 return True 634 635 # Stall detection only if velocity callback provided 636 if self._cb_get_velocity is not None: 637 velocity = abs(self._cb_get_velocity()) 638 639 if velocity < self._min_velocity: 640 if not self._stall_detected: 641 self._stall_detected = True 642 self._stall_timer.restart() 643 elif self._stall_timer.hasElapsed(0.1): 644 home_position = self._home_position 645 self._cb_set_position(home_position) 646 if self._cb_on_limit_detected is not None: 647 direction = ( 648 "forward" if self._homing_forward 649 else "reverse" 650 ) 651 self._cb_on_limit_detected( 652 home_position, direction) 653 self._homing_status_alert.setText( 654 f"{self._name}: homing complete" 655 ) 656 self._homing_end(abort=False) 657 return True 658 else: 659 self._stall_detected = False 660 self._stall_timer.stop() 661 self._stall_timer.reset() 662 663 return False 664 665 def _homing_end(self, abort: bool) -> None: 666 """ 667 Clean up after homing. Stops motor, restores saved settings, 668 updates alerts. 669 670 Args: 671 abort: True if homing was aborted or failed 672 """ 673 self._cb_stop_motor() 674 675 # Restore saved motor settings (if we saved them) 676 if (self._saved_config is not None 677 and self._cb_restore_config is not None): 678 self._cb_restore_config(self._saved_config) 679 680 # Clear homing state 681 self._is_homing = False 682 self._homing_succeeded = not abort 683 684 # Stop timers 685 self._homing_timer.stop() 686 self._stall_timer.stop() 687 688 # Update alerts 689 if abort: 690 self._homing_error_alert.setText( 691 f"{self._name}: homing aborted" 692 ) 693 self._homing_error_alert.set(True) 694 self._homing_status_alert.set(False) 695 else: 696 self._homing_error_alert.set(False) 697 # After successful homing, if calibration data exists, the 698 # encoder is now aligned so soft limits can be applied to 699 # hardware 700 if (not self._is_calibrating 701 and self._is_calibrated 702 and self._cb_set_soft_limits is not None): 703 self._cb_set_soft_limits( 704 self._min_soft_limit, self._max_soft_limit) 705 706 def _calibration_periodic(self) -> None: 707 """Manage the two-phase calibration state machine.""" 708 if not self._is_calibrating: 709 return 710 711 if self._calibration_phase == 1: 712 done = self._homing_periodic() 713 if done: 714 if self._homing_succeeded: 715 # Phase 1 complete - set encoder to 0 716 self._cb_set_position(0.0) 717 self._hard_limit_min = 0.0 718 719 if self._cal_known_range is not None: 720 self._hard_limit_max = self._cal_known_range 721 self._calibration_end(abort=False) 722 else: 723 self._cal_status_alert.setText( 724 f"{self._name}: calibration phase 2" 725 " - homing positive" 726 ) 727 self._calibration_phase = 2 728 self.homing_init( 729 self._cal_max_current, 730 self._cal_max_power_pct, 731 self._cal_max_homing_time, 732 homing_forward=True, 733 min_velocity=self._cal_min_velocity 734 ) 735 else: 736 # Homing failed (timeout) - abort calibration 737 self._calibration_end(abort=True) 738 739 elif self._calibration_phase == 2: 740 measured_position = self._cb_get_position() 741 done = self._homing_periodic() 742 if done: 743 if self._homing_succeeded: 744 self._hard_limit_max = measured_position 745 self._calibration_end(abort=False) 746 else: 747 self._calibration_end(abort=True) 748 749 def _calibration_end(self, abort: bool) -> None: 750 """ 751 Clean up after calibration. Restores motor settings and on 752 success computes and applies soft limits. 753 754 Args: 755 abort: True if calibration failed 756 """ 757 self._cb_stop_motor() 758 self._is_calibrating = False 759 self._calibration_phase = 0 760 self._is_homing = False 761 762 if not abort and self._hard_limit_max is not None: 763 self._is_calibrated = True 764 self._cal_status_alert.setText( 765 f"{self._name}: calibration complete" 766 ) 767 self.set_soft_limit_margin(self._soft_limit_margin) 768 self._save_to_nt() 769 else: 770 # Restore original config on abort 771 if (self._cal_saved_config is not None 772 and self._cb_restore_config is not None): 773 self._cb_restore_config(self._cal_saved_config) 774 self._cal_status_alert.setText( 775 f"{self._name}: calibration aborted" 776 ) 777 778 def _save_to_nt(self) -> None: 779 """Persist calibration data to NetworkTables via ntproperty.""" 780 if self._hard_limit_min is not None: 781 self._nt_hard_limit_min = self._hard_limit_min 782 if self._hard_limit_max is not None: 783 self._nt_hard_limit_max = self._hard_limit_max 784 self._nt_soft_limit_margin = self._soft_limit_margin 785 786 def _load_from_nt(self) -> None: 787 """Load persisted calibration data from ntproperty NT entries.""" 788 nt_min = self._nt_hard_limit_min 789 nt_max = self._nt_hard_limit_max 790 if not math.isnan(nt_min) and not math.isnan(nt_max): 791 self._hard_limit_min = nt_min 792 self._hard_limit_max = nt_max 793 self._soft_limit_margin = self._nt_soft_limit_margin 794 self._is_calibrated = True 795 # Compute soft limits in memory only — don't push to hardware 796 # because encoder position is unknown until homing completes 797 full_range = self._hard_limit_max - self._hard_limit_min 798 margin = full_range * self._soft_limit_margin 799 self._min_soft_limit = self._hard_limit_min + margin 800 self._max_soft_limit = self._hard_limit_max - margin
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 79 Use ``get_callbacks()`` to inspect the current callback dict (returns 80 ``None`` for any callback that has not been set). 81 82 Examples 83 -------- 84 85 SparkMax convenience (auto-generates all callbacks):: 86 87 self.calibration = PositionCalibration( 88 name="Turret", 89 fallback_min=-90, fallback_max=90, 90 motor=self.motor, encoder=self.encoder) 91 92 Pure callbacks with stall detection only:: 93 94 self.calibration = PositionCalibration( 95 name="Elevator", 96 fallback_min=0.0, fallback_max=1.5, 97 set_motor_output=lambda pct: motor.set(pct), 98 stop_motor=lambda: motor.stopMotor(), 99 set_position=lambda v: encoder.setPosition(v), 100 get_velocity=lambda: encoder.getVelocity()) 101 102 Pure callbacks with limit switches only:: 103 104 self.calibration = PositionCalibration( 105 name="Arm", 106 fallback_min=0.0, fallback_max=90.0, 107 set_motor_output=lambda pct: motor.set(pct), 108 stop_motor=lambda: motor.stopMotor(), 109 set_position=lambda v: encoder.setPosition(v), 110 get_forward_limit_switch=lambda: fwd_switch.get(), 111 get_reverse_limit_switch=lambda: rev_switch.get()) 112 113 Deferred setup (set callbacks after construction):: 114 115 cal = PositionCalibration( 116 name="Wrist", 117 fallback_min=-45, fallback_max=45) 118 # ... later, once hardware is ready ... 119 cal.set_callbacks( 120 set_motor_output=motor.set, 121 stop_motor=motor.stopMotor, 122 set_position=encoder.setPosition, 123 get_velocity=encoder.getVelocity) 124 """ 125 126 # -- Callback type declarations -- 127 _cb_get_position: Optional[Callable[[], float]] 128 _cb_get_velocity: Optional[Callable[[], float]] 129 _cb_set_position: Optional[Callable[[float], None]] 130 _cb_set_motor_output: Optional[Callable[[float], None]] 131 _cb_stop_motor: Optional[Callable[[], None]] 132 _cb_set_current_limit: Optional[Callable[[float], None]] 133 _cb_set_soft_limits: Optional[Callable[[float, float], None]] 134 _cb_disable_soft_limits: Optional[Callable[[bool, bool], None]] 135 _cb_save_config: Optional[Callable[[], Any]] 136 _cb_restore_config: Optional[Callable[[Any], None]] 137 _cb_get_forward_limit_switch: Optional[Callable[[], bool]] 138 _cb_get_reverse_limit_switch: Optional[Callable[[], bool]] 139 _cb_on_limit_detected: Optional[Callable[[float, str], None]] 140 141 def __init__( 142 self, 143 name: str, 144 fallback_min: float, 145 fallback_max: float, 146 *, 147 motor=None, 148 encoder=None, 149 **kwargs, 150 ) -> None: 151 """ 152 Create a new PositionCalibration controller. 153 154 Args: 155 name: mechanism name used for NT paths and alerts (e.g. "Turret") 156 fallback_min: reverse soft limit used before calibration data 157 exists. Once calibrated, the persisted hard limits are used 158 instead. The caller is responsible for the far end of travel 159 if only homing (not full calibration) is performed. 160 fallback_max: forward soft limit used before calibration data 161 exists. See fallback_min for details. 162 motor: (optional) rev.SparkMax for convenience callback generation 163 encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) 164 **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys) 165 """ 166 # Assign this instance to a cached subclass with ntproperty 167 # descriptors keyed to this mechanism's name. ntproperty is a 168 # class-level descriptor, so each mechanism name needs its own 169 # class to avoid key collisions. The cache ensures the 170 # ntproperty is created only once per name so defaults don't 171 # overwrite previously persisted values. 172 if name not in _calibration_classes: 173 prefix = f"/{name}/calibration" 174 attrs = { 175 "_nt_hard_limit_min": ntproperty( 176 f"{prefix}/hard_limit_min", float("nan"), 177 writeDefault=False, persistent=True), 178 "_nt_hard_limit_max": ntproperty( 179 f"{prefix}/hard_limit_max", float("nan"), 180 writeDefault=False, persistent=True), 181 "_nt_soft_limit_margin": ntproperty( 182 f"{prefix}/soft_limit_margin", 0.05, 183 writeDefault=False, persistent=True), 184 } 185 # Non-persistent booleans for callback status 186 for cb_name in sorted(_VALID_CALLBACKS): 187 attrs[f"_nt_cb_{cb_name}"] = ntproperty( 188 f"{prefix}/callbacks/{cb_name}", False, 189 writeDefault=True, persistent=False) 190 _calibration_classes[name] = type( 191 f"PositionCalibration_{name}", 192 (PositionCalibration,), 193 attrs, 194 ) 195 self.__class__ = _calibration_classes[name] 196 197 self._name = name 198 199 # Soft limits — initialized to defaults, overwritten by 200 # _load_from_nt() if persisted calibration data exists 201 self._min_soft_limit = fallback_min 202 self._max_soft_limit = fallback_max 203 204 # Calibration state 205 self._hard_limit_min = None 206 self._hard_limit_max = None 207 self._is_calibrated = False 208 self._is_calibrating = False 209 self._calibration_phase = 0 210 self._soft_limit_margin = 0.05 211 self._position_offset = 0.0 212 213 # Homing state 214 self._is_homing = False 215 self._saved_config = None 216 217 # -- Build callbacks -- 218 # Validate kwargs keys 219 unknown = set(kwargs.keys()) - _VALID_CALLBACKS 220 if unknown: 221 raise ValueError( 222 f"Unknown callback(s): {', '.join(sorted(unknown))}" 223 ) 224 225 # If motor provided, generate SparkMax defaults 226 defaults = {} 227 if motor is not None: 228 from utils.spark_max_callbacks import SparkMaxCallbacks 229 defaults = SparkMaxCallbacks(motor, encoder).as_dict() 230 231 # Merge: explicit kwargs override SparkMax defaults 232 merged = {**defaults, **kwargs} 233 234 # Store each callback as self._cb_{name} (None if not provided) 235 for cb_name in _VALID_CALLBACKS: 236 setattr(self, f'_cb_{cb_name}', merged.get(cb_name)) 237 238 # Publish callback status to NT 239 self._publish_callback_status() 240 241 # Attempt to load persisted calibration from NT 242 self._load_from_nt() 243 244 # ---- Public properties ---- 245 246 @property 247 def is_busy(self) -> bool: 248 """True when homing or calibrating (blocks motor commands).""" 249 return self._is_homing or self._is_calibrating 250 251 @property 252 def is_homing(self) -> bool: 253 """Whether a homing routine is active.""" 254 return self._is_homing 255 256 @property 257 def is_calibrating(self) -> bool: 258 """Whether a calibration routine is active.""" 259 return self._is_calibrating 260 261 @property 262 def is_calibrated(self) -> bool: 263 """Whether the full mechanical range has been discovered.""" 264 return self._is_calibrated 265 266 @property 267 def is_zeroed(self) -> bool: 268 """Whether the encoder has been aligned to a reference point.""" 269 return self._hard_limit_min is not None 270 271 @property 272 def min_limit(self): 273 """Hard limit min, or None if uncalibrated.""" 274 return self._hard_limit_min 275 276 @property 277 def max_limit(self): 278 """Hard limit max, or None if uncalibrated.""" 279 return self._hard_limit_max 280 281 @property 282 def min_soft_limit(self) -> float: 283 """Current minimum soft limit.""" 284 return self._min_soft_limit 285 286 @property 287 def max_soft_limit(self) -> float: 288 """Current maximum soft limit.""" 289 return self._max_soft_limit 290 291 @property 292 def soft_limit_margin(self) -> float: 293 """Current safety margin as a fraction of the full range.""" 294 return self._soft_limit_margin 295 296 # ---- Public methods ---- 297 298 def get_callbacks(self) -> dict: 299 """ 300 Return a dict of all callback names and their current values. 301 302 Each key is a callback name from _VALID_CALLBACKS. The value is 303 the callable if set, or None if not set. Useful for inspecting 304 which callbacks are configured:: 305 306 cbs = cal.get_callbacks() 307 for name, func in cbs.items(): 308 print(f"{name}: {'set' if func else 'not set'}") 309 310 Returns: 311 dict mapping callback name -> callable or None 312 """ 313 return { 314 name: getattr(self, f'_cb_{name}', None) 315 for name in sorted(_VALID_CALLBACKS) 316 } 317 318 def set_callbacks(self, **kwargs) -> None: 319 """ 320 Set, override, or clear one or more callbacks by name. 321 322 Pass a callable to set a callback, or None to clear it:: 323 324 # Set a callback 325 cal.set_callbacks(get_velocity=encoder.getVelocity) 326 327 # Clear callbacks that don't apply to this mechanism 328 cal.set_callbacks( 329 get_forward_limit_switch=None, 330 get_reverse_limit_switch=None) 331 332 Args: 333 **kwargs: callback name/value pairs (see _VALID_CALLBACKS). 334 Pass None to clear a callback. 335 """ 336 for key, value in kwargs.items(): 337 if key not in _VALID_CALLBACKS: 338 raise ValueError(f"Unknown callback: {key}") 339 setattr(self, f'_cb_{key}', value) 340 self._publish_callback_status() 341 342 def homing_init( 343 self, 344 max_current: float, 345 max_power_pct: float, 346 max_homing_time: float, 347 homing_forward: bool, 348 min_velocity: float = None, 349 home_position: float = 0.0 350 ) -> None: 351 """ 352 Initialize the sensorless or sensor homing routine. 353 354 Saves current motor settings (if save_config callback is set), 355 applies homing-specific configuration, and begins driving toward 356 the specified hard stop. 357 358 Args: 359 max_current: current limit in amps during homing 360 max_power_pct: motor duty cycle during homing (0.0-1.0) 361 max_homing_time: maximum time in seconds before timeout 362 homing_forward: True to home toward forward limit, False for reverse 363 min_velocity: stall detection threshold in user units/second. 364 Defaults to 5% of soft limit range over 2 seconds. 365 home_position: encoder value to set when the hard stop is 366 found (default 0.0) 367 """ 368 # Validate callbacks before starting 369 self._validate_homing(homing_forward) 370 371 if min_velocity is None: 372 full_range = self._max_soft_limit - self._min_soft_limit 373 min_velocity = full_range * 0.05 / 2.0 374 375 # Save current motor settings for restoration (if callback exists) 376 if self._cb_save_config is not None: 377 self._saved_config = self._cb_save_config() 378 else: 379 self._saved_config = None 380 381 # Apply homing current limit (if callback exists) 382 if self._cb_set_current_limit is not None: 383 self._cb_set_current_limit(max_current) 384 385 # Disable soft limit in homing direction (if callback exists) 386 if self._cb_disable_soft_limits is not None: 387 if homing_forward: 388 self._cb_disable_soft_limits(True, False) 389 else: 390 self._cb_disable_soft_limits(False, True) 391 392 # Store homing parameters 393 self._homing_forward = homing_forward 394 self._max_power_pct = max_power_pct 395 self._min_velocity = min_velocity 396 self._max_homing_time = max_homing_time 397 self._home_position = home_position 398 399 # Set homing state 400 self._is_homing = True 401 self._stall_detected = False 402 403 # Start timeout timer 404 self._homing_timer = wpilib.Timer() 405 self._homing_timer.start() 406 407 # Stall detection timer 408 self._stall_timer = wpilib.Timer() 409 410 # Alerts 411 self._homing_status_alert = wpilib.Alert( 412 f"{self._name}: homing started", 413 wpilib.Alert.AlertType.kInfo 414 ) 415 self._homing_status_alert.set(True) 416 self._homing_error_alert = wpilib.Alert( 417 f"{self._name}: homing failed", 418 wpilib.Alert.AlertType.kError 419 ) 420 self._homing_error_alert.set(False) 421 422 def calibration_init( 423 self, 424 max_current: float, 425 max_power_pct: float, 426 max_homing_time: float, 427 min_velocity: float = None, 428 known_range: float = None 429 ) -> None: 430 """ 431 Start a calibration routine that discovers the mechanical range. 432 433 Phase 1: Home negative to find the reverse hard limit (set as zero). 434 Phase 2: Home positive to find the forward hard limit (measured). 435 436 If known_range is provided, only phase 1 runs and the forward hard 437 limit is computed as known_range from the zero point. 438 439 Args: 440 max_current: current limit in amps during calibration 441 max_power_pct: motor duty cycle (0.0-1.0) 442 max_homing_time: maximum time per phase before timeout 443 min_velocity: stall detection threshold in user units/second 444 known_range: if provided, skip phase 2 and use this as the 445 full mechanical range from the zero point 446 """ 447 # Validate callbacks before starting 448 self._validate_calibration() 449 450 # Save motor settings for restoration after calibration 451 if self._cb_save_config is not None: 452 self._cal_saved_config = self._cb_save_config() 453 else: 454 self._cal_saved_config = None 455 456 # Disable both soft limits for free travel 457 if self._cb_disable_soft_limits is not None: 458 self._cb_disable_soft_limits(True, True) 459 460 # Store calibration parameters for phase transitions 461 self._cal_max_current = max_current 462 self._cal_max_power_pct = max_power_pct 463 self._cal_max_homing_time = max_homing_time 464 self._cal_min_velocity = min_velocity 465 self._cal_known_range = known_range 466 467 self._is_calibrating = True 468 self._calibration_phase = 1 469 470 # Start phase 1: home negative 471 self.homing_init( 472 max_current, max_power_pct, max_homing_time, 473 homing_forward=False, min_velocity=min_velocity 474 ) 475 476 self._cal_status_alert = wpilib.Alert( 477 f"{self._name}: calibration phase 1 - homing negative", 478 wpilib.Alert.AlertType.kInfo 479 ) 480 self._cal_status_alert.set(True) 481 482 def periodic(self) -> None: 483 """ 484 Run the active homing or calibration state machine. 485 486 Call this every cycle when is_busy is True. 487 """ 488 if self._is_calibrating: 489 self._calibration_periodic() 490 elif self._is_homing: 491 self._homing_periodic() 492 493 def abort(self) -> None: 494 """Abort any active homing or calibration routine.""" 495 if self._is_calibrating: 496 self._calibration_end(abort=True) 497 elif self._is_homing: 498 self._homing_end(abort=True) 499 500 def set_soft_limit_margin(self, margin_pct: float) -> None: 501 """ 502 Set the soft limit safety margin and apply to the motor controller. 503 504 Args: 505 margin_pct: safety margin as a fraction (e.g. 0.05 for 5%) 506 """ 507 if not self._is_calibrated: 508 return 509 self._soft_limit_margin = margin_pct 510 full_range = self._hard_limit_max - self._hard_limit_min 511 margin = full_range * margin_pct 512 self._min_soft_limit = self._hard_limit_min + margin 513 self._max_soft_limit = self._hard_limit_max - margin 514 515 # Apply to hardware only if callback exists 516 if self._cb_set_soft_limits is not None: 517 self._cb_set_soft_limits( 518 self._min_soft_limit, self._max_soft_limit) 519 520 def update_telemetry(self, prefix: str) -> None: 521 """ 522 Publish calibration data to SmartDashboard. 523 524 Args: 525 prefix: SmartDashboard key prefix (e.g. "Turret/") 526 """ 527 sd = wpilib.SmartDashboard 528 sd.putBoolean(prefix + "isCalibrated", self._is_calibrated) 529 sd.putBoolean(prefix + "isCalibrating", self._is_calibrating) 530 sd.putBoolean(prefix + "isHoming", self._is_homing) 531 sd.putNumber( 532 prefix + "hardLimitMin", 533 self._hard_limit_min if self._hard_limit_min is not None 534 else 0.0 535 ) 536 sd.putNumber( 537 prefix + "hardLimitMax", 538 self._hard_limit_max if self._hard_limit_max is not None 539 else 0.0 540 ) 541 sd.putNumber(prefix + "positionOffset", self._position_offset) 542 sd.putNumber(prefix + "softLimitMargin", self._soft_limit_margin) 543 544 # ---- Validation ---- 545 546 def _validate_homing(self, homing_forward: bool): 547 """Validate callbacks needed for homing in the given direction.""" 548 missing = [n for n in sorted(_CORE_REQUIRED) 549 if getattr(self, f'_cb_{n}', None) is None] 550 if missing: 551 raise ValueError( 552 f"Required callbacks not set: {', '.join(missing)}" 553 ) 554 555 has_velocity = self._cb_get_velocity is not None 556 if homing_forward: 557 has_limit = self._cb_get_forward_limit_switch is not None 558 else: 559 has_limit = self._cb_get_reverse_limit_switch is not None 560 561 if not has_velocity and not has_limit: 562 direction = "forward" if homing_forward else "reverse" 563 raise ValueError( 564 f"No detection method for {direction} homing: " 565 f"provide get_velocity and/or " 566 f"get_{direction}_limit_switch" 567 ) 568 569 def _validate_calibration(self): 570 """Validate callbacks needed for full calibration.""" 571 if self._cb_get_position is None: 572 raise ValueError( 573 "get_position callback required for calibration" 574 ) 575 # Need detection in both directions 576 self._validate_homing(homing_forward=False) # phase 1 577 self._validate_homing(homing_forward=True) # phase 2 578 579 # ---- Internal methods ---- 580 581 def _publish_callback_status(self) -> None: 582 """Write True/False for each callback to NT via ntproperty.""" 583 for cb_name in _VALID_CALLBACKS: 584 is_set = getattr(self, f'_cb_{cb_name}', None) is not None 585 setattr(self, f'_nt_cb_{cb_name}', is_set) 586 587 def _homing_periodic(self) -> bool: 588 """ 589 Periodic homing logic. Drives the motor, monitors for stall or 590 limit switch, and handles timeout. 591 592 Returns: 593 True when homing is complete (success or failure), False if ongoing 594 """ 595 if not self._is_homing: 596 return True 597 598 # Check timeout first 599 if self._homing_timer.hasElapsed(self._max_homing_time): 600 self._homing_error_alert.setText( 601 f"{self._name}: homing failed - timeout" 602 ) 603 self._homing_end(abort=True) 604 return True 605 606 # Check hard limit switch in homing direction (if callback exists) 607 limit_hit = False 608 if (self._homing_forward 609 and self._cb_get_forward_limit_switch is not None): 610 limit_hit = self._cb_get_forward_limit_switch() 611 elif (not self._homing_forward 612 and self._cb_get_reverse_limit_switch is not None): 613 limit_hit = self._cb_get_reverse_limit_switch() 614 615 # Drive motor in homing direction 616 if self._homing_forward: 617 self._cb_set_motor_output(self._max_power_pct) 618 else: 619 self._cb_set_motor_output(-self._max_power_pct) 620 621 # Check for limit switch hit 622 if limit_hit: 623 home_position = self._home_position 624 self._cb_set_position(home_position) 625 if self._cb_on_limit_detected is not None: 626 direction = ( 627 "forward" if self._homing_forward else "reverse" 628 ) 629 self._cb_on_limit_detected(home_position, direction) 630 self._homing_status_alert.setText( 631 f"{self._name}: homing complete" 632 ) 633 self._homing_end(abort=False) 634 return True 635 636 # Stall detection only if velocity callback provided 637 if self._cb_get_velocity is not None: 638 velocity = abs(self._cb_get_velocity()) 639 640 if velocity < self._min_velocity: 641 if not self._stall_detected: 642 self._stall_detected = True 643 self._stall_timer.restart() 644 elif self._stall_timer.hasElapsed(0.1): 645 home_position = self._home_position 646 self._cb_set_position(home_position) 647 if self._cb_on_limit_detected is not None: 648 direction = ( 649 "forward" if self._homing_forward 650 else "reverse" 651 ) 652 self._cb_on_limit_detected( 653 home_position, direction) 654 self._homing_status_alert.setText( 655 f"{self._name}: homing complete" 656 ) 657 self._homing_end(abort=False) 658 return True 659 else: 660 self._stall_detected = False 661 self._stall_timer.stop() 662 self._stall_timer.reset() 663 664 return False 665 666 def _homing_end(self, abort: bool) -> None: 667 """ 668 Clean up after homing. Stops motor, restores saved settings, 669 updates alerts. 670 671 Args: 672 abort: True if homing was aborted or failed 673 """ 674 self._cb_stop_motor() 675 676 # Restore saved motor settings (if we saved them) 677 if (self._saved_config is not None 678 and self._cb_restore_config is not None): 679 self._cb_restore_config(self._saved_config) 680 681 # Clear homing state 682 self._is_homing = False 683 self._homing_succeeded = not abort 684 685 # Stop timers 686 self._homing_timer.stop() 687 self._stall_timer.stop() 688 689 # Update alerts 690 if abort: 691 self._homing_error_alert.setText( 692 f"{self._name}: homing aborted" 693 ) 694 self._homing_error_alert.set(True) 695 self._homing_status_alert.set(False) 696 else: 697 self._homing_error_alert.set(False) 698 # After successful homing, if calibration data exists, the 699 # encoder is now aligned so soft limits can be applied to 700 # hardware 701 if (not self._is_calibrating 702 and self._is_calibrated 703 and self._cb_set_soft_limits is not None): 704 self._cb_set_soft_limits( 705 self._min_soft_limit, self._max_soft_limit) 706 707 def _calibration_periodic(self) -> None: 708 """Manage the two-phase calibration state machine.""" 709 if not self._is_calibrating: 710 return 711 712 if self._calibration_phase == 1: 713 done = self._homing_periodic() 714 if done: 715 if self._homing_succeeded: 716 # Phase 1 complete - set encoder to 0 717 self._cb_set_position(0.0) 718 self._hard_limit_min = 0.0 719 720 if self._cal_known_range is not None: 721 self._hard_limit_max = self._cal_known_range 722 self._calibration_end(abort=False) 723 else: 724 self._cal_status_alert.setText( 725 f"{self._name}: calibration phase 2" 726 " - homing positive" 727 ) 728 self._calibration_phase = 2 729 self.homing_init( 730 self._cal_max_current, 731 self._cal_max_power_pct, 732 self._cal_max_homing_time, 733 homing_forward=True, 734 min_velocity=self._cal_min_velocity 735 ) 736 else: 737 # Homing failed (timeout) - abort calibration 738 self._calibration_end(abort=True) 739 740 elif self._calibration_phase == 2: 741 measured_position = self._cb_get_position() 742 done = self._homing_periodic() 743 if done: 744 if self._homing_succeeded: 745 self._hard_limit_max = measured_position 746 self._calibration_end(abort=False) 747 else: 748 self._calibration_end(abort=True) 749 750 def _calibration_end(self, abort: bool) -> None: 751 """ 752 Clean up after calibration. Restores motor settings and on 753 success computes and applies soft limits. 754 755 Args: 756 abort: True if calibration failed 757 """ 758 self._cb_stop_motor() 759 self._is_calibrating = False 760 self._calibration_phase = 0 761 self._is_homing = False 762 763 if not abort and self._hard_limit_max is not None: 764 self._is_calibrated = True 765 self._cal_status_alert.setText( 766 f"{self._name}: calibration complete" 767 ) 768 self.set_soft_limit_margin(self._soft_limit_margin) 769 self._save_to_nt() 770 else: 771 # Restore original config on abort 772 if (self._cal_saved_config is not None 773 and self._cb_restore_config is not None): 774 self._cb_restore_config(self._cal_saved_config) 775 self._cal_status_alert.setText( 776 f"{self._name}: calibration aborted" 777 ) 778 779 def _save_to_nt(self) -> None: 780 """Persist calibration data to NetworkTables via ntproperty.""" 781 if self._hard_limit_min is not None: 782 self._nt_hard_limit_min = self._hard_limit_min 783 if self._hard_limit_max is not None: 784 self._nt_hard_limit_max = self._hard_limit_max 785 self._nt_soft_limit_margin = self._soft_limit_margin 786 787 def _load_from_nt(self) -> None: 788 """Load persisted calibration data from ntproperty NT entries.""" 789 nt_min = self._nt_hard_limit_min 790 nt_max = self._nt_hard_limit_max 791 if not math.isnan(nt_min) and not math.isnan(nt_max): 792 self._hard_limit_min = nt_min 793 self._hard_limit_max = nt_max 794 self._soft_limit_margin = self._nt_soft_limit_margin 795 self._is_calibrated = True 796 # Compute soft limits in memory only — don't push to hardware 797 # because encoder position is unknown until homing completes 798 full_range = self._hard_limit_max - self._hard_limit_min 799 margin = full_range * self._soft_limit_margin 800 self._min_soft_limit = self._hard_limit_min + margin 801 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 |
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)
141 def __init__( 142 self, 143 name: str, 144 fallback_min: float, 145 fallback_max: float, 146 *, 147 motor=None, 148 encoder=None, 149 **kwargs, 150 ) -> None: 151 """ 152 Create a new PositionCalibration controller. 153 154 Args: 155 name: mechanism name used for NT paths and alerts (e.g. "Turret") 156 fallback_min: reverse soft limit used before calibration data 157 exists. Once calibrated, the persisted hard limits are used 158 instead. The caller is responsible for the far end of travel 159 if only homing (not full calibration) is performed. 160 fallback_max: forward soft limit used before calibration data 161 exists. See fallback_min for details. 162 motor: (optional) rev.SparkMax for convenience callback generation 163 encoder: (optional) rev.RelativeEncoder (defaults to motor.getEncoder()) 164 **kwargs: callback overrides (see _VALID_CALLBACKS for valid keys) 165 """ 166 # Assign this instance to a cached subclass with ntproperty 167 # descriptors keyed to this mechanism's name. ntproperty is a 168 # class-level descriptor, so each mechanism name needs its own 169 # class to avoid key collisions. The cache ensures the 170 # ntproperty is created only once per name so defaults don't 171 # overwrite previously persisted values. 172 if name not in _calibration_classes: 173 prefix = f"/{name}/calibration" 174 attrs = { 175 "_nt_hard_limit_min": ntproperty( 176 f"{prefix}/hard_limit_min", float("nan"), 177 writeDefault=False, persistent=True), 178 "_nt_hard_limit_max": ntproperty( 179 f"{prefix}/hard_limit_max", float("nan"), 180 writeDefault=False, persistent=True), 181 "_nt_soft_limit_margin": ntproperty( 182 f"{prefix}/soft_limit_margin", 0.05, 183 writeDefault=False, persistent=True), 184 } 185 # Non-persistent booleans for callback status 186 for cb_name in sorted(_VALID_CALLBACKS): 187 attrs[f"_nt_cb_{cb_name}"] = ntproperty( 188 f"{prefix}/callbacks/{cb_name}", False, 189 writeDefault=True, persistent=False) 190 _calibration_classes[name] = type( 191 f"PositionCalibration_{name}", 192 (PositionCalibration,), 193 attrs, 194 ) 195 self.__class__ = _calibration_classes[name] 196 197 self._name = name 198 199 # Soft limits — initialized to defaults, overwritten by 200 # _load_from_nt() if persisted calibration data exists 201 self._min_soft_limit = fallback_min 202 self._max_soft_limit = fallback_max 203 204 # Calibration state 205 self._hard_limit_min = None 206 self._hard_limit_max = None 207 self._is_calibrated = False 208 self._is_calibrating = False 209 self._calibration_phase = 0 210 self._soft_limit_margin = 0.05 211 self._position_offset = 0.0 212 213 # Homing state 214 self._is_homing = False 215 self._saved_config = None 216 217 # -- Build callbacks -- 218 # Validate kwargs keys 219 unknown = set(kwargs.keys()) - _VALID_CALLBACKS 220 if unknown: 221 raise ValueError( 222 f"Unknown callback(s): {', '.join(sorted(unknown))}" 223 ) 224 225 # If motor provided, generate SparkMax defaults 226 defaults = {} 227 if motor is not None: 228 from utils.spark_max_callbacks import SparkMaxCallbacks 229 defaults = SparkMaxCallbacks(motor, encoder).as_dict() 230 231 # Merge: explicit kwargs override SparkMax defaults 232 merged = {**defaults, **kwargs} 233 234 # Store each callback as self._cb_{name} (None if not provided) 235 for cb_name in _VALID_CALLBACKS: 236 setattr(self, f'_cb_{cb_name}', merged.get(cb_name)) 237 238 # Publish callback status to NT 239 self._publish_callback_status() 240 241 # Attempt to load persisted calibration from NT 242 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)
246 @property 247 def is_busy(self) -> bool: 248 """True when homing or calibrating (blocks motor commands).""" 249 return self._is_homing or self._is_calibrating
True when homing or calibrating (blocks motor commands).
251 @property 252 def is_homing(self) -> bool: 253 """Whether a homing routine is active.""" 254 return self._is_homing
Whether a homing routine is active.
256 @property 257 def is_calibrating(self) -> bool: 258 """Whether a calibration routine is active.""" 259 return self._is_calibrating
Whether a calibration routine is active.
261 @property 262 def is_calibrated(self) -> bool: 263 """Whether the full mechanical range has been discovered.""" 264 return self._is_calibrated
Whether the full mechanical range has been discovered.
266 @property 267 def is_zeroed(self) -> bool: 268 """Whether the encoder has been aligned to a reference point.""" 269 return self._hard_limit_min is not None
Whether the encoder has been aligned to a reference point.
271 @property 272 def min_limit(self): 273 """Hard limit min, or None if uncalibrated.""" 274 return self._hard_limit_min
Hard limit min, or None if uncalibrated.
276 @property 277 def max_limit(self): 278 """Hard limit max, or None if uncalibrated.""" 279 return self._hard_limit_max
Hard limit max, or None if uncalibrated.
281 @property 282 def min_soft_limit(self) -> float: 283 """Current minimum soft limit.""" 284 return self._min_soft_limit
Current minimum soft limit.
286 @property 287 def max_soft_limit(self) -> float: 288 """Current maximum soft limit.""" 289 return self._max_soft_limit
Current maximum soft limit.
291 @property 292 def soft_limit_margin(self) -> float: 293 """Current safety margin as a fraction of the full range.""" 294 return self._soft_limit_margin
Current safety margin as a fraction of the full range.
298 def get_callbacks(self) -> dict: 299 """ 300 Return a dict of all callback names and their current values. 301 302 Each key is a callback name from _VALID_CALLBACKS. The value is 303 the callable if set, or None if not set. Useful for inspecting 304 which callbacks are configured:: 305 306 cbs = cal.get_callbacks() 307 for name, func in cbs.items(): 308 print(f"{name}: {'set' if func else 'not set'}") 309 310 Returns: 311 dict mapping callback name -> callable or None 312 """ 313 return { 314 name: getattr(self, f'_cb_{name}', None) 315 for name in sorted(_VALID_CALLBACKS) 316 }
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
318 def set_callbacks(self, **kwargs) -> None: 319 """ 320 Set, override, or clear one or more callbacks by name. 321 322 Pass a callable to set a callback, or None to clear it:: 323 324 # Set a callback 325 cal.set_callbacks(get_velocity=encoder.getVelocity) 326 327 # Clear callbacks that don't apply to this mechanism 328 cal.set_callbacks( 329 get_forward_limit_switch=None, 330 get_reverse_limit_switch=None) 331 332 Args: 333 **kwargs: callback name/value pairs (see _VALID_CALLBACKS). 334 Pass None to clear a callback. 335 """ 336 for key, value in kwargs.items(): 337 if key not in _VALID_CALLBACKS: 338 raise ValueError(f"Unknown callback: {key}") 339 setattr(self, f'_cb_{key}', value) 340 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.
342 def homing_init( 343 self, 344 max_current: float, 345 max_power_pct: float, 346 max_homing_time: float, 347 homing_forward: bool, 348 min_velocity: float = None, 349 home_position: float = 0.0 350 ) -> None: 351 """ 352 Initialize the sensorless or sensor homing routine. 353 354 Saves current motor settings (if save_config callback is set), 355 applies homing-specific configuration, and begins driving toward 356 the specified hard stop. 357 358 Args: 359 max_current: current limit in amps during homing 360 max_power_pct: motor duty cycle during homing (0.0-1.0) 361 max_homing_time: maximum time in seconds before timeout 362 homing_forward: True to home toward forward limit, False for reverse 363 min_velocity: stall detection threshold in user units/second. 364 Defaults to 5% of soft limit range over 2 seconds. 365 home_position: encoder value to set when the hard stop is 366 found (default 0.0) 367 """ 368 # Validate callbacks before starting 369 self._validate_homing(homing_forward) 370 371 if min_velocity is None: 372 full_range = self._max_soft_limit - self._min_soft_limit 373 min_velocity = full_range * 0.05 / 2.0 374 375 # Save current motor settings for restoration (if callback exists) 376 if self._cb_save_config is not None: 377 self._saved_config = self._cb_save_config() 378 else: 379 self._saved_config = None 380 381 # Apply homing current limit (if callback exists) 382 if self._cb_set_current_limit is not None: 383 self._cb_set_current_limit(max_current) 384 385 # Disable soft limit in homing direction (if callback exists) 386 if self._cb_disable_soft_limits is not None: 387 if homing_forward: 388 self._cb_disable_soft_limits(True, False) 389 else: 390 self._cb_disable_soft_limits(False, True) 391 392 # Store homing parameters 393 self._homing_forward = homing_forward 394 self._max_power_pct = max_power_pct 395 self._min_velocity = min_velocity 396 self._max_homing_time = max_homing_time 397 self._home_position = home_position 398 399 # Set homing state 400 self._is_homing = True 401 self._stall_detected = False 402 403 # Start timeout timer 404 self._homing_timer = wpilib.Timer() 405 self._homing_timer.start() 406 407 # Stall detection timer 408 self._stall_timer = wpilib.Timer() 409 410 # Alerts 411 self._homing_status_alert = wpilib.Alert( 412 f"{self._name}: homing started", 413 wpilib.Alert.AlertType.kInfo 414 ) 415 self._homing_status_alert.set(True) 416 self._homing_error_alert = wpilib.Alert( 417 f"{self._name}: homing failed", 418 wpilib.Alert.AlertType.kError 419 ) 420 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)
422 def calibration_init( 423 self, 424 max_current: float, 425 max_power_pct: float, 426 max_homing_time: float, 427 min_velocity: float = None, 428 known_range: float = None 429 ) -> None: 430 """ 431 Start a calibration routine that discovers the mechanical range. 432 433 Phase 1: Home negative to find the reverse hard limit (set as zero). 434 Phase 2: Home positive to find the forward hard limit (measured). 435 436 If known_range is provided, only phase 1 runs and the forward hard 437 limit is computed as known_range from the zero point. 438 439 Args: 440 max_current: current limit in amps during calibration 441 max_power_pct: motor duty cycle (0.0-1.0) 442 max_homing_time: maximum time per phase before timeout 443 min_velocity: stall detection threshold in user units/second 444 known_range: if provided, skip phase 2 and use this as the 445 full mechanical range from the zero point 446 """ 447 # Validate callbacks before starting 448 self._validate_calibration() 449 450 # Save motor settings for restoration after calibration 451 if self._cb_save_config is not None: 452 self._cal_saved_config = self._cb_save_config() 453 else: 454 self._cal_saved_config = None 455 456 # Disable both soft limits for free travel 457 if self._cb_disable_soft_limits is not None: 458 self._cb_disable_soft_limits(True, True) 459 460 # Store calibration parameters for phase transitions 461 self._cal_max_current = max_current 462 self._cal_max_power_pct = max_power_pct 463 self._cal_max_homing_time = max_homing_time 464 self._cal_min_velocity = min_velocity 465 self._cal_known_range = known_range 466 467 self._is_calibrating = True 468 self._calibration_phase = 1 469 470 # Start phase 1: home negative 471 self.homing_init( 472 max_current, max_power_pct, max_homing_time, 473 homing_forward=False, min_velocity=min_velocity 474 ) 475 476 self._cal_status_alert = wpilib.Alert( 477 f"{self._name}: calibration phase 1 - homing negative", 478 wpilib.Alert.AlertType.kInfo 479 ) 480 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
482 def periodic(self) -> None: 483 """ 484 Run the active homing or calibration state machine. 485 486 Call this every cycle when is_busy is True. 487 """ 488 if self._is_calibrating: 489 self._calibration_periodic() 490 elif self._is_homing: 491 self._homing_periodic()
Run the active homing or calibration state machine.
Call this every cycle when is_busy is True.
493 def abort(self) -> None: 494 """Abort any active homing or calibration routine.""" 495 if self._is_calibrating: 496 self._calibration_end(abort=True) 497 elif self._is_homing: 498 self._homing_end(abort=True)
Abort any active homing or calibration routine.
500 def set_soft_limit_margin(self, margin_pct: float) -> None: 501 """ 502 Set the soft limit safety margin and apply to the motor controller. 503 504 Args: 505 margin_pct: safety margin as a fraction (e.g. 0.05 for 5%) 506 """ 507 if not self._is_calibrated: 508 return 509 self._soft_limit_margin = margin_pct 510 full_range = self._hard_limit_max - self._hard_limit_min 511 margin = full_range * margin_pct 512 self._min_soft_limit = self._hard_limit_min + margin 513 self._max_soft_limit = self._hard_limit_max - margin 514 515 # Apply to hardware only if callback exists 516 if self._cb_set_soft_limits is not None: 517 self._cb_set_soft_limits( 518 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%)
520 def update_telemetry(self, prefix: str) -> None: 521 """ 522 Publish calibration data to SmartDashboard. 523 524 Args: 525 prefix: SmartDashboard key prefix (e.g. "Turret/") 526 """ 527 sd = wpilib.SmartDashboard 528 sd.putBoolean(prefix + "isCalibrated", self._is_calibrated) 529 sd.putBoolean(prefix + "isCalibrating", self._is_calibrating) 530 sd.putBoolean(prefix + "isHoming", self._is_homing) 531 sd.putNumber( 532 prefix + "hardLimitMin", 533 self._hard_limit_min if self._hard_limit_min is not None 534 else 0.0 535 ) 536 sd.putNumber( 537 prefix + "hardLimitMax", 538 self._hard_limit_max if self._hard_limit_max is not None 539 else 0.0 540 ) 541 sd.putNumber(prefix + "positionOffset", self._position_offset) 542 sd.putNumber(prefix + "softLimitMargin", self._soft_limit_margin)
Publish calibration data to SmartDashboard.
Args: prefix: SmartDashboard key prefix (e.g. "Turret/")