motor-python 0.0.7__tar.gz → 0.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {motor_python-0.0.7 → motor_python-0.0.8}/.gitignore +1 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/PKG-INFO +2 -2
- {motor_python-0.0.7 → motor_python-0.0.8}/pyproject.toml +1 -1
- motor_python-0.0.8/src/motor_python/__init__.py +110 -0
- motor_python-0.0.8/src/motor_python/__main__.py +183 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/base_motor.py +203 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/cube_mars_motor.py +18 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/cube_mars_motor_can.py +775 -103
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/definitions.py +262 -11
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/examples_can.py +65 -51
- motor_python-0.0.8/src/motor_python/motor_control_using_pid.py +113 -0
- motor_python-0.0.8/src/motor_python/motor_manager.py +184 -0
- motor_python-0.0.8/src/motor_python/pid_controller.py +112 -0
- motor_python-0.0.8/src/motor_python/second_order_low_pass_filter.py +567 -0
- motor_python-0.0.8/src/motor_python/utils.py +172 -0
- motor_python-0.0.7/src/motor_python/__init__.py +0 -17
- motor_python-0.0.7/src/motor_python/__main__.py +0 -128
- motor_python-0.0.7/src/motor_python/mit_mode_packer.py +0 -103
- motor_python-0.0.7/src/motor_python/utils.py +0 -74
- {motor_python-0.0.7 → motor_python-0.0.8}/LICENSE +0 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/README.md +0 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/scripts/README.md +0 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/can_protocol.py +0 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/can_utils.py +0 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/examples.py +0 -0
- {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/motor_status_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: motor_python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: CubeMars motor module for Aries exosuits.
|
|
5
5
|
Project-URL: homepage, https://github.com/TUM-Aries-Lab/motor-module
|
|
6
6
|
Author-email: Tsmorz <tony.smoragiewicz@tum.de>, Hannes Nguyen <hannes.nguyen@tum.de>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "motor_python"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.8"
|
|
4
4
|
description = "CubeMars motor module for Aries exosuits."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Tsmorz", email = "tony.smoragiewicz@tum.de"},{ name = "Hannes Nguyen", email = "hannes.nguyen@tum.de" }]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Motor control module for CubeMars AK60-6 exosuit actuators.
|
|
2
|
+
|
|
3
|
+
Primary interface: CAN (CubeMarsAK606v3CAN / Motor).
|
|
4
|
+
Legacy UART interface: CubeMarsAK606v3.
|
|
5
|
+
Base class: BaseMotor (for shared interface & safety logic).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.0.8"
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from motor_python.base_motor import BaseMotor
|
|
13
|
+
from motor_python.cube_mars_motor import CubeMarsAK606v3, CubeMarsAK806v2
|
|
14
|
+
from motor_python.cube_mars_motor_can import (
|
|
15
|
+
CubeMarsAK606v1CAN,
|
|
16
|
+
CubeMarsAK606v3CAN,
|
|
17
|
+
CubeMarsAK806v2CAN,
|
|
18
|
+
CubeMarsBaseCAN,
|
|
19
|
+
)
|
|
20
|
+
from motor_python.definitions import (
|
|
21
|
+
AK60_6_V1_1_MOTOR_SPEC,
|
|
22
|
+
AK60_6_V3_0_MOTOR_SPEC,
|
|
23
|
+
AK80_6_MOTOR_SPEC,
|
|
24
|
+
CAN_DEFAULTS,
|
|
25
|
+
MotorModel,
|
|
26
|
+
MotorSpec,
|
|
27
|
+
)
|
|
28
|
+
from motor_python.motor_manager import MotorManager
|
|
29
|
+
|
|
30
|
+
# Convenience alias — CAN is the primary interface. Later can be changed to AK806v2CAN.
|
|
31
|
+
Motor = CubeMarsAK606v3CAN
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ruff: noqa: PLR0913
|
|
35
|
+
def create_can_motor(
|
|
36
|
+
motor_model: str = MotorModel.AK60_6V3,
|
|
37
|
+
*,
|
|
38
|
+
motor_can_id: int = CAN_DEFAULTS.motor_can_id,
|
|
39
|
+
interface: str = CAN_DEFAULTS.interface,
|
|
40
|
+
bitrate: int = CAN_DEFAULTS.bitrate,
|
|
41
|
+
feedback_can_id: int | None = None,
|
|
42
|
+
mit_velocity_kd: float | None = None,
|
|
43
|
+
motor_spec: MotorSpec | None = None,
|
|
44
|
+
helper_policy: Literal["strict", "fcfd", "legacy"] = "fcfd",
|
|
45
|
+
auto_recover_bus: bool = True,
|
|
46
|
+
allow_legacy_feedback_ids: bool = True,
|
|
47
|
+
aggressive_bus_reset: bool = False,
|
|
48
|
+
) -> CubeMarsBaseCAN:
|
|
49
|
+
"""Build a CAN motor instance for the requested model."""
|
|
50
|
+
model = motor_model.strip().upper()
|
|
51
|
+
if model in {"AK60-6", "AK60_6", "AK60-6_V3.0"}:
|
|
52
|
+
return CubeMarsAK606v3CAN(
|
|
53
|
+
motor_can_id=motor_can_id,
|
|
54
|
+
interface=interface,
|
|
55
|
+
bitrate=bitrate,
|
|
56
|
+
feedback_can_id=feedback_can_id,
|
|
57
|
+
mit_velocity_kd=mit_velocity_kd,
|
|
58
|
+
motor_spec=motor_spec if motor_spec is not None else AK60_6_V3_0_MOTOR_SPEC,
|
|
59
|
+
helper_policy=helper_policy,
|
|
60
|
+
auto_recover_bus=auto_recover_bus,
|
|
61
|
+
allow_legacy_feedback_ids=True,
|
|
62
|
+
aggressive_bus_reset=aggressive_bus_reset,
|
|
63
|
+
)
|
|
64
|
+
if model in {"AK80-6", "AK80_6"}:
|
|
65
|
+
return CubeMarsAK806v2CAN(
|
|
66
|
+
motor_can_id=motor_can_id,
|
|
67
|
+
interface=interface,
|
|
68
|
+
bitrate=bitrate,
|
|
69
|
+
feedback_can_id=feedback_can_id,
|
|
70
|
+
mit_velocity_kd=mit_velocity_kd,
|
|
71
|
+
motor_spec=motor_spec if motor_spec is not None else AK80_6_MOTOR_SPEC,
|
|
72
|
+
helper_policy=helper_policy,
|
|
73
|
+
auto_recover_bus=auto_recover_bus,
|
|
74
|
+
allow_legacy_feedback_ids=False,
|
|
75
|
+
aggressive_bus_reset=aggressive_bus_reset,
|
|
76
|
+
)
|
|
77
|
+
if model in {"AK60-V1", "AK60-V1.1", "AK60-6_V1.1"}:
|
|
78
|
+
return CubeMarsAK606v1CAN(
|
|
79
|
+
motor_can_id=motor_can_id,
|
|
80
|
+
interface=interface,
|
|
81
|
+
bitrate=bitrate,
|
|
82
|
+
feedback_can_id=feedback_can_id,
|
|
83
|
+
mit_velocity_kd=mit_velocity_kd,
|
|
84
|
+
motor_spec=motor_spec if motor_spec is not None else AK60_6_V1_1_MOTOR_SPEC,
|
|
85
|
+
helper_policy=helper_policy,
|
|
86
|
+
auto_recover_bus=auto_recover_bus,
|
|
87
|
+
allow_legacy_feedback_ids=False,
|
|
88
|
+
aggressive_bus_reset=aggressive_bus_reset,
|
|
89
|
+
)
|
|
90
|
+
raise ValueError("Unknown motor model: must be AK60-6 or AK80-6")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"AK60_6_V1_1_MOTOR_SPEC",
|
|
95
|
+
"AK60_6_V3_0_MOTOR_SPEC",
|
|
96
|
+
"AK80_6_MOTOR_SPEC",
|
|
97
|
+
"CAN_DEFAULTS",
|
|
98
|
+
"BaseMotor",
|
|
99
|
+
"CubeMarsAK606v1CAN",
|
|
100
|
+
"CubeMarsAK606v3",
|
|
101
|
+
"CubeMarsAK606v3CAN",
|
|
102
|
+
"CubeMarsAK806v2",
|
|
103
|
+
"CubeMarsAK806v2CAN",
|
|
104
|
+
"CubeMarsBaseCAN",
|
|
105
|
+
"Motor",
|
|
106
|
+
"MotorManager",
|
|
107
|
+
"MotorModel",
|
|
108
|
+
"MotorSpec",
|
|
109
|
+
"create_can_motor",
|
|
110
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Motor control main entry point — CAN interface (CubeMarsAK606v3CAN).
|
|
2
|
+
|
|
3
|
+
Example usage:
|
|
4
|
+
python -m motor_python --motor-ids 0x03
|
|
5
|
+
python -m motor_python --motor-ids 0x03 0x04
|
|
6
|
+
python -m motor_python --discover
|
|
7
|
+
python -m motor_python --dual
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
from motor_python.definitions import (
|
|
16
|
+
CAN_DEFAULTS,
|
|
17
|
+
DEFAULT_LOG_LEVEL,
|
|
18
|
+
DEFAULT_MOTOR_SPEC,
|
|
19
|
+
LogLevel,
|
|
20
|
+
MotorModel,
|
|
21
|
+
set_current_motor_model_by_name,
|
|
22
|
+
)
|
|
23
|
+
from motor_python.examples_can import (
|
|
24
|
+
run_motor_demo_can,
|
|
25
|
+
run_multi_motor_demo,
|
|
26
|
+
)
|
|
27
|
+
from motor_python.motor_manager import MotorManager
|
|
28
|
+
from motor_python.utils import setup_logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main( # noqa: PLR0913
|
|
32
|
+
log_level: str = DEFAULT_LOG_LEVEL,
|
|
33
|
+
stderr_level: str = DEFAULT_LOG_LEVEL,
|
|
34
|
+
dual: bool = False,
|
|
35
|
+
discover: bool = False,
|
|
36
|
+
motor_ids: list[int] | None = None,
|
|
37
|
+
interface: str = CAN_DEFAULTS.interface,
|
|
38
|
+
motor_model: str = DEFAULT_MOTOR_SPEC.model_name,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Run the main CAN motor control loop.
|
|
41
|
+
|
|
42
|
+
Pre-condition: CAN interface must be up.
|
|
43
|
+
sudo ip link set can0 up type can bitrate 1000000 berr-reporting on restart-ms 100
|
|
44
|
+
|
|
45
|
+
:param log_level: The log level to use.
|
|
46
|
+
:param stderr_level: The std err level to use.
|
|
47
|
+
:param dual: If True, run the two-motor synchronized demo instead.
|
|
48
|
+
:param motor_ids: List of CAN motor IDs to control.
|
|
49
|
+
:param interface: The CAN interface to use.
|
|
50
|
+
:return: None
|
|
51
|
+
"""
|
|
52
|
+
setup_logger(log_level=log_level, stderr_level=stderr_level)
|
|
53
|
+
|
|
54
|
+
set_current_motor_model_by_name(motor_model)
|
|
55
|
+
logger.info(f"Selected motor model: {motor_model}")
|
|
56
|
+
|
|
57
|
+
# --- Two-motor mode ---
|
|
58
|
+
if dual:
|
|
59
|
+
motor_ids = [CAN_DEFAULTS.motor_can_id, CAN_DEFAULTS.motor_can_id_2]
|
|
60
|
+
|
|
61
|
+
if motor_ids is None:
|
|
62
|
+
motor_ids = [CAN_DEFAULTS.motor_can_id]
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
if discover:
|
|
66
|
+
manager = MotorManager.discover(
|
|
67
|
+
interface=interface, motor_model=motor_model
|
|
68
|
+
) # sets the discovered ids as motor_ids
|
|
69
|
+
else:
|
|
70
|
+
logger.info(
|
|
71
|
+
f"Starting CAN motor control loop on interface '{interface}' with IDs: {motor_ids}"
|
|
72
|
+
)
|
|
73
|
+
manager = MotorManager(
|
|
74
|
+
motor_ids=motor_ids, interface=interface, motor_model=motor_model
|
|
75
|
+
)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error(f"Failed to initialize CAN motor manager: {e}")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
with manager:
|
|
81
|
+
first_motor = next(iter(manager))
|
|
82
|
+
if not first_motor.connected:
|
|
83
|
+
logger.warning(
|
|
84
|
+
"CAN bus not available. Run: sudo ip link set can0 up "
|
|
85
|
+
"type can bitrate 1000000 berr-reporting on restart-ms 100"
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
manager.send_neutral_commands() # Send neutral commands to all motors
|
|
90
|
+
|
|
91
|
+
status = (
|
|
92
|
+
manager.check_all()
|
|
93
|
+
) # Check communication with all motors before proceeding
|
|
94
|
+
if not any(status.values()):
|
|
95
|
+
logger.warning(
|
|
96
|
+
"No motors responding. Check power, CANH/CANL wiring, "
|
|
97
|
+
"120 ohm termination, and CAN IDs."
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
manager.enable_all() # Enable all motors
|
|
102
|
+
logger.info("All motors enabled. Checking communication...")
|
|
103
|
+
|
|
104
|
+
logger.info(
|
|
105
|
+
f"Motor communication verified for IDs: {[motor_id for motor_id, ok in status.items() if ok]}"
|
|
106
|
+
)
|
|
107
|
+
for motor in manager:
|
|
108
|
+
motor.get_status()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if len(manager) == 1:
|
|
112
|
+
run_motor_demo_can(next(iter(manager)))
|
|
113
|
+
else:
|
|
114
|
+
run_multi_motor_demo(manager)
|
|
115
|
+
except KeyboardInterrupt:
|
|
116
|
+
logger.info("Interrupted by user")
|
|
117
|
+
|
|
118
|
+
logger.info("CAN motor control loop complete!")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__": # pragma: no cover
|
|
122
|
+
parser = argparse.ArgumentParser("Run the pipeline.")
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--log-level",
|
|
125
|
+
default=DEFAULT_LOG_LEVEL,
|
|
126
|
+
choices=list(LogLevel()),
|
|
127
|
+
help="Set the log level.",
|
|
128
|
+
required=False,
|
|
129
|
+
type=str,
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--stderr-level",
|
|
133
|
+
default=DEFAULT_LOG_LEVEL,
|
|
134
|
+
choices=list(LogLevel()),
|
|
135
|
+
help="Set the std err level.",
|
|
136
|
+
required=False,
|
|
137
|
+
type=str,
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--dual",
|
|
141
|
+
action="store_true",
|
|
142
|
+
default=False,
|
|
143
|
+
help="Run the two-motor synchronized demo (requires two motors on the bus).",
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--discover",
|
|
147
|
+
action="store_true",
|
|
148
|
+
default=False,
|
|
149
|
+
help="Automatically discover connected motors on the CAN bus.",
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--motor-ids",
|
|
153
|
+
nargs="+",
|
|
154
|
+
type=lambda x: int(x, 0),
|
|
155
|
+
default=None,
|
|
156
|
+
help=(
|
|
157
|
+
"List of CAN motor IDs to control. "
|
|
158
|
+
"Use space-separated values like --motor-ids 0x03 0x04."
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--interface",
|
|
163
|
+
default=CAN_DEFAULTS.interface,
|
|
164
|
+
help="SocketCAN interface to use (default: can0).",
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--motor-model",
|
|
168
|
+
type=str,
|
|
169
|
+
choices=list(MotorModel),
|
|
170
|
+
default=DEFAULT_MOTOR_SPEC.model_name,
|
|
171
|
+
help="Select motor type (AK60-6 or AK80-6).",
|
|
172
|
+
)
|
|
173
|
+
args = parser.parse_args()
|
|
174
|
+
|
|
175
|
+
main(
|
|
176
|
+
log_level=args.log_level,
|
|
177
|
+
stderr_level=args.stderr_level,
|
|
178
|
+
dual=args.dual,
|
|
179
|
+
discover=args.discover,
|
|
180
|
+
motor_ids=args.motor_ids,
|
|
181
|
+
motor_model=args.motor_model,
|
|
182
|
+
interface=args.interface,
|
|
183
|
+
)
|
|
@@ -8,6 +8,7 @@ must implement the abstract transport-layer methods.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import abc
|
|
11
|
+
import math
|
|
11
12
|
import time
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from typing import Self
|
|
@@ -48,6 +49,8 @@ class MotorState:
|
|
|
48
49
|
current_amps: float # Phase current in amps
|
|
49
50
|
temperature_celsius: int # Driver board temperature in °C
|
|
50
51
|
error_code: int # Fault code (0 = OK)
|
|
52
|
+
timestamp_monotonic: float = 0.0
|
|
53
|
+
is_fresh: bool = True
|
|
51
54
|
|
|
52
55
|
@property
|
|
53
56
|
def error_description(self) -> str:
|
|
@@ -137,6 +140,14 @@ class BaseMotor(abc.ABC):
|
|
|
137
140
|
:param velocity_erpm: Validated and clamped velocity in ERPM.
|
|
138
141
|
"""
|
|
139
142
|
|
|
143
|
+
def degrees_to_radians(self, degrees: float) -> float:
|
|
144
|
+
"""Convert degrees to radians."""
|
|
145
|
+
return degrees * math.pi / 180.0
|
|
146
|
+
|
|
147
|
+
def radians_to_degrees(self, radians: float) -> float:
|
|
148
|
+
"""Convert radians to degrees."""
|
|
149
|
+
return radians * 180.0 / math.pi
|
|
150
|
+
|
|
140
151
|
# ------------------------------------------------------------------
|
|
141
152
|
# Unified Advanced Control Methods (Stubbed by default)
|
|
142
153
|
# ------------------------------------------------------------------
|
|
@@ -364,3 +375,195 @@ class BaseMotor(abc.ABC):
|
|
|
364
375
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
365
376
|
"""Context manager exit."""
|
|
366
377
|
self.close()
|
|
378
|
+
|
|
379
|
+
def get_timing_stats(self) -> dict:
|
|
380
|
+
"""Return Refresh Loop Statistics.
|
|
381
|
+
|
|
382
|
+
Reports:
|
|
383
|
+
- Loop timing statistics (mean dt, std dt, min dt, max dt, and the effective Hz (1/mean_dt))
|
|
384
|
+
- Jitter count (Counts how many intervals exceed 2x the expected period)
|
|
385
|
+
- Cumulative send failures and missed feedback frames (never resets)
|
|
386
|
+
- CAN error counter deltas (current tx_err/rx_err vs values at start)
|
|
387
|
+
- TX pacing metrics (if available)
|
|
388
|
+
|
|
389
|
+
:return: Dictionary with timing statistics, or minimal dict if unavailable.
|
|
390
|
+
"""
|
|
391
|
+
stats = {
|
|
392
|
+
"method": "get_timing_stats",
|
|
393
|
+
"available": False,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
# Trying to get loop timing from refresh timestamps
|
|
397
|
+
refresh_timestamps = getattr(self, "_refresh_timestamps", None)
|
|
398
|
+
if refresh_timestamps is not None:
|
|
399
|
+
timestamps = np.array(refresh_timestamps)
|
|
400
|
+
if len(timestamps) > 1:
|
|
401
|
+
expected_period = (
|
|
402
|
+
getattr(self, "_refresh_interval", 0.01)
|
|
403
|
+
if hasattr(self, "_refresh_interval")
|
|
404
|
+
else 0.01
|
|
405
|
+
)
|
|
406
|
+
dts = np.diff(timestamps)
|
|
407
|
+
threshold = 2.0 * expected_period
|
|
408
|
+
jitter_count = np.sum(
|
|
409
|
+
dts > threshold
|
|
410
|
+
) # Counting intervals that exceed the expected threshold
|
|
411
|
+
|
|
412
|
+
stats.update(
|
|
413
|
+
{
|
|
414
|
+
"available": True,
|
|
415
|
+
"loop_period_expected_s": expected_period,
|
|
416
|
+
"loop_period_mean_s": float(np.mean(dts)),
|
|
417
|
+
"loop_period_std_s": float(np.std(dts)),
|
|
418
|
+
"loop_period_min_s": float(np.min(dts)),
|
|
419
|
+
"loop_period_max_s": float(np.max(dts)),
|
|
420
|
+
"loop_effective_hz": 1.0 / float(np.mean(dts)),
|
|
421
|
+
"loop_intervals_total": len(dts),
|
|
422
|
+
"loop_jitter_count": int(jitter_count),
|
|
423
|
+
"loop_jitter_ratio": float(jitter_count / len(dts))
|
|
424
|
+
if len(dts) > 0
|
|
425
|
+
else 0.0,
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Cumulative send failures
|
|
430
|
+
cumulative_send_failures = getattr(
|
|
431
|
+
self, "_cumulative_refresh_send_failures", None
|
|
432
|
+
)
|
|
433
|
+
if cumulative_send_failures is not None:
|
|
434
|
+
stats["cumulative_send_failures"] = cumulative_send_failures
|
|
435
|
+
|
|
436
|
+
# Cumulative missed feedback
|
|
437
|
+
cumulative_no_feedback = getattr(self, "_cumulative_refresh_no_feedback", None)
|
|
438
|
+
if cumulative_no_feedback is not None:
|
|
439
|
+
stats["cumulative_missed_feedback"] = cumulative_no_feedback
|
|
440
|
+
|
|
441
|
+
# CAN error counter deltas
|
|
442
|
+
initial_can_state = getattr(self, "_initial_can_state", None)
|
|
443
|
+
last_can_state_cache = getattr(self, "_last_can_state_cache", None)
|
|
444
|
+
if initial_can_state is not None and last_can_state_cache is not None:
|
|
445
|
+
stats["can_tx_err_initial"] = initial_can_state.get("tx_err", 0)
|
|
446
|
+
stats["can_tx_err_final"] = last_can_state_cache.get("tx_err", 0)
|
|
447
|
+
stats["can_tx_err_delta"] = last_can_state_cache.get(
|
|
448
|
+
"tx_err", 0
|
|
449
|
+
) - initial_can_state.get("tx_err", 0)
|
|
450
|
+
stats["can_rx_err_initial"] = initial_can_state.get("rx_err", 0)
|
|
451
|
+
stats["can_rx_err_final"] = last_can_state_cache.get("rx_err", 0)
|
|
452
|
+
stats["can_rx_err_delta"] = last_can_state_cache.get(
|
|
453
|
+
"rx_err", 0
|
|
454
|
+
) - initial_can_state.get("rx_err", 0)
|
|
455
|
+
|
|
456
|
+
# TX pacing metrics tells how often pacing delays were needed and how much time was spent sleeping to pace transmissions.
|
|
457
|
+
tx_pace_sleep_count = getattr(self, "_tx_pace_sleep_count", None)
|
|
458
|
+
if tx_pace_sleep_count is not None:
|
|
459
|
+
stats["tx_pace_sleep_count"] = tx_pace_sleep_count
|
|
460
|
+
tx_pace_sleep_time_s = getattr(self, "_tx_pace_sleep_time_s", None)
|
|
461
|
+
if tx_pace_sleep_time_s is not None:
|
|
462
|
+
stats["tx_pace_sleep_time_s"] = tx_pace_sleep_time_s
|
|
463
|
+
|
|
464
|
+
return stats
|
|
465
|
+
|
|
466
|
+
def reset_timing_stats(self) -> None:
|
|
467
|
+
"""Reset timing-related diagnostic state used by get_timing_stats().
|
|
468
|
+
|
|
469
|
+
This clears any transport-specific timestamp buffers and zeroes
|
|
470
|
+
counters that are safe to reset for a fresh measurement run.
|
|
471
|
+
"""
|
|
472
|
+
# Clear refresh timestamps used for jitter analysis (CAN transport)
|
|
473
|
+
if hasattr(self, "_refresh_timestamps"):
|
|
474
|
+
try:
|
|
475
|
+
self._refresh_timestamps.clear()
|
|
476
|
+
except Exception:
|
|
477
|
+
try:
|
|
478
|
+
self._refresh_timestamps = type(self._refresh_timestamps)()
|
|
479
|
+
except Exception:
|
|
480
|
+
logger.warning("Failed to reset _refresh_timestamps")
|
|
481
|
+
|
|
482
|
+
if hasattr(self, "_refresh_send_failures"):
|
|
483
|
+
try:
|
|
484
|
+
self._refresh_send_failures = 0
|
|
485
|
+
except Exception:
|
|
486
|
+
logger.warning("Failed to reset _refresh_send_failures")
|
|
487
|
+
if hasattr(self, "_refresh_no_feedback"):
|
|
488
|
+
try:
|
|
489
|
+
self._refresh_no_feedback = 0
|
|
490
|
+
except Exception:
|
|
491
|
+
logger.warning("Failed to reset _refresh_no_feedback")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def print_timing_stats(
|
|
495
|
+
timing_stats: dict[str, float | int | bool],
|
|
496
|
+
total_feedback_samples: int,
|
|
497
|
+
separator: str,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Print timing and CAN health diagnostics."""
|
|
500
|
+
if not timing_stats.get("available", False):
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
logger.info(f"\n{separator}")
|
|
504
|
+
logger.info("Timing & Health Diagnostics")
|
|
505
|
+
logger.info(separator)
|
|
506
|
+
|
|
507
|
+
logger.info(
|
|
508
|
+
f"Loop effective Hz : {timing_stats.get('loop_effective_hz', 0):.1f}"
|
|
509
|
+
)
|
|
510
|
+
logger.info(
|
|
511
|
+
f"Loop period (expected) : "
|
|
512
|
+
f"{timing_stats.get('loop_period_expected_s', 0):.6f} s"
|
|
513
|
+
)
|
|
514
|
+
logger.info(
|
|
515
|
+
f"Loop period (mean) : {timing_stats.get('loop_period_mean_s', 0):.6f} s"
|
|
516
|
+
)
|
|
517
|
+
logger.info(
|
|
518
|
+
f"Loop period (std) : {timing_stats.get('loop_period_std_s', 0):.6f} s"
|
|
519
|
+
)
|
|
520
|
+
logger.info(
|
|
521
|
+
f"Loop period (min/max) : "
|
|
522
|
+
f"{timing_stats.get('loop_period_min_s', 0):.6f} / "
|
|
523
|
+
f"{timing_stats.get('loop_period_max_s', 0):.6f} s"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
logger.info(
|
|
527
|
+
f"Jitter (>2x period) : "
|
|
528
|
+
f"{timing_stats.get('loop_jitter_count', 0)} / "
|
|
529
|
+
f"{timing_stats.get('loop_intervals_total', 0)} "
|
|
530
|
+
f"({100.0 * timing_stats.get('loop_jitter_ratio', 0):.1f}%)"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
logger.info(
|
|
534
|
+
f"TX pace sleeps : "
|
|
535
|
+
f"{timing_stats.get('tx_pace_sleep_count', 0)} times, "
|
|
536
|
+
f"{timing_stats.get('tx_pace_sleep_time_s', 0):.3f} s total"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
logger.info(
|
|
540
|
+
f"Send failures (cumul.) : {timing_stats.get('cumulative_send_failures', 0)}"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
missed_feedback = timing_stats.get("cumulative_missed_feedback", 0)
|
|
544
|
+
|
|
545
|
+
if total_feedback_samples > 0:
|
|
546
|
+
missed_percentage = (missed_feedback / total_feedback_samples) * 100.0
|
|
547
|
+
logger.info(
|
|
548
|
+
f"Missed feedback (cumul) : "
|
|
549
|
+
f"{missed_feedback}/{total_feedback_samples} "
|
|
550
|
+
f"({missed_percentage:.1f}%)"
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
logger.info(f"Feedback samples (total): {total_feedback_samples}")
|
|
554
|
+
logger.info(f"Missed feedback (cumul) : {missed_feedback}")
|
|
555
|
+
|
|
556
|
+
can_tx_delta = timing_stats.get("can_tx_err_delta", 0)
|
|
557
|
+
can_rx_delta = timing_stats.get("can_rx_err_delta", 0)
|
|
558
|
+
|
|
559
|
+
logger.info(
|
|
560
|
+
f"CAN errors : "
|
|
561
|
+
f"tx_err {timing_stats.get('can_tx_err_initial', 0)}"
|
|
562
|
+
f"→{timing_stats.get('can_tx_err_final', 0)} "
|
|
563
|
+
f"(Δ{can_tx_delta:+d}), "
|
|
564
|
+
f"rx_err {timing_stats.get('can_rx_err_initial', 0)}"
|
|
565
|
+
f"→{timing_stats.get('can_rx_err_final', 0)} "
|
|
566
|
+
f"(Δ{can_rx_delta:+d})"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
logger.info(separator)
|
|
@@ -11,6 +11,7 @@ from loguru import logger
|
|
|
11
11
|
|
|
12
12
|
from motor_python.base_motor import BaseMotor, MotorState
|
|
13
13
|
from motor_python.definitions import (
|
|
14
|
+
AK80_6_MOTOR_SPEC,
|
|
14
15
|
CRC16_TAB,
|
|
15
16
|
CRC_CONSTANTS,
|
|
16
17
|
FRAME_BYTES,
|
|
@@ -429,3 +430,20 @@ class CubeMarsAK606v3(BaseMotor):
|
|
|
429
430
|
self.stop()
|
|
430
431
|
self.serial.close()
|
|
431
432
|
logger.info("Motor connection closed")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class CubeMarsAK806v2(CubeMarsAK606v3):
|
|
436
|
+
"""AK80-6 Motor Controller for CubeMars V3 UART Protocol."""
|
|
437
|
+
|
|
438
|
+
def __init__(
|
|
439
|
+
self,
|
|
440
|
+
port: Path | str = MOTOR_DEFAULTS.port,
|
|
441
|
+
baudrate: int = MOTOR_DEFAULTS.baudrate,
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Initialize AK80-6 UART motor connection.
|
|
444
|
+
|
|
445
|
+
:param port: Serial port path (default: MOTOR_DEFAULTS.port).
|
|
446
|
+
:param baudrate: Communication baudrate (default: MOTOR_DEFAULTS.baudrate).
|
|
447
|
+
"""
|
|
448
|
+
self._motor_spec = AK80_6_MOTOR_SPEC
|
|
449
|
+
super().__init__(port=port, baudrate=baudrate)
|