Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sysidroutine #43

Merged
merged 36 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
aecddd5
dump chatgpt generated java port
LucienMorey Jan 11, 2024
f30ebc9
fix command import
LucienMorey Jan 11, 2024
3abad09
port config class
LucienMorey Jan 11, 2024
6610f9d
port direction enum
LucienMorey Jan 11, 2024
afce167
port mechanism class
LucienMorey Jan 11, 2024
da8d6d0
drop m_ prefix
LucienMorey Jan 11, 2024
51b9ad3
port quasistatic command generator
LucienMorey Jan 11, 2024
85d9813
port dynamic command generator
LucienMorey Jan 11, 2024
bb9a1c2
remove unused imports
LucienMorey Jan 11, 2024
1ce6f18
Apply suggestions from code review
LucienMorey Jan 12, 2024
c8175c1
add ported doc strings
LucienMorey Jan 13, 2024
07d16b5
formatting
LucienMorey Jan 13, 2024
6b63c83
remove unnecessary method
LucienMorey Jan 13, 2024
a83895e
add missing type hint
LucienMorey Jan 13, 2024
58335a1
remove default value initialisers
LucienMorey Jan 13, 2024
0eee4c9
add default name value to mechanism subclass
LucienMorey Jan 13, 2024
61ab8b1
add sysid to packaged classes
LucienMorey Jan 13, 2024
fea5c55
fix state import
LucienMorey Jan 13, 2024
8712dbd
fix intended callable
LucienMorey Jan 13, 2024
20cca3f
fix intended optional
LucienMorey Jan 13, 2024
87de65e
add missing param to end functions
LucienMorey Jan 13, 2024
5ff9001
dump broken ported test routine
LucienMorey Jan 13, 2024
b07d400
add additional fixture to stop and resume timing
LucienMorey Jan 14, 2024
3c763b6
make bookend test call order to match c++ tests
LucienMorey Jan 14, 2024
90221ec
make declare test account for paramaters before the last call
LucienMorey Jan 14, 2024
1fac013
only consider drive calls in sequence
LucienMorey Jan 14, 2024
6fb6d3b
use generic subsystem instead of specific fixture like in java tests
LucienMorey Jan 14, 2024
4df974c
only expect two iterations like in c++ tests
LucienMorey Jan 14, 2024
15b2e95
fix mypy issue
LucienMorey Jan 14, 2024
3d3bfea
fix dangling import reference
LucienMorey Jan 15, 2024
867a765
use direction value for deciding sign of voltage
LucienMorey Jan 15, 2024
8ff6b93
match upstream c++ for initialising state
LucienMorey Jan 15, 2024
99da251
Fix state enum name in dynamic command name
auscompgeek Jan 15, 2024
d3a817e
Fix SysIdRoutine docstring location
auscompgeek Jan 15, 2024
65efa8b
Copy dataclass docstrings from Java
auscompgeek Jan 15, 2024
52bd731
prevent test floating point errors with pytest approx
Jan 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions commands2/sysid/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .sysidroutine import SysIdRoutine


__all__ = ["SysIdRoutine"]
180 changes: 180 additions & 0 deletions commands2/sysid/sysidroutine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
from dataclasses import dataclass
from enum import Enum

from wpilib.sysid import SysIdRoutineLog, State
from ..command import Command
from ..subsystem import Subsystem
from wpilib import Timer

from wpimath.units import seconds, volts

from typing import Callable, Optional


volts_per_second = float


class SysIdRoutine(SysIdRoutineLog):
"""A SysId characterization routine for a single mechanism. Mechanisms may have multiple motors.

A single subsystem may have multiple mechanisms, but mechanisms should not share test
routines. Each complete test of a mechanism should have its own SysIdRoutine instance, since the
log name of the recorded data is determined by the mechanism name.

The test state (e.g. "quasistatic-forward") is logged once per iteration during test
execution, and once with state "none" when a test ends. Motor frames are logged every iteration
during test execution.

Timestamps are not coordinated across data, so motor frames and test state tags may be
recorded on different log frames. Because frame alignment is not guaranteed, SysId parses the log
by using the test state flag to determine the timestamp range for each section of the test, and
then extracts the motor frames within the valid timestamp ranges. If a given test was run
multiple times in a single logfile, the user will need to select which of the tests to use for
the fit in the analysis tool.
"""

@dataclass
class Config:
auscompgeek marked this conversation as resolved.
Show resolved Hide resolved
"""Hardware-independent configuration for a SysId test routine.

:param rampRate: The voltage ramp rate used for quasistatic test routines. Defaults to 1 volt
per second if left null.
:param stepVoltage: The step voltage output used for dynamic test routines. Defaults to 7
volts if left null.
:param timeout: Safety timeout for the test routine commands. Defaults to 10 seconds if left
null.
:param recordState: Optional handle for recording test state in a third-party logging
solution. If provided, the test routine state will be passed to this callback instead of
logged in WPILog.
"""

rampRate: volts_per_second = 1.0
stepVoltage: volts = 7.0
timeout: seconds = 10.0
recordState: Optional[Callable[[State], None]] = None

@dataclass
class Mechanism:
"""A mechanism to be characterized by a SysId routine.

Defines callbacks needed for the SysId test routine to control
and record data from the mechanism.

:param drive: Sends the SysId-specified drive signal to the mechanism motors during test
routines.
:param log: Returns measured data of the mechanism motors during test routines. To return
data, call `motor(string motorName)` on the supplied `SysIdRoutineLog` instance, and then
call one or more of the chainable logging handles (e.g. `voltage`) on the returned
`MotorLog`. Multiple motors can be logged in a single callback by calling `motor`
multiple times.
:param subsystem: The subsystem containing the motor(s) that is (or are) being characterized.
Will be declared as a requirement for the returned test commands.
:param name: The name of the mechanism being tested. Will be appended to the log entry title
for the routine's test state, e.g. "sysid-test-state-mechanism". Defaults to the name of
the subsystem if left null.
"""

drive: Callable[[volts], None]
log: Callable[[SysIdRoutineLog], None]
subsystem: Subsystem
name: Optional[str] = None
LucienMorey marked this conversation as resolved.
Show resolved Hide resolved

def __post_init__(self):
if self.name == None:
self.name = self.subsystem.getName()

class Direction(Enum):
"""Motor direction for a SysId test."""

kForward = 1
kReverse = -1

def __init__(self, config: Config, mechanism: Mechanism):
"""Create a new SysId characterization routine.

:param config: Hardware-independent parameters for the SysId routine.
:param mechanism: Hardware interface for the SysId routine.
"""
super().__init__(mechanism.subsystem.getName())
self.config = config
self.mechanism = mechanism
self.outputVolts = 0.0
self.logState = config.recordState or self.recordState

def quasistatic(self, direction: Direction) -> Command:
"""Returns a command to run a quasistatic test in the specified direction.

The command will call the `drive` and `log` callbacks supplied at routine construction once
per iteration. Upon command end or interruption, the `drive` callback is called with a value of
0 volts.

:param direction: The direction in which to run the test.

:returns: A command to run the test.
"""

timer = Timer()
if direction == self.Direction.kForward:
state = State.kQuasistaticForward
else:
state = State.kQuasistaticReverse

def execute():
self.outputVolts = direction.value * timer.get() * self.config.rampRate
self.mechanism.drive(self.outputVolts)
self.mechanism.log(self)
self.logState(state)

def end(interrupted: bool):
self.mechanism.drive(0.0)
self.logState(State.kNone)
timer.stop()

return (
self.mechanism.subsystem.runOnce(timer.start)
.andThen(self.mechanism.subsystem.run(execute))
.finallyDo(end)
.withName(
f"sysid-{SysIdRoutineLog.stateEnumToString(state)}-{self.mechanism.name}"
)
.withTimeout(self.config.timeout)
)

def dynamic(self, direction: Direction) -> Command:
"""Returns a command to run a dynamic test in the specified direction.

The command will call the `drive` and `log` callbacks supplied at routine construction once
per iteration. Upon command end or interruption, the `drive` callback is called with a value of
0 volts.

:param direction: The direction in which to run the test.

:returns: A command to run the test.
"""

if direction == self.Direction.kForward:
state = State.kDynamicForward
else:
state = State.kDynamicReverse

def command():
self.outputVolts = direction.value * self.config.stepVoltage

def execute():
self.mechanism.drive(self.outputVolts)
self.mechanism.log(self)
self.logState(state)

def end(interrupted: bool):
self.mechanism.drive(0.0)
self.logState(State.kNone)

return (
self.mechanism.subsystem.runOnce(command)
.andThen(self.mechanism.subsystem.run(execute))
.finallyDo(end)
.withName(
f"sysid-{SysIdRoutineLog.stateEnumToString(state)}-{self.mechanism.name}"
)
.withTimeout(self.config.timeout)
)
168 changes: 168 additions & 0 deletions tests/test_sysidroutine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import pytest
from unittest.mock import Mock, call, ANY
from wpilib.simulation import stepTiming, pauseTiming, resumeTiming
from wpimath.units import volts
from commands2 import Command, Subsystem
from commands2.sysid import SysIdRoutine
from wpilib.sysid import SysIdRoutineLog, State


class Mechanism(Subsystem):
def recordState(self, state: State):
pass

def drive(self, voltage: volts):
pass

def log(self, log: SysIdRoutineLog):
pass


@pytest.fixture
def mechanism():
return Mock(spec=Mechanism)


@pytest.fixture
def sysid_routine(mechanism):
return SysIdRoutine(
SysIdRoutine.Config(recordState=mechanism.recordState),
SysIdRoutine.Mechanism(mechanism.drive, mechanism.log, Subsystem()),
)


@pytest.fixture
def quasistatic_forward(sysid_routine):
return sysid_routine.quasistatic(SysIdRoutine.Direction.kForward)


@pytest.fixture
def quasistatic_reverse(sysid_routine):
return sysid_routine.quasistatic(SysIdRoutine.Direction.kReverse)


@pytest.fixture
def dynamic_forward(sysid_routine):
return sysid_routine.dynamic(SysIdRoutine.Direction.kForward)


@pytest.fixture
def dynamic_reverse(sysid_routine):
return sysid_routine.dynamic(SysIdRoutine.Direction.kReverse)


@pytest.fixture(autouse=True)
def timing():
pauseTiming()
yield
resumeTiming()


def run_command(command: Command):
command.initialize()
command.execute()
stepTiming(1)
command.execute()
command.end(True)


def test_record_state_bookends_motor_logging(
mechanism, quasistatic_forward, dynamic_forward
):
run_command(quasistatic_forward)

mechanism.assert_has_calls(
[
call.drive(ANY),
call.log(ANY),
call.recordState(State.kQuasistaticForward),
call.drive(ANY),
call.recordState(State.kNone),
],
any_order=False,
)

mechanism.reset_mock()
run_command(dynamic_forward)

mechanism.assert_has_calls(
[
call.drive(ANY),
call.log(ANY),
call.recordState(State.kDynamicForward),
call.drive(ANY),
call.recordState(State.kNone),
],
any_order=False,
)


def test_tests_declare_correct_state(
mechanism,
quasistatic_forward,
quasistatic_reverse,
dynamic_forward,
dynamic_reverse,
):
run_command(quasistatic_forward)
mechanism.recordState.assert_any_call(State.kQuasistaticForward)

run_command(quasistatic_reverse)
mechanism.recordState.assert_any_call(State.kQuasistaticReverse)

run_command(dynamic_forward)
mechanism.recordState.assert_any_call(State.kDynamicForward)

run_command(dynamic_reverse)
mechanism.recordState.assert_any_call(State.kDynamicReverse)


def test_tests_output_correct_voltage(
mechanism,
quasistatic_forward,
quasistatic_reverse,
dynamic_forward,
dynamic_reverse,
):
run_command(quasistatic_forward)

mechanism.drive.assert_has_calls(
[
call(pytest.approx(1.0)),
call(pytest.approx(0.0)),
],
any_order=False,
)

mechanism.reset_mock()
run_command(quasistatic_reverse)

mechanism.drive.assert_has_calls(
[
call(pytest.approx(-1.0)),
call(pytest.approx(0.0)),
],
any_order=False,
)

mechanism.reset_mock()
run_command(dynamic_forward)

mechanism.drive.assert_has_calls(
[
call(pytest.approx(7.0)),
call(pytest.approx(0.0)),
],
any_order=False,
)

mechanism.reset_mock()
run_command(dynamic_reverse)

mechanism.drive.assert_has_calls(
[
call(pytest.approx(-7.0)),
call(pytest.approx(0.0)),
],
any_order=False,
)