Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\se_ils2t.py: 35%

# Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\se_ils2t.py : 35%

## 340 statements

Hot-keys on this page

r m x p toggle line displays

j k next/prev highlighted chunk

0 (zero) top of page

1 (one) first highlighted chunk

1# Copyright (c) 2019-2020 ETH Zurich, SIS ID and HVL D-ITET

2#

3"""

4 Device class for controlling a Schneider Electric ILS2T stepper drive over modbus TCP.

5"""

6

7 import logging

8 from datetime import timedelta

9 from enum import Flag, IntEnum

10 from numbers import Integral

11 from time import sleep, time

12 from typing import Dict, List, Any, cast, Optional

13

14 import aenum

15 from bitstring import BitArray

16 from pymodbus.constants import Endian

17 from pymodbus.payload import BinaryPayloadDecoder, BinaryPayloadBuilder

18

19 from .base import SingleCommDevice

20 from ..comm import (

21 ModbusTcpCommunication,

22 ModbusTcpConnectionFailedException,

23 ModbusTcpCommunicationConfig,

24)

25 from ..configuration import configdataclass

26 from ..utils.typing import Number

27

28

29 class ILS2TException(Exception):

30 """

31 Exception to indicate problems with the SE ILS2T stepper motor.

32 """

33

34 pass

35

36

37 class IoScanningModeValueError(ILS2TException):

38 """

39 Exception to indicate that the selected IO scanning mode is invalid.

40 """

41

42 pass

43

44

45 class ScalingFactorValueError(ILS2TException):

46 """

47 Exception to indicate that a scaling factor value is invalid.

48 """

49

50 pass

51

52

53@configdataclass

54 class ILS2TModbusTcpCommunicationConfig(ModbusTcpCommunicationConfig):

55 """

56 Configuration dataclass for Modbus/TCP communciation specific for the Schneider

57 Electric ILS2T stepper motor.

58 """

59

60 #: The unit has to be 255 such that IO scanning mode works.

61 unit: int = 255

62

63

64 class ILS2TModbusTcpCommunication(ModbusTcpCommunication):

65 """

66 Specific implementation of Modbus/TCP for the Schneider Electric ILS2T stepper

67 motor.

68 """

69

70 @staticmethod

71 def config_cls():

72 return ILS2TModbusTcpCommunicationConfig

73

74

75@configdataclass

76 class ILS2TConfig:

77 """

78 Configuration for the ILS2T stepper motor device.

79 """

80

81 #: initial maximum RPM for the motor, can be set up to 3000 RPM. The user is

82 #: allowed to set a new max RPM at runtime using :meth:`ILS2T.set_max_rpm`,

83 #: but the value must never exceed this configuration setting.

84 rpm_max_init: Integral = cast(Integral, 1500)

85 wait_sec_post_enable: Number = 1

86 wait_sec_max_disable: Number = 10

87 wait_sec_post_cannot_disable: Number = 1

88 wait_sec_post_relative_step: Number = 2

89 wait_sec_post_absolute_position: Number = 2

90

91 def clean_values(self):

92 if not 0 < self.rpm_max_init <= 3000:

93 raise ValueError(

94 "Maximum RPM for the motor must be integer number between 1 and 3000."

95 )

96 if self.wait_sec_post_enable <= 0:

97 raise ValueError(

98 "Wait time post motor enabling must be a positive value (in seconds)."

99 )

100 if self.wait_sec_max_disable < 0:

101 raise ValueError(

102 "Maximal wait time for attempting to disable motor must be a "

103 "non-negative value (in seconds)."

104 )

105 if self.wait_sec_post_cannot_disable <= 0:

106 raise ValueError(

107 "Wait time post failed motor disable attempt must be a positive value "

108 "(in seconds)."

109 )

110 if self.wait_sec_post_relative_step <= 0:

111 raise ValueError(

112 "Wait time post motor relative step must be a positive value "

113 "(in seconds)."

114 )

115 if self.wait_sec_post_absolute_position <= 0:

116 raise ValueError(

117 "Wait time post motor absolute position change must be a positive "

118 "value (in seconds)."

119 )

120

121

122 class ILS2TRegDatatype(aenum.Enum):

123 """

124 Modbus Register Datatypes for Schneider Electric ILS2T stepper drive.

125

126 From the manual of the drive:

127

128 =========== =========== ============== =============

129 datatype byte min max

130 =========== =========== ============== =============

131 INT8 1 Byte -128 127

132 UINT8 1 Byte 0 255

133 INT16 2 Byte -32_768 32_767

134 UINT16 2 Byte 0 65_535

135 INT32 4 Byte -2_147_483_648 2_147_483_647

136 UINT32 4 Byte 0 4_294_967_295

137 BITS just 32bits N/A N/A

138 =========== =========== ============== =============

139

140 """

141

142 _init_ = "min max"

143 INT32 = -2_147_483_648, 2_147_483_647

144

145 def is_in_range(self, value: int) -> bool:

146 return self.min <= value <= self.max

147

148

149 class ILS2TRegAddr(IntEnum):

150 """

151 Modbus Register Adresses for for Schneider Electric ILS2T stepper drive.

152 """

153

154 POSITION = 7706 # INT32 position of the motor in user defined units

155 IO_SCANNING = 6922 # BITS start register for IO scanning control

156 # and status

157 TEMP = 7200 # INT16 temperature of motor

158 VOLT = 7198 # UINT16 dc voltage of motor

159 SCALE = 1550 # INT32 user defined steps per revolution

160 ACCESS_ENABLE = 282 # BITS not documented register

161 # to enable access via IO scanning

162 JOGN_FAST = 10506 # UINT16 revolutions per minute for fast Jog (1 to 3000)

163 JOGN_SLOW = 10504 # UINT16 revolutions per minute

164 # for slow Jog (1 to 3000)

165

166 RAMP_TYPE = 1574 # INT16 ramp type, 0: linear / -1: motor optimized

167 RAMP_ACC = 1556 # UINT32 acceleration

168 RAMP_DECEL = 1558 # UINT32 deceleration

169 RAMP_N_MAX = 1554 # UINT16 max rpm

170 FLT_INFO = 15362 # 22 registers, code for error

171 FLT_MEM_RESET = 15114 # UINT16 reset fault memory

172 FLT_MEM_DEL = 15112 # UINT16 delete fault memory

173

174

175 class ILS2T(SingleCommDevice):

176 """

177 Schneider Electric ILS2T stepper drive class.

178 """

179

180 RegDatatype = ILS2TRegDatatype

181 """Modbus Register Datatypes

182 """

183 RegAddr = ILS2TRegAddr

184 """Modbus Register Adresses

185 """

186

187 class Mode(IntEnum):

188 """

189 ILS2T device modes

190 """

191

192 PTP = 3 # point to point

193 JOG = 1

194

195 class ActionsPtp(IntEnum):

196 """

197 Allowed actions in the point to point mode (`ILS2T.Mode.PTP`).

198 """

199

200 ABSOLUTE_POSITION = 0

201 RELATIVE_POSITION_TARGET = 1

202 RELATIVE_POSITION_MOTOR = 2

203

204 ACTION_JOG_VALUE = 0

205 """

206 The single action value for `ILS2T.Mode.JOG`

207 """

208

209 # Note: don't use IntFlag here - it allows other then enumerated values

210 class Ref16Jog(Flag):

211 """

212 Allowed values for ILS2T ref_16 register (the shown values are the integer

213 representation of the bits), all in Jog mode = 1

214 """

215

216 NONE = 0

217 POS = 1

218 NEG = 2

219 FAST = 4

220 # allowed combinations

221 POS_FAST = POS | FAST

222 NEG_FAST = NEG | FAST

223

224 class State(IntEnum):

225 """

226 State machine status values

227 """

228

229 QUICKSTOP = 7

230 READY = 4

231 ON = 6

232

233 DEFAULT_IO_SCANNING_CONTROL_VALUES = {

234 "action": ActionsPtp.RELATIVE_POSITION_MOTOR.value,

235 "mode": Mode.PTP.value,

236 "disable_driver_di": 0,

237 "enable_driver_en": 0,

238 "quick_stop_qs": 0,

239 "fault_reset_fr": 0,

240 "execute_stop_sh": 0,

241 "reset_stop_ch": 0,

242 "continue_after_stop_cu": 0,

243 "ref_16": ILS2TConfig.rpm_max_init,

244 "ref_32": 0,

245 }

246 """

247 Default IO Scanning control mode values

248 """

249

250 def __init__(self, com, dev_config=None) -> None:

251 """

252 Constructor for ILS2T.

253

254 :param com: object to use as communication protocol.

255 """

256

257 # Call superclass constructor

258 super().__init__(com, dev_config)

259

260 # toggle reminder bit

261 self._mode_toggle_mt = 0

262

263 self.flt_list: List[Dict[int, Dict[str, Any]]] = []

264

265 @staticmethod

266 def default_com_cls():

267 return ILS2TModbusTcpCommunication

268

269 @staticmethod

270 def config_cls():

271 return ILS2TConfig

272

273 def start(self) -> None:

274 """

275 Start this device.

276 """

277

278 logging.info("Starting device " + str(self))

279 try:

280 # try opening the port

281 super().start()

282 except ModbusTcpConnectionFailedException as exc:

283 logging.error(exc)

284 raise

285

286 # writing 1 to register ACCESS_ENABLE allows to use the IO scanning mode.

287 # This is not documented in the manual!

288 self.com.write_registers(self.RegAddr.ACCESS_ENABLE.value, [0, 1])

289

290 # set maximum RPM from init config

291 self.set_max_rpm(self.config.rpm_max_init)

292

293 def stop(self) -> None:

294 """

295 Stop this device. Disables the motor (applies brake), disables access and

296 closes the communication protocol.

297 """

298

299 logging.info("Stopping device " + str(self))

300 self.disable()

301 self.com.write_registers(self.RegAddr.ACCESS_ENABLE.value, [0, 0])

302 super().stop()

303

304 def get_status(self) -> Dict[str, int]:

305 """

306 Perform an IO Scanning read and return the status of the motor.

307

308 :return: dict with status information.

309 """

310

311 registers = self.com.read_holding_registers(self.RegAddr.IO_SCANNING.value, 8)

312 return self._decode_status_registers(registers)

313

314 def do_ioscanning_write(self, **kwargs: int) -> None:

315 """

316 Perform a write operation using IO Scanning mode.

317

318 :param kwargs:

319 Keyword-argument list with options to send, remaining are taken

320 from the defaults.

321 """

322

323 self._toggle()

324 values = self._generate_control_registers(**kwargs)

325 self.com.write_registers(self.RegAddr.IO_SCANNING.value, values)

326

327 def _generate_control_registers(self, **kwargs: int) -> List[int]:

328 """

329 Generates the control registers for the IO scanning mode.

330 It is necessary to write all 64 bit at the same time, so a list of 4 registers

331 is generated.

332

333 :param kwargs: Keyword-argument list with options different than the defaults.

334 :return: List of registers for the IO scanning mode.

335 """

336

337 cleaned_io_scanning_mode = self._clean_ioscanning_mode_values(kwargs)

338

339 action_bits = f"{cleaned_io_scanning_mode['action']:03b}"

340 mode_bits = f"{cleaned_io_scanning_mode['mode']:04b}"

341 builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Big)

342

343 # add the first byte: Drive control

344 builder.add_bits(

345 [

346 cleaned_io_scanning_mode["disable_driver_di"],

347 cleaned_io_scanning_mode["enable_driver_en"],

348 cleaned_io_scanning_mode["quick_stop_qs"],

349 cleaned_io_scanning_mode["fault_reset_fr"],

350 0, # has to be 0 per default, no meaning

351 cleaned_io_scanning_mode["execute_stop_sh"],

352 cleaned_io_scanning_mode["reset_stop_ch"],

353 cleaned_io_scanning_mode["continue_after_stop_cu"],

354 ]

355 )

356

357 # add the second byte: Mode control

358 builder.add_bits(

359 [

360 int(mode_bits[3]),

361 int(mode_bits[2]),

362 int(mode_bits[1]),

363 int(mode_bits[0]),

364 int(action_bits[2]),

365 int(action_bits[1]),

366 int(action_bits[0]),

367 self._mode_toggle_mt,

368 ]

369 )

370

371 # add the third and fourth byte:

372 # Ref_16 (either JOG direction/speed, or RPM...)

373 builder.add_16bit_uint(cleaned_io_scanning_mode["ref_16"])

374

375 # add 4 bytes Ref_32, Target position

376 builder.add_32bit_int(cleaned_io_scanning_mode["ref_32"])

377

378 return builder.to_registers()

379

380 def _clean_ioscanning_mode_values(

381 self, io_scanning_values: Dict[str, int]

382 ) -> Dict[str, int]:

383 """

384 Checks if the constructed mode is valid.

385

386 :param io_scanning_values: Dictionary with register values to check

387 :return: Dictionary with cleaned register values

388 :raises ValueError: if `io_scanning_values` has unrecognized keys

389 :raises IoScanningModeValueError: if either `'mode'` or either of corresponding

390 `'action'`, `'ref_16'`, or `'ref_32'` keys of `io_scanning_values` has

391 an invalid value.

392 """

393

394 # check if there are too much keys that are not recognized

395 io_scanning_keys = set(io_scanning_values.keys())

396 all_keys = set(self.DEFAULT_IO_SCANNING_CONTROL_VALUES.keys())

397 superfluous_keys = io_scanning_keys.difference(all_keys)

398 if superfluous_keys:

399 raise ValueError(f"Unrecognized mode keys: {list(superfluous_keys)}")

400

401 # fill up io_scanning_values with defaults, if they are not set

402 for mode_key, default_value in self.DEFAULT_IO_SCANNING_CONTROL_VALUES.items():

403 if mode_key not in io_scanning_values:

404 io_scanning_values[mode_key] = cast(int, default_value)

405

406 # perform checks depending on mode

407 # JOG mode

408 if io_scanning_values["mode"] == self.Mode.JOG:

409

410 io_scanning_value = io_scanning_values["action"]

411 if not io_scanning_value == self.ACTION_JOG_VALUE:

412 raise IoScanningModeValueError(f"Wrong action: {io_scanning_value}")

413

414 io_scanning_value = io_scanning_values["ref_16"]

415 try:

416 self.Ref16Jog(io_scanning_value)

417 except ValueError:

418 raise IoScanningModeValueError(

419 f"Wrong value in ref_16 ({io_scanning_value})"

420 )

421

422 io_scanning_value = io_scanning_values["ref_32"]

423 if not io_scanning_value == 0:

424 raise IoScanningModeValueError(

425 f"Wrong value in ref_32 ({io_scanning_value})"

426 )

427

428 return io_scanning_values

429

430 # PTP mode

431 if io_scanning_values["mode"] == self.Mode.PTP:

432

433 io_scanning_value = io_scanning_values["action"]

434 try:

435 self.ActionsPtp(io_scanning_value)

436 except ValueError:

437 raise IoScanningModeValueError(f"Wrong action: {io_scanning_value}")

438

439 io_scanning_value = io_scanning_values["ref_16"]

440 if not self._is_valid_rpm(io_scanning_value):

441 raise IoScanningModeValueError(

442 f"Wrong value in ref_16 ({io_scanning_value})"

443 )

444

445 io_scanning_value = io_scanning_values["ref_32"]

446 if not self._is_int32(io_scanning_value):

447 raise IoScanningModeValueError(

448 f"Wrong value in ref_32 ({io_scanning_value})"

449 )

450

451 return io_scanning_values

452

453 # default

454 raise IoScanningModeValueError(f"Wrong mode: {io_scanning_values['mode']}")

455

456 def _is_valid_rpm(self, num: int) -> bool:

457 """

458 Checks whether `num` is a valid RPM value.

459

460 :param num: RPM value to check

461 :return: `True` if `num` is a valid RPM value, `False` otherwise

462 """

463

464 return isinstance(num, Integral) and 0 < num <= self.config.rpm_max_init

465

466 @classmethod

467 def _is_int32(cls, num: int) -> bool:

468 """

469 Checks whether a number fits in a signed 32-bit integer.

470

471 :param num: is the number to check.

472 :return: check result.

473 """

474 return (

475 isinstance(num, Integral) and

476 cast(ILS2TRegDatatype, ILS2TRegDatatype.INT32).is_in_range(num)

477 )

478

479 @staticmethod

480 def _decode_status_registers(registers: List[int]) -> Dict[str, int]:

481 """

482 Decodes the the status of the stepper drive, derived from IOscanning.

483

484 :param registers: List of 8 registers (6922-6930)

485 :return: dict

486 """

487

488 decoder = BinaryPayloadDecoder.fromRegisters(registers, byteorder=Endian.Big)

489 decoded = {

490 "drive_control": decoder.decode_bits(),

491 "mode_control": decoder.decode_bits(),

492 "ref_16": decoder.decode_16bit_int(),

493 "ref_32": decoder.decode_32bit_int(),

494 "drive_status_1": decoder.decode_bits(),

495 "drive_status_2": decoder.decode_bits(),

496 "mode_status": decoder.decode_bits(),

497 "drive_input": decoder.decode_bits(),

498 "action_word_1": decoder.decode_bits(),

499 "action_word_2": decoder.decode_bits(),

500 "special_function_1": decoder.decode_bits(),

501 "special_function_2": decoder.decode_bits(),

502 }

503

504 return {

505 "mode": BitArray(decoded["mode_status"][3::-1]).int,

506 "action": BitArray(decoded["mode_control"][6:3:-1]).int,

507 "ref_16": decoded["ref_16"],

508 "ref_32": decoded["ref_32"],

509 "state": BitArray(decoded["drive_status_2"][3::-1]).int,

510 "fault": decoded["drive_status_2"][6],

511 "warn": decoded["drive_status_2"][7],

512 "halt": decoded["drive_status_1"][0],

513 "motion_zero": decoded["action_word_2"][6],

514 "turning_positive": decoded["action_word_2"][7],

515 "turning_negative": decoded["action_word_1"][0],

516 }

517

518 def _toggle(self) -> None:

519 """

520 To activate a command it is necessary to toggle the MT bit first.

521 """

522

523 self._mode_toggle_mt = 0 if self._mode_toggle_mt else 1

524

525 def write_relative_step(self, steps: int) -> None:

526 """

527 Write instruction to turn the motor the relative amount of steps. This function

528 does not enable or disable the motor automatically.

529

530 :param steps: Number of steps to turn the motor.

531 """

532 max_step = self.RegDatatype.INT32.max # type: ignore

533 # use _is_int32 instead?

534 if not abs(steps) < max_step:

535 logging.warning(f"number of steps is too big: {steps}")

536

537 logging.info(f"Perform number of steps: {steps}")

538

539 self.do_ioscanning_write(

540 enable_driver_en=1,

541 mode=self.Mode.PTP.value,

542 action=self.ActionsPtp.RELATIVE_POSITION_MOTOR.value,

543 ref_32=steps,

544 )

545

546 def write_absolute_position(self, position: int) -> None:

547 """

548 Write instruction to turn the motor until it reaches the absolute position.

549 This function does not enable or disable the motor automatically.

550

551 :param position: absolute position of motor in user defined steps.

552 """

553

554 max_position = self.RegDatatype.INT32.max # type: ignore

555 # use _is_int32 instead?

556 if not abs(position) < max_position:

557 logging.warning(f"position is out of range: {position}")

558

559 logging.info(f"Absolute position: {position}")

560

561 self.do_ioscanning_write(

562 enable_driver_en=1,

563 mode=self.Mode.PTP.value,

564 action=self.ActionsPtp.ABSOLUTE_POSITION.value,

565 ref_32=position,

566 )

567

568 def _is_position_as_expected(

569 self, position_expected: int, position_actual: int, err_msg: str

570 ) -> bool:

571 """

572 Check if actual drive position is a expected. If expectation is not met,

573 check for possible drive error and log the given error message with appropriate

574 level of severity. Do not raise error; instead, return `bool` stating if

575 expectation was met.

576

577 :param position_expected: Expected drive position.

578 :param position_actual: Actual drive position.

579 :param err_msg: Error message to log if expectation is not met.

580 :return: `True` if actual position is as expected, `False` otherwise.

581 """

582 as_expected = position_expected == position_actual

583 if not as_expected:

584 flt_dict = self.get_error_code()

585 self.flt_list.append(flt_dict)

586 if "empty" in flt_dict[0].keys():

587 logging.warning(

588 "no error in drive, something different must have gone wrong"

589 )

590 logging.warning(err_msg)

591 else:

592 logging.critical("error in drive, drive is know maybe locked")

593 logging.critical(err_msg)

594 return as_expected

595

596 def execute_relative_step(self, steps: int) -> bool:

597 """

598 Execute a relative step, i.e. enable motor, perform relative steps,

599 wait until done and disable motor afterwards.

600

601 Check position at the end if wrong do not raise error; instead just log and

602 return check result.

603

604 :param steps: Number of steps.

605 :return: `True` if actual position is as expected, `False` otherwise.

606 """

607 logging.info(f"Motor steps requested: {steps}")

608

609 with self.com.access_lock:

610 position_before = self.get_position()

611

612 self.enable()

613 sleep(self.config.wait_sec_post_enable)

614 self.write_relative_step(steps)

615 sleep(self.config.wait_sec_post_relative_step)

616 self.disable(log_warn=False)

617

618 # check if steps were made

619 position_after = self.get_position()

620 return self._is_position_as_expected(

621 position_before + steps, position_after, (

622 "The position does not align with the requested step number. "

623 f"Before: {position_before}, after: {position_after}, "

624 f"requested: {steps}, "

625 f"real difference: {position_after - position_before}."

626 )

627 )

628

629 def execute_absolute_position(self, position: int) -> bool:

630 """

631 Execute a absolute position change, i.e. enable motor, perform absolute

632 position change, wait until done and disable motor afterwards.

633

634 Check position at the end if wrong do not raise error; instead just log and

635 return check result.

636

637 :param position: absolute position of motor in user defined steps.

638 :return: `True` if actual position is as expected, `False` otherwise.

639 """

640 logging.info(f"absolute position requested: {position}")

641

642 with self.com.access_lock:

643 position_before = self.get_position()

644

645 self.enable()

646 sleep(self.config.wait_sec_post_enable)

647 self.write_absolute_position(position)

648 sleep(self.config.wait_sec_post_absolute_position)

649 self.disable(log_warn=False)

650

651 # check if steps were made

652 position_after = self.get_position()

653 return self._is_position_as_expected(

654 position, position_after, (

655 "The position does not align with the requested absolute position."

656 f"Before: {position_before}, after: {position_after}, "

657 f"requested: {position}."

658 )

659 )

660

661 def disable(

662 self, log_warn: bool = True, wait_sec_max: Optional[int] = None,

663 ) -> bool:

664 """

665 Disable the driver of the stepper motor and enable the brake.

666

667 Note: the driver cannot be disabled if the motor is still running.

668

669 :param log_warn: if log a warning in case the motor cannot be disabled.

670 :param wait_sec_max: maximal wait time for the motor to stop running and to

671 disable it; by default, with `None`, use a config value

672 :return: `True` if disable request could and was sent, `False` otherwise.

673 """

674 if wait_sec_max is None:

675 wait_sec_max = self.config.wait_sec_max_disable

676

677 try_disable = True

678 elapsed_time = 0.0

679 start_time = time()

680 while try_disable:

681

682 can_disable = bool(self.get_status()["motion_zero"])

683 if can_disable:

684 logging.info("Disable motor, brake.")

685 self.do_ioscanning_write(enable_driver_en=0, disable_driver_di=1)

686 elif log_warn:

687 logging.warning("Cannot disable motor, still running!")

688 elapsed_time += time() - start_time

689

690 try_disable = not can_disable and elapsed_time < wait_sec_max

691 if try_disable:

692 sleep(self.config.wait_sec_post_cannot_disable)

693

694 return can_disable

695

696 def enable(self) -> None:

697 """

698 Enable the driver of the stepper motor and disable the brake.

699 """

700

701 self.do_ioscanning_write(enable_driver_en=1, disable_driver_di=0)

702 logging.info("Enable motor, disable brake.")

703

704 def get_position(self) -> int:

705 """

706 Read the position of the drive and store into status.

707

708 :return: Position step value

709 """

710

711 value = self.com.read_input_registers(self.RegAddr.POSITION.value, 2)

712 return self._decode_32bit(value, True)

713

714 def get_temperature(self) -> int:

715 """

716 Read the temperature of the motor.

717

718 :return: Temperature in degrees Celsius.

719 """

720

721 value = self.com.read_input_registers(self.RegAddr.TEMP.value, 2)

722 return self._decode_32bit(value, True)

723

724 def get_dc_volt(self) -> float:

725 """

726 Read the DC supply voltage of the motor.

727

728 :return: DC input voltage.

729 """

730

731 value = self.com.read_input_registers(self.RegAddr.VOLT.value, 2)

732 return self._decode_32bit(value, True) / 10

733

734 @staticmethod

735 def _decode_32bit(registers: List[int], signed: bool = True) -> int:

736 """

737 Decode two 16-bit ModBus registers to a 32-bit integer.

738

739 :param registers: list of two register values

740 :param signed: True, if register containes a signed value

741 :return: integer representation of the 32-bit register

742 """

743

744 decoder = BinaryPayloadDecoder.fromRegisters(registers, byteorder=Endian.Big)

745 if signed:

746 return decoder.decode_32bit_int()

747 else:

748 return decoder.decode_32bit_uint()

749

750 def user_steps(self, steps: int = 16384, revolutions: int = 1) -> None:

751 """

752 Define steps per revolution.

753 Default is 16384 steps per revolution.

754 Maximum precision is 32768 steps per revolution.

755

756 :param steps: number of steps in `revolutions`.

757 :param revolutions: number of revolutions corresponding to `steps`.

758 """

759

760 if not self._is_int32(revolutions):

761 err_msg = f"Wrong scaling factor: revolutions = {revolutions}"

762 logging.error(err_msg)

763 raise ScalingFactorValueError(err_msg)

764

765 if not self._is_int32(steps):

766 err_msg = f"Wrong scaling factor: steps = {steps}"

767 logging.error(err_msg)

768 raise ScalingFactorValueError(err_msg)

769

770 builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Big)

771 builder.add_32bit_int(steps)

772 builder.add_32bit_int(revolutions)

773 values = builder.to_registers()

774 self.com.write_registers(self.RegAddr.SCALE.value, values)

775

776 def quickstop(self) -> None:

777 """

778 Stops the motor with high deceleration rate and falls into error state. Reset

779 with `reset_error` to recover into normal state.

780 """

781

782 logging.warning("Motor QUICK STOP.")

783 self.do_ioscanning_write(quick_stop_qs=1)

784

785 def reset_error(self) -> None:

786 """

787 Resets the motor into normal state after quick stop or another error occured.

788 """

789

790 logging.info("Reset motor after fault or quick stop.")

791 self.do_ioscanning_write(fault_reset_fr=1)

792

793 def jog_run(self, direction: bool = True, fast: bool = False) -> None:

794 """

795 Slowly turn the motor in positive direction.

796 """

797

798 status = self.get_status()

799

800 if status["mode"] != self.Mode.JOG and not status["motion_zero"]:

801 logging.error("Motor is not in Jog mode or standstill, abort.")

802 return

803

804 if status["state"] != self.State.ON:

805 # need to enable first

806 logging.error("Motor is not enabled or in error state. Try .enable()")

807 return

808

809 ref_16 = self.Ref16Jog.NONE

810

811 if direction:

812 ref_16 = ref_16 | self.Ref16Jog.POS

813 logging.info("Jog mode in positive direction enabled.")

814 else:

815 ref_16 = ref_16 | self.Ref16Jog.NEG

816 logging.info("Jog mode in negative direction enabled.")

817

818 if fast:

819 ref_16 = ref_16 | self.Ref16Jog.FAST

820

821 self.do_ioscanning_write(

822 mode=self.Mode.JOG.value,

823 action=self.ACTION_JOG_VALUE,

824 enable_driver_en=1,

825 ref_16=ref_16.value,

826 )

827

828 def jog_stop(self) -> None:

829 """

830 Stop turning the motor in Jog mode.

831 """

832

833 logging.info("Stop in Jog mode.")

834

835 self.do_ioscanning_write(

836 mode=self.Mode.JOG.value,

837 action=self.ACTION_JOG_VALUE,

838 enable_driver_en=1,

839 ref_16=0,

840 )

841

842 def set_jog_speed(self, slow: int = 60, fast: int = 180) -> None:

843 """

844 Set the speed for jog mode. Default values correspond to startup values of

845 the motor.

846

847 :param slow: RPM for slow jog mode.

848 :param fast: RPM for fast jog mode.

849 """

850

851 logging.info(f"Setting Jog RPM. Slow = {slow} RPM, Fast = {fast} RPM.")

852 self.com.write_registers(self.RegAddr.JOGN_SLOW.value, [0, slow])

853 self.com.write_registers(self.RegAddr.JOGN_FAST.value, [0, fast])

854

855 def get_error_code(self) -> Dict[int, Dict[str, Any]]:

856 """

857 Read all messages in fault memory.

858 Will read the full error message and return the decoded values.

859 At the end the fault memory of the motor will be deleted.

860 In addition, reset_error is called to re-enable the motor for operation.

861

862 :return: Dictionary with all information

863 """

864

865 ret_dict = {}

866 self.com.write_registers(self.RegAddr.FLT_MEM_RESET.value, [0, 1])

867 for i in range(10):

868 registers = self.com.read_input_registers(self.RegAddr.FLT_INFO.value, 22)

869 decoder = BinaryPayloadDecoder.fromRegisters(

870 registers, byteorder=Endian.Big

871 )

872 decoded = {

873 "ignored0": decoder.skip_bytes(2),

874 "error_code": decoder.decode_16bit_uint(),

875 "ignored1": decoder.skip_bytes(2),

876 "error_class": decoder.decode_16bit_uint(),

877 "error_time": decoder.decode_32bit_uint(),

878 "ignored2": decoder.skip_bytes(2),

879 "error_addition": decoder.decode_16bit_uint(),

880 "ignored3": decoder.skip_bytes(2),

881 "error_no_cycle": decoder.decode_16bit_uint(),

882 "ignored4": decoder.skip_bytes(2),

883 "error_after_enable": decoder.decode_16bit_uint(),

884 "ignored5": decoder.skip_bytes(2),

885 "error_voltage_dc": decoder.decode_16bit_uint(),

886 "ignored6": decoder.skip_bytes(2),

887 "error_rpm": decoder.decode_16bit_int(),

888 "ignored7": decoder.skip_bytes(2),

889 "error_current": decoder.decode_16bit_uint(),

890 "ignored8": decoder.skip_bytes(2),

891 "error_amplifier_temperature": decoder.decode_16bit_int(),

892 "ignored9": decoder.skip_bytes(2),

893 "error_device_temperature": decoder.decode_16bit_int(),

894 }

895 flt_dict = {

896 "error_code": hex(decoded["error_code"])[2:],

897 "error_class": decoded["error_class"],

898 "error_time": timedelta(seconds=decoded["error_time"]),

899 "error_addition": decoded["error_addition"],

900 "error_no_cycle": decoded["error_no_cycle"],

901 "error_after_enable": timedelta(seconds=decoded["error_after_enable"]),

902 "error_voltage_dc": decoded["error_voltage_dc"] / 10,

903 "error_rpm": decoded["error_rpm"],

904 "error_current": decoded["error_current"] / 100,

905 "error_amplifier_temperature": decoded["error_amplifier_temperature"],

906 "error_device_temperature": decoded["error_device_temperature"],

907 }

908 ret_dict[i] = flt_dict

909 if flt_dict["error_code"] == "0":

910 flt_dict = {"empty": None}

911 ret_dict = {i: flt_dict}

912 break

913 self.com.write_registers(self.RegAddr.FLT_MEM_DEL.value, [0, 1])

914 self.reset_error()

915 return ret_dict

916

917 def set_max_rpm(self, rpm: int) -> None:

918 """

919 Set the maximum RPM.

920

921 :param rpm: revolution per minute ( 0 < rpm <= RPM_MAX)

922 :raises ILS2TException: if RPM is out of range

923 """

924

925 if self._is_valid_rpm(rpm):

926 self.DEFAULT_IO_SCANNING_CONTROL_VALUES["ref_16"] = rpm

927 self.com.write_registers(self.RegAddr.RAMP_N_MAX.value, [0, rpm])

928 else:

929 raise ILS2TException(

930 f"RPM out of range: {rpm} not in (0, {self.config.rpm_max_init}]"

931 )

932

933 def set_ramp_type(self, ramp_type: int = -1) -> None:

934 """

935 Set the ramp type. There are two options available:

936 0: linear ramp

937 -1: motor optimized ramp

938

939 :param ramp_type: 0: linear ramp | -1: motor optimized ramp

940 """

941

942 self.com.write_registers(self.RegAddr.RAMP_TYPE.value, [0, ramp_type])

943

944 def set_max_acceleration(self, rpm_minute: int) -> None:

945 """

946 Set the maximum acceleration of the motor.

947

948 :param rpm_minute: revolution per minute per minute

949 """

950

951 builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Big)

952 builder.add_32bit_uint(rpm_minute)

953 values = builder.to_registers()

954 self.com.write_registers(self.RegAddr.RAMP_ACC.value, values)

955

956 def set_max_deceleration(self, rpm_minute: int) -> None:

957 """

958 Set the maximum deceleration of the motor.

959

960 :param rpm_minute: revolution per minute per minute

961 """

962

963 builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Big)

964 builder.add_32bit_uint(rpm_minute)

965 values = builder.to_registers()

966 self.com.write_registers(self.RegAddr.RAMP_DECEL.value, values)