Skip to content

Commit a0c3926

Browse files
Add sysidroutine (#43)
Co-authored-by: David Vo <[email protected]>
1 parent a088f5d commit a0c3926

File tree

3 files changed

+352
-0
lines changed

3 files changed

+352
-0
lines changed

commands2/sysid/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .sysidroutine import SysIdRoutine
2+
3+
4+
__all__ = ["SysIdRoutine"]

commands2/sysid/sysidroutine.py

+180
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
from dataclasses import dataclass
2+
from enum import Enum
3+
4+
from wpilib.sysid import SysIdRoutineLog, State
5+
from ..command import Command
6+
from ..subsystem import Subsystem
7+
from wpilib import Timer
8+
9+
from wpimath.units import seconds, volts
10+
11+
from typing import Callable, Optional
12+
13+
14+
volts_per_second = float
15+
16+
17+
class SysIdRoutine(SysIdRoutineLog):
18+
"""A SysId characterization routine for a single mechanism. Mechanisms may have multiple motors.
19+
20+
A single subsystem may have multiple mechanisms, but mechanisms should not share test
21+
routines. Each complete test of a mechanism should have its own SysIdRoutine instance, since the
22+
log name of the recorded data is determined by the mechanism name.
23+
24+
The test state (e.g. "quasistatic-forward") is logged once per iteration during test
25+
execution, and once with state "none" when a test ends. Motor frames are logged every iteration
26+
during test execution.
27+
28+
Timestamps are not coordinated across data, so motor frames and test state tags may be
29+
recorded on different log frames. Because frame alignment is not guaranteed, SysId parses the log
30+
by using the test state flag to determine the timestamp range for each section of the test, and
31+
then extracts the motor frames within the valid timestamp ranges. If a given test was run
32+
multiple times in a single logfile, the user will need to select which of the tests to use for
33+
the fit in the analysis tool.
34+
"""
35+
36+
@dataclass
37+
class Config:
38+
"""Hardware-independent configuration for a SysId test routine.
39+
40+
:param rampRate: The voltage ramp rate used for quasistatic test routines. Defaults to 1 volt
41+
per second if left null.
42+
:param stepVoltage: The step voltage output used for dynamic test routines. Defaults to 7
43+
volts if left null.
44+
:param timeout: Safety timeout for the test routine commands. Defaults to 10 seconds if left
45+
null.
46+
:param recordState: Optional handle for recording test state in a third-party logging
47+
solution. If provided, the test routine state will be passed to this callback instead of
48+
logged in WPILog.
49+
"""
50+
51+
rampRate: volts_per_second = 1.0
52+
stepVoltage: volts = 7.0
53+
timeout: seconds = 10.0
54+
recordState: Optional[Callable[[State], None]] = None
55+
56+
@dataclass
57+
class Mechanism:
58+
"""A mechanism to be characterized by a SysId routine.
59+
60+
Defines callbacks needed for the SysId test routine to control
61+
and record data from the mechanism.
62+
63+
:param drive: Sends the SysId-specified drive signal to the mechanism motors during test
64+
routines.
65+
:param log: Returns measured data of the mechanism motors during test routines. To return
66+
data, call `motor(string motorName)` on the supplied `SysIdRoutineLog` instance, and then
67+
call one or more of the chainable logging handles (e.g. `voltage`) on the returned
68+
`MotorLog`. Multiple motors can be logged in a single callback by calling `motor`
69+
multiple times.
70+
:param subsystem: The subsystem containing the motor(s) that is (or are) being characterized.
71+
Will be declared as a requirement for the returned test commands.
72+
:param name: The name of the mechanism being tested. Will be appended to the log entry title
73+
for the routine's test state, e.g. "sysid-test-state-mechanism". Defaults to the name of
74+
the subsystem if left null.
75+
"""
76+
77+
drive: Callable[[volts], None]
78+
log: Callable[[SysIdRoutineLog], None]
79+
subsystem: Subsystem
80+
name: Optional[str] = None
81+
82+
def __post_init__(self):
83+
if self.name == None:
84+
self.name = self.subsystem.getName()
85+
86+
class Direction(Enum):
87+
"""Motor direction for a SysId test."""
88+
89+
kForward = 1
90+
kReverse = -1
91+
92+
def __init__(self, config: Config, mechanism: Mechanism):
93+
"""Create a new SysId characterization routine.
94+
95+
:param config: Hardware-independent parameters for the SysId routine.
96+
:param mechanism: Hardware interface for the SysId routine.
97+
"""
98+
super().__init__(mechanism.subsystem.getName())
99+
self.config = config
100+
self.mechanism = mechanism
101+
self.outputVolts = 0.0
102+
self.logState = config.recordState or self.recordState
103+
104+
def quasistatic(self, direction: Direction) -> Command:
105+
"""Returns a command to run a quasistatic test in the specified direction.
106+
107+
The command will call the `drive` and `log` callbacks supplied at routine construction once
108+
per iteration. Upon command end or interruption, the `drive` callback is called with a value of
109+
0 volts.
110+
111+
:param direction: The direction in which to run the test.
112+
113+
:returns: A command to run the test.
114+
"""
115+
116+
timer = Timer()
117+
if direction == self.Direction.kForward:
118+
state = State.kQuasistaticForward
119+
else:
120+
state = State.kQuasistaticReverse
121+
122+
def execute():
123+
self.outputVolts = direction.value * timer.get() * self.config.rampRate
124+
self.mechanism.drive(self.outputVolts)
125+
self.mechanism.log(self)
126+
self.logState(state)
127+
128+
def end(interrupted: bool):
129+
self.mechanism.drive(0.0)
130+
self.logState(State.kNone)
131+
timer.stop()
132+
133+
return (
134+
self.mechanism.subsystem.runOnce(timer.start)
135+
.andThen(self.mechanism.subsystem.run(execute))
136+
.finallyDo(end)
137+
.withName(
138+
f"sysid-{SysIdRoutineLog.stateEnumToString(state)}-{self.mechanism.name}"
139+
)
140+
.withTimeout(self.config.timeout)
141+
)
142+
143+
def dynamic(self, direction: Direction) -> Command:
144+
"""Returns a command to run a dynamic test in the specified direction.
145+
146+
The command will call the `drive` and `log` callbacks supplied at routine construction once
147+
per iteration. Upon command end or interruption, the `drive` callback is called with a value of
148+
0 volts.
149+
150+
:param direction: The direction in which to run the test.
151+
152+
:returns: A command to run the test.
153+
"""
154+
155+
if direction == self.Direction.kForward:
156+
state = State.kDynamicForward
157+
else:
158+
state = State.kDynamicReverse
159+
160+
def command():
161+
self.outputVolts = direction.value * self.config.stepVoltage
162+
163+
def execute():
164+
self.mechanism.drive(self.outputVolts)
165+
self.mechanism.log(self)
166+
self.logState(state)
167+
168+
def end(interrupted: bool):
169+
self.mechanism.drive(0.0)
170+
self.logState(State.kNone)
171+
172+
return (
173+
self.mechanism.subsystem.runOnce(command)
174+
.andThen(self.mechanism.subsystem.run(execute))
175+
.finallyDo(end)
176+
.withName(
177+
f"sysid-{SysIdRoutineLog.stateEnumToString(state)}-{self.mechanism.name}"
178+
)
179+
.withTimeout(self.config.timeout)
180+
)

tests/test_sysidroutine.py

+168
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import pytest
2+
from unittest.mock import Mock, call, ANY
3+
from wpilib.simulation import stepTiming, pauseTiming, resumeTiming
4+
from wpimath.units import volts
5+
from commands2 import Command, Subsystem
6+
from commands2.sysid import SysIdRoutine
7+
from wpilib.sysid import SysIdRoutineLog, State
8+
9+
10+
class Mechanism(Subsystem):
11+
def recordState(self, state: State):
12+
pass
13+
14+
def drive(self, voltage: volts):
15+
pass
16+
17+
def log(self, log: SysIdRoutineLog):
18+
pass
19+
20+
21+
@pytest.fixture
22+
def mechanism():
23+
return Mock(spec=Mechanism)
24+
25+
26+
@pytest.fixture
27+
def sysid_routine(mechanism):
28+
return SysIdRoutine(
29+
SysIdRoutine.Config(recordState=mechanism.recordState),
30+
SysIdRoutine.Mechanism(mechanism.drive, mechanism.log, Subsystem()),
31+
)
32+
33+
34+
@pytest.fixture
35+
def quasistatic_forward(sysid_routine):
36+
return sysid_routine.quasistatic(SysIdRoutine.Direction.kForward)
37+
38+
39+
@pytest.fixture
40+
def quasistatic_reverse(sysid_routine):
41+
return sysid_routine.quasistatic(SysIdRoutine.Direction.kReverse)
42+
43+
44+
@pytest.fixture
45+
def dynamic_forward(sysid_routine):
46+
return sysid_routine.dynamic(SysIdRoutine.Direction.kForward)
47+
48+
49+
@pytest.fixture
50+
def dynamic_reverse(sysid_routine):
51+
return sysid_routine.dynamic(SysIdRoutine.Direction.kReverse)
52+
53+
54+
@pytest.fixture(autouse=True)
55+
def timing():
56+
pauseTiming()
57+
yield
58+
resumeTiming()
59+
60+
61+
def run_command(command: Command):
62+
command.initialize()
63+
command.execute()
64+
stepTiming(1)
65+
command.execute()
66+
command.end(True)
67+
68+
69+
def test_record_state_bookends_motor_logging(
70+
mechanism, quasistatic_forward, dynamic_forward
71+
):
72+
run_command(quasistatic_forward)
73+
74+
mechanism.assert_has_calls(
75+
[
76+
call.drive(ANY),
77+
call.log(ANY),
78+
call.recordState(State.kQuasistaticForward),
79+
call.drive(ANY),
80+
call.recordState(State.kNone),
81+
],
82+
any_order=False,
83+
)
84+
85+
mechanism.reset_mock()
86+
run_command(dynamic_forward)
87+
88+
mechanism.assert_has_calls(
89+
[
90+
call.drive(ANY),
91+
call.log(ANY),
92+
call.recordState(State.kDynamicForward),
93+
call.drive(ANY),
94+
call.recordState(State.kNone),
95+
],
96+
any_order=False,
97+
)
98+
99+
100+
def test_tests_declare_correct_state(
101+
mechanism,
102+
quasistatic_forward,
103+
quasistatic_reverse,
104+
dynamic_forward,
105+
dynamic_reverse,
106+
):
107+
run_command(quasistatic_forward)
108+
mechanism.recordState.assert_any_call(State.kQuasistaticForward)
109+
110+
run_command(quasistatic_reverse)
111+
mechanism.recordState.assert_any_call(State.kQuasistaticReverse)
112+
113+
run_command(dynamic_forward)
114+
mechanism.recordState.assert_any_call(State.kDynamicForward)
115+
116+
run_command(dynamic_reverse)
117+
mechanism.recordState.assert_any_call(State.kDynamicReverse)
118+
119+
120+
def test_tests_output_correct_voltage(
121+
mechanism,
122+
quasistatic_forward,
123+
quasistatic_reverse,
124+
dynamic_forward,
125+
dynamic_reverse,
126+
):
127+
run_command(quasistatic_forward)
128+
129+
mechanism.drive.assert_has_calls(
130+
[
131+
call(pytest.approx(1.0)),
132+
call(pytest.approx(0.0)),
133+
],
134+
any_order=False,
135+
)
136+
137+
mechanism.reset_mock()
138+
run_command(quasistatic_reverse)
139+
140+
mechanism.drive.assert_has_calls(
141+
[
142+
call(pytest.approx(-1.0)),
143+
call(pytest.approx(0.0)),
144+
],
145+
any_order=False,
146+
)
147+
148+
mechanism.reset_mock()
149+
run_command(dynamic_forward)
150+
151+
mechanism.drive.assert_has_calls(
152+
[
153+
call(pytest.approx(7.0)),
154+
call(pytest.approx(0.0)),
155+
],
156+
any_order=False,
157+
)
158+
159+
mechanism.reset_mock()
160+
run_command(dynamic_reverse)
161+
162+
mechanism.drive.assert_has_calls(
163+
[
164+
call(pytest.approx(-7.0)),
165+
call(pytest.approx(0.0)),
166+
],
167+
any_order=False,
168+
)

0 commit comments

Comments
 (0)