moteus 0.3.70__py3-none-any.whl → 0.3.72__py3-none-any.whl

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.
@@ -14,6 +14,9 @@
14
14
 
15
15
  import json
16
16
  import math
17
+ import scipy.optimize
18
+
19
+ BIN_COUNT = 64
17
20
 
18
21
  class Entry:
19
22
  direction = 0
@@ -159,8 +162,8 @@ def _window_average(values, window_size):
159
162
  end = i + window_size // 2
160
163
  errs = [0] * (end - start)
161
164
  for j in range(start, end):
162
- errs[j - start] = _wrap_neg_pi_to_pi(values[wrap(j)] - values[wrap(start)])
163
- result[i] = values[wrap(start)] + (sum(errs) / len(errs))
165
+ errs[j - start] = values[wrap(j)]
166
+ result[i] = sum(errs) / len(errs)
164
167
 
165
168
  return result
166
169
 
@@ -176,6 +179,8 @@ class CalibrationResult:
176
179
  self.total_delta = None
177
180
  self.ratio = None
178
181
 
182
+ self.fit_metric = None
183
+
179
184
  self.errors = []
180
185
 
181
186
  def __repr__(self):
@@ -184,6 +189,7 @@ class CalibrationResult:
184
189
  "phase_invert": self.phase_invert,
185
190
  "poles": self.poles,
186
191
  "offset": self.offset,
192
+ "fit_metric": self.fit_metric,
187
193
  "errors": self.errors,
188
194
  })
189
195
 
@@ -193,13 +199,16 @@ class CalibrationResult:
193
199
  'phase_invert': self.phase_invert,
194
200
  'poles': self.poles,
195
201
  'offset': self.offset,
202
+ 'fit_metric': self.fit_metric,
196
203
  }
197
204
 
198
205
 
199
206
  def calibrate(parsed,
200
207
  desired_direction=1,
201
208
  max_remainder_error=0.1,
202
- allow_phase_invert=True):
209
+ allow_phase_invert=True,
210
+ allow_optimize=True,
211
+ force_optimize=False):
203
212
  '''Calibrate the motor.
204
213
 
205
214
  :param desired_direction: For positive unwrapped_position, should
@@ -315,19 +324,89 @@ def calibrate(parsed,
315
324
 
316
325
  expected = [(2.0 * math.pi / 65536.0) * (result.poles / 2) * x for x in xpos]
317
326
 
318
- err = [_wrap_neg_pi_to_pi(a - b) for a, b in zip(avg_interp, expected)]
327
+ err = [a - b for a, b in zip(avg_interp, expected)]
319
328
 
320
- # Make the error seem reasonable, so unwrap if we happen to span
321
- # the pi boundary.
322
- if (max(err) - min(err)) > 1.5 * math.pi:
323
- err = [x if x > 0 else x + 2 * math.pi for x in err]
329
+ # Make the error balanced about 0.
330
+ mean_err = sum(err) / len(err)
331
+ wrapped_mean_err = _wrap_neg_pi_to_pi(mean_err)
332
+ delta_mean_err = mean_err - wrapped_mean_err
333
+ err = [x - delta_mean_err for x in err]
324
334
 
325
335
  avg_window = int(len(err) / result.poles)
326
336
  avg_err = _window_average(err, avg_window)
327
337
 
328
- offset_x = list(range(0, 65536, 1024))
338
+ offset_x = list(range(0, 65536, 65536 // BIN_COUNT))
329
339
  offset = _interpolate(offset_x, xpos, avg_err)
330
340
 
341
+ MAX_ERROR = 0.8
342
+
343
+ # The firmware will complain if individual steps are more
344
+ # than this apart.
345
+ MAX_STEP_CHANGE = 3.5
346
+
347
+ # Penalize solutions that have error greater than this.
348
+ PENALTY_RATIO = 0.80
349
+
350
+ def full_metric(x, *args):
351
+ errors = 0
352
+ result = 0
353
+ resampled = _interpolate(xpos, offset_x + [65536],
354
+ list(x) + [x[0]])
355
+ for a, b in zip(err, resampled):
356
+ this_err = abs(_wrap_neg_pi_to_pi(a - b))
357
+ # Heavily penalize if we would have an error.
358
+ if this_err > PENALTY_RATIO * MAX_ERROR:
359
+ this_err = (PENALTY_RATIO * MAX_ERROR +
360
+ (this_err - PENALTY_RATIO * MAX_ERROR) * 10)
361
+ if this_err > MAX_ERROR:
362
+ errors += 1
363
+ result += this_err ** 2
364
+
365
+ for i in range(0, len(list(x))):
366
+ nexti = (i + 1) % len(list(x))
367
+ delta = abs(x[nexti] - x[i])
368
+ if delta > (PENALTY_RATIO * MAX_STEP_CHANGE):
369
+ result += (
370
+ 10 * (delta - PENALTY_RATIO * MAX_STEP_CHANGE) ** 2)
371
+ return result, errors
372
+
373
+ def metric(x, *args):
374
+ result = full_metric(x, *args)[0]
375
+ print(f"optimizing - metric={result:.5f} ", end='\r', flush=True)
376
+ return result
377
+
378
+ starting_metric, starting_errors = full_metric(offset)
379
+
380
+ if (force_optimize or (
381
+ allow_optimize and
382
+ (starting_metric > 30 or
383
+ starting_errors > 0))):
384
+ print()
385
+ # Optimize these initial offsets.
386
+ optimres = scipy.optimize.minimize(metric, offset, tol=1e1)
387
+
388
+ if not optimres.success:
389
+ result.errors.append(
390
+ f"optimization failed {result.message}")
391
+
392
+ print()
393
+ offset = list(optimres.x)
394
+
395
+ result.fit_metric, _ = full_metric(offset)
396
+
397
+ # Now double check our results.
398
+ resampled_offset = _interpolate(xpos, offset_x + [65536],
399
+ offset + [offset[0]])
400
+ any_sample_error = False
401
+ sample_errors = []
402
+ for a, b in zip(err, resampled_offset):
403
+ sample_error = _wrap_neg_pi_to_pi(a - b)
404
+ sample_errors.append(sample_error)
405
+ if not any_sample_error and abs(sample_error) > MAX_ERROR:
406
+ result.errors.append(
407
+ f"excessive error in curve fit |{sample_error}| > {MAX_ERROR}")
408
+ any_sample_error = True
409
+
331
410
  result.offset = offset
332
411
 
333
412
  result.debug = {
@@ -340,6 +419,9 @@ def calibrate(parsed,
340
419
  'avg_interp' : avg_interp,
341
420
  'err' : err,
342
421
  'avg_err' : avg_err,
422
+ 'offset_x' : offset_x,
423
+ 'offset' : offset,
424
+ 'sample_errors' : sample_errors,
343
425
  }
344
426
 
345
427
  return result
moteus/moteus.py CHANGED
@@ -530,7 +530,7 @@ class Result:
530
530
  id = None
531
531
  arbitration_id = None
532
532
  bus = None
533
- values = []
533
+ values = {}
534
534
 
535
535
  def __repr__(self):
536
536
  value_str = ', '.join(['{}(0x{:03x}): {}'.format(Register(key).name, key, value)
moteus/moteus_tool.py CHANGED
@@ -44,6 +44,14 @@ MAX_FLASH_BLOCK_SIZE = 32
44
44
  FIND_TARGET_TIMEOUT = 0.01 if sys.platform != 'win32' else 0.05
45
45
 
46
46
 
47
+ def _wrap_neg_pi_to_pi(value):
48
+ while value > math.pi:
49
+ value -= 2.0 * math.pi
50
+ while value < -math.pi:
51
+ value += 2.0 * math.pi
52
+ return value
53
+
54
+
47
55
  class FirmwareUpgrade:
48
56
  '''This encodes "magic" rules about upgrading firmware, largely about
49
57
  how to munge configuration options so as to not cause behavior
@@ -54,15 +62,23 @@ class FirmwareUpgrade:
54
62
  self.old = old
55
63
  self.new = new
56
64
 
57
- SUPPORTED_ABI_VERSION = 0x0107
65
+ SUPPORTED_ABI_VERSION = 0x0109
58
66
 
59
67
  if new > SUPPORTED_ABI_VERSION:
60
68
  raise RuntimeError(f"\nmoteus_tool needs to be upgraded to support this firmware\n\n (likely 'python -m pip install --upgrade moteus')\n\nThe provided firmare is ABI version 0x{new:04x} but this moteus_tool only supports up to 0x{SUPPORTED_ABI_VERSION:04x}")
61
69
 
70
+ if old > SUPPORTED_ABI_VERSION:
71
+ raise RuntimeError(f"\nmoteus_tool needs to be upgraded to support this board\n\n (likely 'python -m pip install --upgrade moteus')\n\nThe board firmware is ABI version 0x{old:04x} but this moteus_tool only supports up to 0x{SUPPORTED_ABI_VERSION:04x}")
72
+
62
73
  def fix_config(self, old_config):
63
74
  lines = old_config.split(b'\n')
64
75
  items = dict([line.split(b' ') for line in lines if b' ' in line])
65
76
 
77
+ if self.new <= 0x0107 and self.old >= 0x0108:
78
+ if float(items.get(b'servo.bemf_feedforward', '0')) == 0.0:
79
+ print("Reverting servo.bemf_feedforward to 1.0")
80
+ items[b'servo.bemf_feedforward'] = b'1.0'
81
+
66
82
  if self.new <= 0x0106 and self.old >= 0x0107:
67
83
  # motor_position.output.sign was broken in older versions.
68
84
  if int(items[b'motor_position.output.sign']) != 1:
@@ -294,6 +310,40 @@ class FirmwareUpgrade:
294
310
  # No actual configuration updating is required here.
295
311
  pass
296
312
 
313
+ if self.new >= 0x0108 and self.old <= 0x0107:
314
+ if float(items.get(b'servo.bemf_feedforward', 1.0)) == 1.0:
315
+ print("Upgrading servo.bemf_feedforward to 0.0")
316
+ items[b'servo.bemf_feedforward'] = b'0.0'
317
+
318
+ if self.new >= 0x0109 and self.old <= 0x0108:
319
+ # Try to fix up the motor commutation offset tables.
320
+ old_offsets = []
321
+ i = 0
322
+ while True:
323
+ key = f'motor.offset.{i}'.encode('utf8')
324
+ if key not in items:
325
+ break
326
+ old_offsets.append(float(items.get(key)))
327
+ i += 1
328
+
329
+ offsets = old_offsets[:]
330
+
331
+ # Unwrap this, then re-center the whole thing around 0.
332
+ for i in range(1, len(offsets)):
333
+ offsets[i] = (offsets[i - 1] +
334
+ _wrap_neg_pi_to_pi(offsets[i] -
335
+ offsets[i - 1]))
336
+ mean_offset = sum(offsets) / len(offsets)
337
+ delta = mean_offset - _wrap_neg_pi_to_pi(mean_offset)
338
+ offsets = [x - delta for x in offsets]
339
+
340
+ if any([abs(a - b) > 0.01
341
+ for a, b in zip(offsets, old_offsets)]):
342
+ print("Re-wrapping motor commutation offsets")
343
+ for i in range(len(offsets)):
344
+ key = f'motor.offset.{i}'.encode('utf8')
345
+ items[key] = f'{offsets[i]}'.encode('utf8')
346
+
297
347
  lines = [key + b' ' + value for key, value in items.items()]
298
348
  return b'\n'.join(lines)
299
349
 
@@ -989,6 +1039,10 @@ class Stream:
989
1039
  raise RuntimeError(
990
1040
  'hall effect calibration requires specifying --cal-motor-poles')
991
1041
 
1042
+ if (self.args.cal_motor_poles % 2) == 1:
1043
+ raise RuntimeError(
1044
+ 'only motors with even numbers of poles are supported')
1045
+
992
1046
  commutation_source = await self.read_config_int(
993
1047
  "motor_position.commutation_source")
994
1048
  aux_number = await self.read_config_int(
@@ -1049,21 +1103,26 @@ class Stream:
1049
1103
  await self.command("d stop")
1050
1104
 
1051
1105
  async def ensure_valid_theta(self, encoder_cal_voltage):
1052
- try:
1053
- # We might need to have some sense of pole count first.
1054
- if await self.read_config_double("motor.poles") == 0:
1055
- # Pick something arbitrary for now.
1056
- await self.command(f"conf set motor.poles 2")
1106
+ # We might need to have some sense of pole count first.
1107
+ if await self.read_config_double("motor.poles") == 0:
1108
+ # Pick something arbitrary for now.
1109
+ await self.command(f"conf set motor.poles 2")
1057
1110
 
1111
+ try:
1058
1112
  motor_position = await self.read_data("motor_position")
1059
- if motor_position.homed == 0 and not motor_position.theta_valid:
1060
- # We need to find an index.
1061
- await self.find_index(encoder_cal_voltage)
1062
1113
  except RuntimeError:
1063
1114
  # Odds are we don't support motor_position, in which case
1064
1115
  # theta is always valid for the older versions that don't
1065
1116
  # support it.
1066
- pass
1117
+ return
1118
+
1119
+ if motor_position.error != 0:
1120
+ raise RuntimeError(
1121
+ f"encoder error: {repr(motor_position.error)}")
1122
+
1123
+ if motor_position.homed == 0 and not motor_position.theta_valid:
1124
+ # We need to find an index.
1125
+ await self.find_index(encoder_cal_voltage)
1067
1126
 
1068
1127
  async def calibrate_encoder_mapping_absolute(self, encoder_cal_voltage):
1069
1128
  await self.ensure_valid_theta(encoder_cal_voltage)
@@ -1106,7 +1165,10 @@ class Stream:
1106
1165
  cal_file,
1107
1166
  desired_direction=1 if not self.args.cal_invert else -1,
1108
1167
  max_remainder_error=self.args.cal_max_remainder,
1109
- allow_phase_invert=allow_phase_invert)
1168
+ allow_phase_invert=allow_phase_invert,
1169
+ allow_optimize=not self.args.cal_disable_optimize,
1170
+ force_optimize=self.args.cal_force_optimize,
1171
+ )
1110
1172
 
1111
1173
  if cal_result.errors:
1112
1174
  raise RuntimeError(f"Error(s) calibrating: {cal_result.errors}")
@@ -1347,9 +1409,19 @@ class Stream:
1347
1409
  return 1 if x >= 0 else -1
1348
1410
 
1349
1411
  velocity_samples = []
1412
+ power_samples = []
1350
1413
 
1351
1414
  while True:
1352
1415
  data = await self.read_servo_stats()
1416
+
1417
+ total_current_A = math.hypot(data.d_A, data.q_A)
1418
+ total_power_W = voltage * total_current_A
1419
+
1420
+ power_samples.append(total_power_W)
1421
+
1422
+ if len(power_samples) > AVERAGE_COUNT:
1423
+ del power_samples[0]
1424
+
1353
1425
  velocity_samples.append(data.velocity)
1354
1426
 
1355
1427
  if len(velocity_samples) > (3 * AVERAGE_COUNT):
@@ -1361,6 +1433,24 @@ class Stream:
1361
1433
  if (time.time() - start_time) > 2.0:
1362
1434
  return recent_average
1363
1435
 
1436
+ if len(power_samples) >= AVERAGE_COUNT:
1437
+ average_power_W = sum(power_samples) / len(power_samples)
1438
+ max_power_W = (self.args.cal_max_kv_power_factor *
1439
+ self.args.cal_motor_power)
1440
+
1441
+ # This is a safety. During speed measurement, current
1442
+ # should always be near 0. However, if the encoder
1443
+ # commutation calibration failed, we can sometimes
1444
+ # trigger large currents during the Kv detection phase
1445
+ # while not actually moving.
1446
+ if (abs(recent_average) < 0.2 and
1447
+ average_power_W > max_power_W):
1448
+ await self.command("d stop")
1449
+
1450
+ raise RuntimeError(
1451
+ f"Motor failed to spin, {average_power_W} > " +
1452
+ f"{max_power_W}")
1453
+
1364
1454
  if (len(velocity_samples) >= AVERAGE_COUNT and
1365
1455
  abs(recent_average) < 0.2):
1366
1456
  return recent_average
@@ -1752,11 +1842,17 @@ async def async_main():
1752
1842
  parser.add_argument('--cal-force-kv', metavar='Kv', type=float,
1753
1843
  default=None,
1754
1844
  help='do not calibrate Kv, but use the specified value')
1845
+ parser.add_argument('--cal-force-optimize', action='store_true',
1846
+ help='require nonlinear commutation optimization')
1847
+ parser.add_argument('--cal-disable-optimize', action='store_true',
1848
+ help='prevent nonlinear commutation optimization')
1755
1849
 
1756
1850
 
1757
1851
  parser.add_argument('--cal-max-remainder', metavar='F',
1758
1852
  type=float, default=0.1,
1759
1853
  help='maximum allowed error in calibration')
1854
+ parser.add_argument('--cal-max-kv-power-factor', type=float,
1855
+ default=1.0)
1760
1856
  parser.add_argument('--cal-raw', metavar='FILE', type=str,
1761
1857
  help='write raw calibration data')
1762
1858
 
moteus/version.py CHANGED
@@ -12,4 +12,4 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- VERSION="0.3.70"
15
+ VERSION="0.3.72"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: moteus
3
- Version: 0.3.70
3
+ Version: 0.3.72
4
4
  Summary: moteus brushless controller library and tools
5
5
  Home-page: https://github.com/mjbots/moteus
6
6
  Author: mjbots Robotic Systems
@@ -15,7 +15,9 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: pyserial >=3.5
16
16
  Requires-Dist: python-can >=3.3
17
17
  Requires-Dist: pyelftools >=0.26
18
+ Requires-Dist: scipy >=1.8.0
18
19
  Requires-Dist: importlib-metadata >=3.6
20
+ Requires-Dist: numpy <2
19
21
  Requires-Dist: pywin32 ; platform_system == "Windows"
20
22
 
21
23
  # Python bindings for moteus brushless controller #
@@ -1,12 +1,12 @@
1
1
  moteus/__init__.py,sha256=DxasQre5HmK3BS3I35K8GEUvFZF3_OE_hjVzQy-seIE,739
2
2
  moteus/aioserial.py,sha256=GeWuvsZKCRrfBN33JZFjtBXPr-0sKpQv9shRn2ulcDA,1079
3
3
  moteus/aiostream.py,sha256=YAkVF6QWsA49vqO-GgXEohDghqm_-nnajJzhO_Q9qNQ,3696
4
- moteus/calibrate_encoder.py,sha256=R3pRgGqrDCcmKQqFE7Fr4p8TSie179iqNfBwEJS3pL0,12416
4
+ moteus/calibrate_encoder.py,sha256=6ecNPY1CnCgoa7z9YmeIK471-TQLirNbz_B5erBGarU,15115
5
5
  moteus/command.py,sha256=UkOsbtkso6Oyex8CfbpAKpBNriik519ymxL86EZGkRs,1169
6
6
  moteus/export.py,sha256=vRIfldaqz1eHtWo3993SvatATtu73TZbejL0hzQe8YE,1646
7
7
  moteus/fdcanusb.py,sha256=7PrQiCTROY96gdT2zSZYU1bOCriw-I7H6NspaZpiEx4,7431
8
- moteus/moteus.py,sha256=gARNQ2_irh_1zp5YBTXmZK5U7SziBna8R140e_Wyfxk,48998
9
- moteus/moteus_tool.py,sha256=dawmuZ9SnmZ9rDpKMnnGxeZkadrTaMGPEfs13X7nLP0,70999
8
+ moteus/moteus.py,sha256=GFNGB4XedCOLJSr_ukhvIsGfY1y5p1y-7nUKyFfuOl8,48998
9
+ moteus/moteus_tool.py,sha256=ZdUisFMTy_d5OYqTltSRlung6Zf-5ezQjylgaGSYnjw,75146
10
10
  moteus/multiplex.py,sha256=LF6MuelzYHqqsCJuCB9YeEyUA03eBaTYRwAVotX3qm8,10120
11
11
  moteus/posix_aioserial.py,sha256=2oDrw8TBEwuEQjY41g9rHeuFeffcPHqMwNS3nf5NVq8,3137
12
12
  moteus/pythoncan.py,sha256=ofotOrDuaFhTLvaokaO3EJK6quVc75Bq-ue70lDMtXI,4071
@@ -14,10 +14,10 @@ moteus/reader.py,sha256=9i1-h4aGd4syfqtWJcpg70Bl-bmunkGU4FmXmOLyRt8,12121
14
14
  moteus/regression.py,sha256=M5gjDBYJQ64iBXIrvBhMkD8TYhtlnQ85x8U4py0niGA,1196
15
15
  moteus/router.py,sha256=501W5GZ12rFoc1lmcH3S7IYsoc-Q_-FJ4B3i37RzE3Q,2061
16
16
  moteus/transport.py,sha256=WhkW2G9i25lkOlO55eI5_oXmU0PhDmxTeJ75Sg_7nTI,1021
17
- moteus/version.py,sha256=lm6BoZJ_qZ6gZJ22LrCQaLKZWXRuR2fV_to4JsV9Uvs,627
17
+ moteus/version.py,sha256=QAXlfPdKUv7iVUDMlfA0IophEOfw4S5Pf7yO4Kiq5k4,627
18
18
  moteus/win32_aioserial.py,sha256=culdl-vYxBKD5n2s5LkIMGyUaHyCcEc8BL5-DWEaxX8,2025
19
- moteus-0.3.70.dist-info/METADATA,sha256=GgWzsYPbYst5VXP3tSE15HJuvpZgVAIAdIBpOySqHAc,3388
20
- moteus-0.3.70.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
21
- moteus-0.3.70.dist-info/entry_points.txt,sha256=accRcwir_K8wCf7i3qHb5R6CPh5SiSgd5a1A92ibb9E,56
22
- moteus-0.3.70.dist-info/top_level.txt,sha256=aZzmI_yecTaDrdSp29pTJuowaSQ9dlIZheQpshGg4YQ,7
23
- moteus-0.3.70.dist-info/RECORD,,
19
+ moteus-0.3.72.dist-info/METADATA,sha256=ulIsuC8AzRG9A9h-prW5cKfBLihFDiR-K9xxBGGvHuw,3441
20
+ moteus-0.3.72.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
21
+ moteus-0.3.72.dist-info/entry_points.txt,sha256=accRcwir_K8wCf7i3qHb5R6CPh5SiSgd5a1A92ibb9E,56
22
+ moteus-0.3.72.dist-info/top_level.txt,sha256=aZzmI_yecTaDrdSp29pTJuowaSQ9dlIZheQpshGg4YQ,7
23
+ moteus-0.3.72.dist-info/RECORD,,