utils.loop_timing
Continuous loop-timing instrumentation.
Measures how much of the timed robot periodic budget the robot code consumes, broken down by channel (e.g. userCode vs scheduler). Stats are published to SmartDashboard every cycle so they are always visible — not only on overrun like the built-in CommandScheduler Watchdog.
Timing uses FPGA timestamps (microsecond resolution on real hardware). In simulation the values reflect simulated time, not wall-clock.
1""" 2Continuous loop-timing instrumentation. 3 4Measures how much of the timed robot periodic budget the robot code consumes, 5broken down by channel (e.g. userCode vs scheduler). Stats are published 6to SmartDashboard every cycle so they are always visible — not only on 7overrun like the built-in CommandScheduler Watchdog. 8 9Timing uses FPGA timestamps (microsecond resolution on real hardware). 10In simulation the values reflect simulated time, not wall-clock. 11""" 12 13import math 14import wpilib 15 16 17class LoopTimingStats: 18 """Running statistics accumulator (Welford's online algorithm). 19 20 All public values are in **milliseconds**. Input to :meth:`record` 21 is in seconds (matching ``Timer.getFPGATimestamp()`` units). 22 """ 23 24 def __init__(self): 25 self.reset() 26 27 def reset(self): 28 self._count: int = 0 29 self._min: float = float("inf") 30 self._max: float = float("-inf") 31 self._last: float = 0.0 32 # Welford accumulators 33 self._mean: float = 0.0 34 self._m2: float = 0.0 35 36 def record(self, duration_sec: float): 37 """Record one sample. *duration_sec* is converted to ms internally.""" 38 ms = duration_sec * 1000.0 39 self._last = ms 40 self._count += 1 41 42 if ms < self._min: 43 self._min = ms 44 if ms > self._max: 45 self._max = ms 46 47 # Welford's online update 48 delta = ms - self._mean 49 self._mean += delta / self._count 50 delta2 = ms - self._mean 51 self._m2 += delta * delta2 52 53 @property 54 def count(self) -> int: 55 return self._count 56 57 @property 58 def min_ms(self) -> float: 59 return self._min if self._count > 0 else 0.0 60 61 @property 62 def max_ms(self) -> float: 63 return self._max if self._count > 0 else 0.0 64 65 @property 66 def avg_ms(self) -> float: 67 return self._mean if self._count > 0 else 0.0 68 69 @property 70 def stddev_ms(self) -> float: 71 if self._count < 2: 72 return 0.0 73 return math.sqrt(self._m2 / (self._count - 1)) 74 75 @property 76 def last_ms(self) -> float: 77 return self._last 78 79 80class LoopTimer: 81 """Coordinator that manages named timing channels and publishes stats. 82 83 Typical usage:: 84 85 timer = LoopTimer(budget_sec=0.020) 86 timer.add_channel("userCode") 87 timer.add_channel("scheduler") 88 89 # each cycle: 90 timer.start("userCode") 91 ... 92 timer.stop("userCode") 93 timer.publish() 94 """ 95 96 def __init__(self, budget_sec: float): 97 self._budget_ms = budget_sec * 1000.0 98 self._channels: dict[str, LoopTimingStats] = {} 99 self._starts: dict[str, float] = {} 100 self._alert_info = wpilib.Alert( 101 "Frame Timing", wpilib.Alert.AlertType.kInfo 102 ) 103 self._alert_warning = wpilib.Alert( 104 "Frame Timing", wpilib.Alert.AlertType.kWarning 105 ) 106 self._alert_error = wpilib.Alert( 107 "Frame Timing", wpilib.Alert.AlertType.kError 108 ) 109 110 def add_channel(self, name: str): 111 self._channels[name] = LoopTimingStats() 112 113 def start(self, name: str): 114 self._starts[name] = wpilib.Timer.getFPGATimestamp() 115 116 def stop(self, name: str): 117 end = wpilib.Timer.getFPGATimestamp() 118 begin = self._starts.pop(name, end) 119 self._channels[name].record(end - begin) 120 121 def reset_all(self): 122 for stats in self._channels.values(): 123 stats.reset() 124 self._starts.clear() 125 126 def publish(self): 127 total_ms = 0.0 128 for name, stats in self._channels.items(): 129 prefix = f"FrameTiming/{name}" 130 wpilib.SmartDashboard.putNumber(f"{prefix}/lastMs", stats.last_ms) 131 wpilib.SmartDashboard.putNumber(f"{prefix}/minMs", stats.min_ms) 132 wpilib.SmartDashboard.putNumber(f"{prefix}/maxMs", stats.max_ms) 133 wpilib.SmartDashboard.putNumber(f"{prefix}/avgMs", stats.avg_ms) 134 wpilib.SmartDashboard.putNumber(f"{prefix}/stddevMs", stats.stddev_ms) 135 wpilib.SmartDashboard.putNumber(f"{prefix}/count", stats.count) 136 if self._budget_ms > 0: 137 wpilib.SmartDashboard.putNumber( 138 f"{prefix}/budgetPct", 139 (stats.last_ms / self._budget_ms) * 100.0, 140 ) 141 total_ms += stats.last_ms 142 143 wpilib.SmartDashboard.putNumber("FrameTiming/totalMs", total_ms) 144 total_pct = 0.0 145 if self._budget_ms > 0: 146 total_pct = (total_ms / self._budget_ms) * 100.0 147 wpilib.SmartDashboard.putNumber( 148 "FrameTiming/totalBudgetPct", total_pct, 149 ) 150 151 # Alert based on budget usage 152 overrun = total_pct >= 100.0 153 warning = total_pct >= 80.0 and not overrun 154 info = not warning and not overrun 155 156 self._alert_error.set(overrun) 157 self._alert_warning.set(warning) 158 self._alert_info.set(info) 159 160 if overrun: 161 self._alert_error.setText( 162 f"Frame Timing: OVERRUN {total_pct:.0f}% ({total_ms:.1f}ms)" 163 ) 164 elif warning: 165 self._alert_warning.setText( 166 f"Frame Timing: {total_pct:.0f}% - possible overrun" 167 ) 168 else: 169 self._alert_info.setText( 170 f"Frame Timing: {total_pct:.0f}%" 171 )
18class LoopTimingStats: 19 """Running statistics accumulator (Welford's online algorithm). 20 21 All public values are in **milliseconds**. Input to :meth:`record` 22 is in seconds (matching ``Timer.getFPGATimestamp()`` units). 23 """ 24 25 def __init__(self): 26 self.reset() 27 28 def reset(self): 29 self._count: int = 0 30 self._min: float = float("inf") 31 self._max: float = float("-inf") 32 self._last: float = 0.0 33 # Welford accumulators 34 self._mean: float = 0.0 35 self._m2: float = 0.0 36 37 def record(self, duration_sec: float): 38 """Record one sample. *duration_sec* is converted to ms internally.""" 39 ms = duration_sec * 1000.0 40 self._last = ms 41 self._count += 1 42 43 if ms < self._min: 44 self._min = ms 45 if ms > self._max: 46 self._max = ms 47 48 # Welford's online update 49 delta = ms - self._mean 50 self._mean += delta / self._count 51 delta2 = ms - self._mean 52 self._m2 += delta * delta2 53 54 @property 55 def count(self) -> int: 56 return self._count 57 58 @property 59 def min_ms(self) -> float: 60 return self._min if self._count > 0 else 0.0 61 62 @property 63 def max_ms(self) -> float: 64 return self._max if self._count > 0 else 0.0 65 66 @property 67 def avg_ms(self) -> float: 68 return self._mean if self._count > 0 else 0.0 69 70 @property 71 def stddev_ms(self) -> float: 72 if self._count < 2: 73 return 0.0 74 return math.sqrt(self._m2 / (self._count - 1)) 75 76 @property 77 def last_ms(self) -> float: 78 return self._last
Running statistics accumulator (Welford's online algorithm).
All public values are in milliseconds. Input to record()
is in seconds (matching Timer.getFPGATimestamp() units).
37 def record(self, duration_sec: float): 38 """Record one sample. *duration_sec* is converted to ms internally.""" 39 ms = duration_sec * 1000.0 40 self._last = ms 41 self._count += 1 42 43 if ms < self._min: 44 self._min = ms 45 if ms > self._max: 46 self._max = ms 47 48 # Welford's online update 49 delta = ms - self._mean 50 self._mean += delta / self._count 51 delta2 = ms - self._mean 52 self._m2 += delta * delta2
Record one sample. duration_sec is converted to ms internally.
81class LoopTimer: 82 """Coordinator that manages named timing channels and publishes stats. 83 84 Typical usage:: 85 86 timer = LoopTimer(budget_sec=0.020) 87 timer.add_channel("userCode") 88 timer.add_channel("scheduler") 89 90 # each cycle: 91 timer.start("userCode") 92 ... 93 timer.stop("userCode") 94 timer.publish() 95 """ 96 97 def __init__(self, budget_sec: float): 98 self._budget_ms = budget_sec * 1000.0 99 self._channels: dict[str, LoopTimingStats] = {} 100 self._starts: dict[str, float] = {} 101 self._alert_info = wpilib.Alert( 102 "Frame Timing", wpilib.Alert.AlertType.kInfo 103 ) 104 self._alert_warning = wpilib.Alert( 105 "Frame Timing", wpilib.Alert.AlertType.kWarning 106 ) 107 self._alert_error = wpilib.Alert( 108 "Frame Timing", wpilib.Alert.AlertType.kError 109 ) 110 111 def add_channel(self, name: str): 112 self._channels[name] = LoopTimingStats() 113 114 def start(self, name: str): 115 self._starts[name] = wpilib.Timer.getFPGATimestamp() 116 117 def stop(self, name: str): 118 end = wpilib.Timer.getFPGATimestamp() 119 begin = self._starts.pop(name, end) 120 self._channels[name].record(end - begin) 121 122 def reset_all(self): 123 for stats in self._channels.values(): 124 stats.reset() 125 self._starts.clear() 126 127 def publish(self): 128 total_ms = 0.0 129 for name, stats in self._channels.items(): 130 prefix = f"FrameTiming/{name}" 131 wpilib.SmartDashboard.putNumber(f"{prefix}/lastMs", stats.last_ms) 132 wpilib.SmartDashboard.putNumber(f"{prefix}/minMs", stats.min_ms) 133 wpilib.SmartDashboard.putNumber(f"{prefix}/maxMs", stats.max_ms) 134 wpilib.SmartDashboard.putNumber(f"{prefix}/avgMs", stats.avg_ms) 135 wpilib.SmartDashboard.putNumber(f"{prefix}/stddevMs", stats.stddev_ms) 136 wpilib.SmartDashboard.putNumber(f"{prefix}/count", stats.count) 137 if self._budget_ms > 0: 138 wpilib.SmartDashboard.putNumber( 139 f"{prefix}/budgetPct", 140 (stats.last_ms / self._budget_ms) * 100.0, 141 ) 142 total_ms += stats.last_ms 143 144 wpilib.SmartDashboard.putNumber("FrameTiming/totalMs", total_ms) 145 total_pct = 0.0 146 if self._budget_ms > 0: 147 total_pct = (total_ms / self._budget_ms) * 100.0 148 wpilib.SmartDashboard.putNumber( 149 "FrameTiming/totalBudgetPct", total_pct, 150 ) 151 152 # Alert based on budget usage 153 overrun = total_pct >= 100.0 154 warning = total_pct >= 80.0 and not overrun 155 info = not warning and not overrun 156 157 self._alert_error.set(overrun) 158 self._alert_warning.set(warning) 159 self._alert_info.set(info) 160 161 if overrun: 162 self._alert_error.setText( 163 f"Frame Timing: OVERRUN {total_pct:.0f}% ({total_ms:.1f}ms)" 164 ) 165 elif warning: 166 self._alert_warning.setText( 167 f"Frame Timing: {total_pct:.0f}% - possible overrun" 168 ) 169 else: 170 self._alert_info.setText( 171 f"Frame Timing: {total_pct:.0f}%" 172 )
Coordinator that manages named timing channels and publishes stats.
Typical usage::
timer = LoopTimer(budget_sec=0.020)
timer.add_channel("userCode")
timer.add_channel("scheduler")
# each cycle:
timer.start("userCode")
...
timer.stop("userCode")
timer.publish()
97 def __init__(self, budget_sec: float): 98 self._budget_ms = budget_sec * 1000.0 99 self._channels: dict[str, LoopTimingStats] = {} 100 self._starts: dict[str, float] = {} 101 self._alert_info = wpilib.Alert( 102 "Frame Timing", wpilib.Alert.AlertType.kInfo 103 ) 104 self._alert_warning = wpilib.Alert( 105 "Frame Timing", wpilib.Alert.AlertType.kWarning 106 ) 107 self._alert_error = wpilib.Alert( 108 "Frame Timing", wpilib.Alert.AlertType.kError 109 )
127 def publish(self): 128 total_ms = 0.0 129 for name, stats in self._channels.items(): 130 prefix = f"FrameTiming/{name}" 131 wpilib.SmartDashboard.putNumber(f"{prefix}/lastMs", stats.last_ms) 132 wpilib.SmartDashboard.putNumber(f"{prefix}/minMs", stats.min_ms) 133 wpilib.SmartDashboard.putNumber(f"{prefix}/maxMs", stats.max_ms) 134 wpilib.SmartDashboard.putNumber(f"{prefix}/avgMs", stats.avg_ms) 135 wpilib.SmartDashboard.putNumber(f"{prefix}/stddevMs", stats.stddev_ms) 136 wpilib.SmartDashboard.putNumber(f"{prefix}/count", stats.count) 137 if self._budget_ms > 0: 138 wpilib.SmartDashboard.putNumber( 139 f"{prefix}/budgetPct", 140 (stats.last_ms / self._budget_ms) * 100.0, 141 ) 142 total_ms += stats.last_ms 143 144 wpilib.SmartDashboard.putNumber("FrameTiming/totalMs", total_ms) 145 total_pct = 0.0 146 if self._budget_ms > 0: 147 total_pct = (total_ms / self._budget_ms) * 100.0 148 wpilib.SmartDashboard.putNumber( 149 "FrameTiming/totalBudgetPct", total_pct, 150 ) 151 152 # Alert based on budget usage 153 overrun = total_pct >= 100.0 154 warning = total_pct >= 80.0 and not overrun 155 info = not warning and not overrun 156 157 self._alert_error.set(overrun) 158 self._alert_warning.set(warning) 159 self._alert_info.set(info) 160 161 if overrun: 162 self._alert_error.setText( 163 f"Frame Timing: OVERRUN {total_pct:.0f}% ({total_ms:.1f}ms)" 164 ) 165 elif warning: 166 self._alert_warning.setText( 167 f"Frame Timing: {total_pct:.0f}% - possible overrun" 168 ) 169 else: 170 self._alert_info.setText( 171 f"Frame Timing: {total_pct:.0f}%" 172 )