From bb0440c5760b9e2d8f79387244283c9b449f1839 Mon Sep 17 00:00:00 2001 From: smathis Date: Wed, 9 Apr 2025 15:18:38 +0200 Subject: [PATCH] Fully working version of SeleneGuide This commit is the first fully working version of the selene guide driver. --- Makefile | 18 +- README.md | 275 ++++++- db/seleneGuide.db | 24 + images/AngleLimits.odg | Bin 0 -> 16931 bytes images/AngleLimits.svg | 1358 +++++++++++++++++++++++++++++++++ images/CoordinateSystems.odg | Bin 0 -> 14934 bytes images/CoordinateSystems.svg | 885 +++++++++++++++++++++ images/Geometry.odg | Bin 18846 -> 0 bytes images/LiftLimits.odg | Bin 0 -> 13770 bytes images/LiftLimits.svg | 760 ++++++++++++++++++ images/OffsetLimits.odg | Bin 0 -> 14051 bytes images/OffsetLimits.svg | 805 +++++++++++++++++++ images/PhysicalSetup.odg | Bin 0 -> 12373 bytes images/PhysicalSetup.svg | 569 ++++++++++++++ src/offsetAxis.cpp | 9 - src/offsetAxis.h | 21 - src/seleneAngleAxis.cpp | 226 +++++- src/seleneAngleAxis.h | 119 ++- src/seleneGuide.dbd | 3 + src/seleneGuideController.cpp | 157 ++++ src/seleneGuideController.h | 60 ++ src/seleneLift.dbd | 5 - src/seleneLiftAxis.cpp | 767 +++++++++++++------ src/seleneLiftAxis.h | 262 +++++-- src/seleneOffsetAxis.cpp | 427 +++++++++++ src/seleneOffsetAxis.h | 170 +++++ 26 files changed, 6520 insertions(+), 400 deletions(-) create mode 100644 db/seleneGuide.db create mode 100644 images/AngleLimits.odg create mode 100644 images/AngleLimits.svg create mode 100644 images/CoordinateSystems.odg create mode 100644 images/CoordinateSystems.svg delete mode 100644 images/Geometry.odg create mode 100644 images/LiftLimits.odg create mode 100644 images/LiftLimits.svg create mode 100644 images/OffsetLimits.odg create mode 100644 images/OffsetLimits.svg create mode 100644 images/PhysicalSetup.odg create mode 100644 images/PhysicalSetup.svg delete mode 100644 src/offsetAxis.cpp delete mode 100644 src/offsetAxis.h create mode 100644 src/seleneGuide.dbd create mode 100644 src/seleneGuideController.cpp create mode 100644 src/seleneGuideController.h delete mode 100644 src/seleneLift.dbd create mode 100644 src/seleneOffsetAxis.cpp create mode 100644 src/seleneOffsetAxis.h diff --git a/Makefile b/Makefile index c6f8178..161c012 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,39 @@ # Use the PSI build system include /ioc/tools/driver.makefile -MODULE=turboPmac +MODULE=seleneGuide BUILDCLASSES=Linux EPICS_VERSIONS=7.0.7 ARCH_FILTER=RHEL% # Additional module dependencies REQUIRED+=motorBase -REQUIRED+=sinqMotor # Specify the version of motorBase we want to build against motorBase_VERSION=7.2.2 # Specify the version of sinqMotor we want to build against -sinqMotor_VERSION=mathis_s +sinqMotor_VERSION=0.11.0 # Specify the version of turboPmac we want to build against -turboPmac_VERSION=mathis_s +turboPmac_VERSION=0.10.0 # These headers allow to depend on this library for derived drivers. +HEADERS += src/seleneGuideController.h HEADERS += src/seleneLiftAxis.h +HEADERS += src/seleneAngleAxis.h +HEADERS += src/seleneOffsetAxis.h # Source files to build +SOURCES += src/seleneGuideController.cpp SOURCES += src/seleneLiftAxis.cpp +SOURCES += src/seleneAngleAxis.cpp +SOURCES += src/seleneOffsetAxis.cpp + +# Store the record files +TEMPLATES += db/seleneGuide.db # This file registers the motor-specific functions in the IOC shell. -DBDS += src/seleneLift.dbd +DBDS += src/seleneGuide.dbd USR_CFLAGS += -Wall -Wextra -Weffc++ -Wunused-result -Wextra -Werror # -Wpedantic // Does not work because EPICS macros trigger warnings diff --git a/README.md b/README.md index 6483063..9b0678b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,58 @@ # turboPmac +## Please read the documentation of sinqMotor first: https://git.psi.ch/sinq-epics-modules/sinqmotor + ## Overview -This is a driver for the Turbo PMAC motion controller with the SINQ communication protocol. It is based on the sinqMotor shared library (https://git.psi.ch/sinq-epics-modules/sinqmotor). The header files contain detailed documentation for all public functions. The headers themselves are exported when building the library to allow other drivers to depend on this one. +![Physical setup](images/PhysicalSetup.svg) + +This is a driver for the Selene guide which consists of three beams supported by two linear axes each. These six physical axes are transformed into 8 virtual axes: +- A virtual "lift axis" which represents the general elevation of the entire guide +- A virtual "angle axis" which represents the general tilt of the entire guide +- Six "offset axes" which represent deviations of individual axes from the general guide elevation and tilt. + +The image below shows the used coordinate systems and the axes: + +![Coordinate systems](images/CoordinateSystems.svg) + +The dash-dotted line represents the guide, from which the individual grey beams may deviate due to an offset. The horizontal positions of the individual axes are defined such that axis 1 has the position 0, while the other axes have a position larger than zero. The x-position of the lift and angle axes is at half the distance between axis 1 and axis 6. These position values can be defined [in the axes constructors in the IOC shell](#usage-in-ioc-shell). The z-origins of the individual axes are transformed to a common baseline so that their individual position value is zero when both lift and angle axis position are also zero. + +These definitions shall be illustrated by analyzing the example image: lift ($z_{lift}$) and angle axis ($\alpha$) both have a value smaller zero, offset axis 1 has a positive value $\Delta z_1$, offset axis 2 a smaller negative value $\Delta z_2$. The offset axes 3 ($\Delta z_3$) and 4 ($\Delta z_4$) are both zero, while 5 ($\Delta z_5$) and 6 ($\Delta z_6$) have a positive value. + +Since we have 8 independent virtual axes, but only 6 underlying physical ones, the entire system is overdefined (different position values for individual axes can lead to the same resulting positions). In order to make the system well defined, the following "normalization" procedure is used: The offsets of axis 3 and 4 are defined to be zero and all other axis positions are calculated depending on this definition. In particular, this means that the lift and angle position are calculated as: + +$z_{lift} = 0.5 \cdot (z_3 + z_4)$ + +$\alpha_{angle} = \arctan((z_4 - z_3) / (x_4 - x_3))$ + +The other offsets are then calculated as: + +$z_i = z_{lift} + \tan(\alpha_{angle}) \cdot (x_i - x_{lift})$ + +This normalization procedure can be triggered from a special EPICS PV (see section [user guide](#user-guide)). + +This driver is based on the sinqMotor shared library (https://git.psi.ch/sinq-epics-modules/sinqmotor). The header files contain detailed documentation for all public functions. The headers themselves are exported when building the library to allow other drivers to depend on this one. ## User guide -This driver is a standard sinqMotor-derived driver and does not need any specific configuration. For the general configuration, please see https://git.psi.ch/sinq-epics-modules/sinqmotor/-/blob/main/README.md. +As discussed in the overview section, the selene guide is represented by the six offset axes, the lift and the angle axes. To use the selene guide, all eight axes need to be initialized in EPICS. From the NICOS side, the axes can be treated as "normal" motor record axes (see section [NICOS setup](#nicos-setup)). -The folder "utils" contains utility scripts for working with pmac motor controllers. To read their manual, run the scripts without any arguments. -- writeRead.py: Allows sending commands to and receiving commands from a pmac controller over an ethernet connection. -- analyzeTcpDump.py: Parse the TCP communication between an IOC and a MCU and format it into a dictionary. "demo.py" shows how this data can be easily visualized for analysis. - - -## Developer guide +Two additional PVs are provided in `db/seleneGuide.db`: +- `:AbsolutePositionRBV`: Holds the absolute position of the offset axes as reported from the encoder. For lift and angle axis, its value is undefined and should not be relied on. +- `:Normalize`: Setting this to 1 triggers a normalization (even if the PV already has the value 1). Note that this does not move the motors, it is just a mathematical operation and therefore does not change the absolute motor positions. ### Usage in IOC shell -turboPmac exports the following IOC shell functions: -- `turboPmacController`: Create a new controller object. -- `turboPmacAxis`: Create a new axis object. +seleneGuide exports the following IOC shell functions: +- `seleneGuideController`: Create a new controller object. This object is essentially a `turboPmacController` object from the standard [Turbo PMAC driver](https://git.psi.ch/sinq-epics-modules/turboPmac), but it supports the additional motor PVs for absolute position and normalization. This means that it can be used with "normal" `turboPmacAxis` as well. +- `seleneOffsetAxis`: Create a new offset axis object with the specified x- and z-offset from an arbitrary origin in mm. +- `seleneVirtualAxes`: Create the virtual lift and the angle axes. -The full mcu.cmd file looks like this: +The full `seleneGuide.cmd` file looks like this: ``` # Define the name of the controller and the corresponding port -epicsEnvSet("NAME","mcu") +epicsEnvSet("NAME","turboPmac1") epicsEnvSet("ASYN_PORT","p$(NAME)") # Create the TCP/IP socket used to talk with the controller. The socket can be adressed from within the IOC shell via the port name @@ -33,31 +60,225 @@ drvAsynIPPortConfigure("$(ASYN_PORT)","172.28.101.24:1025") # Create the controller object with the defined name and connect it to the socket via the port name. # The other parameters are as follows: -# 8: Maximum number of axes +# 10: Maximum number of axes # 0.05: Busy poll period in seconds # 1: Idle poll period in seconds -# 1: Socket communication timeout in seconds -turboPmacController("$(NAME)", "$(ASYN_PORT)", 8, 0.05, 1, 1); +# 0.3: Socket communication timeout in seconds +seleneGuideController("$(NAME)", "$(ASYN_PORT)", 10, 0.05, 1, 0.3); -# Define some axes for the specified MCU at the given slot (1, 2 and 5). No slot may be used twice! -turboPmacAxis("$(NAME)",1); -turboPmacAxis("$(NAME)",2); -turboPmacAxis("$(NAME)",5); +# Create the selene offset axes with indices 1 to 6 and the specified x- and +# z-offsets from axis 1 (therefore axis 1 has zero in both entries) in mm. +# The coordinate system of the second axis then has an x-offset from 1.5 m and +# a z-offset of 0 m. The other axes are positioned accordingly. +seleneOffsetAxis("$(NAME)",1, 0.0, 0.0); +seleneOffsetAxis("$(NAME)",2, 1529.43329, 0.0); +seleneOffsetAxis("$(NAME)",3, 2966.59203, 0.0); +seleneOffsetAxis("$(NAME)",4, 4496.35897, 0.0); +seleneOffsetAxis("$(NAME)",5, 5933.99477, 0.0); +seleneOffsetAxis("$(NAME)",6, 7463.87259, 0.0); -# Set the number of subsequent timeouts -setMaxSubsequentTimeouts("$(NAME)", 20); +# These are two "normal" PMAC axes which work the same way they would with a turboPmacController. +turboPmacAxis("$(NAME)",7); +turboPmacAxis("$(NAME)",8); -# Configure the timeout frequency watchdog: -setThresholdComTimeout("$(NAME)", 100, 1); +# This function call creates the lift and the angle axes. +# The arguments on position 2 to 7 are the axis indices of the offset axes +# belonging to the virtual axes. The 8th index "9" is the index of the lift axis, +# the 9th index "10" is the index of the angle axis. +seleneVirtualAxes("$(NAME)", 1, 2, 3, 4, 5, 6, 9, 10); -# Parametrize the EPICS record database with the substitution file named after the MCU. +# Parametrize the EPICS record database with the substitution file named after the motor controller. +# In order to provide the PVs for absolute position and normalization, the +# corresponding `seleneGuide.db` file is loaded here as well. epicsEnvSet("SINQDBPATH","$(sinqMotor_DB)/sinqMotor.db") -dbLoadTemplate("$(TOP)/$(NAME).substitutions", "INSTR=$(INSTR)$(NAME):,CONTROLLER=$(NAME)") +dbLoadTemplate("$(TOP)/motors/$(NAME).substitutions", "INSTR=$(INSTR)$(NAME):,CONTROLLER=$(NAME)") epicsEnvSet("SINQDBPATH","$(turboPmac_DB)/turboPmac.db") -dbLoadTemplate("$(TOP)/$(NAME).substitutions", "INSTR=$(INSTR)$(NAME):,CONTROLLER=$(NAME)") +dbLoadTemplate("$(TOP)/motors/$(NAME).substitutions", "INSTR=$(INSTR)$(NAME):,CONTROLLER=$(NAME)") +epicsEnvSet("SINQDBPATH","$(seleneGuide_DB)/seleneGuide.db") +dbLoadTemplate("$(TOP)/motors/$(NAME).substitutions", "INSTR=$(INSTR)$(NAME):,CONTROLLER=$(NAME)") dbLoadRecords("$(sinqMotor_DB)/asynRecord.db","P=$(INSTR)$(NAME),PORT=$(ASYN_PORT)") ``` +### Substitution file + +Since the position value of the offset axes is relative to those of lift axis and angle axis, the limits are relative as well. Therefore they need to specified manually in the substitution file. As described in the [limit calculation section for offset axes](#offset-axes), these limits are shrunken accordingly if they would exceed the physical limits of the underlying physical axes. + +The relative limits for the offset axes are specified via the DHLM and DLLM fields in engineering units EGU. Lift and angle axis are absolute axes and their limits get calculated directly from the physical axes limits, therefore any values set into the DHLM and DLLM columns get overwritten during IOC initialization as well as during each poll. + +``` +file $(SINQDBPATH) +{ +pattern +{ AXIS, M, EGU, DIR, MRES, ENABLEMOVWATCHDOG, LIMITSOFFSET, DHLM, DLLM } +{ 1, "offsetAxis1", mm, Pos, 0.000001, 0, 0.0, 1.5, -1.5 } +{ 2, "offsetAxis2", mm, Pos, 0.000001, 0, 0.0, 1.5, -1.5 } +{ 3, "offsetAxis3", mm, Pos, 0.000001, 0, 0.0, 1.5, -1.5 } +{ 4, "offsetAxis4", mm, Pos, 0.000001, 0, 0.0, 1.5, -1.5 } +{ 5, "offsetAxis5", mm, Pos, 0.000001, 0, 0.0, 1.5, -1.5 } +{ 6, "offsetAxis6", mm, Pos, 0.000001, 0, 0.0, 1.5, -1.5 } +{ 7, "mot1", mm, Pos, 0.001, 1, 0.001, 0.0, 0.0 } # DHLM and DLLM get overwritten anyway +{ 8, "mot2", mm, Pos, 0.001, 1, 0.001, 0.0, 0.0 } # DHLM and DLLM get overwritten anyway +{ 9, "liftAxis", mm, Pos, 0.000001, 0, 0.0, 0.0, 0.0 } # DHLM and DLLM get overwritten anyway +{ 10, "angleAxis", degree, Pos, 0.000001, 0, 0.0, 0.0, 0.0 } # DHLM and DLLM get overwritten anyway +} +``` + +### NICOS setup + +The code block below shows the Selene guide setup in NICOS in a simplified for the substitution file given above: + +```python +description = 'Selene support stages' + +display_order = 36 + +# Controller PV +pvprefix = '' + +devices = dict( + # Offset motors are normal SinqMotor devices and are therefore configured + # in the same manner. + offsetMot1 = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'offsetAxis1', + unit='mm', + ), + # The absolute positions are simple EpicsReadable devices which use the + # AbsolutePositionRBV PV from the associated axis. It is recommended to add + # a small pollinterval, because otherwise the absolute values will not be + # updated fast enough when moving the motor. + offsetMotAbs1 = device('nicos.devices.epics.pyepics.pyepics.EpicsReadable', + readpv = pvprefix + 'offsetAxis1:AbsolutePositionRBV', + pollinterval = 0.5, + unit='mm', + ), + offsetMot2 = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'offsetAxis2', + unit='mm', + ), + offsetMotAbs2 = device('nicos.devices.epics.pyepics.pyepics.EpicsReadable', + readpv = pvprefix + 'offsetAxis2:AbsolutePositionRBV', + pollinterval = 0.5, + unit='mm', + ), + offsetMot3 = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'offsetAxis3', + unit='mm', + ), + offsetMotAbs3 = device('nicos.devices.epics.pyepics.pyepics.EpicsReadable', + readpv = pvprefix + 'offsetAxis3:AbsolutePositionRBV', + pollinterval = 0.5, + unit='mm', + ), + offsetMot4 = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'offsetAxis4', + unit='mm', + ), + offsetMotAbs4 = device('nicos.devices.epics.pyepics.pyepics.EpicsReadable', + readpv = pvprefix + 'offsetAxis4:AbsolutePositionRBV', + pollinterval = 0.5, + unit='mm', + ), + offsetMot5 = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'offsetAxis5', + unit='mm', + ), + offsetMotAbs5 = device('nicos.devices.epics.pyepics.pyepics.EpicsReadable', + readpv = pvprefix + 'offsetAxis5:AbsolutePositionRBV', + pollinterval = 0.5, + unit='mm', + ), + offsetMot6 = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'offsetAxis6', + unit='mm', + ), + offsetMotAbs6 = device('nicos.devices.epics.pyepics.pyepics.EpicsReadable', + readpv = pvprefix + 'offsetAxis6:AbsolutePositionRBV', + pollinterval = 0.5, + unit='mm', + ), + # Configuration of the lift axis. Again, since this is a "normal" motor + # record, it can be controlled with the normal SinqMotor device from NICOS. + liftAxis = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'liftAxis', + unit='mm', + ), + # Configuration of the angle axis. Again, since this is a "normal" motor + # record, it can be controlled with the normal SinqMotor device from NICOS. + angleAxis = device('nicos_sinq.devices.epics.motor.SinqMotor', + motorpv = pvprefix + 'angleAxis', + unit='degree', + ), + # This device writes to the "Normalize" PV in order to trigger a + # normalization. Since we want to offer the user a descriptive button, this + # device is hidden and used as the backend of a Switcher device. + normalize_num = device('nicos.devices.epics.pyepics.pyepics.EpicsDigitalMoveable', + readpv = pvprefix + 'liftAxis:Normalize', + writepv = pvprefix + 'liftAxis:Normalize', + visibility=(), + ), + # This switcher is the frontend for the normalize_num device. It offers a + # single button "Normalize offsets" which upon pressing it sets the + # Normalize PV to 1 and therefore triggers a normalization. + normalize = device('nicos.devices.generic.switcher.Switcher', + moveable = 'normalize_num', + mapping = { + 'Normalize offsets': 1, + }, + precision = 0, + ), +) +``` + +## Developer guide + +### Limit calculation + +#### Offset axes + +![Offset axis limit calculation](images/OffsetLimits.svg) + +As mentioned in the user guide, the limits of the offset axes are specified in the substitution file and depending on the physical limits further shrunken down if the relative limits would exceed the absolute limits. In the image given above, this is the case for axis 6. If an axis moves, the limits move accordingly: + +$z_{iH} = z_i + \mathrm{DHLM}_i$ + +$z_{iL} = z_i + \mathrm{DLLM}_i$ + +If $z_{iH} would exceed the corresponding absolute high limit $z_{iH,abs}$, it is set to $z_{iH,abs}$. The same holds true for the lower limits. + +#### Lift axis + +![Lift axis limit calculation](images/LiftLimits.svg) + +For the lift axis, the limits are calculated by evaluating the available track length in both directions for each axis (see image) +and finding the shortest value for the distance to high and low limit (distances are always positive). In the example image, these values are $\Delta z_{1L}$ +and $\Delta z_{6H}$ respectively. The limits of the lift axis are then calculated as: + +$z_H = z + \Delta z_{6H}$ + +$z_L = z - \Delta z_{1L}$ + +#### Angle axis + +![Angle axis limit calculation](images/AngleLimits.svg) + +The angle limits are also calculated by looping through each axis and deriving the limits individually, then selecting the smallest value for both upper and lower limit. + +In order to derive the corresponding equations, let's take axis 1 as an example. Since the motor has an offset $\Delta z_1$, the guide may be rotated in positive direction up to the green dashed line, which crosses the axis position $x_1$ at the height $z_{1H} - \Delta z_1$. The corresponding angle $\alpha_{1H}$ can then be calculated from the corresponding triangle as: + +$\alpha_{1H} = -\arctan((z_{1H} - z_{guide} - \Delta z_1) / (x_1 - x_{guide}))$ + +In the same manner, the lower limit is calculated as + +$\alpha_{1L} = -\arctan((z_{1L} - z_{guide} - \Delta z_1) / (x_1 - x_{guide}))$ + +The minus signs result from the convention of the angle coordinate system, which is mathematically negative (hence we need to invert the limits as well). + +For axes which are located behind the center of rotation (lever arm $x_i - x_{guide}$ is positive), the roles of the axes high and low limits in the calculation change. For example, the equations for axis 6 (orange) are: + +$\alpha_{6H} = -\arctan((z_{6L} - z_{guide} - \Delta z_6) / (x_6 - x_{guide}))$ + +$\alpha_{6L} = -\arctan((z_{6H} - z_{guide} - \Delta z_6) / (x_6 - x_{guide}))$ + ### Versioning Please see the documentation for the module sinqMotor: https://git.psi.ch/sinq-epics-modules/sinqmotor/-/blob/main/README.md. diff --git a/db/seleneGuide.db b/db/seleneGuide.db new file mode 100644 index 0000000..f7723db --- /dev/null +++ b/db/seleneGuide.db @@ -0,0 +1,24 @@ +# The fields "RBV" of the "seleneOffsetAxis" motor record are relative positions +# (see README.md). THis PV provides in addition the "raw" encoder value read +# directly from the motor controller. Due to the way the motor record and asyn +# motor code is architectured, this PV is also provided for the virtual +# "seleneLiftAxis" and "seleneAngleAxis". However, it has no meaning for these +# two axes, hence the record value is undefined. +record(ai, "$(INSTR)$(M):AbsolutePositionRBV") +{ + field(DTYP, "asynFloat64") + field(INP, "@asyn($(CONTROLLER),$(AXIS)) MOTOR_ABSOLUTE_POSITION_RBV") + field(SCAN, "I/O Intr") +} + +# Setting this PV to "1" causes a "normalization" of the virtual and offset axes: +# Lift and angle are recalculated based on the position of the central offset +# axes 3 and 4 (whose value is then correspondingly set to 0) and the other offset +# axes positions are recalculated accordingly. The normalization can be triggered +# from any of the offset or the lift or the angle axis. +record(longout, "$(INSTR)$(M):Normalize") { + field(DTYP, "asynInt32") + field(OUT, "@asyn($(CONTROLLER),$(AXIS),1) NORMALIZE") + field(VAL, "1") + field(PINI, "NO") +} \ No newline at end of file diff --git a/images/AngleLimits.odg b/images/AngleLimits.odg new file mode 100644 index 0000000000000000000000000000000000000000..8796b4d87397e1b3898df216af349d89184d418d GIT binary patch literal 16931 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 Lwt9EC|J42;gH6R( literal 0 HcmV?d00001 diff --git a/images/AngleLimits.svg b/images/AngleLimits.svg new file mode 100644 index 0000000..2564835 --- /dev/null +++ b/images/AngleLimits.svg @@ -0,0 +1,1358 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + High limit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Low limit + + + + + + + + + + + + + α + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x + + + + + + + + + + + + + z + + + + + + + + + + + + + + zguide + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + z1 + + + + + + + + + + + + + + x1 - xguide + + + + + + + + + + + + + + z1H + + + + + + + + + + + + + + Δz1 + + + + + + + + + + + + + + Δz1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + α1H + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Δz1 + + + + + + + + + + + + + + α1L + + + + + + + + + + + + + + Δz6 + + + + + + + + + + + + + + z6H + + + + + + + + + + + + + + Δz6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Δz1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + z6 + + + + + + + + + + + + + + z1L + + + + + + + + + + + + + + z6L + + + + + + + + + + + + + + α1L + + + + + + + + + + + + + + α1H + + + + + + + + + + + + + + x1 - xguide + + + + + + + + \ No newline at end of file diff --git a/images/CoordinateSystems.odg b/images/CoordinateSystems.odg new file mode 100644 index 0000000000000000000000000000000000000000..e82c0cc322b49a9c8f1fd7ac87b945d0f2fa0a3e GIT binary patch literal 14934 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 literal 0 HcmV?d00001 diff --git a/images/CoordinateSystems.svg b/images/CoordinateSystems.svg new file mode 100644 index 0000000..f49f904 --- /dev/null +++ b/images/CoordinateSystems.svg @@ -0,0 +1,885 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xlift = ½ x6 + + + + + + x6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x + + + + + + + + + + + + + z + + + + + + + + + + + + + α + + + + + + + + + + + + + + zlift + + + + + + + + + + + + + + z1 + + + + + + + + + + + + + + Δz1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + αangle + + + + + + Δz2 + + + + + + + + + + + + + + + + + + + + + + + + + + + Δz3 + + + + + + Δz4 + + + + + + + + + + + + + Δz5 + + + + + + + + + + + + + + Δz6 + + + + + + + + \ No newline at end of file diff --git a/images/Geometry.odg b/images/Geometry.odg deleted file mode 100644 index 90c2bfaaa94184f229bd0b56fe2aa3111cbbd745..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18846 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 N00=?-ida_=4*&y~sJZ|E diff --git a/images/LiftLimits.odg b/images/LiftLimits.odg new file mode 100644 index 0000000000000000000000000000000000000000..eccc2783088b90c560d1c3fe12278c09f64a0cf9 GIT binary patch literal 13770 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 dkuv?m=&B$M^)@sE04Q%S+P8=x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + High limit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Low limit + + + + + + + + + + + + + + Δz1H + + + + + + + + + + + + + + Δz1L + + + + + + + + + + + + + + + + + + + + Δz2H + + + + + + Δz2L + + + + + + + + + + + + + + + + + + + + + + Δz3H + + + + + + Δz3L + + + + + + + + + + + + + + + + + + + + + + Δz4H + + + + + + Δz4L + + + + + + + + + + + + + + + + + + + + + + Δz5H + + + + + + Δz5L + + + + + + + + + + + + + + + + + + + + + + Δz6H + + + + + + Δz6L + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/OffsetLimits.odg b/images/OffsetLimits.odg new file mode 100644 index 0000000000000000000000000000000000000000..d43e95c9a68dd7fffd70c6b1f9c7bb1d6074e49c GIT binary patch literal 14051 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 literal 0 HcmV?d00001 diff --git a/images/OffsetLimits.svg b/images/OffsetLimits.svg new file mode 100644 index 0000000..20f1b81 --- /dev/null +++ b/images/OffsetLimits.svg @@ -0,0 +1,805 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + High limit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Low limit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + z1H + + + + + + z1L + + + + + + + + + + + + + + + + + + + + + + z6H + + + + + + z6L + + + + + + z2H + + + + + + z2L + + + + + + + + + + + + + + + + + + + + + + + + z3H + + + + + + z3L + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + z4H + + + + + + z4L + + + + + + z5H + + + + + + z5L + + + + + + z1 + + + + + + z2 + + + + + + z3 + + + + + + z4 + + + + + + z5 + + + + + + z6 + + + + + + + + \ No newline at end of file diff --git a/images/PhysicalSetup.odg b/images/PhysicalSetup.odg new file mode 100644 index 0000000000000000000000000000000000000000..88fd84c829880b4f83bad1e43ff6e05772c6dd23 GIT binary patch literal 12373 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + z1 + + + + + + + + + + + + + + + + + + + + + + + + + + z2 + + + + + + + + + + + + + + + + + + + + + + + + + + z3 + + + + + + + + + + + + + + + + + + + + + + + + + + z4 + + + + + + + + + + + + + + + + + + + + + + + + + + z5 + + + + + + + + + + + + + + + + + + + + + + + + + + z6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/offsetAxis.cpp b/src/offsetAxis.cpp deleted file mode 100644 index bf6973e..0000000 --- a/src/offsetAxis.cpp +++ /dev/null @@ -1,9 +0,0 @@ -#include "offsetAxis.h" - -asynStatus offsetAxis::doPoll(bool *moving) { - // Perform the "normal" poll of a normal turboPmac - asynStatus status = turboPmac::doPoll(moving); - - // Convert the motorPosition_ value in the parameter library from an - // absolute value to an offset -} \ No newline at end of file diff --git a/src/offsetAxis.h b/src/offsetAxis.h deleted file mode 100644 index aa2078d..0000000 --- a/src/offsetAxis.h +++ /dev/null @@ -1,21 +0,0 @@ -#include "seleneLiftAxis.h" -#include "turboPmacAxis.h" - -class offsetAxis : public turboPmacAxis { - public: - offsetAxis(turboPmacController *pController, int axisNo, - seleneLiftAxis *liftAxis); - - /** - * @brief Implementation of the `doPoll` function from sinqAxis. The - * parameters are described in the documentation of `sinqAxis::doPoll`. - * - * @param moving - * @return asynStatus - */ - asynStatus doPoll(bool *moving); - - private: - double targetPosition_; - seleneLiftAxis liftAxis_; -} \ No newline at end of file diff --git a/src/seleneAngleAxis.cpp b/src/seleneAngleAxis.cpp index e8b0091..3a0152a 100644 --- a/src/seleneAngleAxis.cpp +++ b/src/seleneAngleAxis.cpp @@ -1,12 +1,234 @@ #include "seleneAngleAxis.h" +#include "seleneGuideController.h" #include "seleneLiftAxis.h" +#include "turboPmacController.h" +#include +#include + +seleneAngleAxis::seleneAngleAxis(seleneGuideController *pController, int axisNo, + seleneLiftAxis *axis) + : turboPmacAxis(pController, axisNo, false), pC_(pController) { + + asynStatus status = asynSuccess; -seleneAngleAxis::seleneAngleAxis(turboPmacController *pController, int axisNo) { // Initialize the associated lift axis as a nullptr. It is populated in the // constructor of seleneLiftAxis. - liftAxis_ = nullptr; + liftAxis_ = axis; + targetSet_ = false; + + // Selene guide motors cannot be disabled + status = pC_->setIntegerParam(axisNo_, pC_->motorCanDisable(), 0); + if (status != asynSuccess) { + pC_->paramLibAccessFailed(status, "motorCanDisable", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } } asynStatus seleneAngleAxis::stop(double acceleration) { return liftAxis_->stop(acceleration); +} + +asynStatus seleneAngleAxis::doMove(double position, int relative, + double min_velocity, double max_velocity, + double acceleration) { + double motorRecResolution = 0.0; + + asynStatus pl_status = pC_->getDoubleParam( + axisNo_, pC_->motorRecResolution(), &motorRecResolution); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorRecResolution_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + targetPosition_ = position * motorRecResolution; + targetSet_ = true; + return liftAxis_->startCombinedMoveFromVirtualAxis(); + targetSet_ = false; +} + +asynStatus seleneAngleAxis::targetPosition(double *targetPosition) { + asynStatus status = asynSuccess; + + if (targetSet_) { + *targetPosition = targetPosition_; + } else { + status = motorPosition(targetPosition); + } + return status; +} + +asynStatus seleneAngleAxis::doPoll(bool *moving) { + + char userMessage[pC_->MAXBUF_] = {0}; + + // In the doPoll method of `seleneLiftAxis`, the parameters + // `motorStatusMoving` and `motorStatusDone` of this axis have already been + // set accordingly. + int isMoving = 0; + + asynStatus pl_status = + pC_->getIntegerParam(axisNo(), pC_->motorStatusMoving(), &isMoving); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorStatusMoving", + axisNo(), __PRETTY_FUNCTION__, + __LINE__); + } + *moving = (isMoving != 0); + + double motPos = 0.0; + pl_status = motorPosition(&motPos); + if (pl_status != asynSuccess) { + return pl_status; + } + + /* + Calculating the limits for the angle is rather involved. Consider the + following example: + + | * + | | + * | + | | + 1 2 + + where "*" is the position of the motor and "|" shows the available drive + train. The center of rotation is in between axis "1" and axis "2" at + position 0.5 (with axis 1 being at 0.0 and axis 2 being at 1.0). The guide + between the two "*" has an elevation of 3 and a negative angle (by + definition). + + Since axis 2 is at its limit, the angle clearly can't get smaller. When we + want to increase the angle (moving axis 1 up and 2 down), axis 1 will hit + its limits before axis 2. At the selene guide, we have 6 axes in total, + meaning that we have to take into consideration each one of them. + + In order to do that, we loop through each axis and calculate the minimum + (low limit) and maximum (high limit) absolute rotation angle allowed by this + particular axis at its current position. We then select the smallest value + for both low and high limit as the current axis limits. See README.md for + the kinematic equations. + */ + double smallestHighLimit = std::numeric_limits::infinity(); + double smallestLowLimit = std::numeric_limits::infinity(); + double liftPosition = 0.0; + pl_status = liftAxis_->motorPosition(&liftPosition); + if (pl_status != asynSuccess) { + return pl_status; + } + + for (int i = 0; i < liftAxis_->numAxes_; i++) { + seleneOffsetAxis *oAxis = liftAxis_->offsetAxis(i); + + double leverArm = oAxis->xOffset() - liftAxis_->xPos(); + + double motPos = 0.0; + pl_status = oAxis->motorPosition(&motPos); + if (pl_status != asynSuccess) { + return pl_status; + } + + /* + If we are before the rotation center, the high limit of the offset axis + must be used to calculate the high angle limit. If we are behind the + rotation center (positive lever arm), the low limit of the offset axis + must be used to calcate the high angle limit. + */ + double calcHighLimit = 0.0; + double calcLowLimit = 0.0; + if (leverArm > 0) { + calcHighLimit = oAxis->absoluteLowLimitLiftCS(); + calcLowLimit = oAxis->absoluteHighLimitLiftCS(); + } else { + calcHighLimit = oAxis->absoluteHighLimitLiftCS(); + calcLowLimit = oAxis->absoluteLowLimitLiftCS(); + } + + // Since the angle coordinate system is inverted, we need to invert the + // limits as well (minus sign). + double angleHighLimit = + -atan((calcHighLimit - liftPosition - motPos) / leverArm) * 180.0 / + std::numbers::pi; + double angleLowLimit = + -atan((calcLowLimit - liftPosition - motPos) / leverArm) * 180.0 / + std::numbers::pi; + + // We are interested in the absolute smallest limits + if (std::abs(smallestHighLimit) > std::abs(angleHighLimit)) { + smallestHighLimit = angleHighLimit; + } + if (std::abs(smallestLowLimit) > std::abs(angleLowLimit)) { + smallestLowLimit = angleLowLimit; + } + } + + pl_status = pC_->setDoubleParam(axisNo_, pC_->motorHighLimitFromDriver(), + smallestHighLimit); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorHighLimitFromDriver", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + + pl_status = pC_->setDoubleParam(axisNo_, pC_->motorLowLimitFromDriver(), + smallestLowLimit); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorLowLimitFromDriver", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + + // This axis should always be seen as enabled, since it is automatically + // enabled before each movement and disabled afterwards + pl_status = setIntegerParam(pC_->motorEnableRBV(), 1); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorEnableRBV_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + // If an error message is set in the liftAxis_, copy this message into this + // axis. + pl_status = + pC_->getStringParam(liftAxis_->axisNo(), pC_->motorMessageText(), + sizeof(userMessage), userMessage); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorMessageText", + liftAxis_->axisNo(), + __PRETTY_FUNCTION__, __LINE__); + } + + if (strlen(userMessage) != 0) { + pl_status = setStringParam(pC_->motorMessageText(), userMessage); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorMessageText_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + return asynError; + } + + return pl_status; +} + +asynStatus seleneAngleAxis::doReset() { return liftAxis_->reset(); } + +asynStatus seleneAngleAxis::enable(bool on) { return liftAxis_->enable(on); } + +asynStatus seleneAngleAxis::readEncoderType() { + asynStatus pl_status = + setStringParam(pC_->encoderType(), IncrementalEncoder); + + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "encoderType_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + return asynSuccess; +} + +asynStatus seleneAngleAxis::doHome(double minVelocity, double maxVelocity, + double acceleration, int forwards) { + return liftAxis_->doHome(minVelocity, maxVelocity, acceleration, forwards); +} + +asynStatus seleneAngleAxis::normalizePositions() { + return liftAxis_->normalizePositions(); } \ No newline at end of file diff --git a/src/seleneAngleAxis.h b/src/seleneAngleAxis.h index fb914df..8b230c7 100644 --- a/src/seleneAngleAxis.h +++ b/src/seleneAngleAxis.h @@ -1,27 +1,42 @@ #ifndef seleneAngleAxis_H #define seleneAngleAxis_H #include "turboPmacAxis.h" -#include "turboPmacController.h" // Forward declaration of the seleneLiftAxis class to resolve the cyclic // dependency between the seleneLiftAxis and the seleneAngleAxis .h-file. // See https://en.cppreference.com/w/cpp/language/class. class seleneLiftAxis; +// Forward declaration of the seleneGuideController class to resolve the cyclic +// dependency. See https://en.cppreference.com/w/cpp/language/class. +class seleneGuideController; + /** - * @brief Virtual axis for setting the angle of a Selene guide + * @brief Virtual axis for setting the angle of the Selene guide + * + * Please see README.md for a detailed explanation. */ class seleneAngleAxis : public turboPmacAxis { public: /** - * @brief Destroy the turboPmacAxis + * @brief Construct a new seleneAngleAxis. * + * This function should not be called directly. Instead use the constructor + * of seleneLiftAxis, which creates a seleneAngleAxis and stores a pointer + * to it. + * + * @param pController Pointer to the associated controller + * @param axisNo Index of the axis + * @param liftAxis Pointer to the associated lift axis */ - virtual ~seleneAngleAxis(); + seleneAngleAxis(seleneGuideController *pController, int axisNo, + seleneLiftAxis *liftAxis); /** * @brief Implementation of the `stop` function from asynMotorAxis * + * Stops all underlying physical axes at once + * * @param acceleration Acceleration ACCEL from the motor record. This * value is currently not used. * @return asynStatus @@ -29,17 +44,8 @@ class seleneAngleAxis : public turboPmacAxis { asynStatus stop(double acceleration); /** - * @brief This function does (almost) nothing, since the poll is done - * directly in seleneLiftAxis::doPoll(). It justs sets *moving. - * - * @param moving - * @return asynStatus - */ - asynStatus poll(bool *moving); - - /** - * @brief Set the target value for the guide angle and trigger a position - * collection cycle + * @brief Rotate the guide around its current lift center to the specified + * position. * * @param position * @param relative @@ -52,27 +58,28 @@ class seleneAngleAxis : public turboPmacAxis { double maxOffset_velocity, double acceleration); /** - * @brief Readout of some values from the controller at IOC startup + * @brief Implementation of the `doPoll` function from sinqAxis. * - * The following steps are performed: - * - Read out the motor status, motor position, velocity and acceleration - * from the MCU and store this information in the parameter library. - * - Set the enable PV according to the initial status of the axis. + * Some paramLib entries of this axis are set in the `doPoll` function of + * `seleneLiftAxis` for convenience (e.g. whether this axis is moving or + * not). * + * @param moving * @return asynStatus */ - asynStatus init(); + asynStatus doPoll(bool *moving); /** - * @brief Reset the error(s) of both physical motors + * @brief Reset all errors of the underlying physical axes. * * @param on * @return asynStatus */ - asynStatus reset(); + asynStatus doReset(); /** - * @brief Call the stop() method of the associated liftAxis. + * @brief This function does nothing, since this axis cannot be enabled / + * disabled * * @param on * @return asynStatus @@ -80,38 +87,66 @@ class seleneAngleAxis : public turboPmacAxis { asynStatus enable(bool on); /** - * @brief The encoder of both physical motors is absolute, hence the encoder - * of the virtual axis is also absolute + * @brief "Home" the virtual axes by driving all underlying real axes to + * position 0. + * + * @param minVelocity + * @param maxVelocity + * @param acceleration + * @param forwards + * @return asynStatus + */ + asynStatus doHome(double minVelocity, double maxVelocity, + double acceleration, int forwards); + + /** + * @brief Read the target position from the axis and write it into the + * provided pointer. + * + * @param targetPosition + * @return asynStatus + */ + asynStatus targetPosition(double *targetPosition); + + /** + * @brief Set the offsets of axis 3 and 4 to zero and recalculate all other + * axes positions based on it. + * + * Calling this function "normalizes" the lift axis, the angle axis and the + * offset axes by performing a "normalization" operation. For details, see + * README.md. This function does not communicate with the controller. + * + * @return asynStatus + */ + asynStatus normalizePositions(); + + /** + * @brief Write the encoder type in the corresponding PV + * + * Since this axis is a virtual one, it does not have an encoder. In order + * to enable reference drives (which in case of this axis simply sets all + * physical axes to their respective physical position zero), this function + * sets the encoder type to "Incremental Encoder". * * @return asynStatus */ asynStatus readEncoderType(); /** - * @brief Trigger a rereading of the encoder position of both physical - * motors + * @brief This function does nothing, since this axis does not have an + * encoder. * * @return asynStatus */ - asynStatus rereadEncoder(); + asynStatus rereadEncoder() { return asynSuccess; } protected: - turboPmacController *pC_; + seleneGuideController *pC_; seleneLiftAxis *liftAxis_; private: - /** - * @brief Construct a new seleneAngleAxis - * - * @param pController Pointer to the associated controller - * @param axisNo Index of the axis - */ - seleneAngleAxis(turboPmacController *pController, int axisNo, - seleneLiftAxis *liftAxis); - - // The friend class declaration here is necessary so the - // seleneLiftAxis cann call the constructor of seleneAngleAxis - friend class seleneLiftAxis; + // True, if a target has been set for this axis + bool targetSet_; }; #endif diff --git a/src/seleneGuide.dbd b/src/seleneGuide.dbd new file mode 100644 index 0000000..7243063 --- /dev/null +++ b/src/seleneGuide.dbd @@ -0,0 +1,3 @@ +registrar(seleneVirtualAxesRegister) +registrar(seleneOffsetAxisRegister) +registrar(seleneGuideControllerRegister) \ No newline at end of file diff --git a/src/seleneGuideController.cpp b/src/seleneGuideController.cpp new file mode 100644 index 0000000..8c85d25 --- /dev/null +++ b/src/seleneGuideController.cpp @@ -0,0 +1,157 @@ +#include "seleneGuideController.h" +#include "turboPmacController.h" +#include +#include +#include + +/** + * @brief Construct a new seleneGuideController object + * + * @param portName See documentation of sinqController + * @param ipPortConfigName See documentation of sinqController + * @param numAxes See documentation of sinqController + * @param movingPollPeriod See documentation of sinqController + * @param idlePollPeriod See documentation of sinqController + * @param comTimeout Time after which a communication timeout error + * is declared in writeRead (in seconds) + * @param extraParams See documentation of sinqController + */ +seleneGuideController::seleneGuideController( + const char *portName, const char *ipPortConfigName, int numAxes, + double movingPollPeriod, double idlePollPeriod, double comTimeout) + : turboPmacController(portName, ipPortConfigName, numAxes, movingPollPeriod, + idlePollPeriod, NUM_seleneGuide_DRIVER_PARAMS) + +{ + + asynStatus status = asynSuccess; + + status = createParam("MOTOR_ABSOLUTE_POSITION_RBV", asynParamFloat64, + &motorAbsolutePositionRBV_); + if (status != asynSuccess) { + asynPrint(this->pasynUserSelf, ASYN_TRACE_ERROR, + "Controller \"%s\" => %s, line %d\nFATAL ERROR (creating a " + "parameter failed with %s).\nTerminating IOC", + portName, __PRETTY_FUNCTION__, __LINE__, + stringifyAsynStatus(status)); + exit(-1); + } + + status = createParam("NORMALIZE", asynParamInt32, &normalize_); + if (status != asynSuccess) { + asynPrint(this->pasynUserSelf, ASYN_TRACE_ERROR, + "Controller \"%s\" => %s, line %d\nFATAL ERROR (creating a " + "parameter failed with %s).\nTerminating IOC", + portName, __PRETTY_FUNCTION__, __LINE__, + stringifyAsynStatus(status)); + exit(-1); + } +} + +asynStatus seleneGuideController::writeInt32(asynUser *pasynUser, + epicsInt32 value) { + int function = pasynUser->reason; + + // ===================================================================== + + asynMotorAxis *axis = getAxis(pasynUser); + + // Check if the provided axis is one of the special selene guide axes + if (function == normalize_) { + + // During initialization of an IOC, the "normalize" PV is initialized to + // zero, which triggers this function. We don't want to call + // normalizePositions() during IOC initialization. + if (value != 0) { + seleneAngleAxis *aAxis = dynamic_cast(axis); + if (aAxis != nullptr) { + return aAxis->normalizePositions(); + } + + seleneLiftAxis *lAxis = dynamic_cast(axis); + if (lAxis != nullptr) { + return lAxis->normalizePositions(); + } + + seleneOffsetAxis *oAxis = dynamic_cast(axis); + if (oAxis != nullptr) { + return oAxis->normalizePositions(); + } + } + + // Any other axis should just ignore an input to "Normalize" + return asynSuccess; + } + return turboPmacController::writeInt32(pasynUser, value); +} + +/*************************************************************************************/ +/** The following functions are C-wrappers, and can be called directly from + * iocsh */ + +extern "C" { + +/* +C wrapper for the controller constructor. Please refer to the +seleneGuideController constructor documentation. +*/ +asynStatus seleneGuideCreateController(const char *portName, + const char *ipPortConfigName, + int numAxes, double movingPollPeriod, + double idlePollPeriod, + double comTimeout) { + /* + We create a new instance of the controller, using the "new" keyword to + allocate it on the heap while avoiding RAII. + https://github.com/epics-modules/motor/blob/master/motorApp/MotorSrc/asynMotorController.cpp + https://github.com/epics-modules/asyn/blob/master/asyn/asynPortDriver/asynPortDriver.cpp + + The created object is registered in EPICS in its constructor and can safely + be "leaked" here. + */ +#pragma GCC diagnostic ignored "-Wunused-but-set-variable" +#pragma GCC diagnostic ignored "-Wunused-variable" + seleneGuideController *pController = + new seleneGuideController(portName, ipPortConfigName, numAxes, + movingPollPeriod, idlePollPeriod, comTimeout); + + return asynSuccess; +} + +/* +Define name and type of the arguments for the CreateController function +in the iocsh. This is done by creating structs with the argument names and +types and then providing "factory" functions +(configCreateControllerCallFunc). These factory functions are used to +register the constructors during compilation. +*/ +static const iocshArg CreateControllerArg0 = {"Controller name (e.g. mcu1)", + iocshArgString}; +static const iocshArg CreateControllerArg1 = {"Asyn IP port name (e.g. pmcu1)", + iocshArgString}; +static const iocshArg CreateControllerArg2 = {"Number of axes", iocshArgInt}; +static const iocshArg CreateControllerArg3 = {"Moving poll rate (s)", + iocshArgDouble}; +static const iocshArg CreateControllerArg4 = {"Idle poll rate (s)", + iocshArgDouble}; +static const iocshArg CreateControllerArg5 = {"Communication timeout (s)", + iocshArgDouble}; +static const iocshArg *const CreateControllerArgs[] = { + &CreateControllerArg0, &CreateControllerArg1, &CreateControllerArg2, + &CreateControllerArg3, &CreateControllerArg4, &CreateControllerArg5}; +static const iocshFuncDef configSeleneGuideCreateController = { + "seleneGuideController", 6, CreateControllerArgs}; +static void configSeleneGuideCreateControllerCallFunc(const iocshArgBuf *args) { + seleneGuideCreateController(args[0].sval, args[1].sval, args[2].ival, + args[3].dval, args[4].dval, args[5].dval); +} + +// This function is made known to EPICS in turboPmac.dbd and is called by EPICS +// in order to register both functions in the IOC shell +static void seleneGuideControllerRegister(void) { + iocshRegister(&configSeleneGuideCreateController, + configSeleneGuideCreateControllerCallFunc); +} +epicsExportRegistrar(seleneGuideControllerRegister); + +} // extern "C" diff --git a/src/seleneGuideController.h b/src/seleneGuideController.h new file mode 100644 index 0000000..dbdb6c8 --- /dev/null +++ b/src/seleneGuideController.h @@ -0,0 +1,60 @@ +/******************************************** + * seleneGuideController.h + * + * Detector tower controller driver based on the asynMotorController class + * + * Stefan Mathis, September 2024 + ********************************************/ + +#ifndef seleneGuideController_H +#define seleneGuideController_H +#include "seleneAngleAxis.h" +#include "seleneLiftAxis.h" +#include "seleneOffsetAxis.h" +#include "turboPmacController.h" + +class seleneGuideController : public turboPmacController { + + public: + /** + * @brief Construct a new seleneGuideController object + * + * This controller object can handle both "normal" TurboPMAC axes created + with the turboPmacAxis constructor as well as one or multiple selene axis + assemblies consisting of offset axes, lift axis and angle axis. + * + * @param portName See turboPmacController constructor + * @param ipPortConfigName See turboPmacController constructor + * @param numAxes See turboPmacController constructor + * @param movingPollPeriod See turboPmacController constructor + * @param idlePollPeriod See turboPmacController constructor + * @param comTimeout See turboPmacController constructor + */ + seleneGuideController(const char *portName, const char *ipPortConfigName, + int numAxes, double movingPollPeriod, + double idlePollPeriod, double comTimeout); + + /** + * @brief Overloaded function of turboPmacController + * + * @param pasynUser Specify the axis via the asynUser + * @param value New value + * @return asynStatus + */ + asynStatus writeInt32(asynUser *pasynUser, epicsInt32 value); + + // Accessors for the indices of the additional PVs + int motorAbsolutePositionRBV() { return motorAbsolutePositionRBV_; } + int normalize() { return normalize_; } + + private: + // Indices of additional PVs +#define FIRST_seleneGuide_PARAM motorAbsolutePositionRBV_ + int motorAbsolutePositionRBV_; + int normalize_; +#define LAST_seleneGuide_PARAM normalize_ +}; +#define NUM_seleneGuide_DRIVER_PARAMS \ + (&LAST_seleneGuide_PARAM - &FIRST_seleneGuide_PARAM + 1) + +#endif /* seleneGuideController_H */ diff --git a/src/seleneLift.dbd b/src/seleneLift.dbd deleted file mode 100644 index 0031597..0000000 --- a/src/seleneLift.dbd +++ /dev/null @@ -1,5 +0,0 @@ -#--------------------------------------------- -# SINQ specific DB definitions -#--------------------------------------------- -registrar(turboPmacControllerRegister) -registrar(turboPmacAxisRegister) \ No newline at end of file diff --git a/src/seleneLiftAxis.cpp b/src/seleneLiftAxis.cpp index 830eea9..2bcc434 100644 --- a/src/seleneLiftAxis.cpp +++ b/src/seleneLiftAxis.cpp @@ -2,7 +2,8 @@ #include "asynOctetSyncIO.h" #include "epicsExport.h" #include "iocsh.h" -#include "offserAxis.h" +#include "seleneGuideController.h" +#include "seleneOffsetAxis.h" #include "turboPmacController.h" #include #include @@ -14,7 +15,7 @@ #include /* -Contains all instances of turboPmacAxis which have been created and is used in +Contains all instances of seleneLiftAxis which have been created and is used in the initialization hook function. */ static std::vector axes; @@ -30,28 +31,54 @@ static void epicsInithookFunction(initHookState iState) { // on each one of them. for (std::vector::iterator itA = axes.begin(); itA != axes.end(); ++itA) { - turboPmacAxis *axis = *itA; + seleneLiftAxis *axis = *itA; axis->init(); } } } -seleneLiftAxis::seleneLiftAxis(turboPmacController *pC) - : turboPmacAxis(pC, numAxes_ + 1, false), pC_(pC) { +seleneLiftAxis::seleneLiftAxis(seleneGuideController *pC, int axis1No, + int axis2No, int axis3No, int axis4No, + int axis5No, int axis6No, int liftAxisNo, + int angleAxisNo) + : turboPmacAxis(pC, liftAxisNo, false), pC_(pC) { - // Create the real axes and the virtual angle axis. - // They are deallocated in the destructor of sinqController + asynStatus status = asynSuccess; + + // Read the offset axes from the controller and write pointers to them into + // offsetAxes_. If they haven't been created, the configuration is wrong + // and therefore the IOC creation should abort + int axisNos[numAxes_]; + axisNos[0] = axis1No; + axisNos[1] = axis2No; + axisNos[2] = axis3No; + axisNos[3] = axis4No; + axisNos[4] = axis5No; + axisNos[5] = axis6No; + + seleneOffsetAxis *oAxis; for (int i = 0; i < numAxes_; i++) { - offsetAxis axis = new offsetAxis(pC, i, this); - offsetAxes_[i] = axis; + oAxis = dynamic_cast(pC->getAxis(axisNos[i])); + if (oAxis == nullptr) { + asynPrint(pC_->asynUserSelf(), ASYN_TRACE_ERROR, + "Controller \"%s\", axis %d => %s, line %d\nFATAL ERROR " + "(given axis number %d is not an instance of " + "seleneOffsetAxis).\nTerminating IOC.\n", + pC_->portName, axisNo_, __PRETTY_FUNCTION__, __LINE__, + axisNos[i]); + exit(-1); + } + offsetAxes_[i] = oAxis; + + // Give the lift axis a link to the lift axis + oAxis->liftAxis_ = this; } - // - angleAxis_ = new seleneAngleAxis(pC, numAxes_ + 2, this); + angleAxis_ = new seleneAngleAxis(pC, angleAxisNo, this); // Initialize the parameters - xLift_ = 0.0; // Overwritten in the init function - targetPosition_ = 0.0; + targetSet_ = false; + virtualAxisMovement_ = true; // Register the hook function during construction of the first axis // object @@ -62,17 +89,25 @@ seleneLiftAxis::seleneLiftAxis(turboPmacController *pC) // Collect all axes into this list which will be used in the hook // function axes.push_back(this); -} -seleneLiftAxis::~seleneLiftAxis() {} + // Center between axis 3 and 4 + xLift_ = 0.5 * (offsetAxes_[2]->xOffset_ + offsetAxes_[3]->xOffset_); + + // Selene guide motors cannot be disabled + status = pC_->setIntegerParam(axisNo_, pC_->motorCanDisable(), 0); + if (status != asynSuccess) { + pC_->paramLibAccessFailed(status, "motorCanDisable", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } +} asynStatus seleneLiftAxis::init() { // Local variable declaration asynStatus status = asynSuccess; double motorRecResolution = 0.0; - char command[pC_->MAXBUF_], response[pC_->MAXBUF_]; - int nvals = 0; + + // ========================================================================= // The parameter library takes some time to be initialized. Therefore we // wait until the status is not asynParamUndefined anymore. @@ -99,294 +134,378 @@ asynStatus seleneLiftAxis::init() { } } - // Read out the horizontal distances of all other axes from axis 1 - xOffset_[0] = 0.0; // By definition - snprintf(command, sizeof(command), "Q652 Q653 Q654 Q655 Q656"); - status = pC_->writeRead(axisNo_, command, response, 5); + // Assume the virtual axes are not moving at the start + status = setIntegerParam(pC_->motorStatusMoving(), 0); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusMoving", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + status = setIntegerParam(pC_->motorStatusDone(), 1); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusDone", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + status = angleAxis_->setIntegerParam(pC_->motorStatusMoving(), 0); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusMoving", + angleAxis_->axisNo(), + __PRETTY_FUNCTION__, __LINE__); + } + status = angleAxis_->setIntegerParam(pC_->motorStatusDone(), 1); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusDone", + angleAxis_->axisNo(), + __PRETTY_FUNCTION__, __LINE__); + } + + // The virtual axes should always be seen as enabled, since the underlying + // hardware is automatically enabled after sending a start command to the + // controller and automatically disabled after the movement finished. + status = setIntegerParam(pC_->motorEnableRBV(), 1); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorEnableRBV_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + status = angleAxis_->setIntegerParam(pC_->motorEnableRBV(), 1); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorEnableRBV_", + angleAxis_->axisNo(), + __PRETTY_FUNCTION__, __LINE__); + } + + status = normalizePositions(); if (status != asynSuccess) { return status; } - nvals = sscanf(response, "%lf %lf %lf %lf %lf", &xOffset_[1], &xOffset_[2], - &xOffset_[3], &xOffset_[4], &xOffset_[5]); - if (nvals != 5) { - return pC_->errMsgCouldNotParseResponse(command, response, axisNo_, - __PRETTY_FUNCTION__, __LINE__); + // After the normalization, initialize all axes + for (int i = 0; i < numAxes_; i++) { + offsetAxes_[i]->init(); } - - // Center between axis 2 and 3 - xLift_ = 0.5 * (xOffset_[2] + xOffset_[3]); - - // Read out the vertical distances of all other axes from axis 1 - zOffset_[0] = 0.0; // By definition - zOffset_[1] = 0.0; - zOffset_[2] = 0.0; - zOffset_[3] = 0.0; - zOffset_[4] = 0.0; - zOffset_[5] = 0.0; - - return normalizeOffsets(); + return asynSuccess; } -asynStatus seleneLiftAxis::normalizeOffsets() { +asynStatus seleneLiftAxis::normalizePositions() { + + double lift = 0.0; + double angle = 0.0; asynStatus status = asynSuccess; char response[pC_->MAXBUF_]; + double encoderPositions[6] = {0.0}; + double absolutePositions[6] = {0.0}; int nvals = 0; + // ========================================================================= + // Read out the absolute positions of all axes - const char *command = "Q0110 Q0210 Q0310 Q0410 Q0510 Q0610"; - status = pC_->writeRead(axisNo_, command, response, 6); + status = pC_->writeRead(axisNo_, "Q0110 Q0210 Q0310 Q0410 Q0510 Q0610", + response, 6); + if (status != asynSuccess) { + return status; + } + nvals = + sscanf(response, "%lf %lf %lf %lf %lf %lf", &encoderPositions[0], + &encoderPositions[1], &encoderPositions[2], &encoderPositions[3], + &encoderPositions[4], &encoderPositions[5]); + if (nvals != 6) { + return pC_->couldNotParseResponse("Q0110 Q0210 Q0310 Q0410 Q0510 Q0610", + response, axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + // Adjust for the individual coordinate system origin of the axes + for (int i = 0; i < numAxes_; i++) { + seleneOffsetAxis *axis = offsetAxes_[i]; + absolutePositions[i] = encoderPositions[i] - axis->zOffset_; + } + + deriveLiftAndAngle(absolutePositions[2], absolutePositions[3], &lift, + &angle); + + // Set the position values in the parameter library + status = setMotorPosition(lift); if (status != asynSuccess) { return status; } - double z[6] = {0.0}; - nvals = sscanf(response, "%lf %lf %lf %lf %lf %lf", &z[0], &z[1], &z[2], - &z[3], &z[4], &z[5]); - if (nvals != 2) { - return pC_->errMsgCouldNotParseResponse(command, response, axisNo_, - __PRETTY_FUNCTION__, __LINE__); - } - - // Normalization - for (int i = 0; i < numAxes_; i++) { - z[i] = z[i] - zOffset_[i]; - } - - // Lift position is simply the mean of axis 2 and 3 - status = setDoubleParam(pC_->motorPosition(), 0.5 * (z[2] + z[3])); + status = angleAxis_->setMotorPosition(angle); if (status != asynSuccess) { - return pC_->paramLibAccessFailed(status, "motorPosition_", axisNo_, - __PRETTY_FUNCTION__, __LINE__); + return status; } - // tan(angle) = (z3 - z2) / (x3 - x2) - double alpha = atan2(z[3] - z[2], xOffset_[3] - xOffset_[2]); - status = angleAxis_->setDoubleParam(pC_->motorPosition(), alpha); - if (status != asynSuccess) { - return pC_->paramLibAccessFailed(status, "motorPosition_", axisNo_, - __PRETTY_FUNCTION__, __LINE__); - } - - // Adjust the position values of all physical axes accordingly. The values - // of axes 2 and 3 should result in zero, if not, something went wrong. - double tanAlpha = tan(alpha); + // Calculate the new liftPosition_ of all seleneOffsetAxis + auto liftPositions = positionsFromLiftAndAngle(lift, angle); for (int i = 0; i < numAxes_; i++) { + double liftPosition = liftPositions[i]; + offsetAxes_[i]->liftPosition_ = liftPosition; + offsetAxes_[i]->setPositionsFromEncoderPosition(encoderPositions[i]); - // Calculate the new offset - double x = xOffset_[i] - xLift_; - double z = z[i] - x * tanAlpha; - - status = axes_[i].setDoubleParam(pC_->motorPosition(), z); + // Write the relative position value to the RBV field + status = offsetAxes_[i]->setMotorPosition( + offsetAxes_[i]->absolutePositionLiftCS_ - + offsetAxes_[i]->liftPosition_); if (status != asynSuccess) { - return pC_->paramLibAccessFailed(status, "motorPosition_", axisNo_, - __PRETTY_FUNCTION__, __LINE__); + return status; } - - // TODO: Adjust limits } // Update all axes callParamCallbacks(); angleAxis_->callParamCallbacks(); for (int i = 0; i < numAxes_; i++) { - axes_[i].callParamCallbacks(); + offsetAxes_[i]->callParamCallbacks(); } + + return asynSuccess; } asynStatus seleneLiftAxis::doPoll(bool *moving) { - // Return value for the poll - asynStatus poll_status = asynSuccess; - // Status of read-write-operations of ASCII commands to the controller asynStatus rw_status = asynSuccess; // Status of parameter library operations asynStatus pl_status = asynSuccess; - char response[pC_->MAXBUF_], userMessage[pC_->MAXBUF_]; + char response[pC_->MAXBUF_], userMessage[pC_->MAXBUF_] = {0}; - int isMoving = 0; int nvals = 0; - int error = 0; int axStatus = 0; - double posAx1 = 0.0; - double highLimitAx1 = 0.0; - double lowLimitAx1 = 0.0; - double posAx6 = 0.0; - double highLimitAx6 = 0.0; - double lowLimitAx6 = 0.0; + int wasDone = 0; // ========================================================================= - // Read the positions and the limits of axis 1 and 6 as well as the status - // and the error of the virtual axis - const char *command = "status error Q0100 Q0113 Q0114 Q0600 Q0613 Q0614"; - rw_status = pC_->writeRead(axisNo_, command, response, 8); + pl_status = + pC_->getIntegerParam(axisNo(), pC_->motorStatusDone(), &wasDone); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorStatusMoving", + axisNo(), __PRETTY_FUNCTION__, + __LINE__); + } + + const char *command = "P158"; + rw_status = pC_->writeRead(axisNo_, command, response, 1); if (rw_status != asynSuccess) { return rw_status; } - nvals = sscanf(response, "%d %d %lf %lf %lf %lf %lf %lf", &axStatus, &error, - &posAx1, &highLimitAx1, &lowLimitAx1, &posAx6, &highLimitAx6, - &lowLimitAx6); - if (nvals != 8) { - return pC_->errMsgCouldNotParseResponse(command, response, axisNo_, - __PRETTY_FUNCTION__, __LINE__); + nvals = sscanf(response, "%d", &axStatus); + if (nvals != 1) { + return pC_->couldNotParseResponse(command, response, axisNo_, + __PRETTY_FUNCTION__, __LINE__); } + *moving = (axStatus != 0); - /* - The following code converts from the vertical height readback values of axis - 1 and 6 to a virtual lift position and a corresponding angle. To do that, we - need first to correct for the different starting point of both axes (see - documentation of member variable yDistAx1ToAx6_) - */ - posAx6 = posAx6 - yDistAx1ToAx6_; - highLimitAx6 = highLimitAx6 - yDistAx1ToAx6_; - lowLimitAx6 = lowLimitAx6 - yDistAx1ToAx6_; - - /* - Convert the z-axis position values of axis 1 and 6 into an angle and a - lift, using the center position between axis 1 and 6 as the rotation point. - The angle is the arcustangens(opposite/adjacent), where the adjacent is - the distance between axis 1 and 6 and the opposite is the difference - between the height of axis 6 and axis 1. - */ - double liftPos = 0.5 * (posAx1 + posAx6); - double liftHighLimit = 0.5 * (highLimitAx1 + highLimitAx6); - double liftLowLimit = 0.5 * (lowLimitAx1 + lowLimitAx6); - double anglePos = atan2(posAx6 - posAx1, xOffset_[5]); - - /* - The angle limits are defined from the center position, hence the lift - position needs to be subtracted from the high and low limits of angle 6. See - the documentation in README.md for details. - */ - double angleHighLimit = atan2(highLimitAx6 - liftPos, xOffset_[5]); - double angleLowLimit = atan2(lowLimitAx6 - liftPos, xOffset_[5]); - - // Set the RBV values for both axes - - pl_status = setIntegerParam(pC_->motorStatusMoving(), isMoving); + // Set the moving status of seleneLiftAxis + pl_status = setIntegerParam(pC_->motorStatusMoving(), *moving); if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorStatusMoving_", + return pC_->paramLibAccessFailed(pl_status, "motorStatusMoving", axisNo_, __PRETTY_FUNCTION__, __LINE__); } - pl_status = angleAxis_->setIntegerParam(pC_->motorStatusMoving(), isMoving); + pl_status = setIntegerParam(pC_->motorStatusDone(), !(*moving)); if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorStatusMoving_", + return pC_->paramLibAccessFailed(pl_status, "motorStatusDone", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + // Set the moving status of seleneAngleAxis + pl_status = angleAxis_->setIntegerParam(pC_->motorStatusMoving(), *moving); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorStatusMoving", + angleAxis_->axisNo(), + __PRETTY_FUNCTION__, __LINE__); + } + pl_status = angleAxis_->setIntegerParam(pC_->motorStatusDone(), !(*moving)); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorStatusDone", angleAxis_->axisNo(), __PRETTY_FUNCTION__, __LINE__); } - pl_status = setIntegerParam(pC_->motorStatusDone(), isMoving == 0); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorStatusDone_", axisNo_, - __PRETTY_FUNCTION__, __LINE__); - } - pl_status = - angleAxis_->setIntegerParam(pC_->motorStatusDone(), isMoving == 0); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorStatusDone_", - angleAxis_->axisNo(), - __PRETTY_FUNCTION__, __LINE__); + /* + If an seleneOffsetAxis is moving, the positions of the virtual axes + for lift and angle should not change. Therefore the motor position entries + in the ParamLib of both axes are simply left alone. + + If a virtual axis is moving or was moving during the last poll, the + positions are calculated from the real positions of axis 3 and 4 corrected + by their respective motor offset + */ + if (wasDone == 0 || (!(*moving && !virtualAxisMovement_))) { + double lift = 0.0; + double angle = 0.0; + double offsetAx2Pos = 0.0; + double offsetAx3Pos = 0.0; + + double motorRecResolution = 0.0; + + asynStatus pl_status = pC_->getDoubleParam( + axisNo_, pC_->motorRecResolution(), &motorRecResolution); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorRecResolution_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + + pl_status = offsetAxes_[2]->motorPosition(&offsetAx2Pos); + if (pl_status != asynSuccess) { + return pl_status; + } + + pl_status = offsetAxes_[3]->motorPosition(&offsetAx3Pos); + if (pl_status != asynSuccess) { + return pl_status; + } + + deriveLiftAndAngle( + offsetAxes_[2]->absolutePositionLiftCS_ - offsetAx2Pos, + offsetAxes_[3]->absolutePositionLiftCS_ - offsetAx3Pos, &lift, + &angle); + + // Set the position values in the parameter library + pl_status = setMotorPosition(lift); + if (pl_status != asynSuccess) { + return pl_status; + } + + pl_status = angleAxis_->setMotorPosition(angle); + if (pl_status != asynSuccess) { + return pl_status; + } + + // Calculate the new liftPosition_ of all seleneOffsetAxis + auto liftPositions = positionsFromLiftAndAngle(lift, angle); + for (int i = 0; i < numAxes_; i++) { + offsetAxes_[i]->liftPosition_ = liftPositions[i]; + } } - // If any of the physical axes has a status problem, the two virtual axes - // have a status problem + // Check which one of the individual motors has the smallest distance to its + // upper and lower limits and derive the lift limits from these. + double smallestDistHighLimit = std::numeric_limits::infinity(); + double smallestDistLowLimit = std::numeric_limits::infinity(); + for (int i = 0; i < numAxes_; i++) { + if (smallestDistHighLimit > offsetAxes_[i]->distHighLimit_) { + smallestDistHighLimit = offsetAxes_[i]->distHighLimit_; + } + if (smallestDistLowLimit > offsetAxes_[i]->distLowLimit_) { + smallestDistLowLimit = offsetAxes_[i]->distLowLimit_; + } + } - // Set the status of the two virtual axes - pl_status = setIntegerParam(pC_->motorStatusProblem(), - (realAxis1StatProb || realAxis2StatProb)); + // Set the limits for the lift axis + double motPos = 0.0; + pl_status = motorPosition(&motPos); if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorStatusProblem_", + return pl_status; + } + + pl_status = pC_->setDoubleParam(axisNo_, pC_->motorHighLimitFromDriver(), + motPos + smallestDistHighLimit); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorHighLimitFromDriver_", axisNo_, __PRETTY_FUNCTION__, __LINE__); } - pl_status = angleAxis_->setIntegerParam( - pC_->motorStatusProblem(), (realAxis1StatProb || realAxis2StatProb)); + + pl_status = pC_->setDoubleParam(axisNo_, pC_->motorLowLimitFromDriver(), + motPos - smallestDistLowLimit); if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorStatusProblem_", - angleAxis_->axisNo(), + return pC_->paramLibAccessFailed(pl_status, "motorLowLimit_", axisNo_, __PRETTY_FUNCTION__, __LINE__); } - pl_status = setStringParam(pC_->motorMessageText(), userMessage); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorMessageText_", - axisNo_, __PRETTY_FUNCTION__, - __LINE__); - } - pl_status = - angleAxis_->setStringParam(pC_->motorMessageText(), userMessage); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorMessageText_", - angleAxis_->axisNo(), - __PRETTY_FUNCTION__, __LINE__); + // The virtual axes are in an error state, if at least one of the underlying + // real axes is in an error state. + for (int i = 0; i < numAxes_; i++) { + pl_status = pC_->getStringParam(offsetAxes_[i]->axisNo(), + pC_->motorMessageText(), + sizeof(userMessage), userMessage); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorMessageText", + offsetAxes_[i]->axisNo(), + __PRETTY_FUNCTION__, __LINE__); + } + + if (strlen(userMessage) != 0) { + pl_status = setStringParam(pC_->motorMessageText(), userMessage); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorMessageText_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + return asynError; + } } - // Calculate the position and the angle - double realAxis1Pos = 0; - double realAxis2Pos = 0; - - pl_status = pC_->getDoubleParam(realAxis1_->axisNo(), pC_->motorPosition(), - &realAxis1Pos); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorPosition_", - realAxis1_->axisNo(), - __PRETTY_FUNCTION__, __LINE__); - } - pl_status = pC_->getDoubleParam(realAxis2_->axisNo(), pC_->motorPosition(), - &realAxis2Pos); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorPosition_", - realAxis2_->axisNo(), - __PRETTY_FUNCTION__, __LINE__); - } - - // Update the parameter library for the seleneAngleAxis (the update for the - // lift axis itself is done in the body of the parent poll() method) - pl_status = angleAxis_->callParamCallbacks(); - if (pC_->getMsgPrintControl().shouldBePrinted( - pC_->portName, angleAxis_->axisNo(), __PRETTY_FUNCTION__, __LINE__, - (pl_status != asynSuccess), pC_->asynUserSelf())) { - asynPrint(pC_->asynUserSelf(), ASYN_TRACE_ERROR, - "Controller \"%s\", axis %d => %s, line " - "%d:\ncallParamCallbacks failed with %s.%s\n", - pC_->portName, angleAxis_->axisNo(), __PRETTY_FUNCTION__, - __LINE__, pC_->stringifyAsynStatus(poll_status), - pC_->getMsgPrintControl().getSuffix()); - } + return asynSuccess; } asynStatus seleneLiftAxis::doMove(double position, int relative, double min_velocity, double max_velocity, double acceleration) { - asynStatus pl_status = pC_->getDoubleParam( - axisNo_, pC_->motorRecResolution(), &motorRecResolution); - if (pl_status != asynSuccess) { - return pC_->paramLibAccessFailed(pl_status, "motorRecResolution_", - axisNo_, __PRETTY_FUNCTION__, - __LINE__); + double motorRecResolution = 0.0; + asynStatus status = asynSuccess; + + status = pC_->getDoubleParam(axisNo_, pC_->motorRecResolution(), + &motorRecResolution); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorRecResolution_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); } targetPosition_ = position * motorRecResolution; + targetSet_ = true; + status = startCombinedMoveFromVirtualAxis(); + targetSet_ = false; + return status; +} - return startCombinedMove(); +asynStatus seleneLiftAxis::targetPosition(double *targetPosition) { + asynStatus status = asynSuccess; + + if (targetSet_) { + *targetPosition = targetPosition_; + } else { + status = motorPosition(targetPosition); + } + return status; } asynStatus seleneLiftAxis::startCombinedMove() { char command[pC_->MAXBUF_], response[pC_->MAXBUF_]; + double liftTargetPosition = 0.0; + double angleTargetPosition = 0.0; + asynStatus status = asynSuccess; + + // ========================================================================= + + status = targetPosition(&liftTargetPosition); + if (status != asynSuccess) { + return status; + } + + status = angleAxis_->targetPosition(&angleTargetPosition); + if (status != asynSuccess) { + return status; + } + + errlogPrintf("Target lift: %lf, Target angle: %lf\n", liftTargetPosition, + angleTargetPosition); auto targetPositions = - calculateMotorPositions(targetPosition_, angleAxis_->targetPosition_); + positionsFromLiftAndAngle(liftTargetPosition, angleTargetPosition); // Apply the offsets of the individual offset axes - for (int i = 0, i < numAxes_, i++) { - targetPositions[i] = - targetPositions[i] + offsetAxes_[i]->targetPosition_; + for (int i = 0; i < numAxes_; i++) { + double offsetTargetPosition = 0.0; + status = offsetAxes_[i]->targetPosition(&offsetTargetPosition); + if (status != asynSuccess) { + return status; + } + targetPositions[i] = targetPositions[i] + offsetTargetPosition; } // Set all target positions and send the move command @@ -395,6 +514,8 @@ asynStatus seleneLiftAxis::startCombinedMove() { targetPositions[0], targetPositions[1], targetPositions[2], targetPositions[3], targetPositions[4], targetPositions[5]); + errlogPrintf("%s\n", command); + // No answer expected return pC_->writeRead(axisNo_, command, response, 0); } @@ -411,8 +532,7 @@ asynStatus seleneLiftAxis::stop(double acceleration) { // ========================================================================= // Stop all axes - const char *command = "P150=8"; - rw_status = pC_->writeRead(axisNo_, command, response, 0); + rw_status = pC_->writeRead(axisNo_, "P150=8", response, 0); if (rw_status != asynSuccess) { asynPrint( @@ -432,18 +552,213 @@ asynStatus seleneLiftAxis::stop(double acceleration) { return rw_status; } -std::array seleneLiftAxis::calculateMotorPositions(double lift, - double angle) { +asynStatus seleneLiftAxis::doReset() { + + // Reset all underlying real axes + for (int i = 0; i < numAxes_; i++) { + offsetAxes_[i]->reset(); + } + return asynSuccess; +} + +asynStatus seleneLiftAxis::enable(bool on) { + asynStatus pl_status = setStringParam(pC_->motorMessageText(), + "Axis cannot be enabled / disabled"); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorMessageText_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + return pl_status; +} + +asynStatus seleneLiftAxis::readEncoderType() { + asynStatus pl_status = + setStringParam(pC_->encoderType(), IncrementalEncoder); + + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "encoderType_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + return asynSuccess; +} + +asynStatus seleneLiftAxis::doHome(double minVelocity, double maxVelocity, + double acceleration, int forwards) { + + char response[pC_->MAXBUF_]; + + // No answer expected + return pC_->writeRead(axisNo_, + "Q151=0 Q152=0 Q153=0 Q154=0 Q155=0 Q156=0 P150=1", + response, 0); +} + +std::array seleneLiftAxis::positionsFromLiftAndAngle(double lift, + double angle) { // Initialize the array containing the positions std::array positions; - // Calculate the individual positions by applying - // y = lift + tan(angle)*(x-xLift_). Apply the individual offset of the axis - // compared to that of the virtual axis (which is on the same level as axis - // 1) as well. - double tanAngle = tan(angle); - for (int i = 0, i < numAxes_, i++) { - positions[i] = lift + tanAngle * (xOffset_[i] - xLift_) - zOffset_[i]; + /* + Calculate the individual positions by applying + z = lift + tan(angle)*(x-xLift_). + + + Apply the individual zOffset_ of the axis compared to that of the virtual + axis (which is on the same level as axis 1) as well. + Angles in degree are converted to radians. + + The angle is inverted by convention: A positive angle means that axes 4 to 6 + go down and 1 to 3 go up + */ + + double tanAngle = tan(-angle * std::numbers::pi / 180.0); + + for (int i = 0; i < numAxes_; i++) { + positions[i] = lift + tanAngle * (offsetAxes_[i]->xOffset_ - xLift_) - + offsetAxes_[i]->zOffset_; } return positions; -} \ No newline at end of file +} + +void seleneLiftAxis::deriveLiftAndAngle(double pos1, double pos2, double *lift, + double *angle) { + // Read the x- and y-value of axes 3 and 4 + double x1 = offsetAxes_[2]->xOffset_; + double x2 = offsetAxes_[3]->xOffset_; + + *lift = 0.5 * (pos1 + pos2); + + /* + Returned radian value is converted to degree + The angle is inverted by convention: A positive angle means that axes 4 to 6 + go down and 1 to 3 go up + */ + *angle = -atan2(pos2 - pos1, x2 - x1) * 180.0 / std::numbers::pi; +} + +seleneOffsetAxis *seleneLiftAxis::offsetAxis(int idx) { + if (idx < numAxes_) { + return offsetAxes_[idx]; + } else { + return nullptr; + } +} + +/*************************************************************************************/ +/** The following functions are C-wrappers, and can be called directly from + * iocsh */ + +extern "C" { + +/* +C wrapper for the axis constructor. Please refer to the detectorTower +constructor documentation. The controller is read from the portName. +*/ +asynStatus seleneVirtualCreateAxes(const char *portName, int axis1No, + int axis2No, int axis3No, int axis4No, + int axis5No, int axis6No, int liftAxisNo, + int angleAxisNo) { + + /* + findAsynPortDriver is a asyn library FFI function which uses the C ABI. + Therefore it returns a void pointer instead of e.g. a pointer to a + superclass of the controller such as asynPortDriver. Type-safe upcasting + via dynamic_cast is therefore not possible directly. However, we do know + that the void pointer is either a pointer to asynPortDriver (if a driver + with the specified name exists) or a nullptr. Therefore, we first do a + nullptr check, then a cast to asynPortDriver and lastly a (typesafe) + dynamic_upcast to Controller + https://stackoverflow.com/questions/70906749/is-there-a-safe-way-to-cast-void-to-class-pointer-in-c + */ + void *ptr = findAsynPortDriver(portName); + if (ptr == nullptr) { + /* + We can't use asynPrint here since this macro would require us + to get a lowLevelPortUser_ from a pointer to an asynPortDriver. + However, the given pointer is a nullptr and therefore doesn'taxis + works w/o that, but doesn't offer the comfort provided + by the asynTrace-facility + */ + errlogPrintf("Controller \"%s\" => %s, line %d\nPort not found.", + portName, __PRETTY_FUNCTION__, __LINE__); + return asynError; + } + // Unsafe cast of the pointer to an asynPortDriver + asynPortDriver *apd = (asynPortDriver *)(ptr); + + // Safe downcast + seleneGuideController *pC = dynamic_cast(apd); + if (pC == nullptr) { + errlogPrintf("Controller \"%s\" => %s, line %d\nController " + "is not a seleneGuideController.", + portName, __PRETTY_FUNCTION__, __LINE__); + return asynError; + } + + // Prevent manipulation of the controller from other threads while we + // create the new axis. + pC->lock(); + +#pragma GCC diagnostic ignored "-Wunused-but-set-variable" +#pragma GCC diagnostic ignored "-Wunused-variable" + seleneLiftAxis *pAxis = + new seleneLiftAxis(pC, axis1No, axis2No, axis3No, axis4No, axis5No, + axis6No, liftAxisNo, angleAxisNo); + + // Allow manipulation of the controller again + pC->unlock(); + return asynSuccess; +} + +static const iocshArg CreateAxisArg0 = {"Controller name (e.g. mcu1)", + iocshArgString}; + +static const iocshArg CreateAxisArg1 = {"Number of the first physical axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg2 = {"Number of the second physical axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg3 = {"Number of the third physical axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg4 = {"Number of the fourth physical axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg5 = {"Number of the fifth physical axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg6 = {"Number of the sixth physical axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg7 = {"Number of the virtual lift axis", + iocshArgInt}; + +static const iocshArg CreateAxisArg8 = {"Number of the virtual angle axis", + iocshArgInt}; + +static const iocshArg *const CreateAxisArgs[] = { + &CreateAxisArg0, &CreateAxisArg1, &CreateAxisArg2, + &CreateAxisArg3, &CreateAxisArg4, &CreateAxisArg5, + &CreateAxisArg6, &CreateAxisArg7, &CreateAxisArg8, +}; +static const iocshFuncDef configSeleneVirtualCreateAxes = {"seleneVirtualAxes", + 9, CreateAxisArgs}; +static void configSeleneVirtualCreateAxesCallFunc(const iocshArgBuf *args) { + seleneVirtualCreateAxes(args[0].sval, args[1].ival, args[2].ival, + args[3].ival, args[4].ival, args[5].ival, + args[6].ival, args[7].ival, args[8].ival); +} + +// ============================================================================= + +// This function is made known to EPICS in detectorTower.dbd and is +// called by EPICS in order to register both functions in the IOC shell +static void seleneVirtualAxesRegister(void) { + iocshRegister(&configSeleneVirtualCreateAxes, + configSeleneVirtualCreateAxesCallFunc); +} +epicsExportRegistrar(seleneVirtualAxesRegister); + +} // extern "C" diff --git a/src/seleneLiftAxis.h b/src/seleneLiftAxis.h index 8a14fd9..a590608 100644 --- a/src/seleneLiftAxis.h +++ b/src/seleneLiftAxis.h @@ -1,28 +1,49 @@ #ifndef seleneLiftAxis_H #define seleneLiftAxis_H #include "seleneAngleAxis.h" +#include "seleneOffsetAxis.h" #include "turboPmacAxis.h" -#include "turboPmacController.h" +#include + +// Forward declaration of the seleneGuideController class to resolve the cyclic +// dependency. See https://en.cppreference.com/w/cpp/language/class. +class seleneGuideController; /** * @brief Virtual axis for setting the height of the center of a Selene guide * - * This axis acts as the "master" for a partnering seleneAngleAxis (i.e. it - * controls the movement and polls the physical motors) + * Please see README.md for a detailed explanation. */ class seleneLiftAxis : public turboPmacAxis { public: - seleneLiftAxis(turboPmacController *pController); + static const int numAxes_ = 6; /** - * @brief Destroy the seleneLiftAxis + * @brief Construct a new seleneLiftAxis object * + * The offset axes need to be constructed in advance and they need to be + * available by the given indices, otherwise the constructor will terminate + * the IOC (configuration error). + * + * @param pC Pointer to the associated controller + * @param axis1No Index of the first associated offset axis + * @param axis2No Index of the second associated offset axis + * @param axis3No Index of the third associated offset axis + * @param axis4No Index of the fourth associated offset axis + * @param axis5No Index of the fifth associated offset axis + * @param axis6No Index of the sixth associated offset axis + * @param liftAxisNo Index of the lift axis itself + * @param angleAxisNo Index of the angle axis itself */ - virtual ~seleneLiftAxis(); + seleneLiftAxis(seleneGuideController *pC, int axis1No, int axis2No, + int axis3No, int axis4No, int axis5No, int axis6No, + int liftAxisNo, int angleAxisNo); /** * @brief Implementation of the `stop` function from asynMotorAxis * + * Stops all underlying physical axes at once + * * @param acceleration Acceleration ACCEL from the motor record. This * value is currently not used. * @return asynStatus @@ -30,8 +51,10 @@ class seleneLiftAxis : public turboPmacAxis { asynStatus stop(double acceleration); /** - * @brief Implementation of the `doPoll` function from sinqAxis. The - * parameters are described in the documentation of `sinqAxis::doPoll`. + * @brief Implementation of the `doPoll` function from sinqAxis. + * + * Some paramLib entries of the associated angle and offset axes are set in + * this function. * * @param moving * @return asynStatus @@ -39,8 +62,7 @@ class seleneLiftAxis : public turboPmacAxis { asynStatus doPoll(bool *moving); /** - * @brief Set the target value for the detector angle and trigger a position - * collection cycle + * @brief Lift the rotation center of the guide to the specified position. * * @param position * @param relative @@ -53,8 +75,98 @@ class seleneLiftAxis : public turboPmacAxis { double maxOffset_velocity, double acceleration); /** - * @brief Start a movement to the target positions of this axis and the - * attached seleneAngleAxis. + * @brief Reset all errors of the underlying physical axes. + * + * @param on + * @return asynStatus + */ + asynStatus doReset(); + + /** + * @brief This function does nothing, since this axis cannot be enabled / + * disabled + * + * @param on + * @return asynStatus + */ + asynStatus enable(bool on); + + /** + * @brief "Home" the virtual axes by driving all underlying real axes to + * position 0. + * + * @param minVelocity + * @param maxVelocity + * @param acceleration + * @param forwards + * @return asynStatus + */ + asynStatus doHome(double minVelocity, double maxVelocity, + double acceleration, int forwards); + + /** + * @brief Write the encoder type in the corresponding PV + * + * Since this axis is a virtual one, it does not have an encoder. In order + * to enable reference drives (which in case of this axis simply sets all + * physical axes to their respective physical position zero), this function + * sets the encoder type to "Incremental Encoder". + * + * @return asynStatus + */ + asynStatus readEncoderType(); + + /** + * @brief This function does nothing, since this axis does not have an + * encoder. + * + * @return asynStatus + */ + asynStatus rereadEncoder() { return asynSuccess; } + + /** + * @brief Start a combined movement from a virtual axis. + * + * This functions sets `virtualAxisMovement_` to `true` and then calls the + * `startCombinedMove()` method. + * + * @return asynStatus + */ + asynStatus startCombinedMoveFromVirtualAxis() { + virtualAxisMovement_ = true; + return startCombinedMove(); + } + + /** + * @brief Start a combined movement from a real offset axis. + * + * This functions sets `virtualAxisMovement_` to `false` and then calls the + * `startCombinedMove()` method. + * + * @return asynStatus + */ + asynStatus startCombinedMoveFromOffsetAxis() { + virtualAxisMovement_ = false; + return startCombinedMove(); + } + + /** + * @brief Start a combined movement using the target positions of the offset + * and virtual axes. + * + * This function takes the target positions of the `seleneLiftAxis`, the + * `seleneAngleAxis` and the associated `seleneOffsetAxis`' and calculates + * the absolute positions of the underlying real axes. In pseudo-code, this + * calculation looks somewhat like this: + * + * ``` + * for i in 0:1:numAxes_ + * pos(i) = target_lift + tan(target_angle) * (x(i) - x_lift) + target(i) + *``` + * where `x(i)` is the distance between the first axis and the i-th axis. + * All numAxes_ positions are calculated and send to the controller as one + *command (even though the position of one or multiple axes might not change + *at all). * * @return asynStatus */ @@ -73,55 +185,36 @@ class seleneLiftAxis : public turboPmacAxis { asynStatus init(); /** - * @brief Reset the error(s) of both physical motors + * @brief Calculate lift and angle axis position based on the actual + positions of the underlying motors. + + This function implements the lift and angle calculation described in + README.md based on the given position values of the central physical axes. + Usually, these positions are those of axis 3 and 4. * - * @param on - * @return asynStatus + * @param pos1 + * @param pos1 + * @param lift + * @param angle */ - asynStatus reset(); + void deriveLiftAndAngle(double pos1, double pos2, double *lift, + double *angle); /** - * @brief Enable / disable both physical motors + * @brief Set the offsets of axis 3 and 4 to zero and recalculate all other + * axes positions based on it. * - * @param on - * @return asynStatus - */ - asynStatus enable(bool on); - - /** - * @brief Override of the home function of asynMotorAxis, which does nothing - * - * @param on - * @return asynStatus - */ - asynStatus home(double min_velocity, double maxOffset_velocity, - double acceleration, int forwards) { - return asynSuccess; - } - - /** - * @brief The encoder of both physical motors is absolute, hence the encoder - * of the virtual axis is also absolute + * Calling this function "normalizes" the lift axis, the angle axis and the + * offset axes by performing a "normalization" operation. For details, see + * README.md. This function does not communicate with the controller. * * @return asynStatus */ - asynStatus readEncoderType(); + asynStatus normalizePositions(); /** - * @brief Trigger a rereading of the encoder position of both physical - * motors - * - * @return asynStatus - */ - asynStatus rereadEncoder(); - - // Calculate - asynStatus normalizeOffsets(); - - /** - * @brief Calculate the positions of all 6 real axes from the given lift and - * angle. This function does not change any values in the parameter library - * and does not communicate with the controller. + * @brief Calculate and return the positions of all 6 underlying physical + * axes from the given lift and angle without considering the offset axes. * * In order to calculate the positions, this function convertes the given * lift and angle into a linear equation. The x-values of the axes are read @@ -130,32 +223,75 @@ class seleneLiftAxis : public turboPmacAxis { * of the corresponding axis in order to calculate the actual positon. * * The corresponding equation is: + * ``` * y = lift + tan(angle)*(x-xLift_) + * ``` + * + * This function does not change any values in the parameter library and + * does not communicate with the controller. * * @param lift Position of the virtual lift axis in mm * @param angle Angle of the virtual angle axis in radian * @return std::array */ - std::array calculateMotorPositions(double lift, double angle); + std::array positionsFromLiftAndAngle(double lift, + double angle); + + /** + * @brief Access the angle axis associated with this lift axis. + * + * @return seleneAngleAxis* + */ + seleneAngleAxis *angleAxis() { return angleAxis_; } + + /** + * @brief Checks whether the current movement was triggered from the lift / + * angle axes or the offset axes. + * + * @return true Triggered by lift / angle axis + * @return false Triggered by an offset axis + */ + bool virtualAxisMovement() { return virtualAxisMovement_; } + + /** + * @brief Read the target position from the axis and write it into the + * provided pointer. + * + * @param targetPosition + * @return asynStatus + */ + asynStatus targetPosition(double *targetPosition); + + /** + * @brief Access the position of the lift axis + * + */ + double xPos() { return xLift_; } + + /** + * @brief Return a pointer to the associated offset axis with index "idx" + * + * If the given index is out of bounds, return a nullptr instead. + * + * @param index + * @return seleneOffsetAxis* + */ + seleneOffsetAxis *offsetAxis(int idx); protected: - static const int numAxes_ = 6; + std::array offsetAxes_; - turboPmacController *pC_; + seleneGuideController *pC_; seleneAngleAxis *angleAxis_; - turboPmacAxis offsetAxes_[numAxes_]; - - // Horizontal distance from the first axis in mm (which is located at 0 mm) - double xOffset_[numAxes_]; - - // Vertical offset from the first axis in mm (which is located at 0 mm) - double zOffset_[numAxes_]; // Horizontal position of the virtual lift axis double xLift_; - // Target position for the lift - double targetPosition_; + // True, if the target has been set from this axis + bool targetSet_; + + // True, if the movement has been started from the lift or angle axis + bool virtualAxisMovement_; }; #endif diff --git a/src/seleneOffsetAxis.cpp b/src/seleneOffsetAxis.cpp new file mode 100644 index 0000000..c68368b --- /dev/null +++ b/src/seleneOffsetAxis.cpp @@ -0,0 +1,427 @@ +#include "seleneOffsetAxis.h" +#include "epicsExport.h" +#include "iocsh.h" +#include "seleneGuideController.h" +#include "seleneLiftAxis.h" +#include "turboPmacController.h" +#include + +/* +Contains all instances of seleneLiftAxis which have been created and is used in +the initialization hook function. + */ +static std::vector axes; + +/** + * @brief Hook function to perform certain actions during the IOC initialization + * + * @param iState + */ +static void epicsInithookFunction(initHookState iState) { + if (iState == initHookAfterDatabaseRunning) { + // Iterate through all axes of each and call the initialization method + // on each one of them. + for (std::vector::iterator itA = axes.begin(); + itA != axes.end(); ++itA) { + seleneOffsetAxis *axis = *itA; + axis->init(); + } + } +} + +seleneOffsetAxis::seleneOffsetAxis(seleneGuideController *pController, + int axisNo, double xPos, double zPos) + : turboPmacAxis(pController, axisNo, false), pC_(pController) { + asynStatus status = asynSuccess; + + xOffset_ = xPos; + zOffset_ = zPos; + + // Placeholders, will be overwritten later + liftPosition_ = 0.0; + targetSet_ = false; + absolutePositionLiftCS_ = 0.0; + highLimit_ = 0.0; + lowLimit_ = 0.0; + distHighLimit_ = 0.0; + distLowLimit_ = 0.0; + absoluteHighLimitLiftCS_ = 0.0; + absoluteLowLimitLiftCS_ = 0.0; + + status = pC_->setIntegerParam(axisNo_, pC_->motorStatusDone(), 1); + if (status != asynSuccess) { + pC_->paramLibAccessFailed(status, "motorStatusDone", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + status = pC_->setIntegerParam(axisNo_, pC_->motorStatusMoving(), 0); + if (status != asynSuccess) { + pC_->paramLibAccessFailed(status, "motorStatusMoving", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + status = pC_->setDoubleParam(axisNo_, pC_->motorEncoderPosition(), 0.0); + if (status != asynSuccess) { + pC_->paramLibAccessFailed(status, "motorEncoderPosition", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + // Register the hook function during construction of the first axis + // object + if (axes.empty()) { + initHookRegister(&epicsInithookFunction); + } + + // Collect all axes into this list which will be used in the hook + // function + axes.push_back(this); + + // Selene guide motors cannot be disabled + status = pC_->setIntegerParam(axisNo_, pC_->motorCanDisable(), 0); + if (status != asynSuccess) { + pC_->paramLibAccessFailed(status, "motorCanDisable", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } +} + +asynStatus seleneOffsetAxis::init() { + + // Local variable declaration + asynStatus status = asynSuccess; + double motorRecResolution = 0.0; + + // ========================================================================= + + // The parameter library takes some time to be initialized. Therefore we + // wait until the status is not asynParamUndefined anymore. + time_t now = time(NULL); + time_t maxInitTime = 60; + while (1) { + status = pC_->getDoubleParam(axisNo_, pC_->motorRecResolution(), + &motorRecResolution); + if (status == asynParamUndefined) { + if (now + maxInitTime < time(NULL)) { + asynPrint(pC_->asynUserSelf(), ASYN_TRACE_ERROR, + "Controller \"%s\", axis %d => %s, line " + "%d\nInitializing the parameter library failed.\n", + pC_->portName, axisNo_, __PRETTY_FUNCTION__, + __LINE__); + return asynError; + } + } else if (status == asynSuccess) { + break; + } else if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorRecResolution_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + } + + // Read the initial limits from the paramlib + status = pC_->getDoubleParam(axisNo_, pC_->motorHighLimit(), &highLimit_); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorHighLimit", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + status = pC_->getDoubleParam(axisNo_, pC_->motorLowLimit(), &lowLimit_); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorLowLimit", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + // The high limits read from the paramLib are divided by motorRecResolution, + // hence we need to multiply them to undo this change. + highLimit_ = highLimit_ * motorRecResolution; + lowLimit_ = lowLimit_ * motorRecResolution; + + return status; +} + +asynStatus seleneOffsetAxis::targetPosition(double *targetPosition) { + asynStatus status = asynSuccess; + + if (targetSet_) { + *targetPosition = targetPosition_; + } else { + status = motorPosition(targetPosition); + } + return status; +} + +asynStatus seleneOffsetAxis::doPoll(bool *moving) { + asynStatus status = asynSuccess; + asynStatus errorStatus = asynSuccess; + double highLimit = 0.0; + double lowLimit = 0.0; + double encoderPosition = 0.0; + int isMoving = 0; + int error = 0; + int nvals = 0; + + char command[pC_->MAXBUF_], response[pC_->MAXBUF_], + userMessage[pC_->MAXBUF_]; + + // ========================================================================= + + /* + Read the following informations: + - Whether the axis is moving (P158) + - Axis position (absolute encoder value!) + - Error code + - Absolute high limit + - Absolute low limit + */ + snprintf(command, sizeof(command), + "P158 Q%2.2d10 P%2.2d01 Q%2.2d13 Q%2.2d14", axisNo_, axisNo_, + axisNo_, axisNo_); + status = pC_->writeRead(axisNo_, command, response, 5); + if (status != asynSuccess) { + return status; + } + + nvals = sscanf(response, "%d %lf %d %lf %lf", &isMoving, &encoderPosition, + &error, &highLimit, &lowLimit); + if (nvals != 5) { + return pC_->couldNotParseResponse(command, response, axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + *moving = (isMoving != 0); + + status = setPositionsFromEncoderPosition(encoderPosition); + if (status != asynSuccess) { + return status; + } + + // ========================================================================= + // Update the parameter library + + errorStatus = handleError(error, userMessage, sizeof(userMessage)); + + // Update the parameter library + if (error != 0) { + status = setIntegerParam(pC_->motorStatusProblem(), true); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusProblem_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + } + + // TODO: To be removed, once the real limits are derived + //highLimit = 10.0; + //lowLimit = -10.0; + + // Convert into lift coordinate system + absoluteHighLimitLiftCS_ = highLimit - zOffset_; + absoluteLowLimitLiftCS_ = lowLimit - zOffset_; + + // Calculate the distance of the axis to its upper and lower limits + distHighLimit_ = highLimit - encoderPosition; + distLowLimit_ = encoderPosition - lowLimit; + + // Check if the relative limits need to be shrunken because we are too close + // to the absolute limit + double relHighLimit = highLimit_; + double relLowLimit = lowLimit_; + if ((highLimit - encoderPosition) < relHighLimit) { + relHighLimit = highLimit - encoderPosition; + } + if ((lowLimit - encoderPosition) > relLowLimit) { + relLowLimit = lowLimit - encoderPosition; + } + + status = pC_->setDoubleParam(axisNo_, pC_->motorHighLimitFromDriver(), + relHighLimit); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorHighLimitFromDriver_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + + status = pC_->setDoubleParam(axisNo_, pC_->motorLowLimitFromDriver(), + relLowLimit); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorLowLimit_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + /* + If this axis is moving as a result of an individual move command to the axis + alone, recalculate the RBV value as the difference between the absolute + position and the lift position. + */ + if (*moving && !liftAxis_->virtualAxisMovement()) { + status = setMotorPosition(absolutePositionLiftCS_ - liftPosition_); + if (status != asynSuccess) { + return status; + } + } + + status = setIntegerParam(pC_->motorStatusMoving(), *moving); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusMoving_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + status = setIntegerParam(pC_->motorStatusDone(), !(*moving)); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorStatusDone_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + // Axis is always shown as enabled, since it is enabled automatically by the + // controller when a move command P150=1 is sent and disabled once the + // movement is completed. + status = setIntegerParam(pC_->motorEnableRBV(), 1); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorEnableRBV_", axisNo_, + __PRETTY_FUNCTION__, __LINE__); + } + + return errorStatus; +} + +asynStatus +seleneOffsetAxis::setPositionsFromEncoderPosition(double encoderPosition) { + asynStatus status = pC_->setDoubleParam( + axisNo_, pC_->motorAbsolutePositionRBV(), encoderPosition); + if (status != asynSuccess) { + return pC_->paramLibAccessFailed(status, "motorEncoderPosition", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + absolutePositionLiftCS_ = encoderPosition - zOffset_; + return status; +} + +asynStatus seleneOffsetAxis::doMove(double position, int relative, + double min_velocity, + double maxOffset_velocity, + double acceleration) { + + double motorRecResolution = 0.0; + + asynStatus pl_status = pC_->getDoubleParam( + axisNo_, pC_->motorRecResolution(), &motorRecResolution); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorRecResolution_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + + // Set the target position + targetPosition_ = position * motorRecResolution; + targetSet_ = true; + asynStatus status = liftAxis_->startCombinedMoveFromOffsetAxis(); + targetSet_ = false; + return status; +} + +asynStatus seleneOffsetAxis::enable(bool on) { + asynStatus pl_status = setStringParam(pC_->motorMessageText(), + "Axis cannot be enabled / disabled"); + if (pl_status != asynSuccess) { + return pC_->paramLibAccessFailed(pl_status, "motorMessageText_", + axisNo_, __PRETTY_FUNCTION__, + __LINE__); + } + return pl_status; +} + +asynStatus seleneOffsetAxis::normalizePositions() { + return liftAxis_->normalizePositions(); +} + +/*************************************************************************************/ +/** The following functions are C-wrappers, and can be called directly from + * iocsh */ + +extern "C" { + +/* +C wrapper for the axis constructor. Please refer to the detectorTower +constructor documentation. The controller is read from the portName. +*/ +asynStatus seleneOffsetAxisCreateAxis(const char *portName, int axisNo, + double xPos, double zPos) { + + /* + findAsynPortDriver is a asyn library FFI function which uses the C ABI. + Therefore it returns a void pointer instead of e.g. a pointer to a + superclass of the controller such as asynPortDriver. Type-safe upcasting + via dynamic_cast is therefore not possible directly. However, we do know + that the void pointer is either a pointer to asynPortDriver (if a driver + with the specified name exists) or a nullptr. Therefore, we first do a + nullptr check, then a cast to asynPortDriver and lastly a (typesafe) + dynamic_upcast to Controller + https://stackoverflow.com/questions/70906749/is-there-a-safe-way-to-cast-void-to-class-pointer-in-c + */ + void *ptr = findAsynPortDriver(portName); + if (ptr == nullptr) { + /* + We can't use asynPrint here since this macro would require us + to get a lowLevelPortUser_ from a pointer to an asynPortDriver. + However, the given pointer is a nullptr and therefore doesn'taxis + works w/o that, but doesn't offer the comfort provided + by the asynTrace-facility + */ + errlogPrintf("Controller \"%s\" => %s, line %d\nPort not found.", + portName, __PRETTY_FUNCTION__, __LINE__); + return asynError; + } + // Unsafe cast of the pointer to an asynPortDriver + asynPortDriver *apd = (asynPortDriver *)(ptr); + + // Safe downcast + seleneGuideController *pC = dynamic_cast(apd); + if (pC == nullptr) { + errlogPrintf("Controller \"%s\" => %s, line %d\nController " + "is not a seleneGuideController.", + portName, __PRETTY_FUNCTION__, __LINE__); + return asynError; + } + + // Prevent manipulation of the controller from other threads while we + // create the new axis. + pC->lock(); + +#pragma GCC diagnostic ignored "-Wunused-but-set-variable" +#pragma GCC diagnostic ignored "-Wunused-variable" + seleneOffsetAxis *pAxis = new seleneOffsetAxis(pC, axisNo, xPos, zPos); + + // Allow manipulation of the controller again + pC->unlock(); + return asynSuccess; +} + +static const iocshArg CreateAxisArg0 = {"Controller name (e.g. mcu1)", + iocshArgString}; +static const iocshArg CreateAxisArg1 = {"Number of the axis", iocshArgInt}; +static const iocshArg CreateAxisArg2 = {"X-coordinate of the axis", + iocshArgDouble}; +static const iocshArg CreateAxisArg3 = {"Z-coordinate of the axis", + iocshArgDouble}; + +static const iocshArg *const CreateAxisArgs[] = { + &CreateAxisArg0, + &CreateAxisArg1, + &CreateAxisArg2, + &CreateAxisArg3, +}; +static const iocshFuncDef configSeleneOffsetAxisCreateAxis = { + "seleneOffsetAxis", 4, CreateAxisArgs}; +static void configSeleneOffsetAxisCreateAxisCallFunc(const iocshArgBuf *args) { + seleneOffsetAxisCreateAxis(args[0].sval, args[1].ival, args[2].dval, + args[3].dval); +} + +// ============================================================================= + +// This function is made known to EPICS in seleneLift.dbd and is +// called by EPICS in order to register both functions in the IOC shell +static void seleneOffsetAxisRegister(void) { + iocshRegister(&configSeleneOffsetAxisCreateAxis, + configSeleneOffsetAxisCreateAxisCallFunc); +} +epicsExportRegistrar(seleneOffsetAxisRegister); + +} // extern "C" diff --git a/src/seleneOffsetAxis.h b/src/seleneOffsetAxis.h new file mode 100644 index 0000000..2696b38 --- /dev/null +++ b/src/seleneOffsetAxis.h @@ -0,0 +1,170 @@ +#ifndef seleneOffsetAxis_H +#define seleneOffsetAxis_H + +#include "turboPmacAxis.h" + +// Forward declaration of the seleneLiftAxis class to resolve the cyclic +// dependency between the seleneLiftAxis and the seleneAngleAxis .h-file. +// See https://en.cppreference.com/w/cpp/language/class. +class seleneLiftAxis; + +// Forward declaration of the seleneGuideController class to resolve the cyclic +// dependency. See https://en.cppreference.com/w/cpp/language/class. +class seleneGuideController; + +/** + * @brief Virtual axis for setting the offset of a motor of the Selene guide + * + * Please see README.md for a detailed explanation. + */ +class seleneOffsetAxis : public turboPmacAxis { + public: + /** + * @brief Construct a new selene Offset Axis object + * + * @param pController Pointer to the associated controller + * @param axisNo Index of the axis + * @param xPos x-Offset of this axis to the origin + * @param zPos z-Offset of this axis to the origin + */ + seleneOffsetAxis(seleneGuideController *pController, int axisNo, + double xPos, double zPos); + + /** + * @brief Initialize this offset axis + * + * This function is started from seleneLiftAxis. + * + * @return asynStatus + */ + asynStatus init(); + + /** + * @brief Implementation of the `doPoll` function from sinqAxis. + * * + * @param moving + * @return asynStatus + */ + asynStatus doPoll(bool *moving); + + /** + * @brief Move the offset position of this axis to parameter "position". + * + * @param position + * @param relative + * @param min_velocity + * @param maxOffset_velocity + * @param acceleration + * @return asynStatus + */ + asynStatus doMove(double position, int relative, double min_velocity, + double maxOffset_velocity, double acceleration); + + /** + * @brief This function does nothing, since this axis cannot be enabled / + * disabled + * + * @param on + * @return asynStatus + */ + asynStatus enable(bool on); + + /** + * @brief Calculate the member variables `absolutePositionLiftCS_` and + * `motorPosition` based on the given encoder position. + * + * @param encoderPosition + * @return asynStatus + */ + asynStatus setPositionsFromEncoderPosition(double encoderPosition); + + /** + * @brief Read the target position from the axis and write it into the + * provided pointer. + * + * @param targetPosition + * @return asynStatus + */ + asynStatus targetPosition(double *targetPosition); + + /** + * @brief Set the offsets of axis 3 and 4 to zero and recalculate all other + * axes positions based on it. + * + * This function does not communicate with the controller. + * + * @return asynStatus + */ + asynStatus normalizePositions(); + + /** + * @brief Returns the horizontal distance from the origin + * + */ + double xOffset() { return xOffset_; } + + /** + * @brief Returns the vertical distance of the axis' zero position from the + * origin + * + */ + double zOffset() { return zOffset_; } + + /** + * @brief Return the absolute high limit of the underlying physical axis in + * lift coordinates, taking the zOffset into account. + * + * @return double + */ + double absoluteHighLimitLiftCS() { return absoluteHighLimitLiftCS_; } + + /** + * @brief Return the absolute low limit of the underlying physical axis in + * lift coordinates, taking the zOffset into account. + * + * @return double + */ + double absoluteLowLimitLiftCS() { return absoluteLowLimitLiftCS_; } + + protected: + // True, if the target has been set from this axis + bool targetSet_; + + // Absolute position in the lift coordinate system = absolute position + // corrected by zOffset. + double absolutePositionLiftCS_; + + // Position of the motor axis on the line defined by the liftAxis and the + // angleAxis position in mm. + double liftPosition_; + + // Horizontal distance from the origin + double xOffset_; + + // Vertical distance from the origin + double zOffset_; + + // Distance of the motor to the limits read from the controller + double distHighLimit_; + double distLowLimit_; + + // Absolute limits read out from the encoder in the lift coordinate system + double absoluteHighLimitLiftCS_; + double absoluteLowLimitLiftCS_; + + // Equal to the motor record fields HLM and LLM from the substitution file + double highLimit_; + double lowLimit_; + + seleneGuideController *pC_; + seleneLiftAxis *liftAxis_; + + /* + The lift axis needs the ability to write to some of the members of this axis + (e.g. it needs to be able to insert a pointer to itself into the member + variable liftAxis_). + */ + friend class seleneLiftAxis; +}; + +#endif \ No newline at end of file