From 467a8542f61e5497bd6d0c0b340bb9a141d18824 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 12 Mar 2025 14:23:35 +0000 Subject: [PATCH 01/61] Initial dev changes to OxInstIPS_SCPI.protocol --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol new file mode 100644 index 0000000..a76cca2 --- /dev/null +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -0,0 +1,295 @@ +# File OxInstIPS_SCPI.protocol +# +# Stream Device protocol file for the Oxford Instruments Modular IPS +# superconducting magnet power supplies. +# This protocol supports the SCPI commands for the IPS, replacing the legacy command set. +# +# The full protocol is described in the IPS Operators Handbook. +# +# The commands are case-sensitive. +# Keywords are a maximum of four characters long. Keywords longer than four characters +# generate an invalid command response. +# Keywords are separated by a colon: (ASCII 0x3Ah). +# The maximum line length is 1024 bytes (characters), including line terminators. +# All command lines are terminated by the new line character \n (ASCII 0x0Ah). +# +Terminator = "\n"; + +# The lagacy timeout values cribbed from OxInstCryojet module - had occasional +# problems with the default settings with one record timing out and +# another record seeing the reply - see if longer time out will fix it. +# Also see if this is still applicable to the SCPI protocol. +# +readtimeout = 500; +replytimeout = 5000; +locktimeout = 20000; +PollPeriod = 500; +ExtraInput = Ignore; + +# Device board/slot names +magnet_temperature_sensor = "MB1.T1" +level_meter = "DB1.L1" +magnet_supply = "GRPZ" +temperature_sensor_10T = "DB8.T1" +pressure_sensor_10T = "DB5.P1" + +######################################################################################### +# --------- +# *IDN? +# --------- +# IDN:OXFORD INSTRUMENTS:MERCURY dd:ss:ff +# Where: +# dd is the basic instrument type (iPS , iPS, Cryojet etc.) +# ss is the serial number of the main board +# ff is the firmware version of the instrument +# Get the unit version information - returns unit type and firmware information. +# Manual says letter commands always reply with themselves at the start, but +# found out this one does not. Manual also shows a copyright symbol, which is not an +# ASCII symbol and might cause problems for EPICS. In practice found (c) instead. +# The %s format grabs the string upto the first space, the %c grabs the rest. It +# was too long to fit into one EPICS string record value. +getVersion { out "*IDN?"; wait 100; in "IDN:%*s:%(\$1MODEL.VAL)s:%*s:%(\$1VERSION.VAL)s"; wait 100;} + +######################################################################################### +# Get demand current (output current) in amps. We are only interested in the Z axis magnet group. +# The return string is of the form: STAT:DEV:GRPZ:PSU:CURR::A +getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; in "STAT:DEV:GRPZ:PSU:SIG:CSET:%f:%*s"; wait 100;} + +# Get measured power supply voltage in volts +getSupplyVoltage { out "READ:DEV:" $magnet_supply ":PSU:SIG:VOLT"; wait 100; in "STAT:DEV:READ:DEV:" $magnet_supply ":PSU:SIG:VOLT:%f:%*s"; wait 100;} + +# Get measured magnet curren in amps - ER=no. +getMeasuredMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CURR"; wait 100; in "STAT:DEV:READ:DEV:" $magnet_supply ":PSU:SIG:CURR:%f:%*s"; wait 100;} + +# Get set point (target current) in amps ER=yes. +getSetpointCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} + +# Get current sweep rate in amps per minute ER=yes. +getCurrentSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RCST"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"; wait 100;} + +# Get demand field (output field) in tesla ER=yes. +getDemandField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"; wait 100;} + +# Get set point (target field) in tesla ER=yes. +getSetpointField { out "READ:DEV:" $magnet_supply "}:PSU:SIG:FSET"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"; wait 100;} + +# Get field sweep rate in tesla per minute ER=yes. +getFieldSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RFST"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"; wait 100;} + +# Get software voltage limit in volts ER=no. +getSoftwareVoltageLimit { out "READ:DEV:" $magnet_supply ":PSU:VLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:VLIM:%f%*s"; wait 100;} + +# Get persistent magnet current in amps ER=yes. +getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PCUR:%f%*s"; wait 100;} + +# Get trip current in amps ER=yes. +#??? +getTripCurrent { out "R17"; wait 100; in "R%f"; wait 100;} + +# Get persistent magnetic field in tesla ER=yes. +getPersistentMagnetField { out "READ:DEV:" $magnet_supply "}:PSU:SIG:PFLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} + +# Get trip field in tesla ER=yes. +#??? +getTripField { out "R19"; wait 100; in "R%f"; wait 100;} + +# Get switch heater current in milliamp ER=no. +getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"); wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; wait 100;} + +# Get safe current limit, most negative in amps ER=no. +getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} + +# Get safe current limit, most positive in amps ER=no. +getPosCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} + +# Get lead resistance in milliohms ER=no. +getLeadResistance { out "READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES"); wait 100; in "STAT:DEV:" $magnet_supply ":TEMP:SIG:RES:%f%*s"; wait 100;} + +# Get magnet inductance in henry ER=no. +getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:IND:%f%*s"; wait 100;} + +# Get Activity status (analogous to the legacy A command) +getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; + wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1STS:SYSTEM:FAULT.VAL){HOLD|RTOS|RTOZ|CLMP}"; + wait 100;} + +# Get PSU Status +getPSUStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; + wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:STAT:%(\$1STS:SYSTEM:FAULT.VAL)1u%(\$1STS:SYSTEM:LIMIT.VAL)1u"; + wait 100;} + +# End of list of commands to read parameters. +######################################################################################### +# --------- +# X Command +# --------- +# Command to get the status - the IPS returns loads of flags in one command +# +# The X command - examine status. +# According to the manual the reply is of the form +# +# XmnAnCnHnMmnPmn +# +# where: +# X, A, C, H, M and P are literal characters introducing the values for different types of status as follows +# and m and n are integer digits. +# X m is the system fault status +# 0 Normal +# 1 Quenched +# 2 Overheated +# 4 Warming Up +# 8 Fault +# X n is the system limiting status +# 0 Normal +# 1 On +ve V Limit +# 2 On -ve V Limit +# 4 Current too -ve +# 8 Current too +ve +# A n is the activity +# 0 Hold +# 1 To Set Point +# 2 To Zero +# 4 Clamped +# C n is the Local/Remote Control status +# 0 Local & Locked +# 1 Remote & Locked +# 2 Local & Unlocked +# 3 Remote & Unlocked +# 4 Auto-Run-Down +# 5 Auto-Run-Down +# 6 Auto-Run-Down +# 7 Auto-Run-Down +# H n is for the switch heater +# 0 Off Mag at 0 +# 1 On +# 2 Off Mag at F +# 5 Heater Fault +# 8 No Switch +# M m is for the sweeping mode parameters +# 0 Amps Fast +# 1 Tesla Fast +# 4 Amps Slow +# 5 Tesla Slow +# M n is for the sweeping status +# 0 At rest +# 1 Sweeping +# 2 Sweep Limiting +# 3 Swping & Lmting +# P m and n are for the polarity and has been superseded by signed values on the current and field parameters. +# P is present for backward compatibility and can be ignored, so we do. +#??? +getStatus { + out "X"; + wait 100; + in + "X%(\$1STS:SYSTEM:FAULT.VAL)1u%(\$1STS:SYSTEM:LIMIT.VAL)1u" + "A%(\$1ACTIVITY.VAL)1u" + "C%(\$1CONTROL.VAL)1u" + "H%(\$1HEATER:STATUS.VAL)1u" + "M%*1u%(\$1STS:SWEEPMODE:SWEEP.VAL)1u" + ; + wait 100; +} + +# End of examining the status. + +# --------- +# W Command +# --------- +# Diddle with the comms wait interval - how long it waits between sending characters. +# Can vary between 0 and 32767 milliseconds, defaults to zero on power up. +setWaitInterval { setRemoteUnlocked; out "W%u" ; wait 100; in "W"; wait 100; } + +# --------- +# C Command +# --------- +# Set Control mode - grab control of the unit from local users. +# C0 Local & Locked +# C1 Remote & Locked +# C2 Local & Unlocked +# C3 Remote & Unlocked +# You cannot set the auto-run-down state read in the status readback - the unit does that. +setControl { setRemoteUnlocked; out "%{C0|C1|C2|C3}" ; wait 100; in "C"; wait 100; } + +# --------- +# A Command +# --------- +# Set the activity - i.e. Make it do something. +# A0 Hold +# A1 To Set Point +# A2 To Zero +# A4 Clamp +setActivity { setRemoteUnlocked; out "A%u" ; wait 100; in "A"; wait 100;} + +# --------- +# F Command +# --------- +# Do not want to interfere with Front Panel Display remotely, therefore not bothering with F command. + +# --------- +# H Command +# --------- +# +# Set the status of the heater. +# 0 = heater off (close switch) +# 1 = heater on (open switch) [Checks that magnet curr == psu curr before having any effect, so safer] +# 2 = heater on (open switch) [no checks... UNSAFE! don't use this.] +# +setHeaterStatus { setRemoteUnlocked; out "H%{0|1}" ; wait 100; in "H"; wait 100;} + +# --------- +# I Command +# --------- +# Set the setpoint (target) current. +# The precision of the controller is different depending on whether "Extended Resolution" is set or not, +# but in practice it just ignores extra digit, so we can always use the most resolution for each +# quantity. +setSetpointCurrent { setRemoteUnlocked; out "I%#.4f" ; wait 100; in "I"; wait 100;} + +# --------- +# J Command +# --------- +# Set the setpoint (target) field. +setSetpointField { setRemoteUnlocked; out "J%#.5f" ; wait 100; in "J"; wait 100;} + +# --------- +# M Command +# --------- +# Set the mode. +# Control "Fast/Slow" sweep - and whether units displayed in Current of Field on Front Panel. +# This is not so straight forwards as it might seem - need to be careful in the template. +setMode { setRemoteUnlocked; out "M%u" ; wait 100; in "M"; wait 100;} + +# --------- +# P Command +# --------- +# Set the polarity. Not implemented - it is obsolete. + +# --------- +# S Command +# --------- +# Set current sweep rate. +# Rate at which current will be ramped or swept to target, either the setpoint or zero. +setCurrentSweeprate { setRemoteUnlocked; out "S%#.3f" ; wait 100; in "S"; wait 100;} + +# --------- +# T Command +# --------- +# Set field sweep rate. +# Rate at which field will be ramped or swept to target, either the setpoint or zero. +setFieldSweeprate { setRemoteUnlocked; out "T%#.4f" ; wait 100; in "T"; wait 100;} + +# ------------------- +# Y, Z and ~ Commands +# ------------------- +# Y and Z give access to read and write the RAM, and are protected system commands not intended for customer use. +# ~ Allows calibration changes to be stored. Do not want users to have access to these functions. +# Therefore not bothering with Y, Z or ~ commands. + +# --------- +# ! Command +# --------- +# This allows the instrument to be given a number so it will share an RS232 line with a chain of other Oxford Instruments controllers. +# We are not intending to use the system like this, so will not bother with this command. From 1dcb2ed1ad8dea9dd0ced8710246f03e9ba64d5f Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 27 Mar 2025 15:34:19 +0000 Subject: [PATCH 02/61] Further SCPI migration --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index a76cca2..0908c39 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -111,15 +111,18 @@ getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; wait 100; in "S # Get Activity status (analogous to the legacy A command) getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1STS:SYSTEM:FAULT.VAL){HOLD|RTOS|RTOZ|CLMP}"; + in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1ACTIVITY.VAL){HOLD|RTOS|RTOZ|CLMP}"; wait 100;} -# Get PSU Status -getPSUStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; +# --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- +# Get PSU Status status DWORD +getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:STAT:%(\$1STS:SYSTEM:FAULT.VAL)1u%(\$1STS:SYSTEM:LIMIT.VAL)1u"; + in "STAT:DEV:" $magnet_supply ":PSU:STAT:%x"; wait 100;} +# -------------------------------------------------------------------------------------------------------------- + # End of list of commands to read parameters. ######################################################################################### # --------- @@ -221,8 +224,10 @@ setControl { setRemoteUnlocked; out "%{C0|C1|C2|C3}" ; wait 100; in "C"; wait 10 # A1 To Set Point # A2 To Zero # A4 Clamp -setActivity { setRemoteUnlocked; out "A%u" ; wait 100; in "A"; wait 100;} - +setActivity { out SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}" ; + wait 100; + in "READ:DEV:" $magnet_supply ":PSU:ACTN:%*s:VALID"; + wait 100;} # --------- # F Command # --------- From 5d73d904e06824b1073cd3b11f75dbcc944c352e Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 1 May 2025 15:45:13 +0100 Subject: [PATCH 03/61] Corrections to SCPI protocol on connection to real instrument --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 195 ++++-------------- 1 file changed, 45 insertions(+), 150 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 0908c39..1253947 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -27,11 +27,11 @@ PollPeriod = 500; ExtraInput = Ignore; # Device board/slot names -magnet_temperature_sensor = "MB1.T1" -level_meter = "DB1.L1" -magnet_supply = "GRPZ" -temperature_sensor_10T = "DB8.T1" -pressure_sensor_10T = "DB5.P1" +magnet_temperature_sensor = "MB1.T1"; +level_meter = "DB1.L1"; +magnet_supply = "GRPZ"; +temperature_sensor_10T = "DB8.T1"; +pressure_sensor_10T = "DB5.P1"; ######################################################################################### # --------- @@ -48,7 +48,7 @@ pressure_sensor_10T = "DB5.P1" # ASCII symbol and might cause problems for EPICS. In practice found (c) instead. # The %s format grabs the string upto the first space, the %c grabs the rest. It # was too long to fit into one EPICS string record value. -getVersion { out "*IDN?"; wait 100; in "IDN:%*s:%(\$1MODEL.VAL)s:%*s:%(\$1VERSION.VAL)s"; wait 100;} +getVersion { out "*IDN?"; wait 100; in "IDN:OXFORD INSTRUMENTS:%(\$1MODEL.VAL)[ a-zA-Z0-9]:%*[ a-zA-Z0-9]:%(\$1VERSION.VAL)s"; wait 100;} ######################################################################################### # Get demand current (output current) in amps. We are only interested in the Z axis magnet group. @@ -71,7 +71,7 @@ getCurrentSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RCST"; wait 100; getDemandField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"; wait 100;} # Get set point (target field) in tesla ER=yes. -getSetpointField { out "READ:DEV:" $magnet_supply "}:PSU:SIG:FSET"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"; wait 100;} +getSetpointField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FSET"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"; wait 100;} # Get field sweep rate in tesla per minute ER=yes. getFieldSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RFST"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"; wait 100;} @@ -87,14 +87,17 @@ getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; wai getTripCurrent { out "R17"; wait 100; in "R%f"; wait 100;} # Get persistent magnetic field in tesla ER=yes. -getPersistentMagnetField { out "READ:DEV:" $magnet_supply "}:PSU:SIG:PFLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} +getPersistentMagnetField { out "READ:DEV:" $magnet_supply ":PSU:SIG:PFLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} # Get trip field in tesla ER=yes. #??? getTripField { out "R19"; wait 100; in "R%f"; wait 100;} # Get switch heater current in milliamp ER=no. -getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"); wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; wait 100;} +getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"; + wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; + wait 100;} # Get safe current limit, most negative in amps ER=no. getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} @@ -103,10 +106,16 @@ getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "S getPosCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} # Get lead resistance in milliohms ER=no. -getLeadResistance { out "READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES"); wait 100; in "STAT:DEV:" $magnet_supply ":TEMP:SIG:RES:%f%*s"; wait 100;} +getLeadResistance { out "READ:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES"; + wait 100; + in "STAT:DEV:" $magnet_supply ":TEMP:SIG:RES:%f%*s"; + wait 100;} # Get magnet inductance in henry ER=no. -getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:IND:%f%*s"; wait 100;} +getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; + wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:IND:%f"; + wait 100;} # Get Activity status (analogous to the legacy A command) getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; @@ -114,6 +123,11 @@ getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1ACTIVITY.VAL){HOLD|RTOS|RTOZ|CLMP}"; wait 100;} +getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; + wait 100; + in "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT:%s"; + wait 100;} + # --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- # Get PSU Status status DWORD getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; @@ -123,178 +137,59 @@ getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; # -------------------------------------------------------------------------------------------------------------- -# End of list of commands to read parameters. -######################################################################################### -# --------- -# X Command -# --------- -# Command to get the status - the IPS returns loads of flags in one command -# -# The X command - examine status. -# According to the manual the reply is of the form -# -# XmnAnCnHnMmnPmn -# -# where: -# X, A, C, H, M and P are literal characters introducing the values for different types of status as follows -# and m and n are integer digits. -# X m is the system fault status -# 0 Normal -# 1 Quenched -# 2 Overheated -# 4 Warming Up -# 8 Fault -# X n is the system limiting status -# 0 Normal -# 1 On +ve V Limit -# 2 On -ve V Limit -# 4 Current too -ve -# 8 Current too +ve -# A n is the activity -# 0 Hold -# 1 To Set Point -# 2 To Zero -# 4 Clamped -# C n is the Local/Remote Control status -# 0 Local & Locked -# 1 Remote & Locked -# 2 Local & Unlocked -# 3 Remote & Unlocked -# 4 Auto-Run-Down -# 5 Auto-Run-Down -# 6 Auto-Run-Down -# 7 Auto-Run-Down -# H n is for the switch heater -# 0 Off Mag at 0 -# 1 On -# 2 Off Mag at F -# 5 Heater Fault -# 8 No Switch -# M m is for the sweeping mode parameters -# 0 Amps Fast -# 1 Tesla Fast -# 4 Amps Slow -# 5 Tesla Slow -# M n is for the sweeping status -# 0 At rest -# 1 Sweeping -# 2 Sweep Limiting -# 3 Swping & Lmting -# P m and n are for the polarity and has been superseded by signed values on the current and field parameters. -# P is present for backward compatibility and can be ignored, so we do. -#??? -getStatus { - out "X"; - wait 100; - in - "X%(\$1STS:SYSTEM:FAULT.VAL)1u%(\$1STS:SYSTEM:LIMIT.VAL)1u" - "A%(\$1ACTIVITY.VAL)1u" - "C%(\$1CONTROL.VAL)1u" - "H%(\$1HEATER:STATUS.VAL)1u" - "M%*1u%(\$1STS:SWEEPMODE:SWEEP.VAL)1u" - ; - wait 100; -} - -# End of examining the status. - -# --------- -# W Command -# --------- -# Diddle with the comms wait interval - how long it waits between sending characters. -# Can vary between 0 and 32767 milliseconds, defaults to zero on power up. -setWaitInterval { setRemoteUnlocked; out "W%u" ; wait 100; in "W"; wait 100; } # --------- -# C Command +# SYS:LOCK Command # --------- # Set Control mode - grab control of the unit from local users. -# C0 Local & Locked -# C1 Remote & Locked -# C2 Local & Unlocked -# C3 Remote & Unlocked -# You cannot set the auto-run-down state read in the status readback - the unit does that. -setControl { setRemoteUnlocked; out "%{C0|C1|C2|C3}" ; wait 100; in "C"; wait 100; } +# OFF | SOFT | ON +setControl { out "SET:SYS:LOCK%{OFF|SOFT|ON}"; in "READ:SYS:LOCK:%*s";} # --------- -# A Command +# DEV::PSU:ACTN # --------- # Set the activity - i.e. Make it do something. -# A0 Hold -# A1 To Set Point -# A2 To Zero -# A4 Clamp -setActivity { out SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}" ; +# HOLD -> Hold +# RTOS -> To Set Point +# RTOZ -> To Zero +# CLMP -> Clamp +setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}" ; wait 100; in "READ:DEV:" $magnet_supply ":PSU:ACTN:%*s:VALID"; wait 100;} -# --------- -# F Command -# --------- -# Do not want to interfere with Front Panel Display remotely, therefore not bothering with F command. # --------- -# H Command +# SWHT Command # --------- # # Set the status of the heater. # 0 = heater off (close switch) # 1 = heater on (open switch) [Checks that magnet curr == psu curr before having any effect, so safer] -# 2 = heater on (open switch) [no checks... UNSAFE! don't use this.] +# DO NOT USE SWHN command!! # -setHeaterStatus { setRemoteUnlocked; out "H%{0|1}" ; wait 100; in "H"; wait 100;} +setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; + wait 100; + in "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT:%s"; + wait 100;} -# --------- -# I Command -# --------- # Set the setpoint (target) current. -# The precision of the controller is different depending on whether "Extended Resolution" is set or not, -# but in practice it just ignores extra digit, so we can always use the most resolution for each -# quantity. -setSetpointCurrent { setRemoteUnlocked; out "I%#.4f" ; wait 100; in "I"; wait 100;} +setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%f:%*s"} -# --------- -# J Command -# --------- # Set the setpoint (target) field. -setSetpointField { setRemoteUnlocked; out "J%#.5f" ; wait 100; in "J"; wait 100;} +setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FLD:%#.5f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"} # --------- -# M Command +# M Command # --------- # Set the mode. # Control "Fast/Slow" sweep - and whether units displayed in Current of Field on Front Panel. # This is not so straight forwards as it might seem - need to be careful in the template. setMode { setRemoteUnlocked; out "M%u" ; wait 100; in "M"; wait 100;} -# --------- -# P Command -# --------- -# Set the polarity. Not implemented - it is obsolete. - -# --------- -# S Command -# --------- # Set current sweep rate. # Rate at which current will be ramped or swept to target, either the setpoint or zero. -setCurrentSweeprate { setRemoteUnlocked; out "S%#.3f" ; wait 100; in "S"; wait 100;} +setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"} -# --------- -# T Command -# --------- # Set field sweep rate. # Rate at which field will be ramped or swept to target, either the setpoint or zero. -setFieldSweeprate { setRemoteUnlocked; out "T%#.4f" ; wait 100; in "T"; wait 100;} - -# ------------------- -# Y, Z and ~ Commands -# ------------------- -# Y and Z give access to read and write the RAM, and are protected system commands not intended for customer use. -# ~ Allows calibration changes to be stored. Do not want users to have access to these functions. -# Therefore not bothering with Y, Z or ~ commands. - -# --------- -# ! Command -# --------- -# This allows the instrument to be given a number so it will share an RS232 line with a chain of other Oxford Instruments controllers. -# We are not intending to use the system like this, so will not bother with this command. +setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.3f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"} From 6f9d1136278ff85322a32a3e7457580bed09d6d4 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 1 May 2025 15:46:29 +0100 Subject: [PATCH 04/61] Tests IOCS config changed to specify use of SCPI protocol --- system_tests/tests/ips.py | 311 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 system_tests/tests/ips.py diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py new file mode 100644 index 0000000..7b6f5ee --- /dev/null +++ b/system_tests/tests/ips.py @@ -0,0 +1,311 @@ +import unittest +from contextlib import contextmanager + +from parameterized import parameterized + +from utils.channel_access import ChannelAccess +from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir +from utils.test_modes import TestModes +from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test + +DEVICE_PREFIX = "IPS_01" +EMULATOR_NAME = "ips" + + +IOCS = [ + { + "name": DEVICE_PREFIX, + "directory": get_default_ioc_dir("IPS"), + "emulator": EMULATOR_NAME, + "lewis_protocol": "ips_scpi", + "ioc_launcher_class": ProcServLauncher, + "macros": { + "STREAMPROTOCOL": "SCPI", + "MANAGER_ASG": "DEFAULT", + "MAX_SWEEP_RATE": "1.0", + "HEATER_WAITTIME": "10", # On a real system the macro has a default of 60s, + # but speed it up a bit for the sake of tests. + }, + }, +] + + +# Only run tests in DEVSIM. Unable to produce detailed enough functionality to be useful in recsim. +TEST_MODES = [TestModes.DEVSIM] + +TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities +TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 + +TOLERANCE = 0.0001 + +HEATER_OFF_STATES = ["Off Mag at 0", "Off Mag at F"] + +# Time to wait for the heater to warm up/cool down (extracted from IOC macros above) +HEATER_WAIT_TIME = float((IOCS[0].get("macros").get("HEATER_WAITTIME"))) + +ACTIVITY_STATES = ["Hold", "To Setpoint", "To Zero", "Clamped"] + +# Generate all the control commands to test that remote and unlocked is set for +# Chain flattens the list +CONTROL_COMMANDS_WITH_VALUES = [ + ("FIELD", 0.1), + ("FIELD:RATE", 0.1), + ("SWEEPMODE:PARAMS", "Tesla Fast"), +] +for activity_state in ACTIVITY_STATES: + CONTROL_COMMANDS_WITH_VALUES.append(("ACTIVITY", activity_state)) +for heater_off_state in HEATER_OFF_STATES: + CONTROL_COMMANDS_WITH_VALUES.append(("HEATER:STATUS", heater_off_state)) + +CONTROL_COMMANDS_WITHOUT_VALUES = ["SET:COMMSRES"] + + +class IpsTests(unittest.TestCase): + """ + Tests for the Ips IOC. + """ + + def setUp(self): + self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) + # Some changes happen on the order of HEATER_WAIT_TIME seconds. Use a significantly longer timeout + # to capture a few heater wait times plus some time for PVs to update. + self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=HEATER_WAIT_TIME * 10) + + # Wait for some critical pvs to be connected. + for pv in ["MAGNET:FIELD:PERSISTENT", "FIELD", "FIELD:SP:RBV", "HEATER:STATUS"]: + self.ca.assert_that_pv_exists(pv) + + # Ensure in the correct mode + self.ca.set_pv_value("CONTROL:SP", "Remote & Unlocked") + self.ca.set_pv_value("ACTIVITY:SP", "To Setpoint") + + # Don't run reset as the sudden change of state confuses the IOC's state machine. No matter what the initial + # state of the device the SNL should be able to deal with it. + # self._lewis.backdoor_run_function_on_device("reset") + + self.ca.set_pv_value("FIELD:RATE:SP", 10) + # self.ca.assert_that_pv_is_number("FIELD:RATE:SP", 10) + + self.ca.process_pv("FIELD:SP") + + # Wait for statemachine to reach "at field" state before every test. + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + def tearDown(self): + # Wait for statemachine to reach "at field" state after every test. + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") + + def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self): + self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") + + def _assert_field_is(self, field, check_stable=False): + self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) + if check_stable: + self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) + self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) + + def _assert_heater_is(self, heater_state): + self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") + if heater_state: + self.ca.assert_that_pv_is( + "HEATER:STATUS", + "On", + ) + else: + self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) + + def _set_and_check_persistent_mode(self, mode): + self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( + self, _, val + ): + initial_field = 1 + + self._set_and_check_persistent_mode(True) + self.ca.set_pv_value("FIELD:SP", initial_field) + self._assert_field_is(initial_field, check_stable=True) + + # Field in the magnet already from persistent mode. + self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) + self._assert_heater_is(False) + + # Set the new field. This will cause all of the following events based on the state machine. + self.ca.set_pv_value("FIELD:SP", val) + + # PSU should be ramped to match the persistent field inside the magnet + self.ca.assert_that_pv_is_number("FIELD", initial_field, tolerance=TOLERANCE) + self.ca.assert_that_pv_is("ACTIVITY", "To Setpoint") + + # Then it is safe to turn on the heater + self._assert_heater_is(True) + + # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up + # after being set. + self._assert_field_is(val) + + # Now that the correct current is in the magnet, the SNL should turn the heater off + self._assert_heater_is(False) + + # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before + # ramping PSU to zero) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field + self.ca.assert_that_pv_is_number( + "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE + ) # Persistent field + self.ca.assert_that_pv_is_number( + "FIELD:USER", val, tolerance=TOLERANCE + ) # User field should be tracking persistent field here + self.ca.assert_that_pv_is("ACTIVITY", "To Zero") + + # ...And the magnet should now be in the right state! + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) + + # "User" field should take the value put in the setpoint, even when the actual field provided by the supply + # drops to zero + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field + self.ca.assert_that_pv_is_number( + "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE + ) # Persistent field + self.ca.assert_that_pv_is_number( + "FIELD:USER", val, tolerance=TOLERANCE + ) # User field should be tracking persistent field here + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero( + self, _, val + ): + initial_field = 1 + + self._set_and_check_persistent_mode(True) + self.ca.set_pv_value("FIELD:SP", initial_field) + self._assert_field_is(initial_field, check_stable=True) + + # Field in the magnet already from persistent mode. + self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) + self._assert_heater_is(False) + + self._set_and_check_persistent_mode(False) + + # Set the new field. This will cause all of the following events based on the state machine. + self.ca.set_pv_value("FIELD:SP", val) + + # PSU should be ramped to match the persistent field inside the magnet (if there was one) + self.ca.assert_that_pv_is("FIELD", initial_field, timeout=10) + + # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it + # was already on out of an abundance of caution). + self._assert_heater_is(True) + + # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up + # after being set. + self._assert_field_is(val) + + # ...And the magnet should now be in the right state! + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) + + # And the PSU should remain stable providing the required current/field + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + self._assert_field_is(val, check_stable=True) + + @contextmanager + def _backdoor_magnet_quench(self, reason="Test framework quench"): + self._lewis.backdoor_run_function_on_device("quench", [reason]) + try: + yield + finally: + # Get back out of the quenched state. This is because the tearDown method checks that magnet has not + # quenched. + self._lewis.backdoor_run_function_on_device("unquench") + # Wait for IOC to notice quench state has gone away + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) + + @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) + def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( + self, _, field + ): + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", field) + self._assert_field_is(field) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + with self._backdoor_magnet_quench(): + self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) + self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") + self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) + + # The trip field should be the field at the point when the magnet quenched. + self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) + + # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + self._lewis.backdoor_set_on_device("inductance", val) + self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + self._lewis.backdoor_set_on_device("measured_current", val) + self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) + def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): + self.ca.set_pv_value("FIELD:RATE:SP", val) + self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) + self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) + + @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES)) + @unstable_test() + def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( + self, _, activity_state + ): + self.ca.set_pv_value("ACTIVITY", activity_state) + if activity_state == "Clamped": + self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") + else: + self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM") + + @parameterized.expand( + control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) + ) + def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( + self, _, control_pv, set_value + ): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.set_pv_value(control_pv, set_value) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + @parameterized.expand( + control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) + ) + def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.process_pv(control_pv) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + # original problem/complaint: + # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields + def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): + # arrange: set mode to non-persistent, set field + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", 3.21) + self._assert_field_is(3.21) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + # act: set new field + self.ca.set_pv_value("FIELD:SP", 4.56) + + # assert: field starts to change by tolerance within timeout, then reaches within second timeout + # timeout present to prove new setpoint moved to _without_ waiting for heater, if already on + self.ca.assert_that_pv_is_not_number("FIELD", 3.21, tolerance=0.01, timeout=20) + self.ca.assert_that_pv_is_number("FIELD", 4.56, tolerance=0.01, timeout=60) From 8fa489d00330b536413c9344eaf0463092686430 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 6 May 2025 12:38:08 +0100 Subject: [PATCH 05/61] Further corrections to SCPI protocol on connection to real instrument --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 114 ++++++++++-------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 1253947..bd8e5a5 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -48,92 +48,99 @@ pressure_sensor_10T = "DB5.P1"; # ASCII symbol and might cause problems for EPICS. In practice found (c) instead. # The %s format grabs the string upto the first space, the %c grabs the rest. It # was too long to fit into one EPICS string record value. -getVersion { out "*IDN?"; wait 100; in "IDN:OXFORD INSTRUMENTS:%(\$1MODEL.VAL)[ a-zA-Z0-9]:%*[ a-zA-Z0-9]:%(\$1VERSION.VAL)s"; wait 100;} +getVersion { out "*IDN?"; wait 100; + in "IDN:OXFORD INSTRUMENTS:%(\$1MODEL.VAL)[ a-zA-Z0-9]:%*[ a-zA-Z0-9]:%(\$1VERSION.VAL)s"; wait 100;} ######################################################################################### # Get demand current (output current) in amps. We are only interested in the Z axis magnet group. # The return string is of the form: STAT:DEV:GRPZ:PSU:CURR::A -getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; in "STAT:DEV:GRPZ:PSU:SIG:CSET:%f:%*s"; wait 100;} +getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} # Get measured power supply voltage in volts -getSupplyVoltage { out "READ:DEV:" $magnet_supply ":PSU:SIG:VOLT"; wait 100; in "STAT:DEV:READ:DEV:" $magnet_supply ":PSU:SIG:VOLT:%f:%*s"; wait 100;} +getSupplyVoltage { out "READ:DEV:" $magnet_supply ":PSU:SIG:VOLT"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:VOLT:%f%*s"; wait 100;} # Get measured magnet curren in amps - ER=no. -getMeasuredMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CURR"; wait 100; in "STAT:DEV:READ:DEV:" $magnet_supply ":PSU:SIG:CURR:%f:%*s"; wait 100;} +getMeasuredMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CURR"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CURR:%f%*s"; wait 100;} # Get set point (target current) in amps ER=yes. -getSetpointCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} +getSetpointCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} # Get current sweep rate in amps per minute ER=yes. -getCurrentSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RCST"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"; wait 100;} +getCurrentSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RCST"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"; wait 100;} # Get demand field (output field) in tesla ER=yes. -getDemandField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"; wait 100;} +getDemandField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FLD"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"; wait 100;} # Get set point (target field) in tesla ER=yes. -getSetpointField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FSET"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"; wait 100;} +getSetpointField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FSET"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"; wait 100;} # Get field sweep rate in tesla per minute ER=yes. -getFieldSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RFST"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"; wait 100;} +# Returns status like: STAT:DEV:GRPZ:PSU:SIG:RFST:0.3850T/m +getFieldSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RFST"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"; wait 100;} # Get software voltage limit in volts ER=no. -getSoftwareVoltageLimit { out "READ:DEV:" $magnet_supply ":PSU:VLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:VLIM:%f%*s"; wait 100;} +# The documentation states that a float is returned, but in reality, a string may be returned instead, such as 'N/A' +getSoftwareVoltageLimit { out "READ:DEV:" $magnet_supply ":PSU:VLIM"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:VLIM:%f%*s"; @mismatch{in "%*s";} wait 100;} # Get persistent magnet current in amps ER=yes. -getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PCUR:%f%*s"; wait 100;} +getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:PCUR:%f%*s"; wait 100;} # Get trip current in amps ER=yes. #??? getTripCurrent { out "R17"; wait 100; in "R%f"; wait 100;} # Get persistent magnetic field in tesla ER=yes. -getPersistentMagnetField { out "READ:DEV:" $magnet_supply ":PSU:SIG:PFLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} +getPersistentMagnetField { out "READ:DEV:" $magnet_supply ":PSU:SIG:PFLD"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} # Get trip field in tesla ER=yes. #??? getTripField { out "R19"; wait 100; in "R%f"; wait 100;} # Get switch heater current in milliamp ER=no. -getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"; - wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; - wait 100;} +getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; wait 100;} # Get safe current limit, most negative in amps ER=no. -getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} +getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} # Get safe current limit, most positive in amps ER=no. -getPosCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} +# no units appended to the value, so assume amps. +getPosCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f"; wait 100;} -# Get lead resistance in milliohms ER=no. -getLeadResistance { out "READ:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES"; - wait 100; - in "STAT:DEV:" $magnet_supply ":TEMP:SIG:RES:%f%*s"; - wait 100;} +# Get lead resistance (PTC/NTC) Ohms. +# unit appended 'R' +getLeadResistance { out "READ:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES"; wait 100; + in "STAT:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES:%f%*s"; wait 100;} # Get magnet inductance in henry ER=no. -getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; - wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:IND:%f"; - wait 100;} +# no units appended +getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:IND:%f"; wait 100;} # Get Activity status (analogous to the legacy A command) -getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; - wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1ACTIVITY.VAL){HOLD|RTOS|RTOZ|CLMP}"; - wait 100;} +getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1ACTIVITY.VAL){HOLD|RTOS|RTOZ|CLMP}"; wait 100;} -getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; - wait 100; - in "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT:%s"; - wait 100;} +getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}"; wait 100;} # --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- # Get PSU Status status DWORD -getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; - wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:STAT:%x"; - wait 100;} +getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:STAT:%x"; wait 100;} # -------------------------------------------------------------------------------------------------------------- @@ -153,10 +160,8 @@ setControl { out "SET:SYS:LOCK%{OFF|SOFT|ON}"; in "READ:SYS:LOCK:%*s";} # RTOS -> To Set Point # RTOZ -> To Zero # CLMP -> Clamp -setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}" ; - wait 100; - in "READ:DEV:" $magnet_supply ":PSU:ACTN:%*s:VALID"; - wait 100;} +setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}"; wait 100; + in "READ:DEV:" $magnet_supply ":PSU:ACTN:%*s:VALID"; wait 100;} # --------- # SWHT Command @@ -165,18 +170,21 @@ setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|C # Set the status of the heater. # 0 = heater off (close switch) # 1 = heater on (open switch) [Checks that magnet curr == psu curr before having any effect, so safer] +# Returns status of this form: STAT:SET:DEV:GRPZ:PSU:SIG:SWHT:OFF:VALID # DO NOT USE SWHN command!! # -setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; - wait 100; - in "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT:%s"; - wait 100;} +setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; wait 100; + in "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT:%*s"; wait 100;} # Set the setpoint (target) current. -setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%f:%*s"} +# Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:CSET:0.0004:VALID +setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 100; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%f:%*s"} # Set the setpoint (target) field. -setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FLD:%#.5f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"} +# Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:FSET:0.00:VALID +setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%#.5f"; wait 100; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"} # --------- # M Command @@ -188,8 +196,12 @@ setMode { setRemoteUnlocked; out "M%u" ; wait 100; in "M"; wait 100;} # Set current sweep rate. # Rate at which current will be ramped or swept to target, either the setpoint or zero. -setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"} +# Returns status like: STAT:DEV:GRPZ:PSU:SIG:RCST:5.5:VALID +setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait 100; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"} # Set field sweep rate. # Rate at which field will be ramped or swept to target, either the setpoint or zero. -setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.3f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"} +# Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:RFST:0.3850:VALID +setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.3f"; wait 100; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"} From c9a7adfe776752b5b62f39272052d02f689bd029 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 6 May 2025 12:39:33 +0100 Subject: [PATCH 06/61] Added voltage_limit to device_scpi.py --- .../lewis_emulators/ips/device_scpi.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 system_tests/lewis_emulators/ips/device_scpi.py diff --git a/system_tests/lewis_emulators/ips/device_scpi.py b/system_tests/lewis_emulators/ips/device_scpi.py new file mode 100644 index 0000000..5729a1f --- /dev/null +++ b/system_tests/lewis_emulators/ips/device_scpi.py @@ -0,0 +1,160 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from lewis_emulators.ips.modes_scpi import Activity, Control, Mode, SweepMode, MagnetSupplyStatus + +from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState + +# As long as no magnetic saturation effects are present, there is a linear relationship between Teslas and Amps. +# +# This is called the load line. For more detailed (technical) discussion about the load line see: +# - http://aries.ucsd.edu/LIB/REPORT/SPPS/FINAL/chap4.pdf (section 4.3.3) +# - http://www.prizz.fi/sites/default/files/tiedostot/linkki1ID346.pdf (slide 11) +LOAD_LINE_GRADIENT = 0.01 + + +def amps_to_tesla(amps): + return amps * LOAD_LINE_GRADIENT + + +def tesla_to_amps(tesla): + return tesla / LOAD_LINE_GRADIENT + + +@has_log +class SimulatedIps(StateMachineDevice): + # Currents that correspond to the switch heater being on and off + HEATER_OFF_CURRENT, HEATER_ON_CURRENT = 0, 10 + + # If there is a difference in current of more than this between the magnet and the power supply, and the switch is + # resistive, then the magnet will quench. + # No idea what this number should be for a physically realistic system so just guess. + QUENCH_CURRENT_DELTA = 0.1 + + # Maximum rate at which the magnet can safely ramp without quenching. + MAGNET_RAMP_RATE = 1000 + + # Fixed rate at which switch heater can ramp up or down + HEATER_RAMP_RATE = 5 + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.reset() + + def reset(self): + # Within the cryostat, there is a wire that is made superconducting because it is in the cryostat. The wire has + # a heater which can be used to make the wire go back to a non-superconducting state. + # + # When the heater is ON, the wire has a high resistance and the magnet is powered directly by the power supply. + # + # When the heater is OFF, the wire is superconducting, which means that the power supply can be ramped down and + # the magnet will stay active (this is "persistent" mode) + self.heater_on: bool = False + self.heater_current: float = 0.0 + + # "Leads" are the non-superconducting wires between the superconducting magnet and the power supply. + # Not sure what a realistic value is for these leads, so I've guessed. + self.lead_resistance: float = 50.0 + + # Current = what the power supply is providing. + self.current: float = 0.0 + self.current_setpoint: float = 0.0 + + # Current for the magnet. May be different from the power supply current if the magnet is in persistent mode. + self.magnet_current: float = 0.0 + + # Measured current may be different from what the PSU is attempting to provide + self.measured_current: float = 0.0 + + # If the device trips, store the last current which caused a trip in here. + # This could be used for diagnostics e.g. finding maximum field which magnet is capable of in a certain config. + self.trip_current: float = 0.0 + + # Ramp rate == sweep rate + self.current_ramp_rate: float = 1 / LOAD_LINE_GRADIENT + + # Set to true if the magnet is quenched - this will cause lewis to enter the quenched state + self.quenched: bool = False + + # Mode of the magnet e.g. HOLD, TO SET POINT, TO ZERO, CLAMP + self.activity: Activity = Activity.TO_SETPOINT + + # No idea what a sensible value is. Hard-code this here for now - can't be changed on real device. + self.inductance: float = 0.005 + + # No idea what sensible values are here. Also not clear what the behaviour is of the controller when these + # limits are hit. + self.neg_current_limit, self.pos_current_limit = -(10**6), 10**6 + + # Local and locked is the zeroth mode of the control command + self.control: Control = Control.LOCAL_LOCKED + + # The only sweep mode we are interested in is tesla fast + # This appears to be unssupported by the SCPI protocol, so the + # corresponding EPICS records have been removed. + self.sweep_mode: SweepMode = SweepMode.TESLA_FAST + + # Not sure what is the sensible value here + self.mode: Mode = Mode.SLOW + + self.bipolar: bool = True + + self.magnet_supply_status = MagnetSupplyStatus.OK + + self.voltage_limit: float = 10.0 + + + def _get_state_handlers(self): + return { + "heater_off": HeaterOffState(), + "heater_on": HeaterOnState(), + "quenched": MagnetQuenchedState(), + } + + def _get_initial_state(self): + return "heater_off" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("heater_off", "heater_on"), lambda: self.heater_on), + (("heater_on", "heater_off"), lambda: not self.heater_on), + (("heater_on", "quenched"), lambda: self.quenched), + (("heater_off", "quenched"), lambda: self.quenched), + # Only triggered when device is reset or similar + (("quenched", "heater_off"), lambda: not self.quenched and not self.heater_on), + (("quenched", "heater_on"), lambda: not self.quenched and self.heater_on), + ] + ) + + def quench(self, reason): + self.log.info("Magnet quenching at current={} because: {}".format(self.current, reason)) + self.trip_current = self.current + self.magnet_current = 0 + self.current = 0 + self.measured_current = 0 + self.quenched = True # Causes LeWiS to enter Quenched state + + def unquench(self): + self.quenched = False + + def get_voltage(self): + """Gets the voltage of the PSU. + + Everything except the leads is superconducting, we use Ohm's law here with the PSU current and the lead + resistance. + + In reality would also need to account for inductance effects from the magnet but I don't think that + extra complexity is necessary for this emulator. + """ + return self.current * self.lead_resistance + + def set_heater_status(self, new_status): + if new_status and abs(self.current - self.magnet_current) > self.QUENCH_CURRENT_DELTA: + raise ValueError( + "Can't set the heater to on while the magnet current and PSU current are mismatched" + ) + self.heater_on = new_status From cc0799da2013087608879f72ba14cbb56bb4cd32 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 6 May 2025 12:43:30 +0100 Subject: [PATCH 07/61] Added system_test files after moving out of generic support/DeviceEmulator tree into this support module, as the modern and preferred method --- system_tests/lewis_emulators/__init__.py | 0 system_tests/lewis_emulators/ips/__init__.py | 6 + system_tests/lewis_emulators/ips/device.py | 153 +++++++++++ .../ips/interfaces/__init__.py | 5 + .../ips/interfaces/stream_interface.py | 210 +++++++++++++++ .../ips/interfaces/stream_interface_scpi.py | 245 ++++++++++++++++++ system_tests/lewis_emulators/ips/modes.py | 25 ++ .../lewis_emulators/ips/modes_scpi.py | 68 +++++ system_tests/lewis_emulators/ips/states.py | 63 +++++ .../lewis_emulators/lewis_versions.py | 2 + system_tests/tests/__init__.py | 0 11 files changed, 777 insertions(+) create mode 100644 system_tests/lewis_emulators/__init__.py create mode 100644 system_tests/lewis_emulators/ips/__init__.py create mode 100644 system_tests/lewis_emulators/ips/device.py create mode 100644 system_tests/lewis_emulators/ips/interfaces/__init__.py create mode 100644 system_tests/lewis_emulators/ips/interfaces/stream_interface.py create mode 100644 system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py create mode 100644 system_tests/lewis_emulators/ips/modes.py create mode 100644 system_tests/lewis_emulators/ips/modes_scpi.py create mode 100644 system_tests/lewis_emulators/ips/states.py create mode 100644 system_tests/lewis_emulators/lewis_versions.py create mode 100644 system_tests/tests/__init__.py diff --git a/system_tests/lewis_emulators/__init__.py b/system_tests/lewis_emulators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system_tests/lewis_emulators/ips/__init__.py b/system_tests/lewis_emulators/ips/__init__.py new file mode 100644 index 0000000..5a8412a --- /dev/null +++ b/system_tests/lewis_emulators/ips/__init__.py @@ -0,0 +1,6 @@ +from ..lewis_versions import LEWIS_LATEST +# from .device import SimulatedIps +from .device_scpi import SimulatedIps + +framework_version = LEWIS_LATEST +__all__ = ["SimulatedIps"] diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py new file mode 100644 index 0000000..b827038 --- /dev/null +++ b/system_tests/lewis_emulators/ips/device.py @@ -0,0 +1,153 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from lewis_emulators.ips.modes import Activity, Control, Mode, SweepMode + +from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState + +# As long as no magnetic saturation effects are present, there is a linear relationship between Teslas and Amps. +# +# This is called the load line. For more detailed (technical) discussion about the load line see: +# - http://aries.ucsd.edu/LIB/REPORT/SPPS/FINAL/chap4.pdf (section 4.3.3) +# - http://www.prizz.fi/sites/default/files/tiedostot/linkki1ID346.pdf (slide 11) +LOAD_LINE_GRADIENT = 0.01 + + +def amps_to_tesla(amps): + return amps * LOAD_LINE_GRADIENT + + +def tesla_to_amps(tesla): + return tesla / LOAD_LINE_GRADIENT + + +@has_log +class SimulatedIps(StateMachineDevice): + # Currents that correspond to the switch heater being on and off + HEATER_OFF_CURRENT, HEATER_ON_CURRENT = 0, 10 + + # If there is a difference in current of more than this between the magnet and the power supply, and the switch is + # resistive, then the magnet will quench. + # No idea what this number should be for a physically realistic system so just guess. + QUENCH_CURRENT_DELTA = 0.1 + + # Maximum rate at which the magnet can safely ramp without quenching. + MAGNET_RAMP_RATE = 1000 + + # Fixed rate at which switch heater can ramp up or down + HEATER_RAMP_RATE = 5 + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.reset() + + def reset(self): + # Within the cryostat, there is a wire that is made superconducting because it is in the cryostat. The wire has + # a heater which can be used to make the wire go back to a non-superconducting state. + # + # When the heater is ON, the wire has a high resistance and the magnet is powered directly by the power supply. + # + # When the heater is OFF, the wire is superconducting, which means that the power supply can be ramped down and + # the magnet will stay active (this is "persistent" mode) + self.heater_on: bool = False + self.heater_current: float = 0.0 + + # "Leads" are the non-superconducting wires between the superconducting magnet and the power supply. + # Not sure what a realistic value is for these leads, so I've guessed. + self.lead_resistance: float = 50.0 + + # Current = what the power supply is providing. + self.current: float = 0.0 + self.current_setpoint: float = 0.0 + + # Current for the magnet. May be different from the power supply current if the magnet is in persistent mode. + self.magnet_current: float = 0.0 + + # Measured current may be different from what the PSU is attempting to provide + self.measured_current: float = 0.0 + + # If the device trips, store the last current which caused a trip in here. + # This could be used for diagnostics e.g. finding maximum field which magnet is capable of in a certain config. + self.trip_current: float = 0.0 + + # Ramp rate == sweep rate + self.current_ramp_rate: float = 1 / LOAD_LINE_GRADIENT + + # Set to true if the magnet is quenched - this will cause lewis to enter the quenched state + self.quenched: bool = False + + # Mode of the magnet e.g. HOLD, TO SET POINT, TO ZERO, CLAMP + self.activity: Activity = Activity.TO_SETPOINT + + # No idea what a sensible value is. Hard-code this here for now - can't be changed on real device. + self.inductance: float = 0.005 + + # No idea what sensible values are here. Also not clear what the behaviour is of the controller when these + # limits are hit. + self.neg_current_limit, self.pos_current_limit = -(10**6), 10**6 + + # Local and locked is the zeroth mode of the control command + self.control: Control = Control.LOCAL_LOCKED + + # The only sweep mode we are interested in is tesla fast + self.sweep_mode: SweepMode = SweepMode.TESLA_FAST + + # Not sure what is the sensible value here + self.mode: Mode = Mode.SLOW + + self.bipolar: bool = True + + def _get_state_handlers(self): + return { + "heater_off": HeaterOffState(), + "heater_on": HeaterOnState(), + "quenched": MagnetQuenchedState(), + } + + def _get_initial_state(self): + return "heater_off" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("heater_off", "heater_on"), lambda: self.heater_on), + (("heater_on", "heater_off"), lambda: not self.heater_on), + (("heater_on", "quenched"), lambda: self.quenched), + (("heater_off", "quenched"), lambda: self.quenched), + # Only triggered when device is reset or similar + (("quenched", "heater_off"), lambda: not self.quenched and not self.heater_on), + (("quenched", "heater_on"), lambda: not self.quenched and self.heater_on), + ] + ) + + def quench(self, reason): + self.log.info("Magnet quenching at current={} because: {}".format(self.current, reason)) + self.trip_current = self.current + self.magnet_current = 0 + self.current = 0 + self.measured_current = 0 + self.quenched = True # Causes LeWiS to enter Quenched state + + def unquench(self): + self.quenched = False + + def get_voltage(self): + """Gets the voltage of the PSU. + + Everything except the leads is superconducting, we use Ohm's law here with the PSU current and the lead + resistance. + + In reality would also need to account for inductance effects from the magnet but I don't think that + extra complexity is necessary for this emulator. + """ + return self.current * self.lead_resistance + + def set_heater_status(self, new_status): + if new_status and abs(self.current - self.magnet_current) > self.QUENCH_CURRENT_DELTA: + raise ValueError( + "Can't set the heater to on while the magnet current and PSU current are mismatched" + ) + self.heater_on = new_status diff --git a/system_tests/lewis_emulators/ips/interfaces/__init__.py b/system_tests/lewis_emulators/ips/interfaces/__init__.py new file mode 100644 index 0000000..2d20843 --- /dev/null +++ b/system_tests/lewis_emulators/ips/interfaces/__init__.py @@ -0,0 +1,5 @@ +# from .stream_interface import IpsStreamInterface +from .stream_interface_scpi import IpsStreamInterface + +# __all__ = ["IpsStreamInterface", "IpsSCPIStreamInterface"] +__all__ = ["IpsStreamInterface"] diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py new file mode 100644 index 0000000..bf7b7b2 --- /dev/null +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py @@ -0,0 +1,210 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from lewis_emulators.ips.modes import Activity, Control + +from ..device import amps_to_tesla, tesla_to_amps + +MODE_MAPPING = { + 0: Activity.HOLD, + 1: Activity.TO_SETPOINT, + 2: Activity.TO_ZERO, + 4: Activity.CLAMP, +} + +CONTROL_MODE_MAPPING = { + 0: Control.LOCAL_LOCKED, + 1: Control.REMOTE_LOCKED, + 2: Control.LOCAL_UNLOCKED, + 3: Control.REMOTE_UNLOCKED, +} + + +@has_log +class IpsStreamInterface(StreamInterface): + protocol = "ips_legacy" + + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_version").escape("V").eos().build(), + CmdBuilder("set_comms_mode").escape("Q4").eos().build(), + CmdBuilder("get_status").escape("X").eos().build(), + CmdBuilder("get_current").escape("R0").eos().build(), + CmdBuilder("get_supply_voltage").escape("R1").eos().build(), + CmdBuilder("get_measured_current").escape("R2").eos().build(), + CmdBuilder("get_current_setpoint").escape("R5").eos().build(), + CmdBuilder("get_current_sweep_rate").escape("R6").eos().build(), + CmdBuilder("get_field").escape("R7").eos().build(), + CmdBuilder("get_field_setpoint").escape("R8").eos().build(), + CmdBuilder("get_field_sweep_rate").escape("R9").eos().build(), + CmdBuilder("get_software_voltage_limit").escape("R15").eos().build(), + CmdBuilder("get_persistent_magnet_current").escape("R16").eos().build(), + CmdBuilder("get_trip_current").escape("R17").eos().build(), + CmdBuilder("get_persistent_magnet_field").escape("R18").eos().build(), + CmdBuilder("get_trip_field").escape("R19").eos().build(), + CmdBuilder("get_heater_current").escape("R20").eos().build(), + CmdBuilder("get_neg_current_limit").escape("R21").eos().build(), + CmdBuilder("get_pos_current_limit").escape("R22").eos().build(), + CmdBuilder("get_lead_resistance").escape("R23").eos().build(), + CmdBuilder("get_magnet_inductance").escape("R24").eos().build(), + CmdBuilder("set_control_mode") + .escape("C") + .arg("0|1|2|3", argument_mapping=int) + .eos() + .build(), + CmdBuilder("set_mode").escape("A").int().eos().build(), + CmdBuilder("set_current").escape("I").float().eos().build(), + CmdBuilder("set_field").escape("J").float().eos().build(), + CmdBuilder("set_field_sweep_rate").escape("T").float().eos().build(), + CmdBuilder("set_sweep_mode").escape("M").int().eos().build(), + CmdBuilder("set_heater_on").escape("H1").eos().build(), + CmdBuilder("set_heater_off").escape("H0").eos().build(), + CmdBuilder("set_heater_off").escape("H2").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def get_version(self): + return "Simulated IPS" + + def set_comms_mode(self): + """This sets the terminator that the device wants, not implemented in emulator. Command does not reply. + """ + + def set_control_mode(self, mode): + self.device.control = CONTROL_MODE_MAPPING[mode] + return "C" + + def set_mode(self, mode): + mode = int(mode) + try: + self.device.activity = MODE_MAPPING[mode] + except KeyError: + raise ValueError("Invalid mode specified") + return "A" + + def get_status(self): + resp = "X{x1}{x2}A{a}C{c}H{h}M{m1}{m2}P{p1}{p2}" + + def translate_activity(): + for k, v in MODE_MAPPING.items(): + if v == self.device.activity: + return k + else: + raise ValueError("Device was in invalid mode, can't construct status") + + def get_heater_status_number(): + if self.device.heater_on: + return 1 + else: + return 0 if self.device.magnet_current == 0 else 2 + + def is_sweeping(): + if self.device.activity == Activity.TO_SETPOINT: + return self.device.current != self.device.current_setpoint + elif self.device.activity == Activity.TO_ZERO: + return self.device.current != 0 + else: + return False + + statuses = { + "x1": 1 if self.device.quenched else 0, + "x2": 0, + "a": translate_activity(), + "c": 4 if self.device.quenched else 3, + "h": get_heater_status_number(), + "m1": self.device.sweep_mode, + "m2": 1 if is_sweeping() else 0, + "p1": 0, + "p2": 0, + } + + return resp.format(**statuses) + + def get_current_setpoint(self): + return "R{}".format(self.device.current_setpoint) + + def get_supply_voltage(self): + return "R{}".format(self.device.get_voltage()) + + def get_measured_current(self): + return "R{}".format(self.device.measured_current) + + def get_current(self): + return "R{}".format(self.device.current) + + def get_current_sweep_rate(self): + return "R{}".format(self.device.current_ramp_rate) + + def get_field(self): + return "R{}".format(amps_to_tesla(self.device.current)) + + def get_field_setpoint(self): + return "R{}".format(amps_to_tesla(self.device.current_setpoint)) + + def get_field_sweep_rate(self): + return "R{}".format(amps_to_tesla(self.device.current_ramp_rate)) + + def get_software_voltage_limit(self): + return "R0" + + def get_persistent_magnet_current(self): + return "R{}".format(self.device.magnet_current) + + def get_trip_current(self): + return "R{}".format(self.device.trip_current) + + def get_persistent_magnet_field(self): + return "R{}".format(amps_to_tesla(self.device.magnet_current)) + + def get_trip_field(self): + return "R{}".format(amps_to_tesla(self.device.trip_current)) + + def get_heater_current(self): + return "R{}".format(self.device.heater_current) + + def get_neg_current_limit(self): + return "R{}".format(self.device.neg_current_limit) + + def get_pos_current_limit(self): + return "R{}".format(self.device.pos_current_limit) + + def get_lead_resistance(self): + return "R{}".format(self.device.lead_resistance) + + def get_magnet_inductance(self): + return "R{}".format(self.device.inductance) + + def set_current(self, current): + self.device.current_setpoint = float(current) + return "I" + + def set_field(self, current): + self.device.current_setpoint = tesla_to_amps(float(current)) + return "J" + + def set_heater_on(self): + self.device.set_heater_status(True) + return "H" + + def set_heater_off(self): + self.device.set_heater_status(False) + return "H" + + def set_field_sweep_rate(self, tesla): + self.device.current_ramp_rate = tesla_to_amps(float(tesla)) + return "T" + + def set_sweep_mode(self, mode): + self.device.sweep_mode = int(mode) + return "M" diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py new file mode 100644 index 0000000..bbe697d --- /dev/null +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -0,0 +1,245 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from lewis_emulators.ips.modes_scpi import Activity, Control + +from ..device_scpi import amps_to_tesla, tesla_to_amps + +MODE_MAPPING = { + 0: Activity.HOLD, + 1: Activity.TO_SETPOINT, + 2: Activity.TO_ZERO, + 4: Activity.CLAMP, +} + +CONTROL_MODE_MAPPING = { + 0: Control.LOCAL_LOCKED, + 1: Control.REMOTE_LOCKED, + 2: Control.LOCAL_UNLOCKED, + 3: Control.REMOTE_UNLOCKED, +} + + +class DeviceUID: + """ + Predefined UIDs for all devices attached to the iPS controller. + """ + magnet_temperature_sensor = "MB1.T1" + level_meter = "DB1.L1" + magnet_supply = "GRPZ" + temperature_sensor_10T = "DB8.T1" + pressure_sensor_10T = "DB5.P1" + + +@has_log +class IpsStreamInterface(StreamInterface): + protocol = "ips_scpi" + in_terminator = "\n" + out_terminator = "\n" + + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_version").escape("*IDN?").eos().build(), + # CmdBuilder("set_comms_mode").escape("Q4").eos().build(), + CmdBuilder("get_magnet_supply_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:STAT").eos().build(), + CmdBuilder("get_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN").eos().build(), + CmdBuilder("get_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), + CmdBuilder("get_supply_voltage").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT").eos().build(), + CmdBuilder("get_measured_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCUR").eos().build(), + CmdBuilder("get_current_setpoint").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET").eos().build(), + CmdBuilder("get_current_sweep_rate").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST").eos().build(), + CmdBuilder("get_field").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD").eos().build(), + CmdBuilder("get_field_setpoint").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET").eos().build(), + CmdBuilder("get_field_sweep_rate").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST").eos().build(), + CmdBuilder("get_software_voltage_limit").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:VLIM").eos().build(), + CmdBuilder("get_persistent_magnet_current").escape( + f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR").eos().build(), + # CmdBuilder("get_trip_current").escape("R17").eos().build(), + CmdBuilder("get_persistent_magnet_field").escape( + f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD").eos().build(), + # CmdBuilder("get_trip_field").escape("R19").eos().build(), + CmdBuilder("get_heater_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SHTC").eos().build(), + # CmdBuilder("get_neg_current_limit").escape("R21").eos().build(), + CmdBuilder("get_pos_current_limit").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:CLIM").eos().build(), + CmdBuilder("get_lead_resistance").escape( + f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES").eos().build(), + CmdBuilder("get_magnet_inductance").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), + CmdBuilder("get_heater_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), + CmdBuilder("get_bipolar_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), + CmdBuilder("set_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:ACTN:").string().eos().build(), + CmdBuilder("set_current").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), + CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:FSET").float().eos().build(), + CmdBuilder("set_field_sweep_rate").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:RFST").float().eos().build(), + CmdBuilder("set_heater_on").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), + CmdBuilder("set_heater_off").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), + CmdBuilder("set_bipolar_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), + } + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + @staticmethod + def get_version(): + """ get_version() + The format of the reply is: + IDN:OXFORD INSTRUMENTS:MERCURY dd:ss:ff + Where: + dd is the basic instrument type (iPS , iPS, Cryojet etc.) + ss is the serial number of the main board + ff is the firmware version of the instrument + :return: Simulated identity string + """ + return "IDN:OXFORD INSTRUMENTS:MERCURY IPS:simulated:0.0.0" + + def set_comms_mode(self): + """This sets the terminator that the device wants, not implemented in emulator. Command does not reply. + """ + + def set_control_mode(self, mode): + self.device.control = CONTROL_MODE_MAPPING[mode] + return "C" + + def get_mode(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{self.device.activity.value}" + + def set_mode(self, mode): + mode = int(mode) + try: + self.device.activity = MODE_MAPPING[mode] + except KeyError: + raise ValueError("Invalid mode specified") + return "A" + + def get_magnet_supply_status(self): + """ + The format of the reply is: + STAT:DEV:{DeviceUID.magnet_supply}:PSU:STAT:00000000 + + Where : + | Status | Bit Value | Bit Position | + |--------------------------------------|-----------|--------------| + | Switch Heater Mismatch | 00000001 | 0 | + | Over Temperature [Rundown Resistors] | 00000002 | 1 | + | Over Temperature [Sense Resistor] | 00000004 | 2 | + | Over Temperature [PCB] | 00000008 | 3 | + | Calibration Failure | 00000010 | 4 | + | MSP430 Firmware Error | 00000020 | 5 | + | Rundown Resistors Failed | 00000040 | 6 | + | MSP430 RS-485 Failure | 00000080 | 7 | + | Quench detected | 00000100 | 8 | + | Catch detected | 00000200 | 9 | + | Over Temperature [Sense Amplifier] | 00001000 | 12 | + | Over Temperature [Amplifier 1] | 00002000 | 13 | + | Over Temperature [Amplifier 2] | 00004000 | 14 | + | PWM Cutoff | 00008000 | 15 | + | Voltage ADC error | 00010000 | 16 | + | Current ADC error | 00020000 | 17 | + + This information is not published and was derived from + direct questions to Oxford Instruments. + """ + resp = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:STAT:{self.device.magnet_supply_status.value:08x}" + return resp + + def get_current_setpoint(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.current_setpoint}:A" + + def get_supply_voltage(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT:{self.device.get_voltage()}:V" + + def get_measured_current(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCUR:{self.device.measured_current}:A" + + def get_current(self): + """Gets the demand current of the PSU.""" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.current}:A" + + def get_current_sweep_rate(self): + # Unsure as to whether units are returned? + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST:{self.device.current_ramp_rate}" + + def get_field(self): + return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{amps_to_tesla(self.device.current)}:VALID" + + def get_field_setpoint(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD:{amps_to_tesla(self.device.current_setpoint)}:T" + + def get_field_sweep_rate(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{amps_to_tesla(self.device.current_ramp_rate)}:T" + + def get_software_voltage_limit(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:VLIM:{self.device.voltage_limit}:V" + + def get_persistent_magnet_current(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR:{self.device.magnet_current}:A" + + # TBD + def get_trip_current(self): + return f"R{self.device.trip_current}" + + def get_persistent_magnet_field(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current)}:T" + + # TBD + def get_trip_field(self): + return f"R{amps_to_tesla(self.device.trip_current)}" + + def get_heater_current(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SHTC:{self.device.heater_current}:mA" + + def get_neg_current_limit(self): + ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.neg_current_limit}:A" + return ret + + def get_pos_current_limit(self): + ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.pos_current_limit}:A" + return ret + + def get_lead_resistance(self): + ret = f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES:{self.device.lead_resistance}:Ohm" + return ret + + def get_magnet_inductance(self): + ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:IND:{self.device.inductance}:H" + return ret + + def get_heater_status(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:{'ON' if self.device.heater_on else 'OFF'}" + + def get_bipolar_mode(self): + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + + def set_current(self, current): + self.device.current_setpoint = float(current) + return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{current}:VALID" + + def set_field(self, current): + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD:{f'amps_to_tesla(float(current)):.5f'}:VALID" + self.device.current_setpoint = tesla_to_amps(float(current)) + return ret + + def set_heater_on(self): + self.device.set_heater_status(True) + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON:VALID" + return ret + + def set_heater_off(self): + self.device.set_heater_status(False) + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF:VALID" + return ret + + def set_field_sweep_rate(self, tesla): + self.device.current_ramp_rate = tesla_to_amps(float(tesla)) + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:RFST:{f'tesla:.3f'}:VALID" + return ret + + def set_bipolar_mode(self, mode): + self.device.bipolar = bool(mode) + print(f"set_bipolar(): mode = {mode}") + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py new file mode 100644 index 0000000..c86bb5e --- /dev/null +++ b/system_tests/lewis_emulators/ips/modes.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class Activity(Enum): + HOLD = "Hold" + TO_SETPOINT = "To Setpoint" + TO_ZERO = "To Zero" + CLAMP = "Clamped" + + +class Control(Enum): + LOCAL_LOCKED = "Local & Locked" + REMOTE_LOCKED = "Remote & Unlocked" + LOCAL_UNLOCKED = "Local & Unlocked" + REMOTE_UNLOCKED = "Remote & Unlocked" + AUTO_RUNDOWN = "Auto-Run-Down" + + +class SweepMode(Enum): + TESLA_FAST = "Tesla Fast" + + +class Mode(Enum): + FAST = "Fast" + SLOW = "Slow" diff --git a/system_tests/lewis_emulators/ips/modes_scpi.py b/system_tests/lewis_emulators/ips/modes_scpi.py new file mode 100644 index 0000000..34892e6 --- /dev/null +++ b/system_tests/lewis_emulators/ips/modes_scpi.py @@ -0,0 +1,68 @@ +from enum import Enum + + +class Activity(Enum): + HOLD = "HOLD" + TO_SETPOINT = "RTOS" + TO_ZERO = "RTOZ" + CLAMP = "CLMP" + + +class Control(Enum): + LOCAL_LOCKED = "Local & Locked" + REMOTE_LOCKED = "Remote & Unlocked" + LOCAL_UNLOCKED = "Local & Unlocked" + REMOTE_UNLOCKED = "Remote & Unlocked" + AUTO_RUNDOWN = "Auto-Run-Down" + + +class SweepMode(Enum): + TESLA_FAST = "Tesla Fast" + + +class Mode(Enum): + FAST = "Fast" + SLOW = "Slow" + + +class MagnetSupplyStatus(Enum): + """ + | Status | Bit Value | Bit Position | + |--------------------------------------|-----------|--------------| + | Switch Heater Mismatch | 00000001 | 0 | + | Over Temperature [Rundown Resistors] | 00000002 | 1 | + | Over Temperature [Sense Resistor] | 00000004 | 2 | + | Over Temperature [PCB] | 00000008 | 3 | + | Calibration Failure | 00000010 | 4 | + | MSP430 Firmware Error | 00000020 | 5 | + | Rundown Resistors Failed | 00000040 | 6 | + | MSP430 RS-485 Failure | 00000080 | 7 | + | Quench detected | 00000100 | 8 | + | Catch detected | 00000200 | 9 | + | Over Temperature [Sense Amplifier] | 00001000 | 12 | + | Over Temperature [Amplifier 1] | 00002000 | 13 | + | Over Temperature [Amplifier 2] | 00004000 | 14 | + | PWM Cutoff | 00008000 | 15 | + | Voltage ADC error | 00010000 | 16 | + | Current ADC error | 00020000 | 17 | + + This information is not published and was derived from + direct questions to Oxford Instruments. +""" + OK = 0x00000000 + SWITCH_HEATER_MISMATCH = 0x00000001 + OVER_TEMPERATURE_RUNDOWN_RESISTORS = 0x00000002 + OVER_TEMPERATURE_SENSE_RESISTOR = 0x00000004 + OVER_TEMPERATURE_PCB = 0x00000008 + CALIBRATION_FAILURE = 0x00000010 + MSP430_FIRMWARE_ERROR = 0x00000020 + RUNDOWN_RESISTORS_FAILED = 0x00000040 + MSP430_RS_485_FAILURE = 0x00000080 + QUENCH_DETECTED = 0x00000100 + CATCH_DETECTED = 0x00000200 + OVER_TEMPERATURE_SENSE_AMPLIFIER = 0x00001000 + OVER_TEMPERATURE_AMPLIFIER_1 = 0x00002000 + OVER_TEMPERATURE_AMPLIFIER_2 = 0x00004000 + PWM_CUTOFF = 0x00008000 + VOLTAGE_ADC_ERROR = 0x00010000 + CURRENT_ADC_ERROR = 0x00020000 diff --git a/system_tests/lewis_emulators/ips/states.py b/system_tests/lewis_emulators/ips/states.py new file mode 100644 index 0000000..f6bc351 --- /dev/null +++ b/system_tests/lewis_emulators/ips/states.py @@ -0,0 +1,63 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + +from lewis_emulators.ips.modes import Activity + +SECS_PER_MIN = 60 + + +class HeaterOnState(State): + def in_state(self, dt): + device = self._context + + device.heater_current = approaches.linear( + device.heater_current, device.HEATER_ON_CURRENT, device.HEATER_RAMP_RATE, dt + ) + + # The magnet can only be ramped at a certain rate. The PSUs ramp rate can be varied. + # If the PSU attempts to ramp too fast for the magnet, then get a quench + curr_ramp_rate = device.current_ramp_rate / SECS_PER_MIN + + if curr_ramp_rate > device.MAGNET_RAMP_RATE: + device.quench("PSU ramp rate is too high") + elif abs(device.current - device.magnet_current) > device.QUENCH_CURRENT_DELTA * dt: + device.quench( + "Difference between PSU current ({}) and magnet current ({}) is higher than allowed ({})".format( + device.current, device.magnet_current, device.QUENCH_CURRENT_DELTA * dt + ) + ) + + elif device.activity == Activity.TO_SETPOINT: + device.current = approaches.linear( + device.current, device.current_setpoint, curr_ramp_rate, dt + ) + device.magnet_current = approaches.linear( + device.magnet_current, device.current_setpoint, curr_ramp_rate, dt + ) + + elif device.activity == Activity.TO_ZERO: + device.current = approaches.linear(device.current, 0, curr_ramp_rate, dt) + device.magnet_current = approaches.linear(device.magnet_current, 0, curr_ramp_rate, dt) + + +class HeaterOffState(State): + def in_state(self, dt): + device = self._context + + device.heater_current = approaches.linear( + device.heater_current, device.HEATER_OFF_CURRENT, device.HEATER_RAMP_RATE, dt + ) + + curr_ramp_rate = device.current_ramp_rate / SECS_PER_MIN + + # In this state, the magnet current is totally unaffected by whatever the PSU decides to do. + if device.activity == Activity.TO_SETPOINT: + device.current = approaches.linear( + device.current, device.current_setpoint, curr_ramp_rate, dt + ) + elif device.activity == Activity.TO_ZERO: + device.current = approaches.linear(device.current, 0, curr_ramp_rate, dt) + + +class MagnetQuenchedState(State): + pass diff --git a/system_tests/lewis_emulators/lewis_versions.py b/system_tests/lewis_emulators/lewis_versions.py new file mode 100644 index 0000000..0f6c45a --- /dev/null +++ b/system_tests/lewis_emulators/lewis_versions.py @@ -0,0 +1,2 @@ +LEWIS_1_3_0 = "1.3.0" +LEWIS_LATEST = LEWIS_1_3_0 diff --git a/system_tests/tests/__init__.py b/system_tests/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 4922bc50e67649eb67176167076f1fc6fc164a6e Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 6 May 2025 12:47:20 +0100 Subject: [PATCH 08/61] .gitignore: added __pycache__ and .idea directories --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ed7e36d..35fefd0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ relPaths.sh /doc/ *_info_positions.req *_info_settings.req +__pycache__/ +.idea From 24387164891f732cc514e0730548ef798eace53a Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 7 May 2025 08:38:30 +0100 Subject: [PATCH 09/61] tests/ips.py reverted to original as scpi tests are now in ips_scpi.py --- system_tests/tests/ips.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 7b6f5ee..7cbddeb 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -17,10 +17,10 @@ "name": DEVICE_PREFIX, "directory": get_default_ioc_dir("IPS"), "emulator": EMULATOR_NAME, - "lewis_protocol": "ips_scpi", + "lewis_protocol": "ips_legacy", "ioc_launcher_class": ProcServLauncher, "macros": { - "STREAMPROTOCOL": "SCPI", + "STREAMPROTOCOL": "LEGACY", "MANAGER_ASG": "DEFAULT", "MAX_SWEEP_RATE": "1.0", "HEATER_WAITTIME": "10", # On a real system the macro has a default of 60s, From 94ffca5c165ede7135964dedd4680fe10aa4a9f1 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 7 May 2025 14:56:23 +0100 Subject: [PATCH 10/61] refactor: Split tests into common and specific to legacy and SCPI --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 5 +- .../ips/interfaces/stream_interface_scpi.py | 68 ++--- system_tests/lewis_emulators/ips/modes.py | 1 - system_tests/run_tests.bat | 13 + system_tests/tests/ips.py | 244 +-------------- system_tests/tests/ips_common.py | 277 ++++++++++++++++++ 6 files changed, 344 insertions(+), 264 deletions(-) create mode 100644 system_tests/run_tests.bat create mode 100644 system_tests/tests/ips_common.py diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index bd8e5a5..71d37c7 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -150,7 +150,10 @@ getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; wait 100; # --------- # Set Control mode - grab control of the unit from local users. # OFF | SOFT | ON -setControl { out "SET:SYS:LOCK%{OFF|SOFT|ON}"; in "READ:SYS:LOCK:%*s";} +# With a real device, this command always replies: STAT:SET:SYS:LOCK:OFF:DENIED +# where 'OFF' may be 'OFF', 'ON' or 'SOFT'. +# It's also not possible to read the LOCK status. So this feature might be totally useless. +setControl { out "SET:SYS:LOCK:%{OFF|SOFT|ON}"; in "READ:SYS:LOCK:%*s";} # --------- # DEV::PSU:ACTN diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index bbe697d..9e0b86d 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -7,10 +7,10 @@ from ..device_scpi import amps_to_tesla, tesla_to_amps MODE_MAPPING = { - 0: Activity.HOLD, - 1: Activity.TO_SETPOINT, - 2: Activity.TO_ZERO, - 4: Activity.CLAMP, + 'HOLD': Activity.HOLD, + 'RTOS': Activity.TO_SETPOINT, + 'RTOZ': Activity.TO_ZERO, + 'CLMP': Activity.CLAMP, } CONTROL_MODE_MAPPING = { @@ -28,7 +28,8 @@ class DeviceUID: magnet_temperature_sensor = "MB1.T1" level_meter = "DB1.L1" magnet_supply = "GRPZ" - temperature_sensor_10T = "DB8.T1" + # temperature_sensor_10T = "DB8.T1" + temperature_sensor_10T = "MB1.T1" pressure_sensor_10T = "DB5.P1" @@ -67,10 +68,10 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_magnet_inductance").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), CmdBuilder("get_heater_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), CmdBuilder("get_bipolar_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), - CmdBuilder("set_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:ACTN:").string().eos().build(), + CmdBuilder("set_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), CmdBuilder("set_current").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), - CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:FSET").float().eos().build(), - CmdBuilder("set_field_sweep_rate").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:RFST").float().eos().build(), + CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:FSET:").float().eos().build(), + CmdBuilder("set_field_sweep_rate").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:").float().eos().build(), CmdBuilder("set_heater_on").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), CmdBuilder("set_heater_off").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), CmdBuilder("set_bipolar_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), @@ -108,13 +109,13 @@ def set_control_mode(self, mode): def get_mode(self): return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{self.device.activity.value}" - def set_mode(self, mode): - mode = int(mode) + def set_mode(self, mode: str): + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{self.device.activity.value}:VALID" try: - self.device.activity = MODE_MAPPING[mode] + self.device.activity = Activity[mode] except KeyError: + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" raise ValueError("Invalid mode specified") - return "A" def get_magnet_supply_status(self): """ @@ -148,65 +149,66 @@ def get_magnet_supply_status(self): return resp def get_current_setpoint(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.current_setpoint}:A" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{self.device.current_setpoint:.4f}A" def get_supply_voltage(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT:{self.device.get_voltage()}:V" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT:{self.device.get_voltage():.4f}V" def get_measured_current(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCUR:{self.device.measured_current}:A" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCUR:{self.device.measured_current:.4f}A" def get_current(self): """Gets the demand current of the PSU.""" - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.current}:A" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.current:.4f}A" def get_current_sweep_rate(self): # Unsure as to whether units are returned? - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST:{self.device.current_ramp_rate}" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST:{self.device.current_ramp_rate:.4f}A/m" def get_field(self): - return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{amps_to_tesla(self.device.current)}:VALID" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD:{amps_to_tesla(self.device.current):.4f}T" def get_field_setpoint(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD:{amps_to_tesla(self.device.current_setpoint)}:T" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{amps_to_tesla(self.device.current_setpoint):.4f}T" def get_field_sweep_rate(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{amps_to_tesla(self.device.current_ramp_rate)}:T" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{amps_to_tesla(self.device.current_ramp_rate):.4f}T/m" def get_software_voltage_limit(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:VLIM:{self.device.voltage_limit}:V" + # According to the manual, this should return with a unit ":V" suffix, but in reality it does not. + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:VLIM:{self.device.voltage_limit}" def get_persistent_magnet_current(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR:{self.device.magnet_current}:A" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR:{self.device.magnet_current:.4f}A" # TBD def get_trip_current(self): return f"R{self.device.trip_current}" def get_persistent_magnet_field(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current)}:T" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current):.4f}T" # TBD def get_trip_field(self): return f"R{amps_to_tesla(self.device.trip_current)}" def get_heater_current(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SHTC:{self.device.heater_current}:mA" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SHTC:{self.device.heater_current:.4f}mA" def get_neg_current_limit(self): - ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.neg_current_limit}:A" + ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.neg_current_limit:.4f}" return ret def get_pos_current_limit(self): - ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.pos_current_limit}:A" + ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.pos_current_limit:.4f}" return ret def get_lead_resistance(self): - ret = f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES:{self.device.lead_resistance}:Ohm" + ret = f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES:{self.device.lead_resistance:.4f}R" return ret def get_magnet_inductance(self): - ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:IND:{self.device.inductance}:H" + ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:IND:{self.device.inductance:.4f}" return ret def get_heater_status(self): @@ -217,11 +219,11 @@ def get_bipolar_mode(self): def set_current(self, current): self.device.current_setpoint = float(current) - return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{current}:VALID" + return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{current:1.4f}:VALID" - def set_field(self, current): - ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD:{f'amps_to_tesla(float(current)):.5f'}:VALID" - self.device.current_setpoint = tesla_to_amps(float(current)) + def set_field(self, field): + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{float(field):.4f}:VALID" + self.device.current_setpoint = tesla_to_amps(float(field)) return ret def set_heater_on(self): @@ -236,7 +238,7 @@ def set_heater_off(self): def set_field_sweep_rate(self, tesla): self.device.current_ramp_rate = tesla_to_amps(float(tesla)) - ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:RFST:{f'tesla:.3f'}:VALID" + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{float(tesla):1.4f}:VALID" return ret def set_bipolar_mode(self, mode): diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index c86bb5e..c9c755e 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -7,7 +7,6 @@ class Activity(Enum): TO_ZERO = "To Zero" CLAMP = "Clamped" - class Control(Enum): LOCAL_LOCKED = "Local & Locked" REMOTE_LOCKED = "Remote & Unlocked" diff --git a/system_tests/run_tests.bat b/system_tests/run_tests.bat new file mode 100644 index 0000000..56f9c25 --- /dev/null +++ b/system_tests/run_tests.bat @@ -0,0 +1,13 @@ +@echo off +REM Run this directory's tests using the IOC Testing Framework + +SET CurrentDir=%~dp0 + +call "%~dp0..\..\..\..\config_env.bat" + +set "PYTHONUNBUFFERED=1" + +REM Command line arguments always passed to the test script +SET ARGS=--test_and_emulator %~dp0 +call %PYTHON3% "%EPICS_KIT_ROOT%\support\IocTestFramework\master\run_tests.py" %ARGS% %* +IF %ERRORLEVEL% NEQ 0 EXIT /b %errorlevel% diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 7cbddeb..6a6ca1e 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -1,7 +1,5 @@ import unittest -from contextlib import contextmanager - -from parameterized import parameterized +from .ips_common import IpsBaseTests from utils.channel_access import ChannelAccess from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir @@ -40,9 +38,6 @@ HEATER_OFF_STATES = ["Off Mag at 0", "Off Mag at F"] -# Time to wait for the heater to warm up/cool down (extracted from IOC macros above) -HEATER_WAIT_TIME = float((IOCS[0].get("macros").get("HEATER_WAITTIME"))) - ACTIVITY_STATES = ["Hold", "To Setpoint", "To Zero", "Clamped"] # Generate all the control commands to test that remote and unlocked is set for @@ -60,16 +55,26 @@ CONTROL_COMMANDS_WITHOUT_VALUES = ["SET:COMMSRES"] -class IpsTests(unittest.TestCase): +class IpsLegacyTests(IpsBaseTests, unittest.TestCase): """ - Tests for the Ips IOC. + Tests for the Ips legacy protocol IOC. """ + def _get_device_prefix(self): + return DEVICE_PREFIX + + def _get_ioc_config(self): + return IOCS def setUp(self): + ioc_config = self._get_ioc_config() + + # Time to wait for the heater to warm up/cool down (extracted from IOC macros above) + heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) + self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) - # Some changes happen on the order of HEATER_WAIT_TIME seconds. Use a significantly longer timeout + # Some changes happen on the order of heater_wait_time seconds. Use a significantly longer timeout # to capture a few heater wait times plus some time for PVs to update. - self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=HEATER_WAIT_TIME * 10) + self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=heater_wait_time * 10) # Wait for some critical pvs to be connected. for pv in ["MAGNET:FIELD:PERSISTENT", "FIELD", "FIELD:SP:RBV", "HEATER:STATUS"]: @@ -90,222 +95,3 @@ def setUp(self): # Wait for statemachine to reach "at field" state before every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - def tearDown(self): - # Wait for statemachine to reach "at field" state after every test. - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") - - def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self): - self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") - - def _assert_field_is(self, field, check_stable=False): - self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) - if check_stable: - self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) - self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) - - def _assert_heater_is(self, heater_state): - self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") - if heater_state: - self.ca.assert_that_pv_is( - "HEATER:STATUS", - "On", - ) - else: - self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) - - def _set_and_check_persistent_mode(self, mode): - self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( - self, _, val - ): - initial_field = 1 - - self._set_and_check_persistent_mode(True) - self.ca.set_pv_value("FIELD:SP", initial_field) - self._assert_field_is(initial_field, check_stable=True) - - # Field in the magnet already from persistent mode. - self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) - self._assert_heater_is(False) - - # Set the new field. This will cause all of the following events based on the state machine. - self.ca.set_pv_value("FIELD:SP", val) - - # PSU should be ramped to match the persistent field inside the magnet - self.ca.assert_that_pv_is_number("FIELD", initial_field, tolerance=TOLERANCE) - self.ca.assert_that_pv_is("ACTIVITY", "To Setpoint") - - # Then it is safe to turn on the heater - self._assert_heater_is(True) - - # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up - # after being set. - self._assert_field_is(val) - - # Now that the correct current is in the magnet, the SNL should turn the heater off - self._assert_heater_is(False) - - # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before - # ramping PSU to zero) - self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field - self.ca.assert_that_pv_is_number( - "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE - ) # Persistent field - self.ca.assert_that_pv_is_number( - "FIELD:USER", val, tolerance=TOLERANCE - ) # User field should be tracking persistent field here - self.ca.assert_that_pv_is("ACTIVITY", "To Zero") - - # ...And the magnet should now be in the right state! - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) - - # "User" field should take the value put in the setpoint, even when the actual field provided by the supply - # drops to zero - self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field - self.ca.assert_that_pv_is_number( - "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE - ) # Persistent field - self.ca.assert_that_pv_is_number( - "FIELD:USER", val, tolerance=TOLERANCE - ) # User field should be tracking persistent field here - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero( - self, _, val - ): - initial_field = 1 - - self._set_and_check_persistent_mode(True) - self.ca.set_pv_value("FIELD:SP", initial_field) - self._assert_field_is(initial_field, check_stable=True) - - # Field in the magnet already from persistent mode. - self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) - self._assert_heater_is(False) - - self._set_and_check_persistent_mode(False) - - # Set the new field. This will cause all of the following events based on the state machine. - self.ca.set_pv_value("FIELD:SP", val) - - # PSU should be ramped to match the persistent field inside the magnet (if there was one) - self.ca.assert_that_pv_is("FIELD", initial_field, timeout=10) - - # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it - # was already on out of an abundance of caution). - self._assert_heater_is(True) - - # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up - # after being set. - self._assert_field_is(val) - - # ...And the magnet should now be in the right state! - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) - - # And the PSU should remain stable providing the required current/field - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - self._assert_field_is(val, check_stable=True) - - @contextmanager - def _backdoor_magnet_quench(self, reason="Test framework quench"): - self._lewis.backdoor_run_function_on_device("quench", [reason]) - try: - yield - finally: - # Get back out of the quenched state. This is because the tearDown method checks that magnet has not - # quenched. - self._lewis.backdoor_run_function_on_device("unquench") - # Wait for IOC to notice quench state has gone away - self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) - - @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) - def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( - self, _, field - ): - self._set_and_check_persistent_mode(False) - self.ca.set_pv_value("FIELD:SP", field) - self._assert_field_is(field) - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - with self._backdoor_magnet_quench(): - self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") - self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) - self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") - self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) - - # The trip field should be the field at the point when the magnet quenched. - self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) - - # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) - self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): - self._lewis.backdoor_set_on_device("inductance", val) - self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): - self._lewis.backdoor_set_on_device("measured_current", val) - self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) - - @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) - def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): - self.ca.set_pv_value("FIELD:RATE:SP", val) - self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) - self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) - - @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES)) - @unstable_test() - def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( - self, _, activity_state - ): - self.ca.set_pv_value("ACTIVITY", activity_state) - if activity_state == "Clamped": - self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") - else: - self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM") - - @parameterized.expand( - control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) - ) - def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( - self, _, control_pv, set_value - ): - self.ca.set_pv_value("CONTROL", "Local & Locked") - self.ca.set_pv_value(control_pv, set_value) - self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - - @parameterized.expand( - control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) - ) - def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): - self.ca.set_pv_value("CONTROL", "Local & Locked") - self.ca.process_pv(control_pv) - self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - - # original problem/complaint: - # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields - def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): - # arrange: set mode to non-persistent, set field - self._set_and_check_persistent_mode(False) - self.ca.set_pv_value("FIELD:SP", 3.21) - self._assert_field_is(3.21) - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - # act: set new field - self.ca.set_pv_value("FIELD:SP", 4.56) - - # assert: field starts to change by tolerance within timeout, then reaches within second timeout - # timeout present to prove new setpoint moved to _without_ waiting for heater, if already on - self.ca.assert_that_pv_is_not_number("FIELD", 3.21, tolerance=0.01, timeout=20) - self.ca.assert_that_pv_is_number("FIELD", 4.56, tolerance=0.01, timeout=60) diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py new file mode 100644 index 0000000..9ac2327 --- /dev/null +++ b/system_tests/tests/ips_common.py @@ -0,0 +1,277 @@ +import unittest + +from abc import ABCMeta, abstractmethod +from contextlib import contextmanager + +from parameterized import parameterized + +from utils.channel_access import ChannelAccess +from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir +from utils.test_modes import TestModes +from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test + +DEVICE_PREFIX = "IPS_01" +EMULATOR_NAME = "ips" + + +# Only run tests in DEVSIM. Unable to produce detailed enough functionality to be useful in recsim. +TEST_MODES = [TestModes.DEVSIM] + +TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities +TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 + +TOLERANCE = 0.0001 + +HEATER_OFF_STATES = ["Off Mag at 0", "Off Mag at F"] + +ACTIVITY_STATES = ["Hold", "To Setpoint", "To Zero", "Clamped"] + +# Generate all the control commands to test that remote and unlocked is set for +# Chain flattens the list +CONTROL_COMMANDS_WITH_VALUES = [ + ("FIELD", 0.1), + ("FIELD:RATE", 0.1), + ("SWEEPMODE:PARAMS", "Tesla Fast"), +] +for activity_state in ACTIVITY_STATES: + CONTROL_COMMANDS_WITH_VALUES.append(("ACTIVITY", activity_state)) +for heater_off_state in HEATER_OFF_STATES: + CONTROL_COMMANDS_WITH_VALUES.append(("HEATER:STATUS", heater_off_state)) + +CONTROL_COMMANDS_WITHOUT_VALUES = ["SET:COMMSRES"] + + +class IpsBaseTests(object, metaclass=ABCMeta): + """ + Tests for the Ips IOC. + """ + @abstractmethod + def _get_device_prefix(self): + pass + + @abstractmethod + def _get_ioc_config(self): + pass + + @abstractmethod + def setUp(self): + pass + + def tearDown(self): + # Wait for statemachine to reach "at field" state after every test. + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") + + def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self): + self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") + + def _assert_field_is(self, field, check_stable=False): + self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) + if check_stable: + self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) + self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) + + def _assert_heater_is(self, heater_state): + self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") + if heater_state: + self.ca.assert_that_pv_is( + "HEATER:STATUS", + "On", + ) + else: + self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) + + def _set_and_check_persistent_mode(self, mode): + self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( + self, _, val + ): + initial_field = 1 + + self._set_and_check_persistent_mode(True) + self.ca.set_pv_value("FIELD:SP", initial_field) + self._assert_field_is(initial_field, check_stable=True) + + # Field in the magnet already from persistent mode. + self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) + self._assert_heater_is(False) + + # Set the new field. This will cause all of the following events based on the state machine. + self.ca.set_pv_value("FIELD:SP", val) + + # PSU should be ramped to match the persistent field inside the magnet + self.ca.assert_that_pv_is_number("FIELD", initial_field, tolerance=TOLERANCE) + self.ca.assert_that_pv_is("ACTIVITY", "To Setpoint") + + # Then it is safe to turn on the heater + self._assert_heater_is(True) + + # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up + # after being set. + self._assert_field_is(val) + + # Now that the correct current is in the magnet, the SNL should turn the heater off + self._assert_heater_is(False) + + # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before + # ramping PSU to zero) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field + self.ca.assert_that_pv_is_number( + "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE + ) # Persistent field + self.ca.assert_that_pv_is_number( + "FIELD:USER", val, tolerance=TOLERANCE + ) # User field should be tracking persistent field here + self.ca.assert_that_pv_is("ACTIVITY", "To Zero") + + # ...And the magnet should now be in the right state! + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) + + # "User" field should take the value put in the setpoint, even when the actual field provided by the supply + # drops to zero + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field + self.ca.assert_that_pv_is_number( + "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE + ) # Persistent field + self.ca.assert_that_pv_is_number( + "FIELD:USER", val, tolerance=TOLERANCE + ) # User field should be tracking persistent field here + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero( + self, _, val + ): + initial_field = 1 + + self._set_and_check_persistent_mode(True) + self.ca.set_pv_value("FIELD:SP", initial_field) + self._assert_field_is(initial_field, check_stable=True) + + # Field in the magnet already from persistent mode. + self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) + self._assert_heater_is(False) + + self._set_and_check_persistent_mode(False) + + # Set the new field. This will cause all of the following events based on the state machine. + self.ca.set_pv_value("FIELD:SP", val) + + # PSU should be ramped to match the persistent field inside the magnet (if there was one) + self.ca.assert_that_pv_is("FIELD", initial_field, timeout=10) + + # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it + # was already on out of an abundance of caution). + self._assert_heater_is(True) + + # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up + # after being set. + self._assert_field_is(val) + + # ...And the magnet should now be in the right state! + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) + + # And the PSU should remain stable providing the required current/field + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + self._assert_field_is(val, check_stable=True) + + @contextmanager + def _backdoor_magnet_quench(self, reason="Test framework quench"): + self._lewis.backdoor_run_function_on_device("quench", [reason]) + try: + yield + finally: + # Get back out of the quenched state. This is because the tearDown method checks that magnet has not + # quenched. + self._lewis.backdoor_run_function_on_device("unquench") + # Wait for IOC to notice quench state has gone away + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) + + @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) + def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( + self, _, field + ): + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", field) + self._assert_field_is(field) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + with self._backdoor_magnet_quench(): + self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) + self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") + self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) + + # The trip field should be the field at the point when the magnet quenched. + self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) + + # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + self._lewis.backdoor_set_on_device("inductance", val) + self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + self._lewis.backdoor_set_on_device("measured_current", val) + self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) + def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): + self.ca.set_pv_value("FIELD:RATE:SP", val) + self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) + self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) + + @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES)) + @unstable_test() + def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( + self, _, activity_state + ): + self.ca.set_pv_value("ACTIVITY", activity_state) + if activity_state == "Clamped": + self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") + else: + self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM") + + @parameterized.expand( + control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) + ) + def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( + self, _, control_pv, set_value + ): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.set_pv_value(control_pv, set_value) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + @parameterized.expand( + control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) + ) + def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.process_pv(control_pv) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + # original problem/complaint: + # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields + def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): + # arrange: set mode to non-persistent, set field + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", 3.21) + self._assert_field_is(3.21) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + # act: set new field + self.ca.set_pv_value("FIELD:SP", 4.56) + + # assert: field starts to change by tolerance within timeout, then reaches within second timeout + # timeout present to prove new setpoint moved to _without_ waiting for heater, if already on + self.ca.assert_that_pv_is_not_number("FIELD", 3.21, tolerance=0.01, timeout=20) + self.ca.assert_that_pv_is_number("FIELD", 4.56, tolerance=0.01, timeout=60) From f82db580b605db4d451a76e0188d25c947052e77 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 8 May 2025 08:40:41 +0100 Subject: [PATCH 11/61] Added ips_scpi.py for specific SCPI protocol tests --- system_tests/tests/ips_scpi.py | 319 +++++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 system_tests/tests/ips_scpi.py diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py new file mode 100644 index 0000000..8bd991d --- /dev/null +++ b/system_tests/tests/ips_scpi.py @@ -0,0 +1,319 @@ +import unittest +from .ips_common import IpsBaseTests +from contextlib import contextmanager +from parameterized import parameterized +from utils.channel_access import ChannelAccess +from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir +from utils.test_modes import TestModes +from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test + +DEVICE_PREFIX = "IPS_01" +EMULATOR_NAME = "ips" + + +IOCS = [ + { + "name": DEVICE_PREFIX, + "directory": get_default_ioc_dir("IPS"), + "emulator": EMULATOR_NAME, + "lewis_protocol": "ips_scpi", + "ioc_launcher_class": ProcServLauncher, + "macros": { + "STREAMPROTOCOL": "SCPI", + "MANAGER_ASG": "DEFAULT", + "MAX_SWEEP_RATE": "1.0", + "HEATER_WAITTIME": "10", # On a real system the macro has a default of 60s, + # but speed it up a bit for the sake of tests. + }, + }, +] + + +# Only run tests in DEVSIM. Unable to produce detailed enough functionality to be useful in recsim. +TEST_MODES = [TestModes.DEVSIM] + +TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities +TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 + +TOLERANCE = 0.0001 + +HEATER_OFF_STATES = ["Off Mag at 0", "Off Mag at F"] + +ACTIVITY_STATES = ["Hold", "To Setpoint", "To Zero", "Clamped"] + +# Generate all the control commands to test that remote and unlocked is set for +# Chain flattens the list +CONTROL_COMMANDS_WITH_VALUES = [ + ("FIELD", 0.1), + ("FIELD:RATE", 0.1), + ("SWEEPMODE:PARAMS", "Tesla Fast"), +] +for activity_state in ACTIVITY_STATES: + CONTROL_COMMANDS_WITH_VALUES.append(("ACTIVITY", activity_state)) +for heater_off_state in HEATER_OFF_STATES: + CONTROL_COMMANDS_WITH_VALUES.append(("HEATER:STATUS", heater_off_state)) + +CONTROL_COMMANDS_WITHOUT_VALUES = ["SET:COMMSRES"] + + +class IpsSCPITests(IpsBaseTests, unittest.TestCase): + """ + Tests for the Ips SCPI protocol IOC. + """ + def _get_device_prefix(self): + return DEVICE_PREFIX + + def _get_ioc_config(self): + return IOCS + + def setUp(self): + ioc_config = self._get_ioc_config() + + # Time to wait for the heater to warm up/cool down (extracted from IOC macros above) + heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) + + self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) + # Some changes happen on the order of HEATER_WAIT_TIME seconds. Use a significantly longer timeout + # to capture a few heater wait times plus some time for PVs to update. + self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=heater_wait_time * 10) + + # Wait for some critical pvs to be connected. + for pv in ["MAGNET:FIELD:PERSISTENT", "FIELD", "FIELD:SP:RBV", "HEATER:STATUS"]: + self.ca.assert_that_pv_exists(pv) + + # Ensure in the correct mode + # The following call was in the original legacy test code but is not needed in the SCPI version. + # self.ca.set_pv_value("CONTROL:SP", "Off") # Remote and Unlocked + + self.ca.set_pv_value("ACTIVITY:SP", "To Setpoint") + + # Don't run reset as the sudden change of state confuses the IOC's state machine. No matter what the initial + # state of the device the SNL should be able to deal with it. + # self._lewis.backdoor_run_function_on_device("reset") + + self.ca.set_pv_value("FIELD:RATE:SP", 10) + # self.ca.assert_that_pv_is_number("FIELD:RATE:SP", 10) + + self.ca.process_pv("FIELD:SP") + + # Wait for statemachine to reach "at field" state before every test. + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + def tearDown(self): + # Wait for statemachine to reach "at field" state after every test. + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") + + def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self): + self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") + + def _assert_field_is(self, field, check_stable=False): + self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) + if check_stable: + self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) + self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) + + def _assert_heater_is(self, heater_state): + self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") + if heater_state: + self.ca.assert_that_pv_is( + "HEATER:STATUS", + "On", + ) + else: + self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) + + def _set_and_check_persistent_mode(self, mode): + self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( + self, _, val + ): + initial_field = 1 + + self._set_and_check_persistent_mode(True) + self.ca.set_pv_value("FIELD:SP", initial_field) + self._assert_field_is(initial_field, check_stable=True) + + # Field in the magnet already from persistent mode. + self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) + self._assert_heater_is(False) + + # Set the new field. This will cause all of the following events based on the state machine. + self.ca.set_pv_value("FIELD:SP", val) + + # PSU should be ramped to match the persistent field inside the magnet + self.ca.assert_that_pv_is_number("FIELD", initial_field, tolerance=TOLERANCE) + self.ca.assert_that_pv_is("ACTIVITY", "To Setpoint") + + # Then it is safe to turn on the heater + self._assert_heater_is(True) + + # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up + # after being set. + self._assert_field_is(val) + + # Now that the correct current is in the magnet, the SNL should turn the heater off + self._assert_heater_is(False) + + # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before + # ramping PSU to zero) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field + self.ca.assert_that_pv_is_number( + "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE + ) # Persistent field + self.ca.assert_that_pv_is_number( + "FIELD:USER", val, tolerance=TOLERANCE + ) # User field should be tracking persistent field here + self.ca.assert_that_pv_is("ACTIVITY", "To Zero") + + # ...And the magnet should now be in the right state! + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) + + # "User" field should take the value put in the setpoint, even when the actual field provided by the supply + # drops to zero + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field + self.ca.assert_that_pv_is_number( + "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE + ) # Persistent field + self.ca.assert_that_pv_is_number( + "FIELD:USER", val, tolerance=TOLERANCE + ) # User field should be tracking persistent field here + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero( + self, _, val + ): + initial_field = 1 + + self._set_and_check_persistent_mode(True) + self.ca.set_pv_value("FIELD:SP", initial_field) + self._assert_field_is(initial_field, check_stable=True) + + # Field in the magnet already from persistent mode. + self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) + self._assert_heater_is(False) + + self._set_and_check_persistent_mode(False) + + # Set the new field. This will cause all of the following events based on the state machine. + self.ca.set_pv_value("FIELD:SP", val) + + # PSU should be ramped to match the persistent field inside the magnet (if there was one) + self.ca.assert_that_pv_is("FIELD", initial_field, timeout=10) + + # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it + # was already on out of an abundance of caution). + self._assert_heater_is(True) + + # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up + # after being set. + self._assert_field_is(val) + + # ...And the magnet should now be in the right state! + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) + + # And the PSU should remain stable providing the required current/field + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + self._assert_field_is(val, check_stable=True) + + @contextmanager + def _backdoor_magnet_quench(self, reason="Test framework quench"): + self._lewis.backdoor_run_function_on_device("quench", [reason]) + try: + yield + finally: + # Get back out of the quenched state. This is because the tearDown method checks that magnet has not + # quenched. + self._lewis.backdoor_run_function_on_device("unquench") + # Wait for IOC to notice quench state has gone away + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) + + @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) + def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( + self, _, field + ): + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", field) + self._assert_field_is(field) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + with self._backdoor_magnet_quench(): + self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) + self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") + self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) + + # The trip field should be the field at the point when the magnet quenched. + self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) + + # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + self._lewis.backdoor_set_on_device("inductance", val) + self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) + def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + self._lewis.backdoor_set_on_device("measured_current", val) + self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) + + @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) + def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): + self.ca.set_pv_value("FIELD:RATE:SP", val) + self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) + self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) + + @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES)) + @unstable_test() + def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( + self, _, activity_state + ): + self.ca.set_pv_value("ACTIVITY", activity_state) + if activity_state == "Clamped": + self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") + else: + self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM") + + @parameterized.expand( + control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) + ) + def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( + self, _, control_pv, set_value + ): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.set_pv_value(control_pv, set_value) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + @parameterized.expand( + control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) + ) + def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.process_pv(control_pv) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + # original problem/complaint: + # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields + def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): + # arrange: set mode to non-persistent, set field + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", 3.21) + self._assert_field_is(3.21) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + # act: set new field + self.ca.set_pv_value("FIELD:SP", 4.56) + + # assert: field starts to change by tolerance within timeout, then reaches within second timeout + # timeout present to prove new setpoint moved to _without_ waiting for heater, if already on + self.ca.assert_that_pv_is_not_number("FIELD", 3.21, tolerance=0.01, timeout=20) + self.ca.assert_that_pv_is_number("FIELD", 4.56, tolerance=0.01, timeout=60) From 255777c7785ffe02a6b4ead60a980029189c4926 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 5 Jun 2025 15:13:58 +0100 Subject: [PATCH 12/61] Added to and made changes with lewis emulator and tests. Originally planning on splitting the device and states was not a good idea, so have reworked the existing files to cater for both variants --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 35 +-- system_tests/lewis_emulators/ips/__init__.py | 5 +- system_tests/lewis_emulators/ips/device.py | 21 +- .../lewis_emulators/ips/device_scpi.py | 160 ------------- .../ips/interfaces/stream_interface.py | 2 +- .../ips/interfaces/stream_interface_scpi.py | 68 ++++-- system_tests/lewis_emulators/ips/modes.py | 48 +++- system_tests/lewis_emulators/ips/states.py | 11 +- system_tests/tests/ips.py | 55 +++++ system_tests/tests/ips_common.py | 62 +---- system_tests/tests/ips_scpi.py | 223 +----------------- 11 files changed, 214 insertions(+), 476 deletions(-) delete mode 100644 system_tests/lewis_emulators/ips/device_scpi.py diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 71d37c7..e0bc0e3 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -52,16 +52,19 @@ getVersion { out "*IDN?"; wait 100; in "IDN:OXFORD INSTRUMENTS:%(\$1MODEL.VAL)[ a-zA-Z0-9]:%*[ a-zA-Z0-9]:%(\$1VERSION.VAL)s"; wait 100;} ######################################################################################### -# Get demand current (output current) in amps. We are only interested in the Z axis magnet group. -# The return string is of the form: STAT:DEV:GRPZ:PSU:CURR::A -getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} # Get measured power supply voltage in volts +# The return string is of the form: STAT:DEV:GRPZ:PSU:SIG:VOLT:-0.0002V getSupplyVoltage { out "READ:DEV:" $magnet_supply ":PSU:SIG:VOLT"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:VOLT:%f%*s"; wait 100;} +# Get demand current (output current) in amps. We are only interested in the Z axis magnet group. +# The return string is of the form: STAT:DEV:GRPZ:PSU:CSET::A +getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} + # Get measured magnet curren in amps - ER=no. +# The return string is of the form: STAT:DEV:GRPZ:PSU:SIG:CURR:-0.0001A getMeasuredMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CURR"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:CURR:%f%*s"; wait 100;} @@ -95,18 +98,10 @@ getSoftwareVoltageLimit { out "READ:DEV:" $magnet_supply ":PSU:VLIM"; wait 100; getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PCUR:%f%*s"; wait 100;} -# Get trip current in amps ER=yes. -#??? -getTripCurrent { out "R17"; wait 100; in "R%f"; wait 100;} - # Get persistent magnetic field in tesla ER=yes. getPersistentMagnetField { out "READ:DEV:" $magnet_supply ":PSU:SIG:PFLD"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} -# Get trip field in tesla ER=yes. -#??? -getTripField { out "R19"; wait 100; in "R%f"; wait 100;} - # Get switch heater current in milliamp ER=no. getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; wait 100;} @@ -132,7 +127,7 @@ getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; wait 100; # Get Activity status (analogous to the legacy A command) getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%(\$1ACTIVITY.VAL){HOLD|RTOS|RTOZ|CLMP}"; wait 100;} + in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}"; wait 100;} getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}"; wait 100;} @@ -164,7 +159,7 @@ setControl { out "SET:SYS:LOCK:%{OFF|SOFT|ON}"; in "READ:SYS:LOCK:%*s";} # RTOZ -> To Zero # CLMP -> Clamp setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}"; wait 100; - in "READ:DEV:" $magnet_supply ":PSU:ACTN:%*s:VALID"; wait 100;} + in "STAT:SET:DEV:" $magnet_supply ":PSU:ACTN:%*s"; wait 100;} # --------- # SWHT Command @@ -177,7 +172,7 @@ setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|C # DO NOT USE SWHN command!! # setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; wait 100; - in "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT:%*s"; wait 100;} + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%*s"; wait 100;} # Set the setpoint (target) current. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:CSET:0.0004:VALID @@ -189,14 +184,6 @@ setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 1 setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%#.5f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"} -# --------- -# M Command -# --------- -# Set the mode. -# Control "Fast/Slow" sweep - and whether units displayed in Current of Field on Front Panel. -# This is not so straight forwards as it might seem - need to be careful in the template. -setMode { setRemoteUnlocked; out "M%u" ; wait 100; in "M"; wait 100;} - # Set current sweep rate. # Rate at which current will be ramped or swept to target, either the setpoint or zero. # Returns status like: STAT:DEV:GRPZ:PSU:SIG:RCST:5.5:VALID @@ -206,5 +193,5 @@ setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait # Set field sweep rate. # Rate at which field will be ramped or swept to target, either the setpoint or zero. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:RFST:0.3850:VALID -setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.3f"; wait 100; +setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.4f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"} diff --git a/system_tests/lewis_emulators/ips/__init__.py b/system_tests/lewis_emulators/ips/__init__.py index 5a8412a..4c3aeec 100644 --- a/system_tests/lewis_emulators/ips/__init__.py +++ b/system_tests/lewis_emulators/ips/__init__.py @@ -1,6 +1,7 @@ from ..lewis_versions import LEWIS_LATEST -# from .device import SimulatedIps -from .device_scpi import SimulatedIps +from .device import SimulatedIps +#from .device_scpi import SimulatedIpsSCPI framework_version = LEWIS_LATEST +#__all__ = ["SimulatedIps", "SimulatedIpsSCPI"] __all__ = ["SimulatedIps"] diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index b827038..278f499 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -3,7 +3,7 @@ from lewis.core.logging import has_log from lewis.devices import StateMachineDevice -from lewis_emulators.ips.modes import Activity, Control, Mode, SweepMode +from .modes import Activity, Control, Mode, SweepMode, MagnetSupplyStatus from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState @@ -22,6 +22,13 @@ def amps_to_tesla(amps): def tesla_to_amps(tesla): return tesla / LOAD_LINE_GRADIENT +def set_bit_value(value, bit_value): + """Sets a bit at the position implied by the value.""" + return value | bit_value + +def clear_bit_value(value, bit_value): + """Clears a bit at the specified position implied by the value.""" + return value & ~bit_value @has_log class SimulatedIps(StateMachineDevice): @@ -93,13 +100,21 @@ def reset(self): self.control: Control = Control.LOCAL_LOCKED # The only sweep mode we are interested in is tesla fast + # This appears to be unsupported by the SCPI protocol, so the + # corresponding EPICS records have been removed in the SCPI database template self.sweep_mode: SweepMode = SweepMode.TESLA_FAST # Not sure what is the sensible value here self.mode: Mode = Mode.SLOW + # SCPI mode specific self.bipolar: bool = True + self.magnet_supply_status = MagnetSupplyStatus.OK + + self.voltage_limit: float = 10.0 + + def _get_state_handlers(self): return { "heater_off": HeaterOffState(), @@ -130,9 +145,13 @@ def quench(self, reason): self.current = 0 self.measured_current = 0 self.quenched = True # Causes LeWiS to enter Quenched state + # For the SCPI protocol, we set the magnet supply status to indicate a quench + self.magnet_supply_status = set_bit_value(self.magnet_supply_status, MagnetSupplyStatus.QUENCH_DETECTED) def unquench(self): self.quenched = False + # For the SCPI protocol, we set the magnet supply status to clear a quench status + self.magnet_supply_status = clear_bit_value(self.magnet_supply_status, MagnetSupplyStatus.QUENCH_DETECTED) def get_voltage(self): """Gets the voltage of the PSU. diff --git a/system_tests/lewis_emulators/ips/device_scpi.py b/system_tests/lewis_emulators/ips/device_scpi.py deleted file mode 100644 index 5729a1f..0000000 --- a/system_tests/lewis_emulators/ips/device_scpi.py +++ /dev/null @@ -1,160 +0,0 @@ -from collections import OrderedDict - -from lewis.core.logging import has_log -from lewis.devices import StateMachineDevice - -from lewis_emulators.ips.modes_scpi import Activity, Control, Mode, SweepMode, MagnetSupplyStatus - -from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState - -# As long as no magnetic saturation effects are present, there is a linear relationship between Teslas and Amps. -# -# This is called the load line. For more detailed (technical) discussion about the load line see: -# - http://aries.ucsd.edu/LIB/REPORT/SPPS/FINAL/chap4.pdf (section 4.3.3) -# - http://www.prizz.fi/sites/default/files/tiedostot/linkki1ID346.pdf (slide 11) -LOAD_LINE_GRADIENT = 0.01 - - -def amps_to_tesla(amps): - return amps * LOAD_LINE_GRADIENT - - -def tesla_to_amps(tesla): - return tesla / LOAD_LINE_GRADIENT - - -@has_log -class SimulatedIps(StateMachineDevice): - # Currents that correspond to the switch heater being on and off - HEATER_OFF_CURRENT, HEATER_ON_CURRENT = 0, 10 - - # If there is a difference in current of more than this between the magnet and the power supply, and the switch is - # resistive, then the magnet will quench. - # No idea what this number should be for a physically realistic system so just guess. - QUENCH_CURRENT_DELTA = 0.1 - - # Maximum rate at which the magnet can safely ramp without quenching. - MAGNET_RAMP_RATE = 1000 - - # Fixed rate at which switch heater can ramp up or down - HEATER_RAMP_RATE = 5 - - def _initialize_data(self): - """Initialize all of the device's attributes. - """ - self.reset() - - def reset(self): - # Within the cryostat, there is a wire that is made superconducting because it is in the cryostat. The wire has - # a heater which can be used to make the wire go back to a non-superconducting state. - # - # When the heater is ON, the wire has a high resistance and the magnet is powered directly by the power supply. - # - # When the heater is OFF, the wire is superconducting, which means that the power supply can be ramped down and - # the magnet will stay active (this is "persistent" mode) - self.heater_on: bool = False - self.heater_current: float = 0.0 - - # "Leads" are the non-superconducting wires between the superconducting magnet and the power supply. - # Not sure what a realistic value is for these leads, so I've guessed. - self.lead_resistance: float = 50.0 - - # Current = what the power supply is providing. - self.current: float = 0.0 - self.current_setpoint: float = 0.0 - - # Current for the magnet. May be different from the power supply current if the magnet is in persistent mode. - self.magnet_current: float = 0.0 - - # Measured current may be different from what the PSU is attempting to provide - self.measured_current: float = 0.0 - - # If the device trips, store the last current which caused a trip in here. - # This could be used for diagnostics e.g. finding maximum field which magnet is capable of in a certain config. - self.trip_current: float = 0.0 - - # Ramp rate == sweep rate - self.current_ramp_rate: float = 1 / LOAD_LINE_GRADIENT - - # Set to true if the magnet is quenched - this will cause lewis to enter the quenched state - self.quenched: bool = False - - # Mode of the magnet e.g. HOLD, TO SET POINT, TO ZERO, CLAMP - self.activity: Activity = Activity.TO_SETPOINT - - # No idea what a sensible value is. Hard-code this here for now - can't be changed on real device. - self.inductance: float = 0.005 - - # No idea what sensible values are here. Also not clear what the behaviour is of the controller when these - # limits are hit. - self.neg_current_limit, self.pos_current_limit = -(10**6), 10**6 - - # Local and locked is the zeroth mode of the control command - self.control: Control = Control.LOCAL_LOCKED - - # The only sweep mode we are interested in is tesla fast - # This appears to be unssupported by the SCPI protocol, so the - # corresponding EPICS records have been removed. - self.sweep_mode: SweepMode = SweepMode.TESLA_FAST - - # Not sure what is the sensible value here - self.mode: Mode = Mode.SLOW - - self.bipolar: bool = True - - self.magnet_supply_status = MagnetSupplyStatus.OK - - self.voltage_limit: float = 10.0 - - - def _get_state_handlers(self): - return { - "heater_off": HeaterOffState(), - "heater_on": HeaterOnState(), - "quenched": MagnetQuenchedState(), - } - - def _get_initial_state(self): - return "heater_off" - - def _get_transition_handlers(self): - return OrderedDict( - [ - (("heater_off", "heater_on"), lambda: self.heater_on), - (("heater_on", "heater_off"), lambda: not self.heater_on), - (("heater_on", "quenched"), lambda: self.quenched), - (("heater_off", "quenched"), lambda: self.quenched), - # Only triggered when device is reset or similar - (("quenched", "heater_off"), lambda: not self.quenched and not self.heater_on), - (("quenched", "heater_on"), lambda: not self.quenched and self.heater_on), - ] - ) - - def quench(self, reason): - self.log.info("Magnet quenching at current={} because: {}".format(self.current, reason)) - self.trip_current = self.current - self.magnet_current = 0 - self.current = 0 - self.measured_current = 0 - self.quenched = True # Causes LeWiS to enter Quenched state - - def unquench(self): - self.quenched = False - - def get_voltage(self): - """Gets the voltage of the PSU. - - Everything except the leads is superconducting, we use Ohm's law here with the PSU current and the lead - resistance. - - In reality would also need to account for inductance effects from the magnet but I don't think that - extra complexity is necessary for this emulator. - """ - return self.current * self.lead_resistance - - def set_heater_status(self, new_status): - if new_status and abs(self.current - self.magnet_current) > self.QUENCH_CURRENT_DELTA: - raise ValueError( - "Can't set the heater to on while the magnet current and PSU current are mismatched" - ) - self.heater_on = new_status diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py index bf7b7b2..0bd5a1e 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py @@ -2,7 +2,7 @@ from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder -from lewis_emulators.ips.modes import Activity, Control +from ..modes import Activity, Control from ..device import amps_to_tesla, tesla_to_amps diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 9e0b86d..a229cd3 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -2,9 +2,9 @@ from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder -from lewis_emulators.ips.modes_scpi import Activity, Control +from ..modes import Activity, Control -from ..device_scpi import amps_to_tesla, tesla_to_amps +from ..device import amps_to_tesla, tesla_to_amps MODE_MAPPING = { 'HOLD': Activity.HOLD, @@ -44,10 +44,10 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_version").escape("*IDN?").eos().build(), # CmdBuilder("set_comms_mode").escape("Q4").eos().build(), CmdBuilder("get_magnet_supply_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:STAT").eos().build(), - CmdBuilder("get_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN").eos().build(), + CmdBuilder("get_activity").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN").eos().build(), CmdBuilder("get_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), CmdBuilder("get_supply_voltage").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT").eos().build(), - CmdBuilder("get_measured_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCUR").eos().build(), + # CmdBuilder("get_measured_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), CmdBuilder("get_current_setpoint").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET").eos().build(), CmdBuilder("get_current_sweep_rate").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST").eos().build(), CmdBuilder("get_field").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD").eos().build(), @@ -68,9 +68,9 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_magnet_inductance").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), CmdBuilder("get_heater_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), CmdBuilder("get_bipolar_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), - CmdBuilder("set_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), + CmdBuilder("set_activity").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), CmdBuilder("set_current").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), - CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:FSET:").float().eos().build(), + CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:").float().eos().build(), CmdBuilder("set_field_sweep_rate").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:").float().eos().build(), CmdBuilder("set_heater_on").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), CmdBuilder("set_heater_off").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), @@ -106,16 +106,32 @@ def set_control_mode(self, mode): self.device.control = CONTROL_MODE_MAPPING[mode] return "C" - def get_mode(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{self.device.activity.value}" - - def set_mode(self, mode: str): - ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{self.device.activity.value}:VALID" + def get_activity(self): + for testmode in MODE_MAPPING: + if self.device.activity == MODE_MAPPING[testmode]: + break + mode = self.device.activity.name + self.log.info("stream_interface_scpi: get_activity() self.device.activity.name = {} self.device.activity.value = {}".format(self.device.activity.name, self.device.activity.value)) + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{testmode}" + + def set_activity(self, mode: str): + found_mode = False + # Set the default return value to invalid (guilty until proven innocent) + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" + + for testmode in MODE_MAPPING: + if mode == MODE_MAPPING[testmode].value: + found_mode = True + break + try: - self.device.activity = Activity[mode] + self.device.activity = MODE_MAPPING[mode] + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:VALID" + self.log.info(f"stream_interface_scpi: set_activity() ret = {ret}") except KeyError: ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" raise ValueError("Invalid mode specified") + return ret def get_magnet_supply_status(self): """ @@ -145,7 +161,7 @@ def get_magnet_supply_status(self): This information is not published and was derived from direct questions to Oxford Instruments. """ - resp = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:STAT:{self.device.magnet_supply_status.value:08x}" + resp = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:STAT:{self.device.magnet_supply_status:08x}" return resp def get_current_setpoint(self): @@ -154,15 +170,16 @@ def get_current_setpoint(self): def get_supply_voltage(self): return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT:{self.device.get_voltage():.4f}V" - def get_measured_current(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCUR:{self.device.measured_current:.4f}A" +# def get_measured_current(self): +# return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.measured_current:.4f}A" def get_current(self): """Gets the demand current of the PSU.""" - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.current:.4f}A" + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.measured_current:.4f}A" def get_current_sweep_rate(self): - # Unsure as to whether units are returned? + # Returns the current ramp rate in amps per second. + # of the form: STAT:DEV:GRPZ:PSU:SIG:RCST:5.3612A/m return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST:{self.device.current_ramp_rate:.4f}A/m" def get_field(self): @@ -172,7 +189,12 @@ def get_field_setpoint(self): return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{amps_to_tesla(self.device.current_setpoint):.4f}T" def get_field_sweep_rate(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{amps_to_tesla(self.device.current_ramp_rate):.4f}T/m" + field = amps_to_tesla(self.device.current_ramp_rate) + self.log.info(f"stream_interface_scpi: get_field_sweep_rate()" + f" field = {field:.4f}" + f" device.current_ramp_rate = {self.device.current_ramp_rate:.4f}" + ) + return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{field:.4f}T/m" def get_software_voltage_limit(self): # According to the manual, this should return with a unit ":V" suffix, but in reality it does not. @@ -236,9 +258,13 @@ def set_heater_off(self): ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF:VALID" return ret - def set_field_sweep_rate(self, tesla): - self.device.current_ramp_rate = tesla_to_amps(float(tesla)) - ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{float(tesla):1.4f}:VALID" + def set_field_sweep_rate(self, tesla_per_min): + self.device.current_ramp_rate = tesla_to_amps(tesla_per_min) + self.log.info(f"stream_interface_scpi: set_field_sweep_rate()" + f" tesla = {tesla_per_min:.4f}" + f" device.current_ramp_rate = {self.device.current_ramp_rate:.4f}" + ) + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{float(tesla_per_min):1.4f}:VALID" return ret def set_bipolar_mode(self, mode): diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index c9c755e..273799f 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import Enum, IntEnum class Activity(Enum): @@ -7,6 +7,7 @@ class Activity(Enum): TO_ZERO = "To Zero" CLAMP = "Clamped" + class Control(Enum): LOCAL_LOCKED = "Local & Locked" REMOTE_LOCKED = "Remote & Unlocked" @@ -22,3 +23,48 @@ class SweepMode(Enum): class Mode(Enum): FAST = "Fast" SLOW = "Slow" + + +class MagnetSupplyStatus(IntEnum): + """ + This class represents the status of the magnet supply + and is only applicable to the IPS SCPI protocol. + | Status | Bit Value | Bit Position | + |--------------------------------------|-----------|--------------| + | Switch Heater Mismatch | 00000001 | 0 | + | Over Temperature [Rundown Resistors] | 00000002 | 1 | + | Over Temperature [Sense Resistor] | 00000004 | 2 | + | Over Temperature [PCB] | 00000008 | 3 | + | Calibration Failure | 00000010 | 4 | + | MSP430 Firmware Error | 00000020 | 5 | + | Rundown Resistors Failed | 00000040 | 6 | + | MSP430 RS-485 Failure | 00000080 | 7 | + | Quench detected | 00000100 | 8 | + | Catch detected | 00000200 | 9 | + | Over Temperature [Sense Amplifier] | 00001000 | 12 | + | Over Temperature [Amplifier 1] | 00002000 | 13 | + | Over Temperature [Amplifier 2] | 00004000 | 14 | + | PWM Cutoff | 00008000 | 15 | + | Voltage ADC error | 00010000 | 16 | + | Current ADC error | 00020000 | 17 | + + This information is not published and was derived from + direct questions to Oxford Instruments. +""" + OK = 0x00000000 + SWITCH_HEATER_MISMATCH = 0x00000001 + OVER_TEMPERATURE_RUNDOWN_RESISTORS = 0x00000002 + OVER_TEMPERATURE_SENSE_RESISTOR = 0x00000004 + OVER_TEMPERATURE_PCB = 0x00000008 + CALIBRATION_FAILURE = 0x00000010 + MSP430_FIRMWARE_ERROR = 0x00000020 + RUNDOWN_RESISTORS_FAILED = 0x00000040 + MSP430_RS_485_FAILURE = 0x00000080 + QUENCH_DETECTED = 0x00000100 + CATCH_DETECTED = 0x00000200 + OVER_TEMPERATURE_SENSE_AMPLIFIER = 0x00001000 + OVER_TEMPERATURE_AMPLIFIER_1 = 0x00002000 + OVER_TEMPERATURE_AMPLIFIER_2 = 0x00004000 + PWM_CUTOFF = 0x00008000 + VOLTAGE_ADC_ERROR = 0x00010000 + CURRENT_ADC_ERROR = 0x00020000 diff --git a/system_tests/lewis_emulators/ips/states.py b/system_tests/lewis_emulators/ips/states.py index f6bc351..95edb46 100644 --- a/system_tests/lewis_emulators/ips/states.py +++ b/system_tests/lewis_emulators/ips/states.py @@ -1,7 +1,7 @@ from lewis.core import approaches from lewis.core.statemachine import State -from lewis_emulators.ips.modes import Activity +from .modes import Activity SECS_PER_MIN = 60 @@ -22,9 +22,9 @@ def in_state(self, dt): device.quench("PSU ramp rate is too high") elif abs(device.current - device.magnet_current) > device.QUENCH_CURRENT_DELTA * dt: device.quench( - "Difference between PSU current ({}) and magnet current ({}) is higher than allowed ({})".format( - device.current, device.magnet_current, device.QUENCH_CURRENT_DELTA * dt - ) + f"Difference between PSU current ({device.current})" + f" and magnet current ({device.magnet_current})" + f" is higher than allowed ({device.QUENCH_CURRENT_DELTA * dt})" ) elif device.activity == Activity.TO_SETPOINT: @@ -37,7 +37,8 @@ def in_state(self, dt): elif device.activity == Activity.TO_ZERO: device.current = approaches.linear(device.current, 0, curr_ramp_rate, dt) - device.magnet_current = approaches.linear(device.magnet_current, 0, curr_ramp_rate, dt) + device.magnet_current = approaches.linear(device.magnet_current, + 0, curr_ramp_rate, dt) class HeaterOffState(State): diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 6a6ca1e..144da2c 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -1,6 +1,7 @@ import unittest from .ips_common import IpsBaseTests +from parameterized import parameterized from utils.channel_access import ChannelAccess from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir from utils.test_modes import TestModes @@ -95,3 +96,57 @@ def setUp(self): # Wait for statemachine to reach "at field" state before every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + def _assert_heater_is(self, heater_state): + self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") + if heater_state: + self.ca.assert_that_pv_is( + "HEATER:STATUS", + "On", + ) + else: + self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) + + @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) + def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( + self, _, field + ): + self._set_and_check_persistent_mode(False) + self.ca.set_pv_value("FIELD:SP", field) + self._assert_field_is(field) + self.ca.assert_that_pv_is("STATEMACHINE", "At field") + + with self._backdoor_magnet_quench(): + self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") + self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) + self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") + self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) + + # The trip field should be the field at the point when the magnet quenched. + self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) + + # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) + self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + + # These tests for locking and unlocking the remote control are only applicable + # to the legacy protocol. SCPI does not have a remote control lock. + @parameterized.expand( + control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) + ) + def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( + self, _, control_pv, set_value + ): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.set_pv_value(control_pv, set_value) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + + @parameterized.expand( + control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) + ) + def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): + self.ca.set_pv_value("CONTROL", "Local & Locked") + self.ca.process_pv(control_pv) + self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") + diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py index 9ac2327..3043b39 100644 --- a/system_tests/tests/ips_common.py +++ b/system_tests/tests/ips_common.py @@ -72,16 +72,10 @@ def _assert_field_is(self, field, check_stable=False): self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) + @abstractmethod def _assert_heater_is(self, heater_state): - self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") - if heater_state: - self.ca.assert_that_pv_is( - "HEATER:STATUS", - "On", - ) - else: - self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) - + pass + def _set_and_check_persistent_mode(self, mode): self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") @@ -190,29 +184,6 @@ def _backdoor_magnet_quench(self, reason="Test framework quench"): # Wait for IOC to notice quench state has gone away self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) - @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) - def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( - self, _, field - ): - self._set_and_check_persistent_mode(False) - self.ca.set_pv_value("FIELD:SP", field) - self._assert_field_is(field) - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - with self._backdoor_magnet_quench(): - self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") - self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) - self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") - self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) - - # The trip field should be the field at the point when the magnet quenched. - self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) - - # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) - self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): self._lewis.backdoor_set_on_device("inductance", val) @@ -225,8 +196,10 @@ def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): + print(f"test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates: Setting sweep rate to {val}") self.ca.set_pv_value("FIELD:RATE:SP", val) self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) + print(f"test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates: FIELD:RATE:SP readback OK") self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) @@ -235,30 +208,17 @@ def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( self, _, activity_state ): - self.ca.set_pv_value("ACTIVITY", activity_state) + if activity_state == "Clamped": + self.ca.set_pv_value("ACTIVITY:SP", "Clamp") + else: + self.ca.set_pv_value("ACTIVITY:SP", activity_state) + + self.ca.assert_that_pv_is("ACTIVITY", activity_state) if activity_state == "Clamped": self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") else: self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM") - @parameterized.expand( - control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) - ) - def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( - self, _, control_pv, set_value - ): - self.ca.set_pv_value("CONTROL", "Local & Locked") - self.ca.set_pv_value(control_pv, set_value) - self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - - @parameterized.expand( - control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) - ) - def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): - self.ca.set_pv_value("CONTROL", "Local & Locked") - self.ca.process_pv(control_pv) - self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - # original problem/complaint: # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 8bd991d..681174f 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -37,24 +37,10 @@ TOLERANCE = 0.0001 -HEATER_OFF_STATES = ["Off Mag at 0", "Off Mag at F"] +HEATER_OFF_STATES = ["Off",] ACTIVITY_STATES = ["Hold", "To Setpoint", "To Zero", "Clamped"] -# Generate all the control commands to test that remote and unlocked is set for -# Chain flattens the list -CONTROL_COMMANDS_WITH_VALUES = [ - ("FIELD", 0.1), - ("FIELD:RATE", 0.1), - ("SWEEPMODE:PARAMS", "Tesla Fast"), -] -for activity_state in ACTIVITY_STATES: - CONTROL_COMMANDS_WITH_VALUES.append(("ACTIVITY", activity_state)) -for heater_off_state in HEATER_OFF_STATES: - CONTROL_COMMANDS_WITH_VALUES.append(("HEATER:STATUS", heater_off_state)) - -CONTROL_COMMANDS_WITHOUT_VALUES = ["SET:COMMSRES"] - class IpsSCPITests(IpsBaseTests, unittest.TestCase): """ @@ -82,38 +68,25 @@ def setUp(self): self.ca.assert_that_pv_exists(pv) # Ensure in the correct mode - # The following call was in the original legacy test code but is not needed in the SCPI version. - # self.ca.set_pv_value("CONTROL:SP", "Off") # Remote and Unlocked + # The following call was in the original legacy test code but is not needed + # in the SCPI version. + # self.ca.set_pv_value("CONTROL:SP", "Off") + # Remote and Unlocked self.ca.set_pv_value("ACTIVITY:SP", "To Setpoint") - # Don't run reset as the sudden change of state confuses the IOC's state machine. No matter what the initial - # state of the device the SNL should be able to deal with it. + # Don't run reset as the sudden change of state confuses the IOC's state machine. + # No matter what the initial state of the device the SNL should be able to deal with it. # self._lewis.backdoor_run_function_on_device("reset") - self.ca.set_pv_value("FIELD:RATE:SP", 10) - # self.ca.assert_that_pv_is_number("FIELD:RATE:SP", 10) + self.ca.set_pv_value("FIELD:RATE:SP", 1) + self.ca.assert_that_pv_is_number("FIELD:RATE:SP", 1, tolerance=TOLERANCE) self.ca.process_pv("FIELD:SP") # Wait for statemachine to reach "at field" state before every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") - def tearDown(self): - # Wait for statemachine to reach "at field" state after every test. - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") - - def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self): - self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") - - def _assert_field_is(self, field, check_stable=False): - self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) - if check_stable: - self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) - self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) - def _assert_heater_is(self, heater_state): self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") if heater_state: @@ -122,115 +95,11 @@ def _assert_heater_is(self, heater_state): "On", ) else: - self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES) - - def _set_and_check_persistent_mode(self, mode): - self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( - self, _, val - ): - initial_field = 1 - - self._set_and_check_persistent_mode(True) - self.ca.set_pv_value("FIELD:SP", initial_field) - self._assert_field_is(initial_field, check_stable=True) - - # Field in the magnet already from persistent mode. - self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) - self._assert_heater_is(False) - - # Set the new field. This will cause all of the following events based on the state machine. - self.ca.set_pv_value("FIELD:SP", val) - - # PSU should be ramped to match the persistent field inside the magnet - self.ca.assert_that_pv_is_number("FIELD", initial_field, tolerance=TOLERANCE) - self.ca.assert_that_pv_is("ACTIVITY", "To Setpoint") - - # Then it is safe to turn on the heater - self._assert_heater_is(True) - - # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up - # after being set. - self._assert_field_is(val) - - # Now that the correct current is in the magnet, the SNL should turn the heater off - self._assert_heater_is(False) - - # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before - # ramping PSU to zero) - self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field - self.ca.assert_that_pv_is_number( - "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE - ) # Persistent field - self.ca.assert_that_pv_is_number( - "FIELD:USER", val, tolerance=TOLERANCE - ) # User field should be tracking persistent field here - self.ca.assert_that_pv_is("ACTIVITY", "To Zero") - - # ...And the magnet should now be in the right state! - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) - - # "User" field should take the value put in the setpoint, even when the actual field provided by the supply - # drops to zero - self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field - self.ca.assert_that_pv_is_number( - "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE - ) # Persistent field - self.ca.assert_that_pv_is_number( - "FIELD:USER", val, tolerance=TOLERANCE - ) # User field should be tracking persistent field here - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero( - self, _, val - ): - initial_field = 1 - - self._set_and_check_persistent_mode(True) - self.ca.set_pv_value("FIELD:SP", initial_field) - self._assert_field_is(initial_field, check_stable=True) - - # Field in the magnet already from persistent mode. - self.ca.assert_that_pv_is("MAGNET:FIELD:PERSISTENT", initial_field) - self._assert_heater_is(False) - - self._set_and_check_persistent_mode(False) - - # Set the new field. This will cause all of the following events based on the state machine. - self.ca.set_pv_value("FIELD:SP", val) - - # PSU should be ramped to match the persistent field inside the magnet (if there was one) - self.ca.assert_that_pv_is("FIELD", initial_field, timeout=10) - - # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it - # was already on out of an abundance of caution). - self._assert_heater_is(True) - - # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up - # after being set. - self._assert_field_is(val) - - # ...And the magnet should now be in the right state! - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) - - # And the PSU should remain stable providing the required current/field - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - self._assert_field_is(val, check_stable=True) + self.ca.assert_that_pv_is( + "HEATER:STATUS", + "Off", + ) - @contextmanager - def _backdoor_magnet_quench(self, reason="Test framework quench"): - self._lewis.backdoor_run_function_on_device("quench", [reason]) - try: - yield - finally: - # Get back out of the quenched state. This is because the tearDown method checks that magnet has not - # quenched. - self._lewis.backdoor_run_function_on_device("unquench") - # Wait for IOC to notice quench state has gone away - self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( @@ -244,76 +113,10 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s with self._backdoor_magnet_quench(): self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) - self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down") - self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR) - - # The trip field should be the field at the point when the magnet quenched. - self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): - self._lewis.backdoor_set_on_device("inductance", val) - self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) - - @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): - self._lewis.backdoor_set_on_device("measured_current", val) - self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) - - @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) - def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): - self.ca.set_pv_value("FIELD:RATE:SP", val) - self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) - self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) - - @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES)) - @unstable_test() - def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( - self, _, activity_state - ): - self.ca.set_pv_value("ACTIVITY", activity_state) - if activity_state == "Clamped": - self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") - else: - self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM") - - @parameterized.expand( - control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) - ) - def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( - self, _, control_pv, set_value - ): - self.ca.set_pv_value("CONTROL", "Local & Locked") - self.ca.set_pv_value(control_pv, set_value) - self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - - @parameterized.expand( - control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) - ) - def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): - self.ca.set_pv_value("CONTROL", "Local & Locked") - self.ca.process_pv(control_pv) - self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - - # original problem/complaint: - # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields - def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): - # arrange: set mode to non-persistent, set field - self._set_and_check_persistent_mode(False) - self.ca.set_pv_value("FIELD:SP", 3.21) - self._assert_field_is(3.21) - self.ca.assert_that_pv_is("STATEMACHINE", "At field") - - # act: set new field - self.ca.set_pv_value("FIELD:SP", 4.56) - # assert: field starts to change by tolerance within timeout, then reaches within second timeout - # timeout present to prove new setpoint moved to _without_ waiting for heater, if already on - self.ca.assert_that_pv_is_not_number("FIELD", 3.21, tolerance=0.01, timeout=20) - self.ca.assert_that_pv_is_number("FIELD", 4.56, tolerance=0.01, timeout=60) From cde4713f2b06e72c374890f83f9b29e160d698ce Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 10 Jun 2025 09:15:23 +0100 Subject: [PATCH 13/61] SCPI protocol: Some 'out' commands had associated 'in' to read back the response, which should have been %*f reads, but were %f, which messed up the record. --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index e0bc0e3..2a18a94 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -177,7 +177,7 @@ setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; # Set the setpoint (target) current. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:CSET:0.0004:VALID setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%f:%*s"} + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%*f:%*s"} # Set the setpoint (target) field. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:FSET:0.00:VALID @@ -188,10 +188,10 @@ setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%#.5f"; wait 100 # Rate at which current will be ramped or swept to target, either the setpoint or zero. # Returns status like: STAT:DEV:GRPZ:PSU:SIG:RCST:5.5:VALID setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"} + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%*f%*s"} # Set field sweep rate. # Rate at which field will be ramped or swept to target, either the setpoint or zero. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:RFST:0.3850:VALID setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.4f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"} + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%*f%*s"} From 19bd866249846e41d396ef8ccab1cb00d7d5d7bd Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 10 Jun 2025 09:17:57 +0100 Subject: [PATCH 14/61] stream_interface_scpi.py: Removed some no longer needed diagnostic logging output --- .../ips/interfaces/stream_interface_scpi.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index a229cd3..50de349 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -190,10 +190,6 @@ def get_field_setpoint(self): def get_field_sweep_rate(self): field = amps_to_tesla(self.device.current_ramp_rate) - self.log.info(f"stream_interface_scpi: get_field_sweep_rate()" - f" field = {field:.4f}" - f" device.current_ramp_rate = {self.device.current_ramp_rate:.4f}" - ) return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{field:.4f}T/m" def get_software_voltage_limit(self): @@ -260,10 +256,6 @@ def set_heater_off(self): def set_field_sweep_rate(self, tesla_per_min): self.device.current_ramp_rate = tesla_to_amps(tesla_per_min) - self.log.info(f"stream_interface_scpi: set_field_sweep_rate()" - f" tesla = {tesla_per_min:.4f}" - f" device.current_ramp_rate = {self.device.current_ramp_rate:.4f}" - ) ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{float(tesla_per_min):1.4f}:VALID" return ret From 13d36a9b3ab3658b9906fe48bd1401aa744200f9 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 10 Jun 2025 09:19:14 +0100 Subject: [PATCH 15/61] ips_common.py tests: Removed some no longer needed diagnostic logging output --- system_tests/tests/ips_common.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py index 3043b39..de36eb5 100644 --- a/system_tests/tests/ips_common.py +++ b/system_tests/tests/ips_common.py @@ -196,10 +196,8 @@ def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): - print(f"test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates: Setting sweep rate to {val}") self.ca.set_pv_value("FIELD:RATE:SP", val) self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) - print(f"test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates: FIELD:RATE:SP readback OK") self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE) From d339f2c3ff1fc7b57c32d8e0513f8159313c5241 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 10 Jun 2025 09:23:13 +0100 Subject: [PATCH 16/61] ips_scpi.py tests: Commented out the ioc_launcher_class attribute for ProcServLauncher in IOC parameters to significantly speed up IOC instantiation. Left as comment in case needed again at a later date. --- system_tests/tests/ips_scpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 681174f..2d7f225 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -17,7 +17,7 @@ "directory": get_default_ioc_dir("IPS"), "emulator": EMULATOR_NAME, "lewis_protocol": "ips_scpi", - "ioc_launcher_class": ProcServLauncher, + # "ioc_launcher_class": ProcServLauncher, "macros": { "STREAMPROTOCOL": "SCPI", "MANAGER_ASG": "DEFAULT", From 59558550bc012211e0e3abc3586e06932bed4601 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 10 Jun 2025 09:31:53 +0100 Subject: [PATCH 17/61] Deleted modes_scpi.py --- .../lewis_emulators/ips/modes_scpi.py | 68 ------------------- 1 file changed, 68 deletions(-) delete mode 100644 system_tests/lewis_emulators/ips/modes_scpi.py diff --git a/system_tests/lewis_emulators/ips/modes_scpi.py b/system_tests/lewis_emulators/ips/modes_scpi.py deleted file mode 100644 index 34892e6..0000000 --- a/system_tests/lewis_emulators/ips/modes_scpi.py +++ /dev/null @@ -1,68 +0,0 @@ -from enum import Enum - - -class Activity(Enum): - HOLD = "HOLD" - TO_SETPOINT = "RTOS" - TO_ZERO = "RTOZ" - CLAMP = "CLMP" - - -class Control(Enum): - LOCAL_LOCKED = "Local & Locked" - REMOTE_LOCKED = "Remote & Unlocked" - LOCAL_UNLOCKED = "Local & Unlocked" - REMOTE_UNLOCKED = "Remote & Unlocked" - AUTO_RUNDOWN = "Auto-Run-Down" - - -class SweepMode(Enum): - TESLA_FAST = "Tesla Fast" - - -class Mode(Enum): - FAST = "Fast" - SLOW = "Slow" - - -class MagnetSupplyStatus(Enum): - """ - | Status | Bit Value | Bit Position | - |--------------------------------------|-----------|--------------| - | Switch Heater Mismatch | 00000001 | 0 | - | Over Temperature [Rundown Resistors] | 00000002 | 1 | - | Over Temperature [Sense Resistor] | 00000004 | 2 | - | Over Temperature [PCB] | 00000008 | 3 | - | Calibration Failure | 00000010 | 4 | - | MSP430 Firmware Error | 00000020 | 5 | - | Rundown Resistors Failed | 00000040 | 6 | - | MSP430 RS-485 Failure | 00000080 | 7 | - | Quench detected | 00000100 | 8 | - | Catch detected | 00000200 | 9 | - | Over Temperature [Sense Amplifier] | 00001000 | 12 | - | Over Temperature [Amplifier 1] | 00002000 | 13 | - | Over Temperature [Amplifier 2] | 00004000 | 14 | - | PWM Cutoff | 00008000 | 15 | - | Voltage ADC error | 00010000 | 16 | - | Current ADC error | 00020000 | 17 | - - This information is not published and was derived from - direct questions to Oxford Instruments. -""" - OK = 0x00000000 - SWITCH_HEATER_MISMATCH = 0x00000001 - OVER_TEMPERATURE_RUNDOWN_RESISTORS = 0x00000002 - OVER_TEMPERATURE_SENSE_RESISTOR = 0x00000004 - OVER_TEMPERATURE_PCB = 0x00000008 - CALIBRATION_FAILURE = 0x00000010 - MSP430_FIRMWARE_ERROR = 0x00000020 - RUNDOWN_RESISTORS_FAILED = 0x00000040 - MSP430_RS_485_FAILURE = 0x00000080 - QUENCH_DETECTED = 0x00000100 - CATCH_DETECTED = 0x00000200 - OVER_TEMPERATURE_SENSE_AMPLIFIER = 0x00001000 - OVER_TEMPERATURE_AMPLIFIER_1 = 0x00002000 - OVER_TEMPERATURE_AMPLIFIER_2 = 0x00004000 - PWM_CUTOFF = 0x00008000 - VOLTAGE_ADC_ERROR = 0x00010000 - CURRENT_ADC_ERROR = 0x00020000 From 03ae20e4e626ef46fda3495f92ea899a1fc0709b Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 25 Jun 2025 11:01:23 +0100 Subject: [PATCH 18/61] Added StreamDevice protocol functions for SCPI getSysAlarms, readSysAlarmsTemperatureBoard, readSysAlarmsLevelMeterBoard, the latter are "I/O Intr" type records" --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 2a18a94..cac6b8d 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -132,6 +132,18 @@ getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; wait 100; getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; wait 100; in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}"; wait 100;} +# *** Need to know what is returned on no alarms present, as it is not documented. *** +getSysAlarms { out "READ:SYS:ALRM"; wait 100;} + +# The following two reads are used to read the system alarms, hopefully in any format or order, +# as long as the essential patterns match somewhere in the input string. +readSysAlarmsTemperatureBoard { + extrainput = ignore; + in "%*/MB1.T1\t/%#{Open Circuit=1|Short Circuit=2|Calibration Error=3|Firmware Error=4|Board Not Configured=5};";} +readSysAlarmsLevelMeterBoard { + extrainput = ignore; + in "%*/DB1.L1\t/%#{Open Circuit=1|Short Circuit=2|ADC Error=3|Over Demand=4|Over Temperature=5|Firmware Error=6|Board Not Configured=7|No Reserve=8};";} + # --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- # Get PSU Status status DWORD getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; wait 100; From 6cab1e460ee319e99b018bde4f4e9e63a66ad4b1 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 25 Jun 2025 11:03:30 +0100 Subject: [PATCH 19/61] Added lewis emulator device support for the level meter board and magnet temperature board status. --- system_tests/lewis_emulators/ips/device.py | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index 278f499..ee3b382 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -3,7 +3,7 @@ from lewis.core.logging import has_log from lewis.devices import StateMachineDevice -from .modes import Activity, Control, Mode, SweepMode, MagnetSupplyStatus +from .modes import Activity, Control, Mode, SweepMode, MagnetSupplyStatus, TemperatureBoardStatus, LevelMeterBoardStatus from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState @@ -113,6 +113,9 @@ def reset(self): self.magnet_supply_status = MagnetSupplyStatus.OK self.voltage_limit: float = 10.0 + + self.tempboard_status: TemperatureBoardStatus = TemperatureBoardStatus.OPEN_CIRCUIT + self.levelboard_status: LevelMeterBoardStatus = LevelMeterBoardStatus.OK def _get_state_handlers(self): @@ -170,3 +173,26 @@ def set_heater_status(self, new_status): "Can't set the heater to on while the magnet current and PSU current are mismatched" ) self.heater_on = new_status + + def set_tempboard_status(self, status_value: int) -> None: + """Sets the temperature board status.""" + self.log.info(f"set_tempboard_status {status_value}") + if status_value in iter(TemperatureBoardStatus): + self.log.info(f"set_tempboard_status: status_value is in TemperatureBoardStatus") + status: TemperatureBoardStatus = TemperatureBoardStatus(status_value) + self.tempboard_status = status + else: + raise ValueError( + f"Invalid temperature board status value: {status_value}. Must be one of {list(TemperatureBoardStatus)}") + + def set_levelboard_status(self, status_value: int) -> None: + """Sets the temperature board status.""" + self.log.info(f"set_levelboard_status {status_value}") + if status_value in iter(LevelMeterBoardStatus): + self.log.info(f"set_levelboard_status: status_value is in LevelMeterBoardStatus") + status: LevelMeterBoardStatus = LevelMeterBoardStatus(status_value) + self.levelboard_status = status + else: + raise ValueError( + f"Invalid level board status value: {status_value}. Must be one of {list(LevelMeterBoardStatus)}") + From 9cea6dab281eaefca366d549239699c96ae0cb0b Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 25 Jun 2025 11:05:26 +0100 Subject: [PATCH 20/61] Added lewis emulator support for reading system alarms (STAT:SYS:ALRM) --- .../ips/interfaces/stream_interface_scpi.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 50de349..0b2563e 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -68,6 +68,7 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_magnet_inductance").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), CmdBuilder("get_heater_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), CmdBuilder("get_bipolar_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), + CmdBuilder("get_system_alarms").escape(f"READ:SYS:ALRM").eos().build(), CmdBuilder("set_activity").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), CmdBuilder("set_current").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:").float().eos().build(), @@ -235,6 +236,21 @@ def get_heater_status(self): def get_bipolar_mode(self): return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + def get_system_alarms(self): + """ + Returns the system alarms in the format: + STAT:SYS:ALRM:MB1.T1Open Circuit; + STAT:SYS:ALRM:DB1.L1Short Circuit; + """ + alarms = ["STAT:SYS:ALRM",] + if self.device.tempboard_status.value != 0: + alarms.append(f":{DeviceUID.magnet_temperature_sensor}\t{self.device.tempboard_status.text()};") + if self.device.levelboard_status.value != 0: + alarms.append(f":{DeviceUID.level_meter}\t{self.device.levelboard_status.text()};") + alarm_list_str = "".join(alarms) + print(f"get_system_alarms(): {alarm_list_str}") + return alarm_list_str + def set_current(self, current): self.device.current_setpoint = float(current) return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{current:1.4f}:VALID" From c5f20353c18a3d6649f7dea654bc8cfee2df3687 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 25 Jun 2025 11:06:54 +0100 Subject: [PATCH 21/61] Added lewis emulator support for temperature board status and level meter board status in modes.py --- system_tests/lewis_emulators/ips/modes.py | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index 273799f..c2b8d63 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from enum import Enum, IntEnum @@ -68,3 +69,85 @@ class MagnetSupplyStatus(IntEnum): PWM_CUTOFF = 0x00008000 VOLTAGE_ADC_ERROR = 0x00010000 CURRENT_ADC_ERROR = 0x00020000 + +class BoardStatus(IntEnum): + """ + Provides the base functionality for board status enums and provides methods to lookup + mbbi strings from given values and vice-versa. + Strings can not be stored as member variables in a IntEnum, so an abstract method is defined + to return a list of strings that represent the status values. This abstract method must be + implemented in any subclass of BoardStatus. + """ + @classmethod + @abstractmethod + def names(cls)->list[str]: + return [] + + def text(self)->str: + try: + ret = self.__class__.names()[self.value] + except IndexError: + ret = "Unknown" + return ret + + +class TemperatureBoardStatus(BoardStatus): + """ + This class represents the status of the temperature board + and is only applicable to the IPS SCPI protocol. + These alarms are returned in response to the READ:SYS:ALRM commnand returning errors as strings + with the following example: STAT:SYS:ALRM:MB1.T1Open Circuit; + + | Status | Description | Bit Value | Bit Position | + |---------------|--------------------------------------------|-----------|--------------| + | Open Circuit | Heater off - Open circuit on sensor input | 00000001 | 0 | + | Short Circuit | Short circuit on sensor input | 00000002 | 1 | + +""" + OK = 0 + OPEN_CIRCUIT = 1 + SHORT_CIRCUIT = 2 + CALIBRATION_ERROR = 3 + FIRMWARE_ERROR = 4 + BOARD_NOT_CONFIGURED = 5 + + @classmethod + def names(cls): + return ["", "Open Circuit", "Short Circuit", "Calibration Error", + "Firmware Error", "Board Not Configured"] + + + +class LevelMeterBoardStatus(BoardStatus): + """ + This class represents the status of the level meter board + and is only applicable to the IPS SCPI protocol. + These alarms are returned in response to the READ:SYS:ALRM commnand returning errors as strings + with the following example: STAT:SYS:ALRM:MB1.L1Open Circuit; + + | Status | Description | Bit Value | Bit Position | + |----------------------|-------------------------------------------|-----------|--------------| + | Open Circuit | Heater off - Open circuit on probe input | 00000001 | 0 | + | Short Circuit | Short circuit on probe input | 00000002 | 1 | + | ADC Error | On-board diagnostic: recalibrate | 00000004 | 2 | + | Over Demand | On-board diagnostic: recalibrate | 00000008 | 3 | + | Over Temperature | | 00000010 | 4 | + | Firmware Error | Error in board firmware: restart iPS | 00000020 | 5 | + | Board Not Configured | Firmware not loaded correctly: update f/w | 00000040 | 6 | + | No Reserve | Autofill valve open but not filling | 00000080 | 7 | + +""" + OK = 0 + OPEN_CIRCUIT = 1 + SHORT_CIRCUIT = 2 + ADC_ERROR = 3 + OVER_DEMAND = 4 + OVER_TEMPERATURE = 5 + FIRMWARE_ERROR = 6 + BOARD_NOT_CONFIGURED = 7 + NO_RESERVE = 8 + + @classmethod + def names(cls): + return ["", "Open Circuit", "Short Circuit", "ADC Error", "Over Demand", + "Over Temperature", "Firmware Error", "Board Not Configured", "No Reserve"] From 744a5da4d1aa2a42316d9af2131b53560cbafb48 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 25 Jun 2025 11:08:01 +0100 Subject: [PATCH 22/61] Added tests for magnet temperature sensor board and level meter board status changes. --- system_tests/tests/ips_scpi.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 2d7f225..d248ee8 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -119,4 +119,15 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + def test_GIVEN_magnet_temperature_sensor_open_circuit_THEN_ioc_states_open_circuit( + self): + # Simulate an open circuit on the temperature sensor + self._lewis.backdoor_run_function_on_device("set_tempboard_status", [1]) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD", "Open Circuit", timeout=10) + + def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit( + self): + # Simulate an short circuit on the level sensor + self._lewis.backdoor_run_function_on_device("set_levelboard_status", [2]) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD", "Short Circuit", timeout=10) From 1e52a2aa5846e98ba6c69f4e494727952f2db6b5 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 26 Jun 2025 15:39:37 +0100 Subject: [PATCH 23/61] Commenced adding Level Sensor board support --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 24 +++++++- system_tests/lewis_emulators/ips/device.py | 8 +-- .../ips/interfaces/stream_interface_scpi.py | 57 +++++++++++++++++-- system_tests/tests/ips_scpi.py | 10 +++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index cac6b8d..a0646c7 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -194,7 +194,7 @@ setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 1 # Set the setpoint (target) field. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:FSET:0.00:VALID setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%#.5f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"} + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%*f%*s"} # Set current sweep rate. # Rate at which current will be ramped or swept to target, either the setpoint or zero. @@ -207,3 +207,25 @@ setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:RFST:0.3850:VALID setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.4f"; wait 100; in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%*f%*s"} + +# ------------------------------------------------------- +# LEVELS BOARD COMMANDS +# ------------------------------------------------------- +getLevelNitFreqZero { out "READ:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO"; wait 100; + in "STAT:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%f"; wait 100;} +setLevelNitFreqZero { out "SET:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO"; wait 100; + in "STAT:SET:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%*f"; wait 100;} +getLevelNitFreqFull { out "READ:DEV:" $level_meter ":LVL:NIT:FREQ:FULL"; wait 100; + in "STAT:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%f"; wait 100;} +setLevelNitFreqFull { out "SET:DEV:" $level_meter ":LVL:NIT:FREQ:FULL"; wait 100; + in "STAT:SET:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%*f"; wait 100;} +getLevelHeEmptyRes { out "READ:DEV:" $level_meter ":LVL:HEL:RES:ZERO"; wait 100; + in "STAT:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%f"; wait 100;} +setLevelHeEmptyRes { out "SET:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%f"; wait 100; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%*f%*s"; wait 100;} +getLevelHeFullRes { out "READ:DEV:" $level_meter ":LVL:HEL:RES:FULL"; wait 100; + in "STAT:DEV:" $level_meter ":LVL:HEL:RES:FULL:%f"; wait 100;} +setLevelHeFullRes { out "SET:DEV:" $level_meter ":LVL:HEL:RES:FULL:%f"; wait 100; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:RES:FULL:%*f%*s"; wait 100;} + + diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index ee3b382..ee28f1d 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -116,6 +116,10 @@ def reset(self): self.tempboard_status: TemperatureBoardStatus = TemperatureBoardStatus.OPEN_CIRCUIT self.levelboard_status: LevelMeterBoardStatus = LevelMeterBoardStatus.OK + self.nitrogen_frequency_at_zero: float = 0.0 + self.nitrogen_frequency_at_full: float = 0.0 + self.helium_empty_resistance: float = 25.0 + self.helium_full_resistance: float = 0.12 def _get_state_handlers(self): @@ -176,9 +180,7 @@ def set_heater_status(self, new_status): def set_tempboard_status(self, status_value: int) -> None: """Sets the temperature board status.""" - self.log.info(f"set_tempboard_status {status_value}") if status_value in iter(TemperatureBoardStatus): - self.log.info(f"set_tempboard_status: status_value is in TemperatureBoardStatus") status: TemperatureBoardStatus = TemperatureBoardStatus(status_value) self.tempboard_status = status else: @@ -187,9 +189,7 @@ def set_tempboard_status(self, status_value: int) -> None: def set_levelboard_status(self, status_value: int) -> None: """Sets the temperature board status.""" - self.log.info(f"set_levelboard_status {status_value}") if status_value in iter(LevelMeterBoardStatus): - self.log.info(f"set_levelboard_status: status_value is in LevelMeterBoardStatus") status: LevelMeterBoardStatus = LevelMeterBoardStatus(status_value) self.levelboard_status = status else: diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 0b2563e..0d15f1b 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -76,6 +76,23 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("set_heater_on").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), CmdBuilder("set_heater_off").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), CmdBuilder("set_bipolar_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), + + CmdBuilder("get_nit_freq_zero").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO").eos().build(), + CmdBuilder("set_nit_freq_zero").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:").float().eos().build(), + CmdBuilder("get_nit_freq_full").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL").eos().build(), + CmdBuilder("set_nit_freq_full").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:").float().eos().build(), + CmdBuilder("get_he_empty_resistance").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO").eos().build(), + CmdBuilder("get_he_full_resistance").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL").eos().build(), + CmdBuilder("set_he_empty_resistance").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:").float().eos().build(), + CmdBuilder("set_he_full_resistance").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:").float().eos().build(), } def handle_error(self, request, error): @@ -112,7 +129,6 @@ def get_activity(self): if self.device.activity == MODE_MAPPING[testmode]: break mode = self.device.activity.name - self.log.info("stream_interface_scpi: get_activity() self.device.activity.name = {} self.device.activity.value = {}".format(self.device.activity.name, self.device.activity.value)) return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{testmode}" def set_activity(self, mode: str): @@ -128,7 +144,6 @@ def set_activity(self, mode: str): try: self.device.activity = MODE_MAPPING[mode] ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:VALID" - self.log.info(f"stream_interface_scpi: set_activity() ret = {ret}") except KeyError: ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" raise ValueError("Invalid mode specified") @@ -248,7 +263,6 @@ def get_system_alarms(self): if self.device.levelboard_status.value != 0: alarms.append(f":{DeviceUID.level_meter}\t{self.device.levelboard_status.text()};") alarm_list_str = "".join(alarms) - print(f"get_system_alarms(): {alarm_list_str}") return alarm_list_str def set_current(self, current): @@ -277,5 +291,40 @@ def set_field_sweep_rate(self, tesla_per_min): def set_bipolar_mode(self, mode): self.device.bipolar = bool(mode) - print(f"set_bipolar(): mode = {mode}") return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + + def get_nit_freq_zero(self) -> str: + ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}" + return ret + + def set_nit_freq_zero(self, freq) -> str: + self.device.nitrogen_frequency_at_zero = float(freq) + ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}:VALID" + return ret + + def get_nit_freq_full(self) -> str: + ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}" + return ret + + def set_nit_freq_full(self, freq) -> str: + self.device.nitrogen_frequency_at_full = float(freq) + ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}:VALID" + return ret + + def get_he_empty_resistance(self) -> str: + ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}" + return ret + + def get_he_full_resistance(self) -> str: + ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}" + return ret + + def set_he_empty_resistance(self, resistance) -> str: + self.device.helium_empty_resistance = float(resistance) + ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}:VALID" + return ret + + def set_he_full_resistance(self, resistance) -> str: + self.device.helium_full_resistance = float(resistance) + ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}:VALID" + return ret diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index d248ee8..016bf9f 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -34,6 +34,7 @@ TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 +TEST_FREQ_FULL_VALUES = 0.0, 0.5, 1.0 # Frequency values for testing TOLERANCE = 0.0001 @@ -124,10 +125,17 @@ def test_GIVEN_magnet_temperature_sensor_open_circuit_THEN_ioc_states_open_circu # Simulate an open circuit on the temperature sensor self._lewis.backdoor_run_function_on_device("set_tempboard_status", [1]) self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD", "Open Circuit", timeout=10) - + def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit( self): # Simulate an short circuit on the level sensor self._lewis.backdoor_run_function_on_device("set_levelboard_status", [2]) self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD", "Short Circuit", timeout=10) + @parameterized.expand(val for val in parameterized_list(TEST_FREQ_FULL_VALUES)) + def test_GIVEN_level_freq_at_zero_THEN_ioc_states_freq(self, _, val) -> None: + # Simulate the nitrogen frequency at zero + self._lewis.backdoor_set_on_device("nitrogen_frequency_at_zero", val) + self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:ZERO", val, tolerance=TOLERANCE, timeout=10) + + From d73ecfbeb1cd1eb315a14246f37c114a0e53608b Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 3 Jul 2025 14:14:20 +0100 Subject: [PATCH 24/61] Added loads of stuff to the SCPI protocol file. --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 213 ++++++++++++------ 1 file changed, 145 insertions(+), 68 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index a0646c7..34527c3 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -48,92 +48,92 @@ pressure_sensor_10T = "DB5.P1"; # ASCII symbol and might cause problems for EPICS. In practice found (c) instead. # The %s format grabs the string upto the first space, the %c grabs the rest. It # was too long to fit into one EPICS string record value. -getVersion { out "*IDN?"; wait 100; - in "IDN:OXFORD INSTRUMENTS:%(\$1MODEL.VAL)[ a-zA-Z0-9]:%*[ a-zA-Z0-9]:%(\$1VERSION.VAL)s"; wait 100;} +getVersion { out "*IDN?"; + in "IDN:OXFORD INSTRUMENTS:%(\$1MODEL.VAL)[ a-zA-Z0-9]:%*[ a-zA-Z0-9]:%(\$1VERSION.VAL)s";} ######################################################################################### # Get measured power supply voltage in volts # The return string is of the form: STAT:DEV:GRPZ:PSU:SIG:VOLT:-0.0002V -getSupplyVoltage { out "READ:DEV:" $magnet_supply ":PSU:SIG:VOLT"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:VOLT:%f%*s"; wait 100;} +getSupplyVoltage { out "READ:DEV:" $magnet_supply ":PSU:SIG:VOLT"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:VOLT:%f%*s";} # Get demand current (output current) in amps. We are only interested in the Z axis magnet group. # The return string is of the form: STAT:DEV:GRPZ:PSU:CSET::A -getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} +getDemandCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s";} # Get measured magnet curren in amps - ER=no. # The return string is of the form: STAT:DEV:GRPZ:PSU:SIG:CURR:-0.0001A -getMeasuredMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CURR"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:CURR:%f%*s"; wait 100;} +getMeasuredMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CURR"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CURR:%f%*s";} # Get set point (target current) in amps ER=yes. -getSetpointCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s"; wait 100;} +getSetpointCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:CSET"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:CSET:%f%*s";} # Get current sweep rate in amps per minute ER=yes. -getCurrentSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RCST"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s"; wait 100;} +getCurrentSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RCST"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:RCST:%f%*s";} # Get demand field (output field) in tesla ER=yes. -getDemandField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FLD"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s"; wait 100;} +getDemandField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FLD"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:FLD:%f%*s";} # Get set point (target field) in tesla ER=yes. -getSetpointField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FSET"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s"; wait 100;} +getSetpointField { out "READ:DEV:" $magnet_supply ":PSU:SIG:FSET"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:FSET:%f%*s";} # Get field sweep rate in tesla per minute ER=yes. # Returns status like: STAT:DEV:GRPZ:PSU:SIG:RFST:0.3850T/m -getFieldSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RFST"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s"; wait 100;} +getFieldSweepRate { out "READ:DEV:" $magnet_supply ":PSU:SIG:RFST"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:RFST:%f%*s";} # Get software voltage limit in volts ER=no. # The documentation states that a float is returned, but in reality, a string may be returned instead, such as 'N/A' -getSoftwareVoltageLimit { out "READ:DEV:" $magnet_supply ":PSU:VLIM"; wait 100; +getSoftwareVoltageLimit { out "READ:DEV:" $magnet_supply ":PSU:VLIM"; in "STAT:DEV:" $magnet_supply ":PSU:VLIM:%f%*s"; @mismatch{in "%*s";} wait 100;} # Get persistent magnet current in amps ER=yes. -getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:PCUR:%f%*s"; wait 100;} +getPersistentMagnetCurrent { out "READ:DEV:" $magnet_supply ":PSU:SIG:PCUR"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:PCUR:%f%*s";} # Get persistent magnetic field in tesla ER=yes. -getPersistentMagnetField { out "READ:DEV:" $magnet_supply ":PSU:SIG:PFLD"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s"; wait 100;} +getPersistentMagnetField { out "READ:DEV:" $magnet_supply ":PSU:SIG:PFLD"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:PFLD:%f%*s";} # Get switch heater current in milliamp ER=no. -getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s"; wait 100;} +getHeaterCurrent { out "READ:DEV:" $magnet_supply ":PSU:SHTC"; + in "STAT:DEV:" $magnet_supply ":PSU:SHTC:%f%*s";} # Get safe current limit, most negative in amps ER=no. -getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s"; wait 100;} +getNegCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; + in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f%*s";} # Get safe current limit, most positive in amps ER=no. # no units appended to the value, so assume amps. -getPosCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f"; wait 100;} +getPosCurrentLimit { out "READ:DEV:" $magnet_supply ":PSU:CLIM"; + in "STAT:DEV:" $magnet_supply ":PSU:CLIM:%f";} # Get lead resistance (PTC/NTC) Ohms. # unit appended 'R' -getLeadResistance { out "READ:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES"; wait 100; - in "STAT:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES:%f%*s"; wait 100;} +getLeadResistance { out "READ:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES"; + in "STAT:DEV:" $magnet_temperature_sensor ":TEMP:SIG:RES:%f%*s";} # Get magnet inductance in henry ER=no. # no units appended -getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:IND:%f"; wait 100;} +getMagnetInductance { out "READ:DEV:" $magnet_supply ":PSU:IND"; + in "STAT:DEV:" $magnet_supply ":PSU:IND:%f";} # Get Activity status (analogous to the legacy A command) -getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}"; wait 100;} +getActivity { out "READ:DEV:" $magnet_supply ":PSU:ACTN"; + in "STAT:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}";} -getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}"; wait 100;} +getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; + in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}";} # *** Need to know what is returned on no alarms present, as it is not documented. *** -getSysAlarms { out "READ:SYS:ALRM"; wait 100;} +getSysAlarms { out "READ:SYS:ALRM";} # The following two reads are used to read the system alarms, hopefully in any format or order, # as long as the essential patterns match somewhere in the input string. @@ -146,8 +146,8 @@ readSysAlarmsLevelMeterBoard { # --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- # Get PSU Status status DWORD -getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; wait 100; - in "STAT:DEV:" $magnet_supply ":PSU:STAT:%x"; wait 100;} +getMagnetSupplyStatus { out "READ:DEV:" $magnet_supply ":PSU:STAT"; + in "STAT:DEV:" $magnet_supply ":PSU:STAT:%x";} # -------------------------------------------------------------------------------------------------------------- @@ -170,8 +170,8 @@ setControl { out "SET:SYS:LOCK:%{OFF|SOFT|ON}"; in "READ:SYS:LOCK:%*s";} # RTOS -> To Set Point # RTOZ -> To Zero # CLMP -> Clamp -setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:ACTN:%*s"; wait 100;} +setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|CLMP=4}"; + in "STAT:SET:DEV:" $magnet_supply ":PSU:ACTN:%*s";} # --------- # SWHT Command @@ -183,49 +183,126 @@ setActivity { out "SET:DEV:" $magnet_supply ":PSU:ACTN:%#{HOLD=0|RTOS=1|RTOZ=2|C # Returns status of this form: STAT:SET:DEV:GRPZ:PSU:SIG:SWHT:OFF:VALID # DO NOT USE SWHN command!! # -setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%*s"; wait 100;} +setHeaterStatus { out "SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%#{OFF=0|ON=1}"; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:SWHT:%*s"; + @init {getHeaterStatus;} } # Set the setpoint (target) current. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:CSET:0.0004:VALID -setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%*f:%*s"} +setSetpointCurrent { out "SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%#.4f"; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:CSET:%*f:%*s"; + @init {getSetpointCurrent;} } # Set the setpoint (target) field. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:FSET:0.00:VALID -setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%#.5f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%*f%*s"} +setSetpointField { out "SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%#.5f"; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:FSET:%*f%*s"; + @init {getSetpointField;} } # Set current sweep rate. # Rate at which current will be ramped or swept to target, either the setpoint or zero. # Returns status like: STAT:DEV:GRPZ:PSU:SIG:RCST:5.5:VALID -setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%*f%*s"} +setCurrentSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%#.3f"; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RCST:%*f%*s"; + @init {getCurrentSweepRate;} } # Set field sweep rate. # Rate at which field will be ramped or swept to target, either the setpoint or zero. # Returns status like: STAT:SET:DEV:GRPZ:PSU:SIG:RFST:0.3850:VALID -setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.4f"; wait 100; - in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%*f%*s"} +setFieldSweepRate { out "SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%#.4f"; + in "STAT:SET:DEV:" $magnet_supply ":PSU:SIG:RFST:%*f%*s"; + @init {getFieldSweepRate;} } # ------------------------------------------------------- # LEVELS BOARD COMMANDS # ------------------------------------------------------- -getLevelNitFreqZero { out "READ:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO"; wait 100; - in "STAT:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%f"; wait 100;} -setLevelNitFreqZero { out "SET:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO"; wait 100; - in "STAT:SET:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%*f"; wait 100;} -getLevelNitFreqFull { out "READ:DEV:" $level_meter ":LVL:NIT:FREQ:FULL"; wait 100; - in "STAT:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%f"; wait 100;} -setLevelNitFreqFull { out "SET:DEV:" $level_meter ":LVL:NIT:FREQ:FULL"; wait 100; - in "STAT:SET:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%*f"; wait 100;} -getLevelHeEmptyRes { out "READ:DEV:" $level_meter ":LVL:HEL:RES:ZERO"; wait 100; - in "STAT:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%f"; wait 100;} -setLevelHeEmptyRes { out "SET:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%f"; wait 100; - in "STAT:SET:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%*f%*s"; wait 100;} -getLevelHeFullRes { out "READ:DEV:" $level_meter ":LVL:HEL:RES:FULL"; wait 100; - in "STAT:DEV:" $level_meter ":LVL:HEL:RES:FULL:%f"; wait 100;} -setLevelHeFullRes { out "SET:DEV:" $level_meter ":LVL:HEL:RES:FULL:%f"; wait 100; - in "STAT:SET:DEV:" $level_meter ":LVL:HEL:RES:FULL:%*f%*s"; wait 100;} +getLevelNitFreqZero { out "READ:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO"; + in "STAT:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%f";} + +setLevelNitFreqZero { out "SET:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:NIT:FREQ:ZERO:%*d%*s"; + @init {getLevelNitFreqZero;} } + +getLevelNitFreqFull { out "READ:DEV:" $level_meter ":LVL:NIT:FREQ:FULL"; + in "STAT:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%f";} + +setLevelNitFreqFull { out "SET:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:NIT:FREQ:FULL:%*d%*s"; + @init {getLevelNitFreqFull;}} + +getLevelHeEmptyRes { out "READ:DEV:" $level_meter ":LVL:HEL:RES:ZERO"; + in "STAT:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%f";} + +setLevelHeEmptyRes { out "SET:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%f"; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:RES:ZERO:%*f%*s"; + @init {getLevelHeEmptyRes;}} + +getLevelHeFullRes { out "READ:DEV:" $level_meter ":LVL:HEL:RES:FULL"; + in "STAT:DEV:" $level_meter ":LVL:HEL:RES:FULL:%f";} + +setLevelHeFullRes { out "SET:DEV:" $level_meter ":LVL:HEL:RES:FULL:%f"; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:RES:FULL:%*f%*s"; + @init {getLevelHeFullRes;}} + + +getLevelHeFillStartThreshold { out "READ:DEV:" $level_meter ":LVL:HEL:LOW"; + in "STAT:DEV:" $level_meter ":LVL:HEL:LOW:%d";} + +setLevelHeFillStartThreshold { out "SET:DEV:" $level_meter ":LVL:HEL:LOW:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:LOW:%*d%*s"; + @init {getLevelHeFillStartThreshold;}} + +getLevelHeFillStopThreshold { out "READ:DEV:" $level_meter ":LVL:HEL:HIGH"; + in "STAT:DEV:" $level_meter ":LVL:HEL:HIGH:%d";} + +setLevelHeFillStopThreshold { out "SET:DEV:" $level_meter ":LVL:HEL:HIGH:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:HIGH:%*d%*s"; + @init {getLevelHeFillStopThreshold;}}; + +getLevelHeRefilling { out "READ:DEV:" $level_meter ":LVL:HEL:RFL"; + in "STAT:DEV:" $level_meter ":LVL:HEL:RFL:%#{ON=1|OFF=0}";} + +getLevelHeReadingRate { out "READ:DEV:" $level_meter ":LVL:HEL:PULS:SLOW"; + in "STAT:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%d";} + +setLevelHeReadingRate { out "SET:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%*s"; + @init {getLevelHeReadingRate;}} + + +getLevelNitReadInterval { out "READ:DEV:" $level_meter ":LVL:NIT:PPS"; + in "STAT:DEV:" $level_meter ":LVL:NIT:PPS:%d"; + @init {getLevelHeFillStopThreshold;}} + +setLevelNitReadInterval { out "SET:DEV:" $level_meter ":LVL:NIT:PPS:%d"; + in "STAT:DEV:" $level_meter ":LVL:NIT:PPS:%*d%*s"; + @init {getLevelNitReadInterval;}} + + +getLevelNitFillStartThreshold { out "READ:DEV:" $level_meter ":LVL:NIT:LOW"; + in "STAT:DEV:" $level_meter ":LVL:NIT:LOW:%d";} + +setLevelNitFillStartThreshold { out "SET:DEV:" $level_meter ":LVL:NIT:LOW:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:NIT:LOW:%*d%*s"; + @init {getLevelNitFillStartThreshold;}} + +getLevelNitFillStopThreshold { out "READ:DEV:" $level_meter ":LVL:NIT:HIGH"; + in "STAT:DEV:" $level_meter ":LVL:NIT:HIGH:%d";} + +setLevelNitFillStopThreshold { out "SET:DEV:" $level_meter ":LVL:NIT:HIGH:%d"; + in "STAT:SET:DEV:" $level_meter ":LVL:NIT:HIGH:%*d%*s"; + @init {getLevelNitFillStopThreshold;}}; + +getLevelNitRefilling { out "READ:DEV:" $level_meter ":LVL:NIT:RFL"; + in "STAT:DEV:" $level_meter ":LVL:NIT:RFL:%#{ON=1|OFF=0}";} + +getLevelNitrogenLevel { out "READ:DEV:" $level_meter ":LVL:SIG:NIT:LEV"; + in "STAT:DEV:" $level_meter ":LVL:SIG:NIT:LEV:%d";} + +getLevelHeliumLevel { out "READ:DEV:" $level_meter ":LVL:SIG:HEL:LEV"; + in "STAT:DEV:" $level_meter ":LVL:SIG:HEL:LEV:%d";} + + + From 21b908d94d068a618190bfc558cad7f8d3aeab23 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 3 Jul 2025 14:16:26 +0100 Subject: [PATCH 25/61] Added loads of device attributes to the lewis device.py file. --- system_tests/lewis_emulators/ips/device.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index ee28f1d..37e75ea 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -3,7 +3,8 @@ from lewis.core.logging import has_log from lewis.devices import StateMachineDevice -from .modes import Activity, Control, Mode, SweepMode, MagnetSupplyStatus, TemperatureBoardStatus, LevelMeterBoardStatus +from .modes import (Activity, Control, Mode, SweepMode, MagnetSupplyStatus, + TemperatureBoardStatus, LevelMeterBoardStatus, LevelMeterHeliumReadRate) from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState @@ -116,10 +117,20 @@ def reset(self): self.tempboard_status: TemperatureBoardStatus = TemperatureBoardStatus.OPEN_CIRCUIT self.levelboard_status: LevelMeterBoardStatus = LevelMeterBoardStatus.OK - self.nitrogen_frequency_at_zero: float = 0.0 - self.nitrogen_frequency_at_full: float = 0.0 + self.helium_empty_resistance: float = 25.0 self.helium_full_resistance: float = 0.12 + self.helium_fill_start_level: int = 10 + self.helium_fill_stop_level: int = 95 + self.helium_level: int = 50 + self.helium_read_rate: int = LevelMeterHeliumReadRate.FAST.value + + self.nitrogen_read_interval: int = 750 # milliseconds + self.nitrogen_frequency_at_zero: float = 1.0 + self.nitrogen_frequency_at_full: float = 100.0 + self.nitrogen_fill_start_level: int = 10 + self.nitrogen_fill_stop_level: int = 95 + self.nitrogen_level: int = 50 def _get_state_handlers(self): @@ -196,3 +207,7 @@ def set_levelboard_status(self, status_value: int) -> None: raise ValueError( f"Invalid level board status value: {status_value}. Must be one of {list(LevelMeterBoardStatus)}") + def get_nitrogen_refilling(self) -> bool: + """Returns whether the nitrogen refilling is in progress.""" + return self.nitrogen_fill_start_level < self.nitrogen_level < self.nitrogen_fill_stop_level + \ No newline at end of file From f699099c50f671e17aa645cff833994169cf251e Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 3 Jul 2025 14:17:31 +0100 Subject: [PATCH 26/61] Added LevelMeterHeliumReadRate Enum class to encapsulate He level reading rate SLOW or FAST. --- system_tests/lewis_emulators/ips/modes.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index c2b8d63..8e42ba3 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -151,3 +151,18 @@ class LevelMeterBoardStatus(BoardStatus): def names(cls): return ["", "Open Circuit", "Short Circuit", "ADC Error", "Over Demand", "Over Temperature", "Firmware Error", "Board Not Configured", "No Reserve"] + +class LevelMeterHeliumReadRate(IntEnum): + """ + This class represents the read rate of the helium level meter. + It is only applicable to the IPS SCPI protocol. + The read rate is used to determine how often the helium level is read, using the + DEV::LVL:HEL:PULS:SLOW:[0|1] command. + """ + SLOW = 0 + FAST = 1 + + @classmethod + def names(cls): + return ["Slow", "Fast"] + \ No newline at end of file From f3c247b637c0d72c02a7463eec0a33b3a62ef820 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 3 Jul 2025 14:18:49 +0100 Subject: [PATCH 27/61] Added tests for helium and nitrogen levels attributes. --- system_tests/tests/ips_scpi.py | 82 +++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 016bf9f..42f5111 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -32,9 +32,16 @@ # Only run tests in DEVSIM. Unable to produce detailed enough functionality to be useful in recsim. TEST_MODES = [TestModes.DEVSIM] -TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities -TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 -TEST_FREQ_FULL_VALUES = 0.0, 0.5, 1.0 # Frequency values for testing +TEST_VALUES = [-0.12345, 6.54321] # Should be able to handle negative polarities +TEST_SWEEP_RATES = [0.001, 0.9876] # Rate can't be negative or >1 +TEST_NITROGEN_LEVEL_FREQ_VALUES = [5000, 32000, 65000] # Frequency values for testing +TEST_HE_LEVEL_RESISTANCE_VALUES = [0.0, 0.1, 10, 25] # Resistance values for testing +TEST_NITROGEN_LEVEL_VALUES = [0, 10, 50, 95] # Nitrogen level values for testing +TEST_HELIUM_LEVEL_VALUES = [0, 10, 50, 95] # Helium level values for testing + +# Setting the default nitrogen fill start and stop levels +NITROGEN_FILL_START_LEVEL = 10 +NITROGEN_FILL_STOP_LEVEL = 90 TOLERANCE = 0.0001 @@ -132,10 +139,75 @@ def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit( self._lewis.backdoor_run_function_on_device("set_levelboard_status", [2]) self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD", "Short Circuit", timeout=10) - @parameterized.expand(val for val in parameterized_list(TEST_FREQ_FULL_VALUES)) + @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) def test_GIVEN_level_freq_at_zero_THEN_ioc_states_freq(self, _, val) -> None: # Simulate the nitrogen frequency at zero - self._lewis.backdoor_set_on_device("nitrogen_frequency_at_zero", val) + self.ca.set_pv_value("LVL:NIT:FREQ:ZERO:SP", val) self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:ZERO", val, tolerance=TOLERANCE, timeout=10) + @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) + def test_GIVEN_level_freq_at_full_THEN_ioc_states_freq(self, _, val) -> None: + # Simulate the nitrogen frequency at full + self.ca.set_pv_value("LVL:NIT:FREQ:FULL:SP", val) + self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:FULL", val, tolerance=TOLERANCE, timeout=10) + + @parameterized.expand(val for val in parameterized_list(TEST_HE_LEVEL_RESISTANCE_VALUES)) + def test_given_level_resistance_empty_THEN_ioc_states_resistance( + self, _, val + ) -> None: + # Simulate the helium level resistance when empty + self.ca.set_pv_value("LVL:HE:EMPTY:RES:SP", val) + self.ca.assert_that_pv_is_number("LVL:HE:EMPTY:RES", val, tolerance=TOLERANCE, timeout=10) + + @parameterized.expand(val for val in parameterized_list(TEST_HE_LEVEL_RESISTANCE_VALUES)) + def test_given_level_resistance_full_THEN_ioc_states_resistance( + self, _, val + ) -> None: + # Simulate the helium level resistance when empty + self.ca.set_pv_value("LVL:HE:FULL:RES:SP", val) + self.ca.assert_that_pv_is_number("LVL:HE:FULL:RES", val, tolerance=TOLERANCE, timeout=10) + + def test_GIVEN_nitrogen_level_THEN_ioc_states_filling_status(self) -> None: + """ + Test that the nitrogen filling status is correctly set based on the nitrogen level + and start/stop refill thresholds. + """ + # Simulate the nitrogen level + self.ca.set_pv_value("LVL:NIT:REFILL:START:SP", 20) + self.ca.set_pv_value("LVL:NIT:REFILL:STOP:SP", 90) + self._lewis.backdoor_set_on_device("nitrogen_level", 10) + self.ca.assert_that_pv_is("LVL:NIT:REFILLING", "Yes") + self._lewis.backdoor_set_on_device("nitrogen_level", 95) + self.ca.assert_that_pv_is("LVL:NIT:REFILLING", "No") + + def test_GIVEN_helium_level_THEN_ioc_states_filling_status(self) -> None: + """ + Test that the helium filling status is correctly set based on the helium level + and start/stop refill thresholds. + """ + # Simulate the helium level + self.ca.set_pv_value("LVL:HE:REFILL:START:SP", 20) + self.ca.set_pv_value("LVL:HE:REFILL:STOP:SP", 90) + self._lewis.backdoor_set_on_device("helium_level", 10) + self.ca.assert_that_pv_is("LVL:HE:REFILLING", "Yes") + self._lewis.backdoor_set_on_device("helium_level", 95) + self.ca.assert_that_pv_is("LVL:HE:REFILLING", "No") + + def test_WHEN_nitrogen_read_interval_set_THEN_ioc_updates_read_interval(self): + """ + Test that the nitrogen read interval can be set and is reflected in the IOC. + """ + # Set the nitrogen read interval + self.ca.set_pv_value("LVL:NIT:READ:INTERVAL:SP", 1000) + self.ca.assert_that_pv_is_number("LVL:NIT:READ:INTERVAL", 1000, tolerance=TOLERANCE) + def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self): + """ + Test that the helium read rate can be set and is reflected in the IOC. + """ + # Set the helium read rate + self.ca.set_pv_value("LVL:HE:PULSE:READ:RATE:SP", 1) + self.ca.assert_that_pv_is("LVL:HE:PULSE:READ:RATE", "Slow") + self.ca.set_pv_value("LVL:HE:PULSE:READ:RATE:SP", 0) + self.ca.assert_that_pv_is("LVL:HE:PULSE:READ:RATE", "Fast") + From d4b0ea09757f0ca6339cf1623561f159b43cb5be Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 3 Jul 2025 14:20:10 +0100 Subject: [PATCH 28/61] Added lewis emulation features for helium and nitrogen levels attributes setting and reading. --- .../ips/interfaces/stream_interface_scpi.py | 171 +++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 0d15f1b..6393e87 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -2,7 +2,7 @@ from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder -from ..modes import Activity, Control +from ..modes import Activity, Control, LevelMeterHeliumReadRate from ..device import amps_to_tesla, tesla_to_amps @@ -77,6 +77,10 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("set_heater_off").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), CmdBuilder("set_bipolar_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), + CmdBuilder("get_nit_read_interval").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS").eos().build(), + CmdBuilder("set_nit_read_interval").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:").int().eos().build(), CmdBuilder("get_nit_freq_zero").escape( f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO").eos().build(), CmdBuilder("set_nit_freq_zero").escape( @@ -85,6 +89,21 @@ class IpsStreamInterface(StreamInterface): f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL").eos().build(), CmdBuilder("set_nit_freq_full").escape( f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:").float().eos().build(), + CmdBuilder("get_nit_fill_start_level").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW").eos().build(), + CmdBuilder("set_nit_fill_start_level").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:").int().eos().build(), + CmdBuilder("get_nit_fill_stop_level").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH").eos().build(), + CmdBuilder("set_nit_fill_stop_level").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:").int().eos().build(), + CmdBuilder("get_nit_refilling").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL").eos().build(), + CmdBuilder("get_nitrogen_level").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:NIT:LEV").eos().build(), + + CmdBuilder("get_helium_level").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:HEL:LEV").eos().build(), CmdBuilder("get_he_empty_resistance").escape( f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO").eos().build(), CmdBuilder("get_he_full_resistance").escape( @@ -93,6 +112,21 @@ class IpsStreamInterface(StreamInterface): f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:").float().eos().build(), CmdBuilder("set_he_full_resistance").escape( f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:").float().eos().build(), + CmdBuilder("get_he_fill_start_level").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW").eos().build(), + CmdBuilder("set_he_fill_start_level").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:").int().eos().build(), + CmdBuilder("get_he_fill_stop_level").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH").eos().build(), + CmdBuilder("set_he_fill_stop_level").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:").int().eos().build(), + CmdBuilder("get_he_refilling").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL").eos().build(), + CmdBuilder("get_he_read_rate").escape( + f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW").eos().build(), + CmdBuilder("set_he_read_rate").escape( + f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:").int().eos().build(), + } def handle_error(self, request, error): @@ -293,6 +327,23 @@ def set_bipolar_mode(self, mode): self.device.bipolar = bool(mode) return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + + def get_nit_read_interval(self) -> str: + """ + Gets the nitrogen read interval in milliseconds. + :return: A string indicating the nitrogen read interval. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:{self.device.nitrogen_read_interval:d}" + + def set_nit_read_interval(self, interval: int) -> str: + """ + Sets the nitrogen read interval in milliseconds. + :param interval: The nitrogen read interval to set. + :return: A string indicating the success of the operation. + """ + self.device.nitrogen_read_interval = interval + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:{interval:d}:VALID" + def get_nit_freq_zero(self) -> str: ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}" return ret @@ -328,3 +379,121 @@ def set_he_full_resistance(self, resistance) -> str: self.device.helium_full_resistance = float(resistance) ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}:VALID" return ret + + def get_he_fill_start_level(self) -> str: + """ + Gets the helium fill start level. + :return: A string indicating the helium fill start level. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:{self.device.helium_fill_start_level:d}" + + def set_he_fill_start_level(self, level: int) -> str: + """ + Sets the helium fill start level. + :param level: The helium fill start level to set. + :return: A string indicating the success of the operation. + """ + self.device.helium_fill_start_level = level + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:{level:d}:VALID" + + def get_he_fill_stop_level(self) -> str: + """ + Gets the helium fill stop level. + :return: A string indicating the helium fill stop level. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:{self.device.helium_fill_stop_level:d}" + + def set_he_fill_stop_level(self, level: int) -> str: + """ + Sets the helium fill stop level. + :param level: The helium fill stop level to set. + :return: A string indicating the success of the operation. + """ + self.device.helium_fill_stop_level = level + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:{level:d}:VALID" + + def get_he_refilling(self) -> str: + """ + Gets the helium refilling status. + :return: A string indicating whether helium is refilling. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL:{'ON' if self.device.helium_level <= self.device.helium_fill_start_level else 'OFF'}" + + def get_nit_fill_start_level(self) -> str: + """ + Gets the nitrogen fill start level. + :return: A string indicating the nitrogen fill start level. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:{self.device.nitrogen_fill_start_level:d}" + + def set_nit_fill_start_level(self, level: int) -> str: + """ + Sets the nitrogen fill start level. + :param level: The nitrogen fill start level to set. + :return: A string indicating the success of the operation. + """ + self.device.nitrogen_fill_start_level = level + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:{level:d}:VALID" + + def get_nit_fill_stop_level(self) -> str: + """ + Gets the nitrogen fill stop level. + :return: A string indicating the nitrogen fill stop level. + """ + return ( + f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:" + f"{self.device.nitrogen_fill_stop_level:d}" + ) + + def set_nit_fill_stop_level(self, level: int) -> str: + """ + Sets the nitrogen fill stop level. + :param level: The nitrogen fill stop level to set. + :return: A string indicating the success of the operation. + """ + self.device.nitrogen_fill_stop_level = level + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:{level:d}:VALID" + + def get_nit_refilling(self) -> str: + """ + Gets the nitrogen refilling status. + :return: A string indicating whether nitrogen is refilling. + """ + state: str = 'ON' if (self.device.nitrogen_level + <= self.device.nitrogen_fill_start_level) else 'OFF' + + return ( + f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL:{state}") + + def get_nitrogen_level(self) -> str: + """ + Gets the current nitrogen level. + :return: A string indicating the nitrogen level. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:SIG:NIT:LEV:{self.device.nitrogen_level:d}" + + def get_helium_level(self) -> str: + """ + Gets the current helium level. + :return: A string indicating the helium level. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:SIG:HEL:LEV:{self.device.helium_level:d}" + + def get_he_read_rate(self) -> str: + """ + Gets the helium read rate. + :return: A string indicating the helium read rate. + """ + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:"\ + f"{self.device.helium_read_rate:d}" + + def set_he_read_rate(self, rate: int) -> str: + """ + Sets the helium read rate. + :param rate: The helium read rate to set. + :return: A string indicating the success of the operation. + """ + self.device.helium_read_rate = rate + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:"\ + f"{self.device.helium_read_rate:d}:VALID" + \ No newline at end of file From 3c96b6b481753ef022ae531f580aaba51721d397 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 9 Jul 2025 11:41:00 +0100 Subject: [PATCH 29/61] Removed commented out code. --- system_tests/lewis_emulators/ips/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/system_tests/lewis_emulators/ips/__init__.py b/system_tests/lewis_emulators/ips/__init__.py index 4c3aeec..9482747 100644 --- a/system_tests/lewis_emulators/ips/__init__.py +++ b/system_tests/lewis_emulators/ips/__init__.py @@ -1,7 +1,5 @@ from ..lewis_versions import LEWIS_LATEST from .device import SimulatedIps -#from .device_scpi import SimulatedIpsSCPI framework_version = LEWIS_LATEST -#__all__ = ["SimulatedIps", "SimulatedIpsSCPI"] __all__ = ["SimulatedIps"] From 4e3f028877275fd264d6058ea2e6ca17ef52231a Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 9 Jul 2025 11:42:38 +0100 Subject: [PATCH 30/61] Ruff formatted: Added type annotations and general formatting. --- system_tests/lewis_emulators/ips/device.py | 100 +++-- .../ips/interfaces/stream_interface.py | 73 ++-- .../ips/interfaces/stream_interface_scpi.py | 382 ++++++++++-------- system_tests/lewis_emulators/ips/modes.py | 6 +- system_tests/lewis_emulators/ips/states.py | 4 +- system_tests/tests/ips_scpi.py | 1 + 6 files changed, 308 insertions(+), 258 deletions(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index 37e75ea..bfc2844 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -3,12 +3,20 @@ from lewis.core.logging import has_log from lewis.devices import StateMachineDevice -from .modes import (Activity, Control, Mode, SweepMode, MagnetSupplyStatus, - TemperatureBoardStatus, LevelMeterBoardStatus, LevelMeterHeliumReadRate) - +from .modes import ( + Activity, + Control, + LevelMeterBoardStatus, + LevelMeterHeliumReadRate, + MagnetSupplyStatus, + Mode, + SweepMode, + TemperatureBoardStatus, +) from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState -# As long as no magnetic saturation effects are present, there is a linear relationship between Teslas and Amps. +# As long as no magnetic saturation effects are present, +# there is a linear relationship between Teslas and Amps. # # This is called the load line. For more detailed (technical) discussion about the load line see: # - http://aries.ucsd.edu/LIB/REPORT/SPPS/FINAL/chap4.pdf (section 4.3.3) @@ -16,18 +24,18 @@ LOAD_LINE_GRADIENT = 0.01 -def amps_to_tesla(amps): +def amps_to_tesla(amps: float) ->float: return amps * LOAD_LINE_GRADIENT -def tesla_to_amps(tesla): +def tesla_to_amps(tesla: float) -> float: return tesla / LOAD_LINE_GRADIENT -def set_bit_value(value, bit_value): +def set_bit_value(value: int, bit_value: int) -> int: """Sets a bit at the position implied by the value.""" return value | bit_value -def clear_bit_value(value, bit_value): +def clear_bit_value(value: int, bit_value: int) -> int: """Clears a bit at the specified position implied by the value.""" return value & ~bit_value @@ -36,8 +44,8 @@ class SimulatedIps(StateMachineDevice): # Currents that correspond to the switch heater being on and off HEATER_OFF_CURRENT, HEATER_ON_CURRENT = 0, 10 - # If there is a difference in current of more than this between the magnet and the power supply, and the switch is - # resistive, then the magnet will quench. + # If there is a difference in current of more than this between the magnet + # and the power supply, and the switch is resistive, then the magnet will quench. # No idea what this number should be for a physically realistic system so just guess. QUENCH_CURRENT_DELTA = 0.1 @@ -47,23 +55,25 @@ class SimulatedIps(StateMachineDevice): # Fixed rate at which switch heater can ramp up or down HEATER_RAMP_RATE = 5 - def _initialize_data(self): + def _initialize_data(self) -> None: """Initialize all of the device's attributes. """ self.reset() - def reset(self): - # Within the cryostat, there is a wire that is made superconducting because it is in the cryostat. The wire has - # a heater which can be used to make the wire go back to a non-superconducting state. - # - # When the heater is ON, the wire has a high resistance and the magnet is powered directly by the power supply. + def reset(self) -> None: + # Within the cryostat, there is a wire that is made superconducting because it is + # in the cryostat. The wire has a heater which can be used to make the wire go back + # to a non-superconducting state. + # When the heater is ON, the wire has a high resistance and the magnet is powered + # directly by the power supply. # - # When the heater is OFF, the wire is superconducting, which means that the power supply can be ramped down and - # the magnet will stay active (this is "persistent" mode) + # When the heater is OFF, the wire is superconducting, which means that the power + # supply can be ramped down and the magnet will stay active (this is "persistent" mode) self.heater_on: bool = False self.heater_current: float = 0.0 - # "Leads" are the non-superconducting wires between the superconducting magnet and the power supply. + # "Leads" are the non-superconducting wires between the superconducting magnet and + # the power supply. # Not sure what a realistic value is for these leads, so I've guessed. self.lead_resistance: float = 50.0 @@ -71,14 +81,16 @@ def reset(self): self.current: float = 0.0 self.current_setpoint: float = 0.0 - # Current for the magnet. May be different from the power supply current if the magnet is in persistent mode. + # Current for the magnet. May be different from the power supply current if the magnet + # is in persistent mode. self.magnet_current: float = 0.0 # Measured current may be different from what the PSU is attempting to provide self.measured_current: float = 0.0 # If the device trips, store the last current which caused a trip in here. - # This could be used for diagnostics e.g. finding maximum field which magnet is capable of in a certain config. + # This could be used for diagnostics e.g. finding maximum field which magnet is capable + # of in a certain config. self.trip_current: float = 0.0 # Ramp rate == sweep rate @@ -90,11 +102,12 @@ def reset(self): # Mode of the magnet e.g. HOLD, TO SET POINT, TO ZERO, CLAMP self.activity: Activity = Activity.TO_SETPOINT - # No idea what a sensible value is. Hard-code this here for now - can't be changed on real device. + # No idea what a sensible value is. + # Hard-code this here for now - can't be changed on real device. self.inductance: float = 0.005 - # No idea what sensible values are here. Also not clear what the behaviour is of the controller when these - # limits are hit. + # No idea what sensible values are here. + # Also not clear what the behaviour is of the controller when these limits are hit. self.neg_current_limit, self.pos_current_limit = -(10**6), 10**6 # Local and locked is the zeroth mode of the control command @@ -133,17 +146,17 @@ def reset(self): self.nitrogen_level: int = 50 - def _get_state_handlers(self): + def _get_state_handlers(self) -> dict: return { "heater_off": HeaterOffState(), "heater_on": HeaterOnState(), "quenched": MagnetQuenchedState(), } - def _get_initial_state(self): + def _get_initial_state(self) -> str: return "heater_off" - def _get_transition_handlers(self): + def _get_transition_handlers(self) -> OrderedDict: return OrderedDict( [ (("heater_off", "heater_on"), lambda: self.heater_on), @@ -156,33 +169,36 @@ def _get_transition_handlers(self): ] ) - def quench(self, reason): - self.log.info("Magnet quenching at current={} because: {}".format(self.current, reason)) + def quench(self, reason: str) -> None: + self.log.info("Magnet quenching at current={} because: {}" + .format(self.current, reason)) self.trip_current = self.current self.magnet_current = 0 self.current = 0 self.measured_current = 0 self.quenched = True # Causes LeWiS to enter Quenched state # For the SCPI protocol, we set the magnet supply status to indicate a quench - self.magnet_supply_status = set_bit_value(self.magnet_supply_status, MagnetSupplyStatus.QUENCH_DETECTED) + self.magnet_supply_status = set_bit_value(self.magnet_supply_status, + MagnetSupplyStatus.QUENCH_DETECTED) - def unquench(self): + def unquench(self) -> None: self.quenched = False # For the SCPI protocol, we set the magnet supply status to clear a quench status - self.magnet_supply_status = clear_bit_value(self.magnet_supply_status, MagnetSupplyStatus.QUENCH_DETECTED) + self.magnet_supply_status = clear_bit_value(self.magnet_supply_status, + MagnetSupplyStatus.QUENCH_DETECTED) - def get_voltage(self): + def get_voltage(self) -> float: """Gets the voltage of the PSU. - Everything except the leads is superconducting, we use Ohm's law here with the PSU current and the lead - resistance. + Everything except the leads is superconducting, + we use Ohm's law here with the PSU current and the lead resistance. - In reality would also need to account for inductance effects from the magnet but I don't think that - extra complexity is necessary for this emulator. + In reality would also need to account for inductance effects from the magnet + but I don't think that extra complexity is necessary for this emulator. """ return self.current * self.lead_resistance - def set_heater_status(self, new_status): + def set_heater_status(self, new_status: bool) -> None: if new_status and abs(self.current - self.magnet_current) > self.QUENCH_CURRENT_DELTA: raise ValueError( "Can't set the heater to on while the magnet current and PSU current are mismatched" @@ -196,7 +212,9 @@ def set_tempboard_status(self, status_value: int) -> None: self.tempboard_status = status else: raise ValueError( - f"Invalid temperature board status value: {status_value}. Must be one of {list(TemperatureBoardStatus)}") + (f"Invalid temperature board status value: {status_value}." + f" Must be one of {list(TemperatureBoardStatus)}") + ) def set_levelboard_status(self, status_value: int) -> None: """Sets the temperature board status.""" @@ -205,7 +223,9 @@ def set_levelboard_status(self, status_value: int) -> None: self.levelboard_status = status else: raise ValueError( - f"Invalid level board status value: {status_value}. Must be one of {list(LevelMeterBoardStatus)}") + (f"Invalid level board status value: {status_value}." + f" Must be one of {list(LevelMeterBoardStatus)}") + ) def get_nitrogen_refilling(self) -> bool: """Returns whether the nitrogen refilling is in progress.""" diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py index 0bd5a1e..a1b8b82 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py @@ -2,9 +2,8 @@ from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder -from ..modes import Activity, Control - from ..device import amps_to_tesla, tesla_to_amps +from ..modes import Activity, Control MODE_MAPPING = { 0: Activity.HOLD, @@ -66,7 +65,7 @@ class IpsStreamInterface(StreamInterface): in_terminator = "\r" out_terminator = "\r" - def handle_error(self, request, error): + def handle_error(self, request: str, error:str) -> str: err_string = "command was: {}, error was: {}: {}\n".format( request, error.__class__.__name__, error ) @@ -74,18 +73,20 @@ def handle_error(self, request, error): self.log.error(err_string) return err_string - def get_version(self): + @classmethod + def get_version(cls) -> str: return "Simulated IPS" - def set_comms_mode(self): - """This sets the terminator that the device wants, not implemented in emulator. Command does not reply. + def set_comms_mode(self) -> None: + """This sets the terminator that the device wants, not implemented in emulator. + Command does not reply. """ - def set_control_mode(self, mode): + def set_control_mode(self, mode: int) -> str: self.device.control = CONTROL_MODE_MAPPING[mode] return "C" - def set_mode(self, mode): + def set_mode(self, mode: int) -> str: mode = int(mode) try: self.device.activity = MODE_MAPPING[mode] @@ -93,23 +94,23 @@ def set_mode(self, mode): raise ValueError("Invalid mode specified") return "A" - def get_status(self): + def get_status(self) -> str: resp = "X{x1}{x2}A{a}C{c}H{h}M{m1}{m2}P{p1}{p2}" - def translate_activity(): + def translate_activity() -> int: for k, v in MODE_MAPPING.items(): if v == self.device.activity: return k else: raise ValueError("Device was in invalid mode, can't construct status") - def get_heater_status_number(): + def get_heater_status_number() -> int: if self.device.heater_on: return 1 else: return 0 if self.device.magnet_current == 0 else 2 - def is_sweeping(): + def is_sweeping() -> bool: if self.device.activity == Activity.TO_SETPOINT: return self.device.current != self.device.current_setpoint elif self.device.activity == Activity.TO_ZERO: @@ -131,80 +132,80 @@ def is_sweeping(): return resp.format(**statuses) - def get_current_setpoint(self): + def get_current_setpoint(self) -> str: return "R{}".format(self.device.current_setpoint) - def get_supply_voltage(self): + def get_supply_voltage(self) -> str: return "R{}".format(self.device.get_voltage()) - def get_measured_current(self): + def get_measured_current(self) -> str: return "R{}".format(self.device.measured_current) - def get_current(self): + def get_current(self) -> str: return "R{}".format(self.device.current) - def get_current_sweep_rate(self): + def get_current_sweep_rate(self) -> str: return "R{}".format(self.device.current_ramp_rate) - def get_field(self): + def get_field(self) -> str: return "R{}".format(amps_to_tesla(self.device.current)) - def get_field_setpoint(self): + def get_field_setpoint(self) -> str: return "R{}".format(amps_to_tesla(self.device.current_setpoint)) - def get_field_sweep_rate(self): + def get_field_sweep_rate(self) -> str: return "R{}".format(amps_to_tesla(self.device.current_ramp_rate)) - def get_software_voltage_limit(self): + def get_software_voltage_limit(self) -> str: return "R0" - def get_persistent_magnet_current(self): + def get_persistent_magnet_current(self) -> str: return "R{}".format(self.device.magnet_current) - def get_trip_current(self): + def get_trip_current(self) -> str: return "R{}".format(self.device.trip_current) - def get_persistent_magnet_field(self): + def get_persistent_magnet_field(self) -> str: return "R{}".format(amps_to_tesla(self.device.magnet_current)) - def get_trip_field(self): + def get_trip_field(self) -> str: return "R{}".format(amps_to_tesla(self.device.trip_current)) - def get_heater_current(self): + def get_heater_current(self) -> str: return "R{}".format(self.device.heater_current) - def get_neg_current_limit(self): + def get_neg_current_limit(self) -> str: return "R{}".format(self.device.neg_current_limit) - def get_pos_current_limit(self): + def get_pos_current_limit(self) -> str: return "R{}".format(self.device.pos_current_limit) - def get_lead_resistance(self): + def get_lead_resistance(self) -> str: return "R{}".format(self.device.lead_resistance) - def get_magnet_inductance(self): + def get_magnet_inductance(self) -> str: return "R{}".format(self.device.inductance) - def set_current(self, current): + def set_current(self, current: float) -> str: self.device.current_setpoint = float(current) return "I" - def set_field(self, current): + def set_field(self, current: float) -> str: self.device.current_setpoint = tesla_to_amps(float(current)) return "J" - def set_heater_on(self): + def set_heater_on(self) -> str: self.device.set_heater_status(True) return "H" - def set_heater_off(self): + def set_heater_off(self) -> str: self.device.set_heater_status(False) return "H" - def set_field_sweep_rate(self, tesla): + def set_field_sweep_rate(self, tesla: float) -> str: self.device.current_ramp_rate = tesla_to_amps(float(tesla)) return "T" - def set_sweep_mode(self, mode): + def set_sweep_mode(self, mode: int) -> str: self.device.sweep_mode = int(mode) return "M" diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 6393e87..8c37174 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -2,9 +2,8 @@ from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder -from ..modes import Activity, Control, LevelMeterHeliumReadRate - from ..device import amps_to_tesla, tesla_to_amps +from ..modes import Activity, Control MODE_MAPPING = { 'HOLD': Activity.HOLD, @@ -29,8 +28,8 @@ class DeviceUID: level_meter = "DB1.L1" magnet_supply = "GRPZ" # temperature_sensor_10T = "DB8.T1" - temperature_sensor_10T = "MB1.T1" - pressure_sensor_10T = "DB5.P1" + temperature_sensor_10t = "MB1.T1" + pressure_sensor_10t = "DB5.P1" @has_log @@ -41,95 +40,114 @@ class IpsStreamInterface(StreamInterface): # Commands that we expect via serial during normal operation commands = { - CmdBuilder("get_version").escape("*IDN?").eos().build(), - # CmdBuilder("set_comms_mode").escape("Q4").eos().build(), - CmdBuilder("get_magnet_supply_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:STAT").eos().build(), - CmdBuilder("get_activity").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN").eos().build(), - CmdBuilder("get_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), - CmdBuilder("get_supply_voltage").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT").eos().build(), - # CmdBuilder("get_measured_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), - CmdBuilder("get_current_setpoint").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET").eos().build(), - CmdBuilder("get_current_sweep_rate").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST").eos().build(), - CmdBuilder("get_field").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD").eos().build(), - CmdBuilder("get_field_setpoint").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET").eos().build(), - CmdBuilder("get_field_sweep_rate").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST").eos().build(), - CmdBuilder("get_software_voltage_limit").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:VLIM").eos().build(), - CmdBuilder("get_persistent_magnet_current").escape( - f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR").eos().build(), - # CmdBuilder("get_trip_current").escape("R17").eos().build(), - CmdBuilder("get_persistent_magnet_field").escape( - f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD").eos().build(), - # CmdBuilder("get_trip_field").escape("R19").eos().build(), - CmdBuilder("get_heater_current").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SHTC").eos().build(), - # CmdBuilder("get_neg_current_limit").escape("R21").eos().build(), - CmdBuilder("get_pos_current_limit").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:CLIM").eos().build(), - CmdBuilder("get_lead_resistance").escape( - f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES").eos().build(), - CmdBuilder("get_magnet_inductance").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), - CmdBuilder("get_heater_status").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), - CmdBuilder("get_bipolar_mode").escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), - CmdBuilder("get_system_alarms").escape(f"READ:SYS:ALRM").eos().build(), - CmdBuilder("set_activity").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), - CmdBuilder("set_current").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), - CmdBuilder("set_field").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:").float().eos().build(), - CmdBuilder("set_field_sweep_rate").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:").float().eos().build(), - CmdBuilder("set_heater_on").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), - CmdBuilder("set_heater_off").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), - CmdBuilder("set_bipolar_mode").escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), - - CmdBuilder("get_nit_read_interval").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS").eos().build(), - CmdBuilder("set_nit_read_interval").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:").int().eos().build(), - CmdBuilder("get_nit_freq_zero").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO").eos().build(), - CmdBuilder("set_nit_freq_zero").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:").float().eos().build(), - CmdBuilder("get_nit_freq_full").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL").eos().build(), - CmdBuilder("set_nit_freq_full").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:").float().eos().build(), - CmdBuilder("get_nit_fill_start_level").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW").eos().build(), - CmdBuilder("set_nit_fill_start_level").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:").int().eos().build(), - CmdBuilder("get_nit_fill_stop_level").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH").eos().build(), - CmdBuilder("set_nit_fill_stop_level").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:").int().eos().build(), - CmdBuilder("get_nit_refilling").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL").eos().build(), - CmdBuilder("get_nitrogen_level").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:NIT:LEV").eos().build(), - - CmdBuilder("get_helium_level").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:HEL:LEV").eos().build(), - CmdBuilder("get_he_empty_resistance").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO").eos().build(), - CmdBuilder("get_he_full_resistance").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL").eos().build(), - CmdBuilder("set_he_empty_resistance").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:").float().eos().build(), - CmdBuilder("set_he_full_resistance").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:").float().eos().build(), - CmdBuilder("get_he_fill_start_level").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW").eos().build(), - CmdBuilder("set_he_fill_start_level").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:").int().eos().build(), - CmdBuilder("get_he_fill_stop_level").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH").eos().build(), - CmdBuilder("set_he_fill_stop_level").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:").int().eos().build(), - CmdBuilder("get_he_refilling").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL").eos().build(), - CmdBuilder("get_he_read_rate").escape( - f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW").eos().build(), - CmdBuilder("set_he_read_rate").escape( - f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:").int().eos().build(), + CmdBuilder("get_version") + .escape("*IDN?").eos().build(), + CmdBuilder("get_magnet_supply_status") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:STAT").eos().build(), + CmdBuilder("get_activity") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN").eos().build(), + CmdBuilder("get_current") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), + CmdBuilder("get_supply_voltage") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT").eos().build(), + CmdBuilder("get_current_setpoint") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET").eos().build(), + CmdBuilder("get_current_sweep_rate") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST").eos().build(), + CmdBuilder("get_field") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD").eos().build(), + CmdBuilder("get_field_setpoint") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET").eos().build(), + CmdBuilder("get_field_sweep_rate") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST").eos().build(), + CmdBuilder("get_software_voltage_limit") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:VLIM").eos().build(), + CmdBuilder("get_persistent_magnet_current") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR").eos().build(), + CmdBuilder("get_persistent_magnet_field") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD").eos().build(), + CmdBuilder("get_heater_current") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SHTC").eos().build(), + CmdBuilder("get_pos_current_limit") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:CLIM").eos().build(), + CmdBuilder("get_lead_resistance") + .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES").eos().build(), + CmdBuilder("get_magnet_inductance") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), + CmdBuilder("get_heater_status") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), + CmdBuilder("get_bipolar_mode") + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), + CmdBuilder("get_system_alarms") + .escape("READ:SYS:ALRM").eos().build(), + CmdBuilder("set_activity") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), + CmdBuilder("set_current") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), + CmdBuilder("set_field") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:").float().eos().build(), + CmdBuilder("set_field_sweep_rate") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:").float().eos().build(), + CmdBuilder("set_heater_on") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), + CmdBuilder("set_heater_off") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), + CmdBuilder("set_bipolar_mode") + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), + + CmdBuilder("get_nit_read_interval") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS").eos().build(), + CmdBuilder("set_nit_read_interval") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:").int().eos().build(), + CmdBuilder("get_nit_freq_zero") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO").eos().build(), + CmdBuilder("set_nit_freq_zero") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:").float().eos().build(), + CmdBuilder("get_nit_freq_full") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL").eos().build(), + CmdBuilder("set_nit_freq_full") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:").float().eos().build(), + CmdBuilder("get_nit_fill_start_level") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW").eos().build(), + CmdBuilder("set_nit_fill_start_level") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:").int().eos().build(), + CmdBuilder("get_nit_fill_stop_level") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH").eos().build(), + CmdBuilder("set_nit_fill_stop_level") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:").int().eos().build(), + CmdBuilder("get_nit_refilling") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL").eos().build(), + CmdBuilder("get_nitrogen_level") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:NIT:LEV").eos().build(), + + CmdBuilder("get_helium_level") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:HEL:LEV").eos().build(), + CmdBuilder("get_he_empty_resistance") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO").eos().build(), + CmdBuilder("get_he_full_resistance") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL").eos().build(), + CmdBuilder("set_he_empty_resistance") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:").float().eos().build(), + CmdBuilder("set_he_full_resistance") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:").float().eos().build(), + CmdBuilder("get_he_fill_start_level") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW").eos().build(), + CmdBuilder("set_he_fill_start_level") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:").int().eos().build(), + CmdBuilder("get_he_fill_stop_level") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH").eos().build(), + CmdBuilder("set_he_fill_stop_level") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:").int().eos().build(), + CmdBuilder("get_he_refilling") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL").eos().build(), + CmdBuilder("get_he_read_rate") + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW").eos().build(), + CmdBuilder("set_he_read_rate") + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:").int().eos().build(), } - def handle_error(self, request, error): + def handle_error(self, request: str, error: str) -> str: err_string = "command was: {}, error was: {}: {}\n".format( request, error.__class__.__name__, error ) @@ -138,7 +156,7 @@ def handle_error(self, request, error): return err_string @staticmethod - def get_version(): + def get_version() -> str: """ get_version() The format of the reply is: IDN:OXFORD INSTRUMENTS:MERCURY dd:ss:ff @@ -150,29 +168,18 @@ def get_version(): """ return "IDN:OXFORD INSTRUMENTS:MERCURY IPS:simulated:0.0.0" - def set_comms_mode(self): - """This sets the terminator that the device wants, not implemented in emulator. Command does not reply. - """ - - def set_control_mode(self, mode): - self.device.control = CONTROL_MODE_MAPPING[mode] - return "C" - - def get_activity(self): + def get_activity(self) -> str: for testmode in MODE_MAPPING: if self.device.activity == MODE_MAPPING[testmode]: break - mode = self.device.activity.name return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{testmode}" - def set_activity(self, mode: str): - found_mode = False + def set_activity(self, mode: str) -> str: # Set the default return value to invalid (guilty until proven innocent) ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" for testmode in MODE_MAPPING: if mode == MODE_MAPPING[testmode].value: - found_mode = True break try: @@ -183,7 +190,7 @@ def set_activity(self, mode: str): raise ValueError("Invalid mode specified") return ret - def get_magnet_supply_status(self): + def get_magnet_supply_status(self) -> str: """ The format of the reply is: STAT:DEV:{DeviceUID.magnet_supply}:PSU:STAT:00000000 @@ -211,81 +218,85 @@ def get_magnet_supply_status(self): This information is not published and was derived from direct questions to Oxford Instruments. """ - resp = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:STAT:{self.device.magnet_supply_status:08x}" + resp = (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:STAT:{self.device.magnet_supply_status:08x}") return resp - def get_current_setpoint(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{self.device.current_setpoint:.4f}A" + def get_current_setpoint(self) -> str: + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:CSET:{self.device.current_setpoint:.4f}A") - def get_supply_voltage(self): + def get_supply_voltage(self) -> str: return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT:{self.device.get_voltage():.4f}V" -# def get_measured_current(self): -# return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.measured_current:.4f}A" - - def get_current(self): + def get_current(self) -> str: """Gets the demand current of the PSU.""" - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR:{self.device.measured_current:.4f}A" + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:CURR:{self.device.measured_current:.4f}A") - def get_current_sweep_rate(self): + def get_current_sweep_rate(self) -> str: # Returns the current ramp rate in amps per second. # of the form: STAT:DEV:GRPZ:PSU:SIG:RCST:5.3612A/m - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST:{self.device.current_ramp_rate:.4f}A/m" + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:RCST:{self.device.current_ramp_rate:.4f}A/m") - def get_field(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD:{amps_to_tesla(self.device.current):.4f}T" + def get_field(self) -> str: + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:FLD:{amps_to_tesla(self.device.current):.4f}T") - def get_field_setpoint(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{amps_to_tesla(self.device.current_setpoint):.4f}T" + def get_field_setpoint(self) -> str: + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:FSET:{amps_to_tesla(self.device.current_setpoint):.4f}T") - def get_field_sweep_rate(self): + def get_field_sweep_rate(self) -> str: field = amps_to_tesla(self.device.current_ramp_rate) return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{field:.4f}T/m" - def get_software_voltage_limit(self): - # According to the manual, this should return with a unit ":V" suffix, but in reality it does not. + def get_software_voltage_limit(self) -> str: + # According to the manual, this should return with a unit ":V" suffix + # but in reality it does not. return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:VLIM:{self.device.voltage_limit}" - def get_persistent_magnet_current(self): + def get_persistent_magnet_current(self) -> str: return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR:{self.device.magnet_current:.4f}A" # TBD - def get_trip_current(self): + def get_trip_current(self) -> str: return f"R{self.device.trip_current}" - def get_persistent_magnet_field(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current):.4f}T" + def get_persistent_magnet_field(self) -> str: + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current):.4f}T") - # TBD - def get_trip_field(self): - return f"R{amps_to_tesla(self.device.trip_current)}" - - def get_heater_current(self): + def get_heater_current(self) -> str: return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SHTC:{self.device.heater_current:.4f}mA" - def get_neg_current_limit(self): + def get_neg_current_limit(self) -> str: ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.neg_current_limit:.4f}" return ret - def get_pos_current_limit(self): + def get_pos_current_limit(self) -> str: ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:CLIM:{self.device.pos_current_limit:.4f}" return ret - def get_lead_resistance(self): - ret = f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES:{self.device.lead_resistance:.4f}R" + def get_lead_resistance(self) -> str: + ret = (f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}" + f":TEMP:SIG:RES:{self.device.lead_resistance:.4f}R") return ret - def get_magnet_inductance(self): + def get_magnet_inductance(self) -> str: ret = f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:IND:{self.device.inductance:.4f}" return ret - def get_heater_status(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:{'ON' if self.device.heater_on else 'OFF'}" + def get_heater_status(self) -> str: + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:SWHT:{'ON' if self.device.heater_on else 'OFF'}") - def get_bipolar_mode(self): - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + def get_bipolar_mode(self) -> str: + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}") - def get_system_alarms(self): + def get_system_alarms(self) -> str: """ Returns the system alarms in the format: STAT:SYS:ALRM:MB1.T1Open Circuit; @@ -293,47 +304,50 @@ def get_system_alarms(self): """ alarms = ["STAT:SYS:ALRM",] if self.device.tempboard_status.value != 0: - alarms.append(f":{DeviceUID.magnet_temperature_sensor}\t{self.device.tempboard_status.text()};") + alarms.append((f":{DeviceUID.magnet_temperature_sensor}\t" + f"{self.device.tempboard_status.text()};")) if self.device.levelboard_status.value != 0: - alarms.append(f":{DeviceUID.level_meter}\t{self.device.levelboard_status.text()};") + alarms.append((f":{DeviceUID.level_meter}\t" + f"{self.device.levelboard_status.text()};")) alarm_list_str = "".join(alarms) return alarm_list_str - def set_current(self, current): - self.device.current_setpoint = float(current) + def set_current(self, current: float) -> str: + self.device.current_setpoint = current return f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:{current:1.4f}:VALID" - def set_field(self, field): - ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{float(field):.4f}:VALID" - self.device.current_setpoint = tesla_to_amps(float(field)) + def set_field(self, field: float) -> str: + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:{field:.4f}:VALID" + self.device.current_setpoint = tesla_to_amps(field) return ret - def set_heater_on(self): + def set_heater_on(self) -> str: self.device.set_heater_status(True) ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON:VALID" return ret - def set_heater_off(self): + def set_heater_off(self) -> str: self.device.set_heater_status(False) ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF:VALID" return ret - def set_field_sweep_rate(self, tesla_per_min): + def set_field_sweep_rate(self, tesla_per_min: float) -> str: self.device.current_ramp_rate = tesla_to_amps(tesla_per_min) - ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{float(tesla_per_min):1.4f}:VALID" + ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:{tesla_per_min:1.4f}:VALID" return ret - def set_bipolar_mode(self, mode): + def set_bipolar_mode(self, mode: bool) -> str: self.device.bipolar = bool(mode) - return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" - + return (f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}") def get_nit_read_interval(self) -> str: """ Gets the nitrogen read interval in milliseconds. :return: A string indicating the nitrogen read interval. """ - return f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:{self.device.nitrogen_read_interval:d}" + return (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:PPS:{self.device.nitrogen_read_interval:d}") def set_nit_read_interval(self, interval: int) -> str: """ @@ -345,39 +359,47 @@ def set_nit_read_interval(self, interval: int) -> str: return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:{interval:d}:VALID" def get_nit_freq_zero(self) -> str: - ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}" + ret = (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}") return ret - def set_nit_freq_zero(self, freq) -> str: - self.device.nitrogen_frequency_at_zero = float(freq) - ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}:VALID" + def set_nit_freq_zero(self, freq: float) -> str: + self.device.nitrogen_frequency_at_zero = freq + ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}:VALID") return ret def get_nit_freq_full(self) -> str: - ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}" + ret = (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}") return ret - def set_nit_freq_full(self, freq) -> str: - self.device.nitrogen_frequency_at_full = float(freq) - ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}:VALID" + def set_nit_freq_full(self, freq: float) -> str: + self.device.nitrogen_frequency_at_full = freq + ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}:VALID") return ret def get_he_empty_resistance(self) -> str: - ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}" + ret = (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}") return ret def get_he_full_resistance(self) -> str: - ret = f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}" + ret = (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}") return ret - def set_he_empty_resistance(self, resistance) -> str: - self.device.helium_empty_resistance = float(resistance) - ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}:VALID" + def set_he_empty_resistance(self, resistance: float) -> str: + self.device.helium_empty_resistance = resistance + ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}:VALID") return ret - def set_he_full_resistance(self, resistance) -> str: - self.device.helium_full_resistance = float(resistance) - ret = f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}:VALID" + def set_he_full_resistance(self, resistance: float) -> str: + self.device.helium_full_resistance = resistance + ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}:VALID") return ret def get_he_fill_start_level(self) -> str: @@ -385,7 +407,8 @@ def get_he_fill_start_level(self) -> str: Gets the helium fill start level. :return: A string indicating the helium fill start level. """ - return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:{self.device.helium_fill_start_level:d}" + return (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:LOW:{self.device.helium_fill_start_level:d}") def set_he_fill_start_level(self, level: int) -> str: """ @@ -401,7 +424,8 @@ def get_he_fill_stop_level(self) -> str: Gets the helium fill stop level. :return: A string indicating the helium fill stop level. """ - return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:{self.device.helium_fill_stop_level:d}" + return (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:HIGH:{self.device.helium_fill_stop_level:d}") def set_he_fill_stop_level(self, level: int) -> str: """ @@ -417,14 +441,18 @@ def get_he_refilling(self) -> str: Gets the helium refilling status. :return: A string indicating whether helium is refilling. """ - return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL:{'ON' if self.device.helium_level <= self.device.helium_fill_start_level else 'OFF'}" + refilling: bool = self.device.helium_level <= self.device.helium_fill_start_level + + return (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RFL:{'ON' if refilling else 'OFF'}") def get_nit_fill_start_level(self) -> str: """ Gets the nitrogen fill start level. :return: A string indicating the nitrogen fill start level. """ - return f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:{self.device.nitrogen_fill_start_level:d}" + return (f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:LOW:{self.device.nitrogen_fill_start_level:d}") def set_nit_fill_start_level(self, level: int) -> str: """ diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index 8e42ba3..797394e 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -112,7 +112,7 @@ class TemperatureBoardStatus(BoardStatus): BOARD_NOT_CONFIGURED = 5 @classmethod - def names(cls): + def names(cls) -> list[str]: return ["", "Open Circuit", "Short Circuit", "Calibration Error", "Firmware Error", "Board Not Configured"] @@ -148,7 +148,7 @@ class LevelMeterBoardStatus(BoardStatus): NO_RESERVE = 8 @classmethod - def names(cls): + def names(cls) -> list[str]: return ["", "Open Circuit", "Short Circuit", "ADC Error", "Over Demand", "Over Temperature", "Firmware Error", "Board Not Configured", "No Reserve"] @@ -163,6 +163,6 @@ class LevelMeterHeliumReadRate(IntEnum): FAST = 1 @classmethod - def names(cls): + def names(cls) -> list[str]: return ["Slow", "Fast"] \ No newline at end of file diff --git a/system_tests/lewis_emulators/ips/states.py b/system_tests/lewis_emulators/ips/states.py index 95edb46..7f86ede 100644 --- a/system_tests/lewis_emulators/ips/states.py +++ b/system_tests/lewis_emulators/ips/states.py @@ -7,7 +7,7 @@ class HeaterOnState(State): - def in_state(self, dt): + def in_state(self, dt: float) -> None: device = self._context device.heater_current = approaches.linear( @@ -42,7 +42,7 @@ def in_state(self, dt): class HeaterOffState(State): - def in_state(self, dt): + def in_state(self, dt: float) -> None: device = self._context device.heater_current = approaches.linear( diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 42f5111..62a7dbc 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -211,3 +211,4 @@ def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self): self.ca.set_pv_value("LVL:HE:PULSE:READ:RATE:SP", 0) self.ca.assert_that_pv_is("LVL:HE:PULSE:READ:RATE", "Fast") + \ No newline at end of file From 7829d0bcdc1b11b62d9153e7ae4941e5ee680840 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 9 Jul 2025 15:00:52 +0100 Subject: [PATCH 31/61] Ruff formatted: Added type annotations and general formatting. --- system_tests/tests/ips.py | 50 +++++++++++++-------- system_tests/tests/ips_common.py | 74 +++++++++++++++++--------------- system_tests/tests/ips_scpi.py | 71 +++++++++++++++++------------- 3 files changed, 111 insertions(+), 84 deletions(-) diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 144da2c..738e06f 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -1,11 +1,17 @@ import unittest -from .ips_common import IpsBaseTests from parameterized import parameterized from utils.channel_access import ChannelAccess from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir from utils.test_modes import TestModes -from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test +from utils.testing import get_running_lewis_and_ioc, parameterized_list + +from .ips_common import IpsBaseTests + +# Tell ruff to ignore the N802 warning (function name should be lowercase). +# Names contain GIVEN, WHEN, THEN +# Ignore line length as well, as this is a common pattern in tests. +# ruff: noqa: N802, E501 DEVICE_PREFIX = "IPS_01" EMULATOR_NAME = "ips" @@ -32,8 +38,8 @@ # Only run tests in DEVSIM. Unable to produce detailed enough functionality to be useful in recsim. TEST_MODES = [TestModes.DEVSIM] -TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities -TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 +TEST_VALUES = [-0.12345, 6.54321] # Should be able to handle negative polarities +TEST_SWEEP_RATES = [0.001, 0.9876] # Rate can't be negative or >1 TOLERANCE = 0.0001 @@ -60,21 +66,22 @@ class IpsLegacyTests(IpsBaseTests, unittest.TestCase): """ Tests for the Ips legacy protocol IOC. """ - def _get_device_prefix(self): + def _get_device_prefix(self) -> str: return DEVICE_PREFIX - def _get_ioc_config(self): + def _get_ioc_config(self) -> list: return IOCS - def setUp(self): + def setUp(self) -> None: ioc_config = self._get_ioc_config() # Time to wait for the heater to warm up/cool down (extracted from IOC macros above) heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) - # Some changes happen on the order of heater_wait_time seconds. Use a significantly longer timeout - # to capture a few heater wait times plus some time for PVs to update. + # Some changes happen on the order of heater_wait_time seconds. + # Use a significantly longer timeout to capture a few heater wait times + # plus some time for PVs to update. self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=heater_wait_time * 10) # Wait for some critical pvs to be connected. @@ -85,8 +92,8 @@ def setUp(self): self.ca.set_pv_value("CONTROL:SP", "Remote & Unlocked") self.ca.set_pv_value("ACTIVITY:SP", "To Setpoint") - # Don't run reset as the sudden change of state confuses the IOC's state machine. No matter what the initial - # state of the device the SNL should be able to deal with it. + # Don't run reset as the sudden change of state confuses the IOC's state machine. + # No matter what the initial state of the device the SNL should be able to deal with it. # self._lewis.backdoor_run_function_on_device("reset") self.ca.set_pv_value("FIELD:RATE:SP", 10) @@ -97,7 +104,7 @@ def setUp(self): # Wait for statemachine to reach "at field" state before every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") - def _assert_heater_is(self, heater_state): + def _assert_heater_is(self, heater_state: bool) -> None: self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") if heater_state: self.ca.assert_that_pv_is( @@ -109,8 +116,8 @@ def _assert_heater_is(self, heater_state): @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( - self, _, field - ): + self, _:str, field : float + ) -> None: self._set_and_check_persistent_mode(False) self.ca.set_pv_value("FIELD:SP", field) self._assert_field_is(field) @@ -125,10 +132,13 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s # The trip field should be the field at the point when the magnet quenched. self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE) - # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) + # Field should be set to zero by emulator + # (mirroring what the field ought to do in the real device) self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", + 0, + tolerance=TOLERANCE) # These tests for locking and unlocking the remote control are only applicable # to the legacy protocol. SCPI does not have a remote control lock. @@ -136,8 +146,8 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) ) def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( - self, _, control_pv, set_value - ): + self, _:str, control_pv: str, set_value: str + ) -> None: self.ca.set_pv_value("CONTROL", "Local & Locked") self.ca.set_pv_value(control_pv, set_value) self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") @@ -145,7 +155,9 @@ def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( @parameterized.expand( control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) ) - def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv): + def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, + _:str, + control_pv: str) -> None: self.ca.set_pv_value("CONTROL", "Local & Locked") self.ca.process_pv(control_pv) self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py index de36eb5..1187aa0 100644 --- a/system_tests/tests/ips_common.py +++ b/system_tests/tests/ips_common.py @@ -1,14 +1,15 @@ -import unittest from abc import ABCMeta, abstractmethod from contextlib import contextmanager from parameterized import parameterized - -from utils.channel_access import ChannelAccess -from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir from utils.test_modes import TestModes -from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test +from utils.testing import parameterized_list, unstable_test + +# Tell ruff to ignore the N802 warning (function name should be lowercase). +# Names contain GIVEN, WHEN, THEN +# Ignore line length as well, as this is a common pattern in tests. +# ruff: noqa: N802, E501 DEVICE_PREFIX = "IPS_01" EMULATOR_NAME = "ips" @@ -17,8 +18,8 @@ # Only run tests in DEVSIM. Unable to produce detailed enough functionality to be useful in recsim. TEST_MODES = [TestModes.DEVSIM] -TEST_VALUES = -0.12345, 6.54321 # Should be able to handle negative polarities -TEST_SWEEP_RATES = 0.001, 0.9876 # Rate can't be negative or >1 +TEST_VALUES = [-0.12345, 6.54321] # Should be able to handle negative polarities +TEST_SWEEP_RATES = [0.001, 0.9876] # Rate can't be negative or >1 TOLERANCE = 0.0001 @@ -46,43 +47,43 @@ class IpsBaseTests(object, metaclass=ABCMeta): Tests for the Ips IOC. """ @abstractmethod - def _get_device_prefix(self): + def _get_device_prefix(self) -> str: pass @abstractmethod - def _get_ioc_config(self): + def _get_ioc_config(self) -> list[dict]: pass @abstractmethod - def setUp(self): + def setUp(self) -> None: pass - def tearDown(self): + def tearDown(self) -> None: # Wait for statemachine to reach "at field" state after every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") - def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self): + def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self) -> None: self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") - def _assert_field_is(self, field, check_stable=False): + def _assert_field_is(self, field: float, check_stable:bool=False) -> None: self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) if check_stable: self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10) @abstractmethod - def _assert_heater_is(self, heater_state): + def _assert_heater_is(self, heater_state: bool) -> None: pass - def _set_and_check_persistent_mode(self, mode): + def _set_and_check_persistent_mode(self, mode: bool) -> None: self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( - self, _, val - ): + self, _:str, val: float + ) -> None: initial_field = 1 self._set_and_check_persistent_mode(True) @@ -103,15 +104,15 @@ def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_ # Then it is safe to turn on the heater self._assert_heater_is(True) - # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up - # after being set. + # Assert that value gets passed to device by SNL. + # SNL waits 30s for the heater to cool down/warm up after being set. self._assert_field_is(val) # Now that the correct current is in the magnet, the SNL should turn the heater off self._assert_heater_is(False) - # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before - # ramping PSU to zero) + # Now that the heater is off, can ramp down the PSU to zero + # (SNL waits some time for heater to be off before ramping PSU to zero) self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field self.ca.assert_that_pv_is_number( "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE @@ -125,8 +126,8 @@ def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_ self.ca.assert_that_pv_is("STATEMACHINE", "At field") self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE) - # "User" field should take the value put in the setpoint, even when the actual field provided by the supply - # drops to zero + # "User" field should take the value put in the setpoint, + # even when the actual field provided by the supply drops to zero self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) # PSU field self.ca.assert_that_pv_is_number( "MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE @@ -137,8 +138,8 @@ def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_ @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero( - self, _, val - ): + self, _: str, val: float + ) -> None: initial_field = 1 self._set_and_check_persistent_mode(True) @@ -157,12 +158,13 @@ def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN # PSU should be ramped to match the persistent field inside the magnet (if there was one) self.ca.assert_that_pv_is("FIELD", initial_field, timeout=10) - # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it + # Then it is safe to turn on the heater + # (the heater is explicitly switched on and we wait for it even if it # was already on out of an abundance of caution). self._assert_heater_is(True) - # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up - # after being set. + # Assert that value gets passed to device by SNL. + # SNL waits 30s for the heater to cool down/warm up after being set. self._assert_field_is(val) # ...And the magnet should now be in the right state! @@ -173,7 +175,7 @@ def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN self._assert_field_is(val, check_stable=True) @contextmanager - def _backdoor_magnet_quench(self, reason="Test framework quench"): + def _backdoor_magnet_quench(self, reason: str="Test framework quench") -> None: self._lewis.backdoor_run_function_on_device("quench", [reason]) try: yield @@ -185,17 +187,19 @@ def _backdoor_magnet_quench(self, reason="Test framework quench"): self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates\ + (self, _: str, val: float) -> None: self._lewis.backdoor_set_on_device("inductance", val) self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val): + def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates\ + (self, _: str, val:float) -> None: self._lewis.backdoor_set_on_device("measured_current", val) self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES)) - def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): + def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _: str, val: float) -> None: self.ca.set_pv_value("FIELD:RATE:SP", val) self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE) @@ -204,8 +208,8 @@ def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val): @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES)) @unstable_test() def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm( - self, _, activity_state - ): + self, _: str, activity_state: str + ) -> None: if activity_state == "Clamped": self.ca.set_pv_value("ACTIVITY:SP", "Clamp") else: @@ -219,7 +223,7 @@ def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alar # original problem/complaint: # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields - def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self): + def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self) -> None: # arrange: set mode to non-persistent, set field self._set_and_check_persistent_mode(False) self.ca.set_pv_value("FIELD:SP", 3.21) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 62a7dbc..fda1181 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -1,15 +1,20 @@ import unittest -from .ips_common import IpsBaseTests -from contextlib import contextmanager + from parameterized import parameterized from utils.channel_access import ChannelAccess -from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir +from utils.ioc_launcher import get_default_ioc_dir from utils.test_modes import TestModes -from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test +from utils.testing import get_running_lewis_and_ioc, parameterized_list + +from .ips_common import IpsBaseTests DEVICE_PREFIX = "IPS_01" EMULATOR_NAME = "ips" +# Tell ruff to ignore the N802 warning (function name should be lowercase). +# Names contain GIVEN, WHEN, THEN +# Ignore line length as well, as this is a common pattern in tests. +# ruff: noqa: N802, E501 IOCS = [ { @@ -54,13 +59,13 @@ class IpsSCPITests(IpsBaseTests, unittest.TestCase): """ Tests for the Ips SCPI protocol IOC. """ - def _get_device_prefix(self): + def _get_device_prefix(self) -> str: return DEVICE_PREFIX - def _get_ioc_config(self): + def _get_ioc_config(self) -> list[dict]: return IOCS - def setUp(self): + def setUp(self) -> None: ioc_config = self._get_ioc_config() # Time to wait for the heater to warm up/cool down (extracted from IOC macros above) @@ -95,7 +100,7 @@ def setUp(self): # Wait for statemachine to reach "at field" state before every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") - def _assert_heater_is(self, heater_state): + def _assert_heater_is(self, heater_state: bool) -> None: self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off") if heater_state: self.ca.assert_that_pv_is( @@ -111,8 +116,8 @@ def _assert_heater_is(self, heater_state): @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( - self, _, field - ): + self, _: str, field: float ) -> None: + self._set_and_check_persistent_mode(False) self.ca.set_pv_value("FIELD:SP", field) self._assert_field_is(field) @@ -122,50 +127,55 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched") self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR) - # Field should be set to zero by emulator (mirroring what the field ought to do in the real device) + # Field should be set to zero by emulator + # (mirroring what the field ought to do in the real device) self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", + 0, tolerance=TOLERANCE) def test_GIVEN_magnet_temperature_sensor_open_circuit_THEN_ioc_states_open_circuit( - self): + self) -> None: # Simulate an open circuit on the temperature sensor self._lewis.backdoor_run_function_on_device("set_tempboard_status", [1]) - self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD", "Open Circuit", timeout=10) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD", + "Open Circuit", timeout=10) def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit( - self): + self) -> None: # Simulate an short circuit on the level sensor self._lewis.backdoor_run_function_on_device("set_levelboard_status", [2]) - self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD", "Short Circuit", timeout=10) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD", + "Short Circuit", timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) - def test_GIVEN_level_freq_at_zero_THEN_ioc_states_freq(self, _, val) -> None: + def test_GIVEN_level_freq_at_zero_THEN_ioc_states_freq(self, _: str, val: float) -> None: # Simulate the nitrogen frequency at zero self.ca.set_pv_value("LVL:NIT:FREQ:ZERO:SP", val) - self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:ZERO", val, tolerance=TOLERANCE, timeout=10) + self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:ZERO", val, + tolerance=TOLERANCE, timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) - def test_GIVEN_level_freq_at_full_THEN_ioc_states_freq(self, _, val) -> None: + def test_GIVEN_level_freq_at_full_THEN_ioc_states_freq(self, _: str, val: float) -> None: # Simulate the nitrogen frequency at full self.ca.set_pv_value("LVL:NIT:FREQ:FULL:SP", val) self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:FULL", val, tolerance=TOLERANCE, timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_HE_LEVEL_RESISTANCE_VALUES)) - def test_given_level_resistance_empty_THEN_ioc_states_resistance( - self, _, val - ) -> None: + def test_given_level_resistance_empty_THEN_ioc_states_resistance(self, _: str, + val: float) -> None: # Simulate the helium level resistance when empty self.ca.set_pv_value("LVL:HE:EMPTY:RES:SP", val) - self.ca.assert_that_pv_is_number("LVL:HE:EMPTY:RES", val, tolerance=TOLERANCE, timeout=10) + self.ca.assert_that_pv_is_number("LVL:HE:EMPTY:RES", val, + tolerance=TOLERANCE, timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_HE_LEVEL_RESISTANCE_VALUES)) - def test_given_level_resistance_full_THEN_ioc_states_resistance( - self, _, val - ) -> None: + def test_given_level_resistance_full_THEN_ioc_states_resistance(self, _: str, + val: float) -> None: # Simulate the helium level resistance when empty self.ca.set_pv_value("LVL:HE:FULL:RES:SP", val) - self.ca.assert_that_pv_is_number("LVL:HE:FULL:RES", val, tolerance=TOLERANCE, timeout=10) + self.ca.assert_that_pv_is_number("LVL:HE:FULL:RES", val, + tolerance=TOLERANCE, timeout=10) def test_GIVEN_nitrogen_level_THEN_ioc_states_filling_status(self) -> None: """ @@ -193,15 +203,16 @@ def test_GIVEN_helium_level_THEN_ioc_states_filling_status(self) -> None: self._lewis.backdoor_set_on_device("helium_level", 95) self.ca.assert_that_pv_is("LVL:HE:REFILLING", "No") - def test_WHEN_nitrogen_read_interval_set_THEN_ioc_updates_read_interval(self): + def test_WHEN_nitrogen_read_interval_set_THEN_ioc_updates_read_interval(self) -> None: """ Test that the nitrogen read interval can be set and is reflected in the IOC. """ # Set the nitrogen read interval self.ca.set_pv_value("LVL:NIT:READ:INTERVAL:SP", 1000) - self.ca.assert_that_pv_is_number("LVL:NIT:READ:INTERVAL", 1000, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("LVL:NIT:READ:INTERVAL", + 1000, tolerance=TOLERANCE) - def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self): + def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self) -> None: """ Test that the helium read rate can be set and is reflected in the IOC. """ From 035241ae33ba6adf19e87f232db878a342dcd954 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 9 Jul 2025 15:08:36 +0100 Subject: [PATCH 32/61] Fixed a couple of long lines. --- system_tests/tests/ips_scpi.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index fda1181..3ccb2fb 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -72,8 +72,9 @@ def setUp(self) -> None: heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) - # Some changes happen on the order of HEATER_WAIT_TIME seconds. Use a significantly longer timeout - # to capture a few heater wait times plus some time for PVs to update. + # Some changes happen on the order of HEATER_WAIT_TIME seconds. + # Use a significantly longer timeout to capture a few heater wait times + # plus some time for PVs to update. self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=heater_wait_time * 10) # Wait for some critical pvs to be connected. From 24867b75284ce3701ce01935848dd4b7ab78b495 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 29 Jul 2025 08:46:53 +0100 Subject: [PATCH 33/61] aSub record framework added to handle system error processing. --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 58 ++++++++++++++---- OxInstIPSApp/src/Makefile | 10 ++- OxInstIPSApp/src/OxInstIPS.dbd | 1 + OxInstIPSApp/src/alarms.c | 61 +++++++++++++++++++ OxInstIPSApp/src/alarms.h | 14 +++++ configure/RELEASE | 7 ++- 6 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 OxInstIPSApp/src/OxInstIPS.dbd create mode 100644 OxInstIPSApp/src/alarms.c create mode 100644 OxInstIPSApp/src/alarms.h diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 34527c3..93e39c4 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -133,16 +133,27 @@ getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}";} # *** Need to know what is returned on no alarms present, as it is not documented. *** -getSysAlarms { out "READ:SYS:ALRM";} +getSysAlarms { out "READ:SYS:ALRM"; + in "STAT:SYS:ALRM:%s";} # The following two reads are used to read the system alarms, hopefully in any format or order, # as long as the essential patterns match somewhere in the input string. +# On testing with a real device, the input string is of the form: +# STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Open Circuit; +# If no errors are present, the input string is empty: +# STAT:SYS:ALRM: readSysAlarmsTemperatureBoard { - extrainput = ignore; - in "%*/MB1.T1\t/%#{Open Circuit=1|Short Circuit=2|Calibration Error=3|Firmware Error=4|Board Not Configured=5};";} + in "STAT:SYS:ALRM:%*/MB1.T1\t/%#{Open Circuit=1|Short Circuit=2|Calibration Error=3|Firmware Error=4|Board Not Configured=5};";} +readSysAlarms10TBoard { + in "STAT:SYS:ALRM:%*/DB8.T1\t/%#{Open Circuit=1|Short Circuit=2|Calibration Error=3|Firmware Error=4|Board Not Configured=5};";} readSysAlarmsLevelMeterBoard { - extrainput = ignore; - in "%*/DB1.L1\t/%#{Open Circuit=1|Short Circuit=2|ADC Error=3|Over Demand=4|Over Temperature=5|Firmware Error=6|Board Not Configured=7|No Reserve=8};";} + in "STAT:SYS:ALRM:%*/DB1.L1\t/%#{Open Circuit=1|Short Circuit=2|ADC Error=3|Over Demand=4|Over Temperature=5|Firmware Error=6|Board Not Configured=7|No Reserve=8};";} + +# Cater for when no alarms are present, where we just receive STAT:SYS:ALRM: +# The intention is that in the empty scenario, all alarm records will be reset to No Alarm, as they +# can't determine on their own that there are no alarms present. +readSysAlarmsEmpty { in "STAT:SYS:ALRM:%s"; } + # --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- # Get PSU Status status DWORD @@ -257,15 +268,19 @@ getLevelHeFillStopThreshold { out "READ:DEV:" $level_meter ":LVL:HEL:HIGH"; setLevelHeFillStopThreshold { out "SET:DEV:" $level_meter ":LVL:HEL:HIGH:%d"; in "STAT:SET:DEV:" $level_meter ":LVL:HEL:HIGH:%*d%*s"; - @init {getLevelHeFillStopThreshold;}}; + @init {getLevelHeFillStopThreshold;}} -getLevelHeRefilling { out "READ:DEV:" $level_meter ":LVL:HEL:RFL"; - in "STAT:DEV:" $level_meter ":LVL:HEL:RFL:%#{ON=1|OFF=0}";} +# The documentation is wrong!: +# READ:DEV:DB1.L1:LVL:HEL:RFL actually returns which relay output is used to turn the +# autofill on or off (or indeed can be used to set which relay is used), not the current status. +# If this is implemented in future, it will need to be modified. +#getLevelHeRefilling { out "READ:DEV:" $level_meter ":LVL:HEL:RFL"; +# in "STAT:DEV:" $level_meter ":LVL:HEL:RFL:%{OFF|ON}";} getLevelHeReadingRate { out "READ:DEV:" $level_meter ":LVL:HEL:PULS:SLOW"; - in "STAT:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%d";} + in "STAT:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%{OFF|ON}";} -setLevelHeReadingRate { out "SET:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%d"; +setLevelHeReadingRate { out "SET:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%{OFF|ON}"; in "STAT:SET:DEV:" $level_meter ":LVL:HEL:PULS:SLOW:%*s"; @init {getLevelHeReadingRate;}} @@ -293,8 +308,12 @@ setLevelNitFillStopThreshold { out "SET:DEV:" $level_meter ":LVL:NIT:HIGH:%d"; in "STAT:SET:DEV:" $level_meter ":LVL:NIT:HIGH:%*d%*s"; @init {getLevelNitFillStopThreshold;}}; -getLevelNitRefilling { out "READ:DEV:" $level_meter ":LVL:NIT:RFL"; - in "STAT:DEV:" $level_meter ":LVL:NIT:RFL:%#{ON=1|OFF=0}";} +# The documentation is wrong!: +# READ:DEV:DB1.L1:LVL:NIT:RFL actually returns which relay output is used to turn the +# autofill on or off (or indeed can be used to set which relay is used), not the current status. +# If this is implemented in future, it will need to be modified. +#getLevelNitRefilling { out "READ:DEV:" $level_meter ":LVL:NIT:RFL"; +# in "STAT:DEV:" $level_meter ":LVL:NIT:RFL:%{OFF|ON}";} getLevelNitrogenLevel { out "READ:DEV:" $level_meter ":LVL:SIG:NIT:LEV"; in "STAT:DEV:" $level_meter ":LVL:SIG:NIT:LEV:%d";} @@ -302,6 +321,21 @@ getLevelNitrogenLevel { out "READ:DEV:" $level_meter ":LVL:SIG:NIT:LEV"; getLevelHeliumLevel { out "READ:DEV:" $level_meter ":LVL:SIG:HEL:LEV"; in "STAT:DEV:" $level_meter ":LVL:SIG:HEL:LEV:%d";} +# ------------------------------------------------------- +# TEMPERATURE BOARD COMMANDS +# ------------------------------------------------------- +getMagnetTemperature { out "READ:DEV:" $magnet_temperature_sensor ":TEMP:SIG:TEMP"; + in "STAT:DEV:" $magnet_temperature_sensor ":TEMP:SIG:TEMP:%f%*s";} + +getLambdaPlateTemperature { out "READ:DEV:" $temperature_sensor_10T ":TEMP:SIG:TEMP"; + in "STAT:DEV:" $temperature_sensor_10T ":TEMP:SIG:TEMP:%f%*s";} + + +# ------------------------------------------------------- +# PRESSURE BOARD COMMANDS +# ------------------------------------------------------- +getPressure { out "READ:DEV:" $pressure_sensor_10T ":PRES:SIG:PRES"; + in "STAT:DEV:" $pressure_sensor_10T ":PRES:SIG:PRES:%f%*s";} diff --git a/OxInstIPSApp/src/Makefile b/OxInstIPSApp/src/Makefile index 2609d42..ca5d4cd 100644 --- a/OxInstIPSApp/src/Makefile +++ b/OxInstIPSApp/src/Makefile @@ -2,11 +2,7 @@ TOP=../.. include $(TOP)/configure/CONFIG -# ------------------------------- -# Build an Diamond Support Module -# ------------------------------- - -PROD_IOC += OxInstIPS +LIBRARY_IOC += OxInstIPS # xxxRecord.h will be created from xxxRecord.dbd #DBDINC += xxx.h @@ -26,10 +22,12 @@ OxInstIPS_DBD += base.dbd OxInstIPS_DBD += calcSupport.dbd OxInstIPS_DBD += asyn.dbd OxInstIPS_DBD += stream.dbd +OxInstIPS_DBD += asubFunctions.dbd # OxInstIPS_registerRecordDeviceDriver.cpp will be created # OxInstIPS.dbd OxInstIPS_SRCS += OxInstIPS_registerRecordDeviceDriver.cpp +OxInstIPS_SRCS += alarms.c # These two lines are needed for non-vxWorks builds, such as Linux OxInstIPS_SRCS_DEFAULT += OxInstIPSMain.cpp @@ -43,7 +41,7 @@ OxInstIPS_OBJS_vxWorks += $(EPICS_BASE_BIN)/vxComLibrary # This line says that this IOC Application depends on the # xxx Support Module -OxInstIPS_LIBS += stream asyn calc sscan pcre +OxInstIPS_LIBS += stream calc sscan pcre utilities asubFunctions # We need to link this IOC Application against the EPICS Base libraries OxInstIPS_LIBS += $(EPICS_BASE_IOC_LIBS) diff --git a/OxInstIPSApp/src/OxInstIPS.dbd b/OxInstIPSApp/src/OxInstIPS.dbd new file mode 100644 index 0000000..5efdeaf --- /dev/null +++ b/OxInstIPSApp/src/OxInstIPS.dbd @@ -0,0 +1 @@ +function(handle_system_alarm_status) diff --git a/OxInstIPSApp/src/alarms.c b/OxInstIPSApp/src/alarms.c new file mode 100644 index 0000000..c8f00ec --- /dev/null +++ b/OxInstIPSApp/src/alarms.c @@ -0,0 +1,61 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "alarms.h" + +static const char* BOARD_ARRAY[] = { + "MB1.T1", // 0 + "DB1.L1", // 1 + "DB8.T1", // 2 + "DB5.P1", // 3 +}; + +#define BOARD_ARRAY (sizeof(BOARD_ARRAY) / sizeof(const char*)) + +static long handle_system_alarm_status(aSubRecord *prec) + { + epicsInt32 i; + epicsOldString* status = (epicsOldString*)prec->vala; + i = *(epicsInt32*)prec->a; + if (prec->fta != menuFtypeSTRING || prec->ftva != menuFtypeLONG) + { + errlogPrintf("%s incorrect input type. Should be A (STRING), VALA (LONG)", prec->name); + return -1; + } + + errlogPrintf("handle_system_alarm_status: result=%s\n", status); + + // Parse the input string, searching for the board name, followed by its status. + const char * board_name = "MB1.T1"; + char *found_board = strstr(status, board_name); + + if (found_board != NULL) + { + // Found the board name, now extract the status. + // The expected format is "MB1.T1\tstatus", where '\t' is a tab character. + board_status = strchr(found_board, '\t'); + if (board_status == NULL) + { + errlogPrintf("handle_system_alarm_status: No tab character found after board name.\n"); + return -1; + } + + // Move past the tab character to get the status. + found_board++; + } + else + { + errlogPrintf("handle_system_alarm_status: Board %s not found in status string.\n", board_name); + return -1; + } + char *board_status = found_board+1; // jump over the tab character (9) + + } + +epicsRegisterFunction(handle_system_alarm_status); diff --git a/OxInstIPSApp/src/alarms.h b/OxInstIPSApp/src/alarms.h new file mode 100644 index 0000000..bd29adb --- /dev/null +++ b/OxInstIPSApp/src/alarms.h @@ -0,0 +1,14 @@ +#ifndef ALARMS_H +#define ALARMS_H + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +extern long handle_system_alarm_status(aSubRecord *prec); + +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#endif /* ALARMS_H */ diff --git a/configure/RELEASE b/configure/RELEASE index cecf82f..7601c2e 100644 --- a/configure/RELEASE +++ b/configure/RELEASE @@ -16,11 +16,12 @@ TEMPLATE_TOP=$(EPICS_BASE)/templates/makeBaseApp/top # define INSTALL_LOCATION_APP here #INSTALL_LOCATION_APP= -# Support Area -SUPPORT=/dls_sw/prod/R3.14.12.3/support - # Support Modules ASYN=$(SUPPORT)/asyn/master +ONCRPC=$(SUPPORT)/oncrpc/master +SNCSEQ=$(SUPPORT)/seq/master +UTILITIES=$(SUPPORT)/utilities/master +ASUBFUNCTIONS=$(SUPPORT)/asubFunctions/master STREAMDEVICE=$(SUPPORT)/StreamDevice/master CALC=$(SUPPORT)/calc/master SSCAN=$(SUPPORT)/sscan/master From 78a54322b96b48050c797b4c81352f202c2bc3da Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 08:59:11 +0100 Subject: [PATCH 34/61] Added pressure and levels daughter board support --- .../ips/interfaces/stream_interface_scpi.py | 85 ++++++++++++++----- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 8c37174..8f795d1 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -3,7 +3,11 @@ from lewis.utils.command_builder import CmdBuilder from ..device import amps_to_tesla, tesla_to_amps -from ..modes import Activity, Control +from ..modes import (Activity, + Control, + LevelMeterHeliumReadRate, + TemperatureBoardStatus, + LevelMeterBoardStatus, PressureBoardStatus) MODE_MAPPING = { 'HOLD': Activity.HOLD, @@ -27,8 +31,7 @@ class DeviceUID: magnet_temperature_sensor = "MB1.T1" level_meter = "DB1.L1" magnet_supply = "GRPZ" - # temperature_sensor_10T = "DB8.T1" - temperature_sensor_10t = "MB1.T1" + temperature_sensor_10T = "DB8.T1" pressure_sensor_10t = "DB5.P1" @@ -143,7 +146,13 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_he_read_rate") .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW").eos().build(), CmdBuilder("set_he_read_rate") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:").int().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:").enum("OFF", "ON").eos().build(), + CmdBuilder("get_magnet_temperature") + .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP").eos().build(), + CmdBuilder("get_lambda_plate_temperature") + .escape(f"READ:DEV:{DeviceUID.temperature_sensor_10T}:TEMP:SIG:TEMP").eos().build(), + CmdBuilder("get_pressure") + .escape(f"READ:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES").eos().build(), } @@ -302,13 +311,19 @@ def get_system_alarms(self) -> str: STAT:SYS:ALRM:MB1.T1Open Circuit; STAT:SYS:ALRM:DB1.L1Short Circuit; """ - alarms = ["STAT:SYS:ALRM",] - if self.device.tempboard_status.value != 0: - alarms.append((f":{DeviceUID.magnet_temperature_sensor}\t" - f"{self.device.tempboard_status.text()};")) - if self.device.levelboard_status.value != 0: - alarms.append((f":{DeviceUID.level_meter}\t" - f"{self.device.levelboard_status.text()};")) + alarms = ["STAT:SYS:ALRM:",] + if self.device.tempboard_status != TemperatureBoardStatus.OK: + alarms.append((f"{DeviceUID.magnet_temperature_sensor}\t" + f"{TemperatureBoardStatus.names()[self.device.tempboard_status.value]};")) + if self.device.tempboard_10T_status != TemperatureBoardStatus.OK: + alarms.append((f"{DeviceUID.temperature_sensor_10T}\t" + f"{TemperatureBoardStatus.names()[self.device.tempboard_10T_status.value]};")) + if self.device.levelboard_status.value != LevelMeterBoardStatus.OK: + alarms.append((f"{DeviceUID.level_meter}\t" + f"{LevelMeterBoardStatus.names()[self.device.levelboard_status.value]};")) + if self.device.pressureboard_status.value != PressureBoardStatus.OK: + alarms.append((f"{DeviceUID.pressure_sensor_10t}\t" + f"{LevelMeterBoardStatus.names()[self.device.pressureboard_status.value]};")) alarm_list_str = "".join(alarms) return alarm_list_str @@ -512,16 +527,44 @@ def get_he_read_rate(self) -> str: Gets the helium read rate. :return: A string indicating the helium read rate. """ - return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:"\ - f"{self.device.helium_read_rate:d}" - - def set_he_read_rate(self, rate: int) -> str: + state: str = 'ON' if (self.device.helium_read_rate == LevelMeterHeliumReadRate.SLOW.value)\ + else 'OFF' + + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:{state}" + + def set_he_read_rate(self, slow_rate: str) -> str: """ - Sets the helium read rate. - :param rate: The helium read rate to set. + Sets the helium read slow_rate (from bo) + :param slow_rate: The helium read slow rate to set: OFF -> FAST, ON -> SLOW :return: A string indicating the success of the operation. """ - self.device.helium_read_rate = rate - return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:"\ - f"{self.device.helium_read_rate:d}:VALID" - \ No newline at end of file + self.device.helium_read_rate = LevelMeterHeliumReadRate.FAST.value if (slow_rate == "OFF") \ + else LevelMeterHeliumReadRate.SLOW.value + + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:{slow_rate}:VALID" + + def get_magnet_temperature(self) -> str: + """ + Gets the temperature of the magnet. + :return: The temperature in Kelvin. + """ + return (f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP:" + f"{self.device.magnet_temperature:.4f}K") + + def get_lambda_plate_temperature(self) -> str: + """ + Gets the temperature of the lambda plate. + :return: The temperature in Kelvin. + """ + return (f"STAT:DEV:{DeviceUID.temperature_sensor_10T}:TEMP:SIG:TEMP:" + f"{self.device.lambda_plate_temperature:.4f}K") + + + def get_pressure(self) -> str: + """ + Gets the pressure in mBar. + :return: The pressure in mBar. + """ + #return self.device.pressure + return (f"STAT:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES:" + f"{self.device.pressure:.4f}mB") From 923e6d37e8333ad56433a9342df37a549846c9bb Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 09:01:02 +0100 Subject: [PATCH 35/61] Modified src/Makefile to build for both the test IOC and support module library --- OxInstIPSApp/src/Makefile | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/OxInstIPSApp/src/Makefile b/OxInstIPSApp/src/Makefile index ca5d4cd..7d998c7 100644 --- a/OxInstIPSApp/src/Makefile +++ b/OxInstIPSApp/src/Makefile @@ -3,6 +3,7 @@ TOP=../.. include $(TOP)/configure/CONFIG LIBRARY_IOC += OxInstIPS +PROD_IOC += OxInstIPSIoc # xxxRecord.h will be created from xxxRecord.dbd #DBDINC += xxx.h @@ -17,7 +18,14 @@ LIBRARY_IOC += OxInstIPS # OxInstIPS.dbd will be installed into /dbd DBD += OxInstIPS.dbd -# OxInstIPS.dbd will be created from these files +# OxInstIPSIoc.dbd will be created from these files +OxInstIPSIoc_DBD += base.dbd +OxInstIPSIoc_DBD += calcSupport.dbd +OxInstIPSIoc_DBD += asyn.dbd +OxInstIPSIoc_DBD += stream.dbd +OxInstIPSIoc_DBD += asubFunctions.dbd +OxInstIPSIoc_DBD += OxInstIPS.dbd + OxInstIPS_DBD += base.dbd OxInstIPS_DBD += calcSupport.dbd OxInstIPS_DBD += asyn.dbd @@ -26,24 +34,27 @@ OxInstIPS_DBD += asubFunctions.dbd # OxInstIPS_registerRecordDeviceDriver.cpp will be created # OxInstIPS.dbd -OxInstIPS_SRCS += OxInstIPS_registerRecordDeviceDriver.cpp +OxInstIPSIoc_SRCS += OxInstIPS_registerRecordDeviceDriver.cpp OxInstIPS_SRCS += alarms.c # These two lines are needed for non-vxWorks builds, such as Linux -OxInstIPS_SRCS_DEFAULT += OxInstIPSMain.cpp -OxInstIPS_SRCS_vxWorks += -nil- +OxInstIPSIoc_SRCS_DEFAULT += OxInstIPSMain.cpp +OxInstIPSIoc_SRCS_vxWorks += -nil- # Add locally compiled object code #OxInstIPS_SRCS += # The following adds object code from base/src/vxWorks -OxInstIPS_OBJS_vxWorks += $(EPICS_BASE_BIN)/vxComLibrary +OxInstIPSIoc_OBJS_vxWorks += $(EPICS_BASE_BIN)/vxComLibrary # This line says that this IOC Application depends on the # xxx Support Module -OxInstIPS_LIBS += stream calc sscan pcre utilities asubFunctions +OxInstIPSIoc_LIBS += stream calc sscan pcre utilities asubFunctions OxInstIPS # We need to link this IOC Application against the EPICS Base libraries +OxInstIPSIoc_LIBS += $(EPICS_BASE_IOC_LIBS) + +OxInstIPS_LIBS += stream calc sscan pcre utilities asubFunctions OxInstIPS_LIBS += $(EPICS_BASE_IOC_LIBS) # --------------------------------------------------- @@ -75,3 +86,5 @@ OxInstIPS_LIBS += $(EPICS_BASE_IOC_LIBS) #endif include $(TOP)/configure/RULES + + From b3cbf3b9f7d6a423f3f508cda8ce0a4ba708e21d Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 09:03:14 +0100 Subject: [PATCH 36/61] device.py: Added Temperature and pressure values along with system alarm status for daughter boards --- system_tests/lewis_emulators/ips/device.py | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index bfc2844..46f7840 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -11,7 +11,7 @@ MagnetSupplyStatus, Mode, SweepMode, - TemperatureBoardStatus, + TemperatureBoardStatus, PressureBoardStatus, ) from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState @@ -127,9 +127,12 @@ def reset(self) -> None: self.magnet_supply_status = MagnetSupplyStatus.OK self.voltage_limit: float = 10.0 - + + # Daughter boards status - returned by READ:SYS:ALRM self.tempboard_status: TemperatureBoardStatus = TemperatureBoardStatus.OPEN_CIRCUIT + self.tempboard_10T_status: TemperatureBoardStatus = TemperatureBoardStatus.OK self.levelboard_status: LevelMeterBoardStatus = LevelMeterBoardStatus.OK + self.pressureboard_status: PressureBoardStatus = PressureBoardStatus.OK self.helium_empty_resistance: float = 25.0 self.helium_full_resistance: float = 0.12 @@ -145,6 +148,10 @@ def reset(self) -> None: self.nitrogen_fill_stop_level: int = 95 self.nitrogen_level: int = 50 + self.magnet_temperature: float = 4.2345 # Kelvin + self.lambda_plate_temperature: float =4.3456 # Kelvin + self.pressure: float = 28.3898 # mBar + def _get_state_handlers(self) -> dict: return { @@ -215,6 +222,16 @@ def set_tempboard_status(self, status_value: int) -> None: (f"Invalid temperature board status value: {status_value}." f" Must be one of {list(TemperatureBoardStatus)}") ) + def set_tempboard_10T_status(self, status_value: int) -> None: + """Sets the temperature board 10T status.""" + if status_value in iter(TemperatureBoardStatus): + status: TemperatureBoardStatus = TemperatureBoardStatus(status_value) + self.tempboard_10T_status = status + else: + raise ValueError( + (f"Invalid temperature board 10T status value: {status_value}." + f" Must be one of {list(TemperatureBoardStatus)}") + ) def set_levelboard_status(self, status_value: int) -> None: """Sets the temperature board status.""" @@ -227,6 +244,18 @@ def set_levelboard_status(self, status_value: int) -> None: f" Must be one of {list(LevelMeterBoardStatus)}") ) + def set_pressureboard_status(self, status_value: int) -> None: + """Sets the pressure board status.""" + if status_value in iter(PressureBoardStatus): + status: PressureBoardStatus = PressureBoardStatus(status_value) + self.pressureboard_status = status + else: + raise ValueError( + (f"Invalid pressure board status value: {status_value}." + f" Must be one of {list(PressureBoardStatus)}") + ) + + def get_nitrogen_refilling(self) -> bool: """Returns whether the nitrogen refilling is in progress.""" return self.nitrogen_fill_start_level < self.nitrogen_level < self.nitrogen_fill_stop_level From d6c6b7e5ffce6c83cb4fd0d4c8c0dd0e1b185bce Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 09:09:08 +0100 Subject: [PATCH 37/61] OxInstIPS_SCPI.protocol: SYS:ALRM now read in one string then passed to an aSub record for parsing --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 93e39c4..58840a2 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -133,26 +133,11 @@ getHeaterStatus { out "READ:DEV:" $magnet_supply ":PSU:SIG:SWHT"; in "STAT:DEV:" $magnet_supply ":PSU:SIG:SWHT:%{OFF|ON}";} # *** Need to know what is returned on no alarms present, as it is not documented. *** +# *** Found empirically that it returns an empty string (afterSTAT:SYS:ALRM:) +# when no alarms are present. *** +# Note: For the input we must use %#s (not just %s) as there may be a tab character delimiter getSysAlarms { out "READ:SYS:ALRM"; - in "STAT:SYS:ALRM:%s";} - -# The following two reads are used to read the system alarms, hopefully in any format or order, -# as long as the essential patterns match somewhere in the input string. -# On testing with a real device, the input string is of the form: -# STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Open Circuit; -# If no errors are present, the input string is empty: -# STAT:SYS:ALRM: -readSysAlarmsTemperatureBoard { - in "STAT:SYS:ALRM:%*/MB1.T1\t/%#{Open Circuit=1|Short Circuit=2|Calibration Error=3|Firmware Error=4|Board Not Configured=5};";} -readSysAlarms10TBoard { - in "STAT:SYS:ALRM:%*/DB8.T1\t/%#{Open Circuit=1|Short Circuit=2|Calibration Error=3|Firmware Error=4|Board Not Configured=5};";} -readSysAlarmsLevelMeterBoard { - in "STAT:SYS:ALRM:%*/DB1.L1\t/%#{Open Circuit=1|Short Circuit=2|ADC Error=3|Over Demand=4|Over Temperature=5|Firmware Error=6|Board Not Configured=7|No Reserve=8};";} - -# Cater for when no alarms are present, where we just receive STAT:SYS:ALRM: -# The intention is that in the empty scenario, all alarm records will be reset to No Alarm, as they -# can't determine on their own that there are no alarms present. -readSysAlarmsEmpty { in "STAT:SYS:ALRM:%s"; } + in "STAT:SYS:ALRM:%#s";} # --------------- The following work around limitation of getting legacy status from SCPI protocol ------------- From 707e698b3a49f59f2e3252fa4a481dee72f6137f Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 09:11:18 +0100 Subject: [PATCH 38/61] modes.py: Pressure board class added and case corrected for some alarm messages --- system_tests/lewis_emulators/ips/modes.py | 64 +++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index 797394e..bcd8f94 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -113,8 +113,8 @@ class TemperatureBoardStatus(BoardStatus): @classmethod def names(cls) -> list[str]: - return ["", "Open Circuit", "Short Circuit", "Calibration Error", - "Firmware Error", "Board Not Configured"] + return ["", "Open circuit", "Short circuit", "Calibration error", + "Firmware error", "Board not configured"] @@ -149,8 +149,8 @@ class LevelMeterBoardStatus(BoardStatus): @classmethod def names(cls) -> list[str]: - return ["", "Open Circuit", "Short Circuit", "ADC Error", "Over Demand", - "Over Temperature", "Firmware Error", "Board Not Configured", "No Reserve"] + return ["", "Open circuit", "Short circuit", "ADC error", "Over demand", + "Over temperature", "Firmware error", "board not configured", "No reserve"] class LevelMeterHeliumReadRate(IntEnum): """ @@ -165,4 +165,58 @@ class LevelMeterHeliumReadRate(IntEnum): @classmethod def names(cls) -> list[str]: return ["Slow", "Fast"] - \ No newline at end of file + + +class PressureBoardStatus(BoardStatus): + """ + This class represents the status of the pressure board + and is only applicable to the IPS SCPI protocol. + These alarms are returned in response to the READ:SYS:ALRM commnand returning errors as strings + with the following example: STAT:SYS:ALRM:MB1.L1Open Circuit; + + | Status | Description | Bit Value | Bit Position | + |----------------------|-------------------------------------------|-----------|--------------| + | Open Circuit | Heater off - Open circuit on probe input | 00000001 | 0 | + | Short Circuit | Short circuit on probe input | 00000002 | 1 | + | ADC Error | On-board diagnostic: recalibrate | 00000004 | 2 | + | Over Demand | On-board diagnostic: recalibrate | 00000008 | 3 | + | Over Temperature | | 00000010 | 4 | + | Firmware Error | Error in board firmware: restart iPS | 00000020 | 5 | + | Board Not Configured | Firmware not loaded correctly: update f/w | 00000040 | 6 | + | No Reserve | Autofill valve open but not filling | 00000080 | 7 | + +""" + OK = 0 + OPEN_CIRCUIT = 1 + SHORT_CIRCUIT = 2 + CALIB_ERROR = 3 + FWERROR = 4 + NOTCONFIGD = 5 + OVER_CURRENT = 6 + CURRENT_LEAK = 7 + PWRONFAIL = 8 + CHKSUMERR = 9 + CLKFAIL = 10 + ADC_ERROR = 11 + MAINS_FAIL = 12 + REFERENCE_FAIL = 13 + PLUS12VFAIL = 14 + MINUS12VFAIL = 15 + PLUS8VFAIL = 16 + MINUS8VFAIL = 17 + AMPGAIN_ERR = 18 + AMPOFFSET_ERR = 19 + ADCPGA_ERR = 20 + ADCXTAL_ERR = 21 + PLUSEXCITE_ERR = 22 + MINUSXCITE_ERR = 23 + + @classmethod + def names(cls) -> list[str]: + return ["", "Open circuit", "Short circuit", "Calibration error", "Firmware error", + "Board not configured", "Over current", "Current leakage", "Power on fail", + "Checksum fail", "Clock fail", "ADC fail", "Mains fail", + "Reference fail", "12V fail", "-12V fail", "8V fail", "-8V fail", + "Ampl gain error", "Amp offset error", "ADC PGA error", "ADC XTAL error", + "Excitation + error", "Excitation - error"] + From 735cc3796bf09c968fc0c1f1bd59b9a9a0a3939e Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 09:12:22 +0100 Subject: [PATCH 39/61] RELEASE cosmetic mods --- configure/RELEASE | 1 + 1 file changed, 1 insertion(+) diff --git a/configure/RELEASE b/configure/RELEASE index 7601c2e..5087440 100644 --- a/configure/RELEASE +++ b/configure/RELEASE @@ -21,6 +21,7 @@ ASYN=$(SUPPORT)/asyn/master ONCRPC=$(SUPPORT)/oncrpc/master SNCSEQ=$(SUPPORT)/seq/master UTILITIES=$(SUPPORT)/utilities/master + ASUBFUNCTIONS=$(SUPPORT)/asubFunctions/master STREAMDEVICE=$(SUPPORT)/StreamDevice/master CALC=$(SUPPORT)/calc/master From b17feea54876d3aacd7cb6a359039dcdc08bad51 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 7 Aug 2025 09:13:50 +0100 Subject: [PATCH 40/61] alarms.c: aSub function starting to receive alarm data and preliminary parsing. Needs refactoring. --- OxInstIPSApp/src/alarms.c | 234 +++++++++++++++++++++++++++++++++----- 1 file changed, 203 insertions(+), 31 deletions(-) diff --git a/OxInstIPSApp/src/alarms.c b/OxInstIPSApp/src/alarms.c index c8f00ec..24b8fdb 100644 --- a/OxInstIPSApp/src/alarms.c +++ b/OxInstIPSApp/src/alarms.c @@ -1,5 +1,35 @@ +/* alarms.c + * + * This C code contains the implementation of the aSub record for handling system alarm status. + * It is part of the OxInstIPS application and is used to process alarm messages from the + * system, specifically for the temperature, levels and pressure control boards. + * Board identifiers are provided as macros and passed to the aSub as input fields B-E. + * + * The aSub record processes alarm messages received from the system. The input is a string + * containing the alarm message, which includes the board identifier and its status. + * It extracts the board identifier and the alarm message, and writes them to the appropriate + * output fields. The output fields (OUTA) reference mbbidirect records, where the bit patterns will be + * established according to active alarms. + * + * INPA - Input string containing the alarm message. + * INPB - Board identifier form the magnet temperature controller (e.g. "MB1.T1") + * INPC - Board identifier form the 10T magnet temperature controller (e.g. "DB8.T1") + * INPD - Board identifier form the Levels controller (e.g. "DB1.L1") + * INPE - Board identifier form the pressure controller (e.g. "DB5.P1") + * + * OUTA - Output field for the magnet temperature alarm status (mbbidirect). + * OUTA - Output field for the magnet 10T temperature alarm status (mbbidirect). + * OUTA - Output field for the levels alarm status (mbbidirect). + * OUTA - Output field for the pressure alarm status (mbbidirect). + * + * Incoming alarm messages are expected to be in the format: + * "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Open Circuit;" + * where <9> is the tab character. + */ + #include #include +#include #include #include #include @@ -9,53 +39,195 @@ #include #include "alarms.h" -static const char* BOARD_ARRAY[] = { - "MB1.T1", // 0 - "DB1.L1", // 1 - "DB8.T1", // 2 - "DB5.P1", // 3 +// The number of control boards we are monitoring +#define NBOARDS 4 + +// The maximum number of tokens we expect in the status string +#define MAX_TOKENS 32 + +static const char *STATUS_TEXT_TEMPERATURE[] = { + "Open circuit", + "Short circuit", + "Calibration error", + "Firmware error", + "Board not configured", +}; + +static const char *STATUS_TEXT_LEVEL[] = { + "Open circuit", + "Short circuit", + "ADC error", + "Over demand", + "Over temperature", + "Firmware error", + "Board not configured", + "No reserve" +}; + +static const char *STATUS_TEXT_PRESSURE[] = { + "Open circuit", + "Short circuit", + "Calibration error", + "Firmware error", + "Board not configured", + "Over current", + "Current leakage", + "Power on fail", + "Checksum fail", + "Clock fail", + "ADC fail", + "Mains fail", + "Reference fail", + "12V fail", + "-12V fail", + "8V fail", + "-8V fail", + "Amp gain error", + "Amp offset error", + "ADC offset error", + "ADC PGA error", + "ADC XTAL error", + "Excitation + error", + "Excitation - error" }; -#define BOARD_ARRAY (sizeof(BOARD_ARRAY) / sizeof(const char*)) +// Predetermine the number of status text entries for each board type +#define NUM_STATUS_TEXT_TEMPERATURE (sizeof(STATUS_TEXT_TEMPERATURE) / sizeof(STATUS_TEXT_TEMPERATURE[0])) +#define NUM_STATUS_TEXT_LEVEL (sizeof(STATUS_TEXT_LEVEL) / sizeof(STATUS_TEXT_LEVEL[0])) +#define NUM_STATUS_TEXT_PRESSURE (sizeof(STATUS_TEXT_PRESSURE) / sizeof(STATUS_TEXT_PRESSURE[0])) + +static const char **STATUS_TEXT_ARRAY[] = { + STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board} + STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board + STATUS_TEXT_LEVEL, // Levels Controller Board + STATUS_TEXT_PRESSURE // Pressure Controller Board +}; + +// Helper to reduce code complexity +static const int STATUS_TEXT_ARRAY_SIZE[] = { + NUM_STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board + NUM_STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board + NUM_STATUS_TEXT_LEVEL, // Levels Controller Board + NUM_STATUS_TEXT_PRESSURE // Pressure Controller Board +}; static long handle_system_alarm_status(aSubRecord *prec) { - epicsInt32 i; - epicsOldString* status = (epicsOldString*)prec->vala; - i = *(epicsInt32*)prec->a; - if (prec->fta != menuFtypeSTRING || prec->ftva != menuFtypeLONG) + char* BOARD_ARRAY[NBOARDS]; + + if ( + prec->fta != menuFtypeSTRING + || prec->ftb != menuFtypeSTRING + || prec->ftc != menuFtypeSTRING + || prec->ftd != menuFtypeSTRING + || prec->fte != menuFtypeSTRING + || prec->ftva != menuFtypeLONG + || prec->ftvb != menuFtypeLONG + || prec->ftvc != menuFtypeLONG + || prec->ftvd != menuFtypeLONG + ) { - errlogPrintf("%s incorrect input type. Should be A (STRING), VALA (LONG)", prec->name); + errlogPrintf("%s incorrect input type. Should be INPA,B,C,D,E (STRING), VALA,B,C,D (LONG)", + prec->name); return -1; } + errlogPrintf("%s: handle_system_alarm_status: about to copy names.\n", prec->name); + + BOARD_ARRAY[0] = "MB1.T1"; // Magnet Temperature Controller Board + BOARD_ARRAY[1] = "DB8.T1"; // 10T Magnet Temperature Controller Board + BOARD_ARRAY[2] = "DB1.L1"; // Levels Controller Board + BOARD_ARRAY[3] = "DB5.P1"; // Pressure Controller Board + + // Populate the BOARD_ARRAY with the board identifiers from the input fields. + // Typically: "MB1.T1", "DB8.T1", "DB1.L1", "DB5.P1" + //strcpy(BOARD_ARRAY[0], (char *)prec->b); // Magnet Temperature Controller Board + //strcpy(BOARD_ARRAY[1], (char *)prec->c); // 10T Magnet Temperature Controller Board + //strcpy(BOARD_ARRAY[2], (char *)prec->d); // Levels Controller Board + //strcpy(BOARD_ARRAY[3], (char *)prec->e); // Pressure Controller Board + + errlogPrintf("%s: handle_system_alarm_status: names copied - getting status from INPA.\n", prec->name); + + char* status = (char *)((epicsOldString*)prec->a); + errlogPrintf("handle_system_alarm_status: result=%s\n", status); // Parse the input string, searching for the board name, followed by its status. - const char * board_name = "MB1.T1"; - char *found_board = strstr(status, board_name); - - if (found_board != NULL) + for (int board_index = 0; board_index < NBOARDS; board_index++) { - // Found the board name, now extract the status. - // The expected format is "MB1.T1\tstatus", where '\t' is a tab character. - board_status = strchr(found_board, '\t'); - if (board_status == NULL) + epicsInt32 bitpattern = 0L; + char *board_name = BOARD_ARRAY[board_index]; + char *found_board = strstr(status, board_name); + + if (found_board != NULL) { - errlogPrintf("handle_system_alarm_status: No tab character found after board name.\n"); - return -1; - } + // Found the board name, now extract the status. + // The expected format is "MB1.T1\tstatus", where '\t' is a tab character. + char *board_status = strchr(found_board, '\t'); + if (board_status == NULL) + { + errlogPrintf("%s: handle_system_alarm_status: No tab character found after board name.\n", prec->name); + return -1; + } - // Move past the tab character to get the status. - found_board++; - } - else - { - errlogPrintf("handle_system_alarm_status: Board %s not found in status string.\n", board_name); - return -1; - } - char *board_status = found_board+1; // jump over the tab character (9) + // Move past the tab character to get the semicolon delimited status strings. + board_status++; + // Split the status string by semicolons to get individual status codes. + char *token_list[MAX_TOKENS]; + int token_index = 0; + char *token = strtok(board_status, ";"); + while (token != NULL) + { + // Process each token (status code) + errlogPrintf("Status token: %s\n", token); + char *new_token = (char*) malloc((strlen(token) + 1)*sizeof(char)); + strcpy(new_token, token); + token_list[token_index] = new_token; + token_index++; + token = strtok(NULL, ";"); + } + // Look up the status bit position code in the corresponding status text array + // for each token. + for (int iLookup_index=0; iLookup_index < token_index; iLookup_index++) + { + // Convert the token to an integer to find its position in the status text array. + //int bit_pos = atoi(token_list[j]); + bool bMsgFound = false; + for (int iBoard_status_item = 0; iBoard_status_item < STATUS_TEXT_ARRAY_SIZE[board_index]; iBoard_status_item++) + { + if (strcmp(token_list[iLookup_index], STATUS_TEXT_ARRAY[board_index][iBoard_status_item]) == 0) + { + bMsgFound = true; + errlogPrintf("%s: handle_system_alarm_status: Found match to: %s .\n", prec->name, STATUS_TEXT_ARRAY[board_index][iBoard_status_item]); + // Found a matching status code, set the corresponding bit in the bitpattern. + errlogPrintf("%s: handle_system_alarm_status: Setting bit %d.\n", prec->name, iBoard_status_item); + bitpattern |= (1UL << iBoard_status_item); + } + } + if (bMsgFound == false) + { + // The incoming message wasn't found in the array of expected messages. + errlogPrintf( + "handle_system_alarm_status: Invalid status code '%s' for board %s.\n", + token_list[iLookup_index], board_name); + } + free(token_list[iLookup_index]); // Free the allocated memory for the token + } + // Write the status to the output field + //memcpy(prec->vala, &bitpattern, sizeof(unsigned long)); + errlogPrintf("%s: handle_system_alarm_status: Setting VALA to bit pattern: %ud\n", prec->name, bitpattern); + *(epicsInt32*)prec->vala = bitpattern; // Ensure the output is in the correct format + } + else + { + errlogPrintf("handle_system_alarm_status: Board %s not found in status string.\n", board_name); + bitpattern = 0L; + //memcpy(prec->vala, &bitpattern, sizeof(unsigned long)); + *(epicsInt32*)prec->vala = bitpattern; // Ensure the output is in the correct format + } + } + return 0; // Process output links } epicsRegisterFunction(handle_system_alarm_status); From 5a3ed7dc4e773dcdc8918487aaff44889b8411ec Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 12 Aug 2025 08:59:18 +0100 Subject: [PATCH 41/61] Switch alarms code from C to C++ and renamed to alarms.cpp --- OxInstIPSApp/src/alarms.cpp | 265 ++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 OxInstIPSApp/src/alarms.cpp diff --git a/OxInstIPSApp/src/alarms.cpp b/OxInstIPSApp/src/alarms.cpp new file mode 100644 index 0000000..d381e44 --- /dev/null +++ b/OxInstIPSApp/src/alarms.cpp @@ -0,0 +1,265 @@ +/* alarms.c + * + * This C code contains the implementation of the aSub record for handling system alarm status. + * It is part of the OxInstIPS application and is used to process alarm messages from the + * system, specifically for the temperature, levels and pressure control boards. + * Board identifiers are provided as macros and passed to the aSub as input fields B-E. + * + * The aSub record processes alarm messages received from the system. The input is a string + * containing the alarm message, which includes the board identifier and its status. + * It extracts the board identifier and the alarm message, and writes them to the appropriate + * output fields. The output fields (OUTA) reference mbbidirect records, where the bit patterns will be + * established according to active alarms. + * + * INPA - Input string containing the alarm message. + * INPB - Board identifier form the magnet temperature controller (e.g. "MB1.T1") + * INPC - Board identifier form the 10T magnet temperature controller (e.g. "DB8.T1") + * INPD - Board identifier form the Levels controller (e.g. "DB1.L1") + * INPE - Board identifier form the pressure controller (e.g. "DB5.P1") + * + * OUTA - Output field for the magnet temperature alarm status (mbbidirect). + * OUTA - Output field for the magnet 10T temperature alarm status (mbbidirect). + * OUTA - Output field for the levels alarm status (mbbidirect). + * OUTA - Output field for the pressure alarm status (mbbidirect). + * + * Incoming alarm messages are expected to be in the format: + * "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Open Circuit;" + * where <9> is the tab character. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "alarms.h" + +using namespace std; + +// The number of control boards we are monitoring +#define NBOARDS 4 + +// The maximum number of tokens we expect in the status string +#define MAX_TOKENS 32 + +static const char *STATUS_TEXT_TEMPERATURE[] = { + "Open circuit", + "Short circuit", + "Calibration error", + "Firmware error", + "Board not configured", +}; + +static const char *STATUS_TEXT_LEVEL[] = { + "Open circuit", + "Short circuit", + "ADC error", + "Over demand", + "Over temperature", + "Firmware error", + "Board not configured", + "No reserve" +}; + +static const char *STATUS_TEXT_PRESSURE[] = { + "Open circuit", + "Short circuit", + "Calibration error", + "Firmware error", + "Board not configured", + "Over current", + "Current leakage", + "Power on fail", + "Checksum fail", + "Clock fail", + "ADC fail", + "Mains fail", + "Reference fail", + "12V fail", + "-12V fail", + "8V fail", + "-8V fail", + "Amp gain error", + "Amp offset error", + "ADC offset error", + "ADC PGA error", + "ADC XTAL error", + "Excitation + error", + "Excitation - error" +}; + +// Predetermine the number of status text entries for each board type +#define NUM_STATUS_TEXT_TEMPERATURE (sizeof(STATUS_TEXT_TEMPERATURE) / sizeof(STATUS_TEXT_TEMPERATURE[0])) +#define NUM_STATUS_TEXT_LEVEL (sizeof(STATUS_TEXT_LEVEL) / sizeof(STATUS_TEXT_LEVEL[0])) +#define NUM_STATUS_TEXT_PRESSURE (sizeof(STATUS_TEXT_PRESSURE) / sizeof(STATUS_TEXT_PRESSURE[0])) + +static const char **STATUS_TEXT_ARRAY[] = { + STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board} + STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board + STATUS_TEXT_LEVEL, // Levels Controller Board + STATUS_TEXT_PRESSURE // Pressure Controller Board +}; + +// Helper to reduce code complexity +static const int STATUS_TEXT_ARRAY_SIZE[] = { + NUM_STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board + NUM_STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board + NUM_STATUS_TEXT_LEVEL, // Levels Controller Board + NUM_STATUS_TEXT_PRESSURE // Pressure Controller Board +}; + +//epicsShareExtern?? +static long handle_system_alarm_status(aSubRecord *prec) + { + vector token_list; + vector BOARD_ARRAY; + vector out_bit_patterns(NBOARDS, 0); // vala, valb, valc, vald accumulated bit patterns + + if ( + prec->fta != menuFtypeSTRING + || prec->ftb != menuFtypeSTRING + || prec->ftc != menuFtypeSTRING + || prec->ftd != menuFtypeSTRING + || prec->fte != menuFtypeSTRING + || prec->ftva != menuFtypeLONG + || prec->ftvb != menuFtypeLONG + || prec->ftvc != menuFtypeLONG + || prec->ftvd != menuFtypeLONG + ) + { + errlogPrintf("%s incorrect input type. Should be INPA,B,C,D,E (STRING), VALA,B,C,D (LONG)", + prec->name); + return -1; + } + + errlogPrintf("%s: handle_system_alarm_status: about to copy names.\n", prec->name); + + BOARD_ARRAY.push_back("MB1.T1"); // Magnet Temperature Controller Board + BOARD_ARRAY.push_back("DB8.T1"); // 10T Magnet Temperature Controller Board + BOARD_ARRAY.push_back("DB1.L1"); // Levels Controller Board + BOARD_ARRAY.push_back("DB5.P1"); // Pressure Controller Board + + // Populate the BOARD_ARRAY with the board identifiers from the input fields. + // Typically: "MB1.T1", "DB8.T1", "DB1.L1", "DB5.P1" + //strcpy(BOARD_ARRAY[0], (char *)prec->b); // Magnet Temperature Controller Board + //strcpy(BOARD_ARRAY[1], (char *)prec->c); // 10T Magnet Temperature Controller Board + //strcpy(BOARD_ARRAY[2], (char *)prec->d); // Levels Controller Board + //strcpy(BOARD_ARRAY[3], (char *)prec->e); // Pressure Controller Board + + errlogPrintf("%s: handle_system_alarm_status: names copied - getting status from INPA.\n", prec->name); + + string status = string((char *)((epicsOldString*)prec->a)); + + errlogPrintf("handle_system_alarm_status: result=%s\n", status.c_str()); + + // Tokenise the input string to extract the list of board+status. + // Of the form: "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Short Circuit;DB1.L1<9>Over Demand;DB5.P1<9>Open Circuit;" + // Or empty if no alarms are present. + // We'll split the string by semicolons which will produce a list of status messages. + stringstream check(status); + string token; + while (getline(check, token, ';')) + { + if (!token.empty()) + { + // Remove the trailing semicolon if present + if (token.back() == ';') + token.pop_back(); + // Add the token to the list + token_list.push_back(token); + } + } + for(int i = 0; i < token_list.size(); i++) + errlogPrintf("%s: token %d: %s\n", prec->name, i, token_list[i].c_str()); + + // Now we have a list of tokens, each of which is of the form "status message". + // We will process each token to extract the board ID and status message. + for (const auto& token : token_list) + { + size_t tab_pos = token.find('\t'); + if (tab_pos == string::npos) + { + errlogPrintf("%s: Invalid token format: %s\n", prec->name, token.c_str()); + continue; // Skip invalid tokens + } + + string board_id = token.substr(0, tab_pos); + string status_message = token.substr(tab_pos + 1); + + // Find the board index based on the board ID + int board_index = -1; + for (int i = 0; i < NBOARDS; ++i) + { + if (board_id == BOARD_ARRAY[i]) + { + board_index = i; + break; + } + } + + if (board_index == -1) + { + errlogPrintf("%s: Unknown board ID: %s\n", prec->name, board_id.c_str()); + continue; // Skip unknown boards + } + + // Now we have the board index and the status message. + // We need to convert the status message to a numeric value. + int status_value = -1; + const char **status_text_array = STATUS_TEXT_ARRAY[board_index]; + int num_status_text = STATUS_TEXT_ARRAY_SIZE[board_index]; + + for (int j = 0; j < num_status_text; ++j) + { + if (status_message == status_text_array[j]) + { + status_value = j; + break; + } + } + + if (status_value == -1) + { + errlogPrintf("%s: Unknown status message: %s\n", prec->name, status_message.c_str()); + continue; // Skip unknown status messages + } + + out_bit_patterns[board_index] |= (1 << status_value); // Set the bit corresponding to the status value + } // for each token + + for (int board_index = 0; board_index < NBOARDS; ++board_index) + { + // Write the status value to the appropriate output field + switch (board_index) + { + case 0: + prec->vala = (void *)out_bit_patterns[board_index]; // Magnet Temperature Controller Board + break; + case 1: + prec->valb = out_bit_patterns[board_index]; // 10T Magnet Temperature Controller Board + break; + case 2: + prec->valc = out_bit_patterns[board_index]; // Levels Controller Board + break; + case 3: + prec->vald = out_bit_patterns[board_index]; // Pressure Controller Board + break; + default: + errlogPrintf("%s: Invalid board index: %d\n", prec->name, board_index); + break; + } + } + return 0; // Process output links + } + +extern "C" { +epicsRegisterFunction(handle_system_alarm_status); +} + From 3da1d99203bc9cab3462b9c66cbbab7063e01fd6 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 12 Aug 2025 09:01:02 +0100 Subject: [PATCH 42/61] alarms.c renamed to alarms.cpp --- OxInstIPSApp/src/Makefile | 2 +- OxInstIPSApp/src/alarms.c | 233 -------------------------------------- 2 files changed, 1 insertion(+), 234 deletions(-) delete mode 100644 OxInstIPSApp/src/alarms.c diff --git a/OxInstIPSApp/src/Makefile b/OxInstIPSApp/src/Makefile index 7d998c7..0b38495 100644 --- a/OxInstIPSApp/src/Makefile +++ b/OxInstIPSApp/src/Makefile @@ -35,7 +35,7 @@ OxInstIPS_DBD += asubFunctions.dbd # OxInstIPS_registerRecordDeviceDriver.cpp will be created # OxInstIPS.dbd OxInstIPSIoc_SRCS += OxInstIPS_registerRecordDeviceDriver.cpp -OxInstIPS_SRCS += alarms.c +OxInstIPS_SRCS += alarms.cpp # These two lines are needed for non-vxWorks builds, such as Linux OxInstIPSIoc_SRCS_DEFAULT += OxInstIPSMain.cpp diff --git a/OxInstIPSApp/src/alarms.c b/OxInstIPSApp/src/alarms.c deleted file mode 100644 index 24b8fdb..0000000 --- a/OxInstIPSApp/src/alarms.c +++ /dev/null @@ -1,233 +0,0 @@ -/* alarms.c - * - * This C code contains the implementation of the aSub record for handling system alarm status. - * It is part of the OxInstIPS application and is used to process alarm messages from the - * system, specifically for the temperature, levels and pressure control boards. - * Board identifiers are provided as macros and passed to the aSub as input fields B-E. - * - * The aSub record processes alarm messages received from the system. The input is a string - * containing the alarm message, which includes the board identifier and its status. - * It extracts the board identifier and the alarm message, and writes them to the appropriate - * output fields. The output fields (OUTA) reference mbbidirect records, where the bit patterns will be - * established according to active alarms. - * - * INPA - Input string containing the alarm message. - * INPB - Board identifier form the magnet temperature controller (e.g. "MB1.T1") - * INPC - Board identifier form the 10T magnet temperature controller (e.g. "DB8.T1") - * INPD - Board identifier form the Levels controller (e.g. "DB1.L1") - * INPE - Board identifier form the pressure controller (e.g. "DB5.P1") - * - * OUTA - Output field for the magnet temperature alarm status (mbbidirect). - * OUTA - Output field for the magnet 10T temperature alarm status (mbbidirect). - * OUTA - Output field for the levels alarm status (mbbidirect). - * OUTA - Output field for the pressure alarm status (mbbidirect). - * - * Incoming alarm messages are expected to be in the format: - * "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Open Circuit;" - * where <9> is the tab character. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "alarms.h" - -// The number of control boards we are monitoring -#define NBOARDS 4 - -// The maximum number of tokens we expect in the status string -#define MAX_TOKENS 32 - -static const char *STATUS_TEXT_TEMPERATURE[] = { - "Open circuit", - "Short circuit", - "Calibration error", - "Firmware error", - "Board not configured", -}; - -static const char *STATUS_TEXT_LEVEL[] = { - "Open circuit", - "Short circuit", - "ADC error", - "Over demand", - "Over temperature", - "Firmware error", - "Board not configured", - "No reserve" -}; - -static const char *STATUS_TEXT_PRESSURE[] = { - "Open circuit", - "Short circuit", - "Calibration error", - "Firmware error", - "Board not configured", - "Over current", - "Current leakage", - "Power on fail", - "Checksum fail", - "Clock fail", - "ADC fail", - "Mains fail", - "Reference fail", - "12V fail", - "-12V fail", - "8V fail", - "-8V fail", - "Amp gain error", - "Amp offset error", - "ADC offset error", - "ADC PGA error", - "ADC XTAL error", - "Excitation + error", - "Excitation - error" -}; - -// Predetermine the number of status text entries for each board type -#define NUM_STATUS_TEXT_TEMPERATURE (sizeof(STATUS_TEXT_TEMPERATURE) / sizeof(STATUS_TEXT_TEMPERATURE[0])) -#define NUM_STATUS_TEXT_LEVEL (sizeof(STATUS_TEXT_LEVEL) / sizeof(STATUS_TEXT_LEVEL[0])) -#define NUM_STATUS_TEXT_PRESSURE (sizeof(STATUS_TEXT_PRESSURE) / sizeof(STATUS_TEXT_PRESSURE[0])) - -static const char **STATUS_TEXT_ARRAY[] = { - STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board} - STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board - STATUS_TEXT_LEVEL, // Levels Controller Board - STATUS_TEXT_PRESSURE // Pressure Controller Board -}; - -// Helper to reduce code complexity -static const int STATUS_TEXT_ARRAY_SIZE[] = { - NUM_STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board - NUM_STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board - NUM_STATUS_TEXT_LEVEL, // Levels Controller Board - NUM_STATUS_TEXT_PRESSURE // Pressure Controller Board -}; - -static long handle_system_alarm_status(aSubRecord *prec) - { - char* BOARD_ARRAY[NBOARDS]; - - if ( - prec->fta != menuFtypeSTRING - || prec->ftb != menuFtypeSTRING - || prec->ftc != menuFtypeSTRING - || prec->ftd != menuFtypeSTRING - || prec->fte != menuFtypeSTRING - || prec->ftva != menuFtypeLONG - || prec->ftvb != menuFtypeLONG - || prec->ftvc != menuFtypeLONG - || prec->ftvd != menuFtypeLONG - ) - { - errlogPrintf("%s incorrect input type. Should be INPA,B,C,D,E (STRING), VALA,B,C,D (LONG)", - prec->name); - return -1; - } - - errlogPrintf("%s: handle_system_alarm_status: about to copy names.\n", prec->name); - - BOARD_ARRAY[0] = "MB1.T1"; // Magnet Temperature Controller Board - BOARD_ARRAY[1] = "DB8.T1"; // 10T Magnet Temperature Controller Board - BOARD_ARRAY[2] = "DB1.L1"; // Levels Controller Board - BOARD_ARRAY[3] = "DB5.P1"; // Pressure Controller Board - - // Populate the BOARD_ARRAY with the board identifiers from the input fields. - // Typically: "MB1.T1", "DB8.T1", "DB1.L1", "DB5.P1" - //strcpy(BOARD_ARRAY[0], (char *)prec->b); // Magnet Temperature Controller Board - //strcpy(BOARD_ARRAY[1], (char *)prec->c); // 10T Magnet Temperature Controller Board - //strcpy(BOARD_ARRAY[2], (char *)prec->d); // Levels Controller Board - //strcpy(BOARD_ARRAY[3], (char *)prec->e); // Pressure Controller Board - - errlogPrintf("%s: handle_system_alarm_status: names copied - getting status from INPA.\n", prec->name); - - char* status = (char *)((epicsOldString*)prec->a); - - errlogPrintf("handle_system_alarm_status: result=%s\n", status); - - // Parse the input string, searching for the board name, followed by its status. - for (int board_index = 0; board_index < NBOARDS; board_index++) - { - epicsInt32 bitpattern = 0L; - char *board_name = BOARD_ARRAY[board_index]; - char *found_board = strstr(status, board_name); - - if (found_board != NULL) - { - // Found the board name, now extract the status. - // The expected format is "MB1.T1\tstatus", where '\t' is a tab character. - char *board_status = strchr(found_board, '\t'); - if (board_status == NULL) - { - errlogPrintf("%s: handle_system_alarm_status: No tab character found after board name.\n", prec->name); - return -1; - } - - // Move past the tab character to get the semicolon delimited status strings. - board_status++; - // Split the status string by semicolons to get individual status codes. - char *token_list[MAX_TOKENS]; - int token_index = 0; - char *token = strtok(board_status, ";"); - while (token != NULL) - { - // Process each token (status code) - errlogPrintf("Status token: %s\n", token); - char *new_token = (char*) malloc((strlen(token) + 1)*sizeof(char)); - strcpy(new_token, token); - token_list[token_index] = new_token; - token_index++; - token = strtok(NULL, ";"); - } - // Look up the status bit position code in the corresponding status text array - // for each token. - for (int iLookup_index=0; iLookup_index < token_index; iLookup_index++) - { - // Convert the token to an integer to find its position in the status text array. - //int bit_pos = atoi(token_list[j]); - bool bMsgFound = false; - for (int iBoard_status_item = 0; iBoard_status_item < STATUS_TEXT_ARRAY_SIZE[board_index]; iBoard_status_item++) - { - if (strcmp(token_list[iLookup_index], STATUS_TEXT_ARRAY[board_index][iBoard_status_item]) == 0) - { - bMsgFound = true; - errlogPrintf("%s: handle_system_alarm_status: Found match to: %s .\n", prec->name, STATUS_TEXT_ARRAY[board_index][iBoard_status_item]); - // Found a matching status code, set the corresponding bit in the bitpattern. - errlogPrintf("%s: handle_system_alarm_status: Setting bit %d.\n", prec->name, iBoard_status_item); - bitpattern |= (1UL << iBoard_status_item); - } - } - if (bMsgFound == false) - { - // The incoming message wasn't found in the array of expected messages. - errlogPrintf( - "handle_system_alarm_status: Invalid status code '%s' for board %s.\n", - token_list[iLookup_index], board_name); - } - free(token_list[iLookup_index]); // Free the allocated memory for the token - } - - // Write the status to the output field - //memcpy(prec->vala, &bitpattern, sizeof(unsigned long)); - errlogPrintf("%s: handle_system_alarm_status: Setting VALA to bit pattern: %ud\n", prec->name, bitpattern); - *(epicsInt32*)prec->vala = bitpattern; // Ensure the output is in the correct format - } - else - { - errlogPrintf("handle_system_alarm_status: Board %s not found in status string.\n", board_name); - bitpattern = 0L; - //memcpy(prec->vala, &bitpattern, sizeof(unsigned long)); - *(epicsInt32*)prec->vala = bitpattern; // Ensure the output is in the correct format - } - } - return 0; // Process output links - } - -epicsRegisterFunction(handle_system_alarm_status); From fff7596ec53364f5255c41fd0f07c86aea63b7b4 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 13 Aug 2025 08:43:30 +0100 Subject: [PATCH 43/61] Refactor: Used C++ STL to remove complexity of arrays of pointers to char* and avoiding all the associated memory (dis)management. --- OxInstIPSApp/src/alarms.cpp | 96 ++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/OxInstIPSApp/src/alarms.cpp b/OxInstIPSApp/src/alarms.cpp index d381e44..2c90a5f 100644 --- a/OxInstIPSApp/src/alarms.cpp +++ b/OxInstIPSApp/src/alarms.cpp @@ -1,6 +1,6 @@ -/* alarms.c +/* alarms.cpp * - * This C code contains the implementation of the aSub record for handling system alarm status. + * This C++ code contains the implementation of the aSub record for handling system alarm status. * It is part of the OxInstIPS application and is used to process alarm messages from the * system, specifically for the temperature, levels and pressure control boards. * Board identifiers are provided as macros and passed to the aSub as input fields B-E. @@ -8,8 +8,8 @@ * The aSub record processes alarm messages received from the system. The input is a string * containing the alarm message, which includes the board identifier and its status. * It extracts the board identifier and the alarm message, and writes them to the appropriate - * output fields. The output fields (OUTA) reference mbbidirect records, where the bit patterns will be - * established according to active alarms. + * output fields. The output fields (OUTA) reference mbbidirect records, + * where the bit patterns will be established according to active alarms. * * INPA - Input string containing the alarm message. * INPB - Board identifier form the magnet temperature controller (e.g. "MB1.T1") @@ -46,18 +46,19 @@ using namespace std; // The number of control boards we are monitoring #define NBOARDS 4 -// The maximum number of tokens we expect in the status string -#define MAX_TOKENS 32 - -static const char *STATUS_TEXT_TEMPERATURE[] = { +static const vector STATUS_TEXT_TEMPERATURE( + { "Open circuit", "Short circuit", "Calibration error", "Firmware error", "Board not configured", -}; + } +); -static const char *STATUS_TEXT_LEVEL[] = { + +static const vector STATUS_TEXT_LEVEL( + { "Open circuit", "Short circuit", "ADC error", @@ -66,9 +67,11 @@ static const char *STATUS_TEXT_LEVEL[] = { "Firmware error", "Board not configured", "No reserve" -}; +} +); -static const char *STATUS_TEXT_PRESSURE[] = { +static const vector STATUS_TEXT_PRESSURE( + { "Open circuit", "Short circuit", "Calibration error", @@ -93,37 +96,40 @@ static const char *STATUS_TEXT_PRESSURE[] = { "ADC XTAL error", "Excitation + error", "Excitation - error" -}; +}); // Predetermine the number of status text entries for each board type -#define NUM_STATUS_TEXT_TEMPERATURE (sizeof(STATUS_TEXT_TEMPERATURE) / sizeof(STATUS_TEXT_TEMPERATURE[0])) -#define NUM_STATUS_TEXT_LEVEL (sizeof(STATUS_TEXT_LEVEL) / sizeof(STATUS_TEXT_LEVEL[0])) -#define NUM_STATUS_TEXT_PRESSURE (sizeof(STATUS_TEXT_PRESSURE) / sizeof(STATUS_TEXT_PRESSURE[0])) +#define NUM_STATUS_TEXT_TEMPERATURE STATUS_TEXT_TEMPERATURE.size() +#define NUM_STATUS_TEXT_LEVEL STATUS_TEXT_LEVEL.size() +#define NUM_STATUS_TEXT_PRESSURE STATUS_TEXT_PRESSURE.size() -static const char **STATUS_TEXT_ARRAY[] = { +static const vector> STATUS_TEXT_ARRAY( + { STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board} STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board STATUS_TEXT_LEVEL, // Levels Controller Board STATUS_TEXT_PRESSURE // Pressure Controller Board -}; +} +); // Helper to reduce code complexity -static const int STATUS_TEXT_ARRAY_SIZE[] = { - NUM_STATUS_TEXT_TEMPERATURE, // Magnet Temperature Controller Board - NUM_STATUS_TEXT_TEMPERATURE, // 10T Magnet Temperature Controller Board - NUM_STATUS_TEXT_LEVEL, // Levels Controller Board - NUM_STATUS_TEXT_PRESSURE // Pressure Controller Board -}; +static const vector STATUS_TEXT_ARRAY_SIZE( { + STATUS_TEXT_TEMPERATURE.size(), // Magnet Temperature Controller Board + STATUS_TEXT_TEMPERATURE.size(), // 10T Magnet Temperature Controller Board + STATUS_TEXT_LEVEL.size(), // Levels Controller Board + STATUS_TEXT_PRESSURE.size() // Pressure Controller Board +}); -//epicsShareExtern?? static long handle_system_alarm_status(aSubRecord *prec) { vector token_list; vector BOARD_ARRAY; - vector out_bit_patterns(NBOARDS, 0); // vala, valb, valc, vald accumulated bit patterns + // vala, valb, valc, vald accumulated bit patterns. + // These ultimately will be written to mbbidirect records. + vector out_bit_patterns(NBOARDS, 0); if ( - prec->fta != menuFtypeSTRING + prec->fta != menuFtypeCHAR || prec->ftb != menuFtypeSTRING || prec->ftc != menuFtypeSTRING || prec->ftd != menuFtypeSTRING @@ -153,16 +159,19 @@ static long handle_system_alarm_status(aSubRecord *prec) //strcpy(BOARD_ARRAY[2], (char *)prec->d); // Levels Controller Board //strcpy(BOARD_ARRAY[3], (char *)prec->e); // Pressure Controller Board - errlogPrintf("%s: handle_system_alarm_status: names copied - getting status from INPA.\n", prec->name); + errlogPrintf("%s: handle_system_alarm_status: names copied - getting status from INPA.\n", + prec->name); string status = string((char *)((epicsOldString*)prec->a)); errlogPrintf("handle_system_alarm_status: result=%s\n", status.c_str()); // Tokenise the input string to extract the list of board+status. - // Of the form: "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Short Circuit;DB1.L1<9>Over Demand;DB5.P1<9>Open Circuit;" + // Of the form: + // "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Short Circuit;DB1.L1<9>Over Demand;DB5.P1<9>Open Circuit;" // Or empty if no alarms are present. - // We'll split the string by semicolons which will produce a list of status messages. + // We'll split the string by semicolons which will produce + // a list of status messages. stringstream check(status); string token; while (getline(check, token, ';')) @@ -176,11 +185,13 @@ static long handle_system_alarm_status(aSubRecord *prec) token_list.push_back(token); } } + + // Debug output to show the tokens we have extracted for(int i = 0; i < token_list.size(); i++) errlogPrintf("%s: token %d: %s\n", prec->name, i, token_list[i].c_str()); // Now we have a list of tokens, each of which is of the form "status message". - // We will process each token to extract the board ID and status message. + // We will process each token to extract the board ID and corresponding status message. for (const auto& token : token_list) { size_t tab_pos = token.find('\t'); @@ -190,6 +201,9 @@ static long handle_system_alarm_status(aSubRecord *prec) continue; // Skip invalid tokens } + // Extract board ID and status message. + // The board ID is everything before the tab character + // and the status message is everything after the tab character. string board_id = token.substr(0, tab_pos); string status_message = token.substr(tab_pos + 1); @@ -211,9 +225,10 @@ static long handle_system_alarm_status(aSubRecord *prec) } // Now we have the board index and the status message. - // We need to convert the status message to a numeric value. + // We need to convert the status message to a numeric value, which will represent + // the required bit position for the mbbidirect. int status_value = -1; - const char **status_text_array = STATUS_TEXT_ARRAY[board_index]; + const vector status_text_array = STATUS_TEXT_ARRAY[board_index]; int num_status_text = STATUS_TEXT_ARRAY_SIZE[board_index]; for (int j = 0; j < num_status_text; ++j) @@ -231,7 +246,8 @@ static long handle_system_alarm_status(aSubRecord *prec) continue; // Skip unknown status messages } - out_bit_patterns[board_index] |= (1 << status_value); // Set the bit corresponding to the status value + // Set the bit corresponding to the status value + out_bit_patterns[board_index] |= (1 << status_value); } // for each token for (int board_index = 0; board_index < NBOARDS; ++board_index) @@ -240,16 +256,20 @@ static long handle_system_alarm_status(aSubRecord *prec) switch (board_index) { case 0: - prec->vala = (void *)out_bit_patterns[board_index]; // Magnet Temperature Controller Board + // Magnet Temperature Controller Board + *(epicsInt32*)prec->vala = out_bit_patterns[board_index]; break; case 1: - prec->valb = out_bit_patterns[board_index]; // 10T Magnet Temperature Controller Board + // 10T Magnet Temperature Controller Board + *(epicsInt32*)prec->valb = out_bit_patterns[board_index]; break; case 2: - prec->valc = out_bit_patterns[board_index]; // Levels Controller Board + // Levels Controller Board + *(epicsInt32*)prec->valc = out_bit_patterns[board_index]; break; case 3: - prec->vald = out_bit_patterns[board_index]; // Pressure Controller Board + // Pressure Controller Board + *(epicsInt32*)prec->vald = out_bit_patterns[board_index]; break; default: errlogPrintf("%s: Invalid board index: %d\n", prec->name, board_index); From db24ffbf2baa2a49469ab014495d963a9bfc93a4 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 13 Aug 2025 08:45:41 +0100 Subject: [PATCH 44/61] Refactor: Augmented code documentation. Corrected the order of some messages and their associated bit positions. --- system_tests/lewis_emulators/ips/modes.py | 54 ++++++++++++++++------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index bcd8f94..f50e0b4 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -98,10 +98,13 @@ class TemperatureBoardStatus(BoardStatus): These alarms are returned in response to the READ:SYS:ALRM commnand returning errors as strings with the following example: STAT:SYS:ALRM:MB1.T1Open Circuit; - | Status | Description | Bit Value | Bit Position | - |---------------|--------------------------------------------|-----------|--------------| - | Open Circuit | Heater off - Open circuit on sensor input | 00000001 | 0 | - | Short Circuit | Short circuit on sensor input | 00000002 | 1 | + | Status | Description | Bit Value | Bit Position | + |----------------------|--------------------------------------------|-----------|--------------| + | Open Circuit | Heater off - Open circuit on sensor input | 00000001 | 0 | + | Short Circuit | Short circuit on sensor input | 00000002 | 1 | + | Calibration | On-board diagnostic: recalibrate | 00000004 | 2 | + | Firmware Error | Error in board firmware: restart iPS | 00000008 | 3 | + | Board Not Configured | Firmware not loaded correctly: update f/w | 00000010 | 4 | """ OK = 0 @@ -150,7 +153,7 @@ class LevelMeterBoardStatus(BoardStatus): @classmethod def names(cls) -> list[str]: return ["", "Open circuit", "Short circuit", "ADC error", "Over demand", - "Over temperature", "Firmware error", "board not configured", "No reserve"] + "Over temperature", "Firmware error", "Board not configured", "No reserve"] class LevelMeterHeliumReadRate(IntEnum): """ @@ -178,12 +181,28 @@ class PressureBoardStatus(BoardStatus): |----------------------|-------------------------------------------|-----------|--------------| | Open Circuit | Heater off - Open circuit on probe input | 00000001 | 0 | | Short Circuit | Short circuit on probe input | 00000002 | 1 | - | ADC Error | On-board diagnostic: recalibrate | 00000004 | 2 | - | Over Demand | On-board diagnostic: recalibrate | 00000008 | 3 | - | Over Temperature | | 00000010 | 4 | - | Firmware Error | Error in board firmware: restart iPS | 00000020 | 5 | - | Board Not Configured | Firmware not loaded correctly: update f/w | 00000040 | 6 | - | No Reserve | Autofill valve open but not filling | 00000080 | 7 | + | Calibration Error | On-board diagnostic: recalibrate | 00000004 | 2 | + | Firmware error | Error in board firmware: restart iPS | 00000008 | 3 | + | Board Not Configured | Firmware not loaded correctly: update f/w | 00000010 | 4 | + | Over current | Look for partial short circuits | 00000020 | 5 | + | Current leakage | Look for short to ground | 00000040 | 6 | + | Power on fail | restart iPS | 00000080 | 7 | + | Checksum fail | restart iPS | 00000100 | 8 | + | Clock fail | restart iPS | 00000200 | 9 | + | ADC fail | On-board diagnostic: recalibrate | 00000400 | 10 | + | Mains fail | restart iPS | 00000800 | 11 | + | Reference fail | restart iPS | 00001000 | 12 | + | 12V fail | restart iPS | 00002000 | 13 | + | -12V fail | restart iPS | 00004000 | 14 | + | 8V fail | restart iPS | 00008000 | 15 | + | -8V fail | restart iPS | 00010000 | 16 | + | Ampl gain error | restart iPS | 00020000 | 17 | + | Amp offset error | restart iPS | 00040000 | 18 | + | ADC offset error | restart iPS | 00060000 | 19 | + | ADC PGA error | restart iPS | 00080000 | 20 | + | ADC XTAL error | restart iPS | 00100000 | 21 | + | Excitation + error | restart iPS | 00200000 | 22 | + | Excitation - error | restart iPS | 00400000 | 23 | """ OK = 0 @@ -206,10 +225,11 @@ class PressureBoardStatus(BoardStatus): MINUS8VFAIL = 17 AMPGAIN_ERR = 18 AMPOFFSET_ERR = 19 - ADCPGA_ERR = 20 - ADCXTAL_ERR = 21 - PLUSEXCITE_ERR = 22 - MINUSXCITE_ERR = 23 + ADCOFFSET_ERR = 20 + ADCPGA_ERR = 21 + ADCXTAL_ERR = 22 + PLUSEXCITE_ERR = 23 + MINUSXCITE_ERR = 24 @classmethod def names(cls) -> list[str]: @@ -217,6 +237,6 @@ def names(cls) -> list[str]: "Board not configured", "Over current", "Current leakage", "Power on fail", "Checksum fail", "Clock fail", "ADC fail", "Mains fail", "Reference fail", "12V fail", "-12V fail", "8V fail", "-8V fail", - "Ampl gain error", "Amp offset error", "ADC PGA error", "ADC XTAL error", - "Excitation + error", "Excitation - error"] + "Ampl gain error", "Amp offset error", "ADC offset error", "ADC PGA error", + "ADC XTAL error", "Excitation + error", "Excitation - error"] From 4d2eb07eba849791d98d63e9cbbc17c41de8059b Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 13 Aug 2025 08:47:26 +0100 Subject: [PATCH 45/61] fix: Corrected system alarm messages source for pressure board. --- .../lewis_emulators/ips/interfaces/stream_interface_scpi.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 8f795d1..8a7bfe0 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -308,8 +308,7 @@ def get_bipolar_mode(self) -> str: def get_system_alarms(self) -> str: """ Returns the system alarms in the format: - STAT:SYS:ALRM:MB1.T1Open Circuit; - STAT:SYS:ALRM:DB1.L1Short Circuit; + STAT:SYS:ALRM:MB1.T1Open Circuit;DB1.L1Short Circuit; """ alarms = ["STAT:SYS:ALRM:",] if self.device.tempboard_status != TemperatureBoardStatus.OK: @@ -323,7 +322,7 @@ def get_system_alarms(self) -> str: f"{LevelMeterBoardStatus.names()[self.device.levelboard_status.value]};")) if self.device.pressureboard_status.value != PressureBoardStatus.OK: alarms.append((f"{DeviceUID.pressure_sensor_10t}\t" - f"{LevelMeterBoardStatus.names()[self.device.pressureboard_status.value]};")) + f"{PressureBoardStatus.names()[self.device.pressureboard_status.value]};")) alarm_list_str = "".join(alarms) return alarm_list_str From b5c9447bcbfb3f75bb02c8d670a2e64b9f65e65c Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 19 Aug 2025 13:17:17 +0100 Subject: [PATCH 46/61] Added tests for system alarm handling and removed He/N filling status tests as these PVs are no longer needed and removed --- system_tests/tests/ips_scpi.py | 42 +++++++++------------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 3ccb2fb..8f873e1 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -139,15 +139,22 @@ def test_GIVEN_magnet_temperature_sensor_open_circuit_THEN_ioc_states_open_circu self) -> None: # Simulate an open circuit on the temperature sensor self._lewis.backdoor_run_function_on_device("set_tempboard_status", [1]) - self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD", - "Open Circuit", timeout=10) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD.B0", "1", timeout=10) def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit( self) -> None: # Simulate an short circuit on the level sensor self._lewis.backdoor_run_function_on_device("set_levelboard_status", [2]) - self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD", - "Short Circuit", timeout=10) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD.B1", "1", timeout=10) + + def test_WHEN_SYSALARM_Tboard_is_firmware_error_THEN_ioc_states_firmware_error( + self) -> None: + """ + Test that the SYSALARM TBOARD PV is set to 'Firmware Error' when the temperature board + firmware is in error state. + """ + self._lewis.backdoor_run_function_on_device("set_tempboard_status", [3]) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD.B2", '1', timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) def test_GIVEN_level_freq_at_zero_THEN_ioc_states_freq(self, _: str, val: float) -> None: @@ -178,32 +185,6 @@ def test_given_level_resistance_full_THEN_ioc_states_resistance(self, _: str, self.ca.assert_that_pv_is_number("LVL:HE:FULL:RES", val, tolerance=TOLERANCE, timeout=10) - def test_GIVEN_nitrogen_level_THEN_ioc_states_filling_status(self) -> None: - """ - Test that the nitrogen filling status is correctly set based on the nitrogen level - and start/stop refill thresholds. - """ - # Simulate the nitrogen level - self.ca.set_pv_value("LVL:NIT:REFILL:START:SP", 20) - self.ca.set_pv_value("LVL:NIT:REFILL:STOP:SP", 90) - self._lewis.backdoor_set_on_device("nitrogen_level", 10) - self.ca.assert_that_pv_is("LVL:NIT:REFILLING", "Yes") - self._lewis.backdoor_set_on_device("nitrogen_level", 95) - self.ca.assert_that_pv_is("LVL:NIT:REFILLING", "No") - - def test_GIVEN_helium_level_THEN_ioc_states_filling_status(self) -> None: - """ - Test that the helium filling status is correctly set based on the helium level - and start/stop refill thresholds. - """ - # Simulate the helium level - self.ca.set_pv_value("LVL:HE:REFILL:START:SP", 20) - self.ca.set_pv_value("LVL:HE:REFILL:STOP:SP", 90) - self._lewis.backdoor_set_on_device("helium_level", 10) - self.ca.assert_that_pv_is("LVL:HE:REFILLING", "Yes") - self._lewis.backdoor_set_on_device("helium_level", 95) - self.ca.assert_that_pv_is("LVL:HE:REFILLING", "No") - def test_WHEN_nitrogen_read_interval_set_THEN_ioc_updates_read_interval(self) -> None: """ Test that the nitrogen read interval can be set and is reflected in the IOC. @@ -223,4 +204,3 @@ def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self) -> None: self.ca.set_pv_value("LVL:HE:PULSE:READ:RATE:SP", 0) self.ca.assert_that_pv_is("LVL:HE:PULSE:READ:RATE", "Fast") - \ No newline at end of file From 9f8475aebde37af0de8d1b7399e25565fb4e641f Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 21 Aug 2025 14:48:26 +0100 Subject: [PATCH 47/61] Type checking sorted for pyright. --- system_tests/lewis_emulators/ips/device.py | 16 ++++++---- .../ips/interfaces/stream_interface.py | 31 +++++++++++++------ .../ips/interfaces/stream_interface_scpi.py | 23 ++++++++++---- system_tests/lewis_emulators/ips/states.py | 10 ++++-- system_tests/tests/ips.py | 10 +++--- system_tests/tests/ips_common.py | 20 +++++++----- system_tests/tests/ips_scpi.py | 12 +++---- 7 files changed, 79 insertions(+), 43 deletions(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index 46f7840..4d48e1e 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from typing import Callable from lewis.core.logging import has_log from lewis.devices import StateMachineDevice @@ -14,6 +15,9 @@ TemperatureBoardStatus, PressureBoardStatus, ) from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState +from .states import State + +# Do not attempt to import DefaultState - it breaks the tests! # As long as no magnetic saturation effects are present, # there is a linear relationship between Teslas and Amps. @@ -153,7 +157,7 @@ def reset(self) -> None: self.pressure: float = 28.3898 # mBar - def _get_state_handlers(self) -> dict: + def _get_state_handlers(self) -> dict[str, State]: return { "heater_off": HeaterOffState(), "heater_on": HeaterOnState(), @@ -163,7 +167,7 @@ def _get_state_handlers(self) -> dict: def _get_initial_state(self) -> str: return "heater_off" - def _get_transition_handlers(self) -> OrderedDict: + def _get_transition_handlers(self) -> dict[tuple[str, str], Callable[[], bool]]: return OrderedDict( [ (("heater_off", "heater_on"), lambda: self.heater_on), @@ -214,7 +218,7 @@ def set_heater_status(self, new_status: bool) -> None: def set_tempboard_status(self, status_value: int) -> None: """Sets the temperature board status.""" - if status_value in iter(TemperatureBoardStatus): + if status_value in list(iter(TemperatureBoardStatus)): status: TemperatureBoardStatus = TemperatureBoardStatus(status_value) self.tempboard_status = status else: @@ -224,7 +228,7 @@ def set_tempboard_status(self, status_value: int) -> None: ) def set_tempboard_10T_status(self, status_value: int) -> None: """Sets the temperature board 10T status.""" - if status_value in iter(TemperatureBoardStatus): + if status_value in list(iter(TemperatureBoardStatus)): status: TemperatureBoardStatus = TemperatureBoardStatus(status_value) self.tempboard_10T_status = status else: @@ -235,7 +239,7 @@ def set_tempboard_10T_status(self, status_value: int) -> None: def set_levelboard_status(self, status_value: int) -> None: """Sets the temperature board status.""" - if status_value in iter(LevelMeterBoardStatus): + if status_value in list(iter(LevelMeterBoardStatus)): status: LevelMeterBoardStatus = LevelMeterBoardStatus(status_value) self.levelboard_status = status else: @@ -246,7 +250,7 @@ def set_levelboard_status(self, status_value: int) -> None: def set_pressureboard_status(self, status_value: int) -> None: """Sets the pressure board status.""" - if status_value in iter(PressureBoardStatus): + if status_value in list(iter(PressureBoardStatus)): status: PressureBoardStatus = PressureBoardStatus(status_value) self.pressureboard_status = status else: diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py index a1b8b82..43d7073 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py @@ -1,9 +1,17 @@ -from lewis.adapters.stream import StreamInterface -from lewis.core.logging import has_log -from lewis.utils.command_builder import CmdBuilder +import typing +from functools import partial + +from lewis.adapters.stream import StreamInterface # pyright: ignore +from lewis.core.logging import has_log # pyright: ignore +from lewis.utils.command_builder import CmdBuilder # pyright: ignore from ..device import amps_to_tesla, tesla_to_amps -from ..modes import Activity, Control +from ..modes import Activity, Control, SweepMode + +# Ensure device has the appropriate type hinting for pyright +if typing.TYPE_CHECKING: + from ..device import SimulatedIps + MODE_MAPPING = { 0: Activity.HOLD, @@ -49,7 +57,7 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_magnet_inductance").escape("R24").eos().build(), CmdBuilder("set_control_mode") .escape("C") - .arg("0|1|2|3", argument_mapping=int) + .arg("0|1|2|3", argument_mapping=partial(int)) .eos() .build(), CmdBuilder("set_mode").escape("A").int().eos().build(), @@ -65,13 +73,16 @@ class IpsStreamInterface(StreamInterface): in_terminator = "\r" out_terminator = "\r" - def handle_error(self, request: str, error:str) -> str: + def __init__(self) -> None: + super(StreamInterface, self).__init__() + self.device: "SimulatedIps" + + def handle_error(self, request: str, error:str) -> None: err_string = "command was: {}, error was: {}: {}\n".format( request, error.__class__.__name__, error ) print(err_string) - self.log.error(err_string) - return err_string + self.log.error(err_string) # pyright: ignore @classmethod def get_version(cls) -> str: @@ -207,5 +218,7 @@ def set_field_sweep_rate(self, tesla: float) -> str: return "T" def set_sweep_mode(self, mode: int) -> str: - self.device.sweep_mode = int(mode) + #self.device.sweep_mode = int(mode) + if mode < len(list(SweepMode)): + self.device.sweep_mode = list(SweepMode)[mode] return "M" diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 8a7bfe0..7d699f1 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -1,4 +1,5 @@ -from lewis.adapters.stream import StreamInterface +import typing +from lewis.adapters.stream import StreamInterface # pyright: ignore [reportMissingImports] from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder @@ -9,6 +10,10 @@ TemperatureBoardStatus, LevelMeterBoardStatus, PressureBoardStatus) +if typing.TYPE_CHECKING: + from ..device import SimulatedIps + + MODE_MAPPING = { 'HOLD': Activity.HOLD, 'RTOS': Activity.TO_SETPOINT, @@ -156,13 +161,17 @@ class IpsStreamInterface(StreamInterface): } - def handle_error(self, request: str, error: str) -> str: + def __init__(self) -> None: + super(StreamInterface, self).__init__() + self.device: "SimulatedIps" + + + def handle_error(self, request: str, error: str) -> None: err_string = "command was: {}, error was: {}: {}\n".format( request, error.__class__.__name__, error ) print(err_string) - self.log.error(err_string) - return err_string + self.log.error(err_string) # pyright: ignore @staticmethod def get_version() -> str: @@ -178,8 +187,10 @@ def get_version() -> str: return "IDN:OXFORD INSTRUMENTS:MERCURY IPS:simulated:0.0.0" def get_activity(self) -> str: - for testmode in MODE_MAPPING: - if self.device.activity == MODE_MAPPING[testmode]: + testmode: str = "" + for tm in MODE_MAPPING: + if self.device.activity == MODE_MAPPING[tm]: + testmode = tm break return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{testmode}" diff --git a/system_tests/lewis_emulators/ips/states.py b/system_tests/lewis_emulators/ips/states.py index 7f86ede..4723707 100644 --- a/system_tests/lewis_emulators/ips/states.py +++ b/system_tests/lewis_emulators/ips/states.py @@ -1,14 +1,18 @@ +import typing from lewis.core import approaches from lewis.core.statemachine import State from .modes import Activity -SECS_PER_MIN = 60 +if typing.TYPE_CHECKING: + from .device import SimulatedIps + +SECS_PER_MIN = 60 class HeaterOnState(State): def in_state(self, dt: float) -> None: - device = self._context + device = typing.cast("SimulatedIps", self._context) device.heater_current = approaches.linear( device.heater_current, device.HEATER_ON_CURRENT, device.HEATER_RAMP_RATE, dt @@ -43,7 +47,7 @@ def in_state(self, dt: float) -> None: class HeaterOffState(State): def in_state(self, dt: float) -> None: - device = self._context + device = typing.cast("SimulatedIps", self._context) device.heater_current = approaches.linear( device.heater_current, device.HEATER_OFF_CURRENT, device.HEATER_RAMP_RATE, dt diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 738e06f..710bbda 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -1,10 +1,10 @@ import unittest -from parameterized import parameterized -from utils.channel_access import ChannelAccess -from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir -from utils.test_modes import TestModes -from utils.testing import get_running_lewis_and_ioc, parameterized_list +from parameterized import parameterized # pyright: ignore +from utils.channel_access import ChannelAccess # pyright: ignore +from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir # pyright: ignore +from utils.test_modes import TestModes # pyright: ignore +from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore from .ips_common import IpsBaseTests diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py index 1187aa0..94790bb 100644 --- a/system_tests/tests/ips_common.py +++ b/system_tests/tests/ips_common.py @@ -1,10 +1,12 @@ from abc import ABCMeta, abstractmethod from contextlib import contextmanager +from typing import ContextManager, Generator -from parameterized import parameterized -from utils.test_modes import TestModes -from utils.testing import parameterized_list, unstable_test +from parameterized import parameterized # pyright: ignore +from utils.test_modes import TestModes # pyright: ignore +from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test # pyright: ignore +from utils.channel_access import ChannelAccess # pyright: ignore # Tell ruff to ignore the N802 warning (function name should be lowercase). # Names contain GIVEN, WHEN, THEN @@ -56,13 +58,14 @@ def _get_ioc_config(self) -> list[dict]: @abstractmethod def setUp(self) -> None: - pass + self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) + self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX) def tearDown(self) -> None: # Wait for statemachine to reach "at field" state after every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") - self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") + self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") # pyright: ignore def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self) -> None: self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") @@ -175,7 +178,7 @@ def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN self._assert_field_is(val, check_stable=True) @contextmanager - def _backdoor_magnet_quench(self, reason: str="Test framework quench") -> None: + def _backdoor_magnet_quench(self, reason: str="Test framework quench") -> Generator[None, None, None]: self._lewis.backdoor_run_function_on_device("quench", [reason]) try: yield @@ -233,7 +236,8 @@ def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_f # act: set new field self.ca.set_pv_value("FIELD:SP", 4.56) - # assert: field starts to change by tolerance within timeout, then reaches within second timeout - # timeout present to prove new setpoint moved to _without_ waiting for heater, if already on + # assert: field starts to change by tolerance within timeout, then reaches within + # second timeout timeout present to prove new setpoint moved to + # _without_ waiting for heater, if already on self.ca.assert_that_pv_is_not_number("FIELD", 3.21, tolerance=0.01, timeout=20) self.ca.assert_that_pv_is_number("FIELD", 4.56, tolerance=0.01, timeout=60) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 8f873e1..3c77ccb 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -1,10 +1,10 @@ import unittest -from parameterized import parameterized -from utils.channel_access import ChannelAccess -from utils.ioc_launcher import get_default_ioc_dir -from utils.test_modes import TestModes -from utils.testing import get_running_lewis_and_ioc, parameterized_list +from parameterized import parameterized # pyright: ignore +from utils.channel_access import ChannelAccess # pyright: ignore +from utils.ioc_launcher import get_default_ioc_dir # pyright: ignore +from utils.test_modes import TestModes # pyright: ignore +from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore from .ips_common import IpsBaseTests @@ -69,7 +69,7 @@ def setUp(self) -> None: ioc_config = self._get_ioc_config() # Time to wait for the heater to warm up/cool down (extracted from IOC macros above) - heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) + heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) # pyright: ignore self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) # Some changes happen on the order of HEATER_WAIT_TIME seconds. From b3bc2fad7f861d6f8f74ed96302ef742e2d9359c Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 21 Aug 2025 15:19:11 +0100 Subject: [PATCH 48/61] Corrected for ruff checks. --- system_tests/lewis_emulators/ips/device.py | 8 +++--- .../ips/interfaces/stream_interface.py | 6 ++--- .../ips/interfaces/stream_interface_scpi.py | 25 +++++++++++-------- system_tests/lewis_emulators/ips/states.py | 1 + system_tests/tests/ips.py | 10 ++++---- system_tests/tests/ips_common.py | 18 +++++++------ system_tests/tests/ips_scpi.py | 10 ++++---- 7 files changed, 44 insertions(+), 34 deletions(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index 4d48e1e..29614ba 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -11,11 +11,11 @@ LevelMeterHeliumReadRate, MagnetSupplyStatus, Mode, + PressureBoardStatus, SweepMode, - TemperatureBoardStatus, PressureBoardStatus, + TemperatureBoardStatus, ) -from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState -from .states import State +from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState, State # Do not attempt to import DefaultState - it breaks the tests! @@ -226,7 +226,7 @@ def set_tempboard_status(self, status_value: int) -> None: (f"Invalid temperature board status value: {status_value}." f" Must be one of {list(TemperatureBoardStatus)}") ) - def set_tempboard_10T_status(self, status_value: int) -> None: + def set_tempboard_10t_status(self, status_value: int) -> None: """Sets the temperature board 10T status.""" if status_value in list(iter(TemperatureBoardStatus)): status: TemperatureBoardStatus = TemperatureBoardStatus(status_value) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py index 43d7073..543fa38 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py @@ -1,9 +1,9 @@ import typing from functools import partial -from lewis.adapters.stream import StreamInterface # pyright: ignore -from lewis.core.logging import has_log # pyright: ignore -from lewis.utils.command_builder import CmdBuilder # pyright: ignore +from lewis.adapters.stream import StreamInterface # pyright: ignore +from lewis.core.logging import has_log # pyright: ignore +from lewis.utils.command_builder import CmdBuilder # pyright: ignore from ..device import amps_to_tesla, tesla_to_amps from ..modes import Activity, Control, SweepMode diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 7d699f1..c2d9a4c 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -1,14 +1,18 @@ import typing + from lewis.adapters.stream import StreamInterface # pyright: ignore [reportMissingImports] from lewis.core.logging import has_log from lewis.utils.command_builder import CmdBuilder from ..device import amps_to_tesla, tesla_to_amps -from ..modes import (Activity, - Control, - LevelMeterHeliumReadRate, - TemperatureBoardStatus, - LevelMeterBoardStatus, PressureBoardStatus) +from ..modes import ( + Activity, + Control, + LevelMeterBoardStatus, + LevelMeterHeliumReadRate, + PressureBoardStatus, + TemperatureBoardStatus, +) if typing.TYPE_CHECKING: from ..device import SimulatedIps @@ -36,7 +40,7 @@ class DeviceUID: magnet_temperature_sensor = "MB1.T1" level_meter = "DB1.L1" magnet_supply = "GRPZ" - temperature_sensor_10T = "DB8.T1" + temperature_sensor_10t = "DB8.T1" pressure_sensor_10t = "DB5.P1" @@ -151,11 +155,12 @@ class IpsStreamInterface(StreamInterface): CmdBuilder("get_he_read_rate") .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW").eos().build(), CmdBuilder("set_he_read_rate") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:").enum("OFF", "ON").eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:") + .enum("OFF", "ON").eos().build(), CmdBuilder("get_magnet_temperature") .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP").eos().build(), CmdBuilder("get_lambda_plate_temperature") - .escape(f"READ:DEV:{DeviceUID.temperature_sensor_10T}:TEMP:SIG:TEMP").eos().build(), + .escape(f"READ:DEV:{DeviceUID.temperature_sensor_10t}:TEMP:SIG:TEMP").eos().build(), CmdBuilder("get_pressure") .escape(f"READ:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES").eos().build(), @@ -326,7 +331,7 @@ def get_system_alarms(self) -> str: alarms.append((f"{DeviceUID.magnet_temperature_sensor}\t" f"{TemperatureBoardStatus.names()[self.device.tempboard_status.value]};")) if self.device.tempboard_10T_status != TemperatureBoardStatus.OK: - alarms.append((f"{DeviceUID.temperature_sensor_10T}\t" + alarms.append((f"{DeviceUID.temperature_sensor_10t}\t" f"{TemperatureBoardStatus.names()[self.device.tempboard_10T_status.value]};")) if self.device.levelboard_status.value != LevelMeterBoardStatus.OK: alarms.append((f"{DeviceUID.level_meter}\t" @@ -566,7 +571,7 @@ def get_lambda_plate_temperature(self) -> str: Gets the temperature of the lambda plate. :return: The temperature in Kelvin. """ - return (f"STAT:DEV:{DeviceUID.temperature_sensor_10T}:TEMP:SIG:TEMP:" + return (f"STAT:DEV:{DeviceUID.temperature_sensor_10t}:TEMP:SIG:TEMP:" f"{self.device.lambda_plate_temperature:.4f}K") diff --git a/system_tests/lewis_emulators/ips/states.py b/system_tests/lewis_emulators/ips/states.py index 4723707..a3838a1 100644 --- a/system_tests/lewis_emulators/ips/states.py +++ b/system_tests/lewis_emulators/ips/states.py @@ -1,4 +1,5 @@ import typing + from lewis.core import approaches from lewis.core.statemachine import State diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 710bbda..e8fcb8f 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -1,10 +1,10 @@ import unittest -from parameterized import parameterized # pyright: ignore -from utils.channel_access import ChannelAccess # pyright: ignore -from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir # pyright: ignore -from utils.test_modes import TestModes # pyright: ignore -from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore +from parameterized import parameterized # pyright: ignore +from utils.channel_access import ChannelAccess # pyright: ignore +from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir # pyright: ignore +from utils.test_modes import TestModes # pyright: ignore +from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore from .ips_common import IpsBaseTests diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py index 94790bb..639a9a8 100644 --- a/system_tests/tests/ips_common.py +++ b/system_tests/tests/ips_common.py @@ -1,12 +1,16 @@ - +# ruff: noqa: N802 from abc import ABCMeta, abstractmethod from contextlib import contextmanager -from typing import ContextManager, Generator - -from parameterized import parameterized # pyright: ignore -from utils.test_modes import TestModes # pyright: ignore -from utils.testing import get_running_lewis_and_ioc, parameterized_list, unstable_test # pyright: ignore -from utils.channel_access import ChannelAccess # pyright: ignore +from typing import Generator + +from parameterized import parameterized # pyright: ignore +from utils.channel_access import ChannelAccess # pyright: ignore +from utils.test_modes import TestModes # pyright: ignore +from utils.testing import ( # pyright: ignore + get_running_lewis_and_ioc, + parameterized_list, + unstable_test, +) # Tell ruff to ignore the N802 warning (function name should be lowercase). # Names contain GIVEN, WHEN, THEN diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 3c77ccb..3f0c477 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -1,10 +1,10 @@ import unittest -from parameterized import parameterized # pyright: ignore -from utils.channel_access import ChannelAccess # pyright: ignore -from utils.ioc_launcher import get_default_ioc_dir # pyright: ignore -from utils.test_modes import TestModes # pyright: ignore -from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore +from parameterized import parameterized # pyright: ignore +from utils.channel_access import ChannelAccess # pyright: ignore +from utils.ioc_launcher import get_default_ioc_dir # pyright: ignore +from utils.test_modes import TestModes # pyright: ignore +from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore from .ips_common import IpsBaseTests From 29d6c15556a27365573eb3d58e80af8882155134 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 28 Aug 2025 11:30:08 +0100 Subject: [PATCH 49/61] Added support for discovered alarm status 'Magnet Safety' --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 4 +-- OxInstIPSApp/src/alarms.cpp | 26 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index 58840a2..f14f811 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -301,10 +301,10 @@ setLevelNitFillStopThreshold { out "SET:DEV:" $level_meter ":LVL:NIT:HIGH:%d"; # in "STAT:DEV:" $level_meter ":LVL:NIT:RFL:%{OFF|ON}";} getLevelNitrogenLevel { out "READ:DEV:" $level_meter ":LVL:SIG:NIT:LEV"; - in "STAT:DEV:" $level_meter ":LVL:SIG:NIT:LEV:%d";} + in "STAT:DEV:" $level_meter ":LVL:SIG:NIT:LEV:%f";} getLevelHeliumLevel { out "READ:DEV:" $level_meter ":LVL:SIG:HEL:LEV"; - in "STAT:DEV:" $level_meter ":LVL:SIG:HEL:LEV:%d";} + in "STAT:DEV:" $level_meter ":LVL:SIG:HEL:LEV:%f";} # ------------------------------------------------------- # TEMPERATURE BOARD COMMANDS diff --git a/OxInstIPSApp/src/alarms.cpp b/OxInstIPSApp/src/alarms.cpp index 2c90a5f..98b80d0 100644 --- a/OxInstIPSApp/src/alarms.cpp +++ b/OxInstIPSApp/src/alarms.cpp @@ -66,7 +66,8 @@ static const vector STATUS_TEXT_LEVEL( "Over temperature", "Firmware error", "Board not configured", - "No reserve" + "No reserve", + "Magnet Safety" } ); @@ -120,6 +121,16 @@ static const vector STATUS_TEXT_ARRAY_SIZE( { STATUS_TEXT_PRESSURE.size() // Pressure Controller Board }); +// Helper function for case-insensitive string comparison +bool strcmp_nocase(const string& a, const string& b) +{ + return std::equal(a.begin(), a.end(), + b.begin(), b.end(), + [](char a, char b) { + return tolower(a) == tolower(b); + }); +} + static long handle_system_alarm_status(aSubRecord *prec) { vector token_list; @@ -145,8 +156,6 @@ static long handle_system_alarm_status(aSubRecord *prec) return -1; } - errlogPrintf("%s: handle_system_alarm_status: about to copy names.\n", prec->name); - BOARD_ARRAY.push_back("MB1.T1"); // Magnet Temperature Controller Board BOARD_ARRAY.push_back("DB8.T1"); // 10T Magnet Temperature Controller Board BOARD_ARRAY.push_back("DB1.L1"); // Levels Controller Board @@ -159,13 +168,8 @@ static long handle_system_alarm_status(aSubRecord *prec) //strcpy(BOARD_ARRAY[2], (char *)prec->d); // Levels Controller Board //strcpy(BOARD_ARRAY[3], (char *)prec->e); // Pressure Controller Board - errlogPrintf("%s: handle_system_alarm_status: names copied - getting status from INPA.\n", - prec->name); - string status = string((char *)((epicsOldString*)prec->a)); - errlogPrintf("handle_system_alarm_status: result=%s\n", status.c_str()); - // Tokenise the input string to extract the list of board+status. // Of the form: // "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Short Circuit;DB1.L1<9>Over Demand;DB5.P1<9>Open Circuit;" @@ -187,8 +191,8 @@ static long handle_system_alarm_status(aSubRecord *prec) } // Debug output to show the tokens we have extracted - for(int i = 0; i < token_list.size(); i++) - errlogPrintf("%s: token %d: %s\n", prec->name, i, token_list[i].c_str()); + // for(int i = 0; i < token_list.size(); i++) + // errlogPrintf("%s: token %d: %s\n", prec->name, i, token_list[i].c_str()); // Now we have a list of tokens, each of which is of the form "status message". // We will process each token to extract the board ID and corresponding status message. @@ -233,7 +237,7 @@ static long handle_system_alarm_status(aSubRecord *prec) for (int j = 0; j < num_status_text; ++j) { - if (status_message == status_text_array[j]) + if (strcmp_nocase(status_message, status_text_array[j])) { status_value = j; break; From 1784122487e758d75fc391600ff6a8e8c7690243 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Thu, 28 Aug 2025 11:53:56 +0100 Subject: [PATCH 50/61] alarms.cpp: Removed diagnostic log messages and additional documentation. --- OxInstIPSApp/src/alarms.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OxInstIPSApp/src/alarms.cpp b/OxInstIPSApp/src/alarms.cpp index 98b80d0..3ce100e 100644 --- a/OxInstIPSApp/src/alarms.cpp +++ b/OxInstIPSApp/src/alarms.cpp @@ -161,6 +161,10 @@ static long handle_system_alarm_status(aSubRecord *prec) BOARD_ARRAY.push_back("DB1.L1"); // Levels Controller Board BOARD_ARRAY.push_back("DB5.P1"); // Pressure Controller Board + // *** The following is not used as we are using fixed board IDs above. *** + // *** It would be better to pass these strings from the EPICS layer but the code below *** + // *** caused the subroutine to crash. Probably a pointer issue, but I have to draw the line *** + // *** somewhere with the intention to do it properly at a later date. *** // Populate the BOARD_ARRAY with the board identifiers from the input fields. // Typically: "MB1.T1", "DB8.T1", "DB1.L1", "DB5.P1" //strcpy(BOARD_ARRAY[0], (char *)prec->b); // Magnet Temperature Controller Board From 3485c9627f1d6da511936d6604f0747cfe04e6f6 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 11:15:15 +0100 Subject: [PATCH 51/61] Ruff formatted (again) --- system_tests/lewis_emulators/ips/device.py | 57 ++- .../ips/interfaces/stream_interface.py | 6 +- .../ips/interfaces/stream_interface_scpi.py | 483 ++++++++++++------ system_tests/lewis_emulators/ips/modes.py | 84 ++- system_tests/lewis_emulators/ips/states.py | 4 +- 5 files changed, 435 insertions(+), 199 deletions(-) diff --git a/system_tests/lewis_emulators/ips/device.py b/system_tests/lewis_emulators/ips/device.py index 29614ba..1e23111 100644 --- a/system_tests/lewis_emulators/ips/device.py +++ b/system_tests/lewis_emulators/ips/device.py @@ -28,21 +28,24 @@ LOAD_LINE_GRADIENT = 0.01 -def amps_to_tesla(amps: float) ->float: +def amps_to_tesla(amps: float) -> float: return amps * LOAD_LINE_GRADIENT def tesla_to_amps(tesla: float) -> float: return tesla / LOAD_LINE_GRADIENT + def set_bit_value(value: int, bit_value: int) -> int: """Sets a bit at the position implied by the value.""" return value | bit_value + def clear_bit_value(value: int, bit_value: int) -> int: """Clears a bit at the specified position implied by the value.""" return value & ~bit_value + @has_log class SimulatedIps(StateMachineDevice): # Currents that correspond to the switch heater being on and off @@ -60,8 +63,7 @@ class SimulatedIps(StateMachineDevice): HEATER_RAMP_RATE = 5 def _initialize_data(self) -> None: - """Initialize all of the device's attributes. - """ + """Initialize all of the device's attributes.""" self.reset() def reset(self) -> None: @@ -110,7 +112,7 @@ def reset(self) -> None: # Hard-code this here for now - can't be changed on real device. self.inductance: float = 0.005 - # No idea what sensible values are here. + # No idea what sensible values are here. # Also not clear what the behaviour is of the controller when these limits are hit. self.neg_current_limit, self.pos_current_limit = -(10**6), 10**6 @@ -145,7 +147,7 @@ def reset(self) -> None: self.helium_level: int = 50 self.helium_read_rate: int = LevelMeterHeliumReadRate.FAST.value - self.nitrogen_read_interval: int = 750 # milliseconds + self.nitrogen_read_interval: int = 750 # milliseconds self.nitrogen_frequency_at_zero: float = 1.0 self.nitrogen_frequency_at_full: float = 100.0 self.nitrogen_fill_start_level: int = 10 @@ -153,10 +155,9 @@ def reset(self) -> None: self.nitrogen_level: int = 50 self.magnet_temperature: float = 4.2345 # Kelvin - self.lambda_plate_temperature: float =4.3456 # Kelvin + self.lambda_plate_temperature: float = 4.3456 # Kelvin self.pressure: float = 28.3898 # mBar - def _get_state_handlers(self) -> dict[str, State]: return { "heater_off": HeaterOffState(), @@ -167,7 +168,7 @@ def _get_state_handlers(self) -> dict[str, State]: def _get_initial_state(self) -> str: return "heater_off" - def _get_transition_handlers(self) -> dict[tuple[str, str], Callable[[], bool]]: + def _get_transition_handlers(self) -> dict[tuple[str, str], Callable[[], bool]]: return OrderedDict( [ (("heater_off", "heater_on"), lambda: self.heater_on), @@ -181,22 +182,23 @@ def _get_transition_handlers(self) -> dict[tuple[str, str], Callable[[], bool]] ) def quench(self, reason: str) -> None: - self.log.info("Magnet quenching at current={} because: {}" - .format(self.current, reason)) + self.log.info("Magnet quenching at current={} because: {}".format(self.current, reason)) self.trip_current = self.current self.magnet_current = 0 self.current = 0 self.measured_current = 0 self.quenched = True # Causes LeWiS to enter Quenched state # For the SCPI protocol, we set the magnet supply status to indicate a quench - self.magnet_supply_status = set_bit_value(self.magnet_supply_status, - MagnetSupplyStatus.QUENCH_DETECTED) + self.magnet_supply_status = set_bit_value( + self.magnet_supply_status, MagnetSupplyStatus.QUENCH_DETECTED + ) def unquench(self) -> None: self.quenched = False # For the SCPI protocol, we set the magnet supply status to clear a quench status - self.magnet_supply_status = clear_bit_value(self.magnet_supply_status, - MagnetSupplyStatus.QUENCH_DETECTED) + self.magnet_supply_status = clear_bit_value( + self.magnet_supply_status, MagnetSupplyStatus.QUENCH_DETECTED + ) def get_voltage(self) -> float: """Gets the voltage of the PSU. @@ -223,9 +225,12 @@ def set_tempboard_status(self, status_value: int) -> None: self.tempboard_status = status else: raise ValueError( - (f"Invalid temperature board status value: {status_value}." - f" Must be one of {list(TemperatureBoardStatus)}") + ( + f"Invalid temperature board status value: {status_value}." + f" Must be one of {list(TemperatureBoardStatus)}" + ) ) + def set_tempboard_10t_status(self, status_value: int) -> None: """Sets the temperature board 10T status.""" if status_value in list(iter(TemperatureBoardStatus)): @@ -233,8 +238,10 @@ def set_tempboard_10t_status(self, status_value: int) -> None: self.tempboard_10T_status = status else: raise ValueError( - (f"Invalid temperature board 10T status value: {status_value}." - f" Must be one of {list(TemperatureBoardStatus)}") + ( + f"Invalid temperature board 10T status value: {status_value}." + f" Must be one of {list(TemperatureBoardStatus)}" + ) ) def set_levelboard_status(self, status_value: int) -> None: @@ -244,8 +251,10 @@ def set_levelboard_status(self, status_value: int) -> None: self.levelboard_status = status else: raise ValueError( - (f"Invalid level board status value: {status_value}." - f" Must be one of {list(LevelMeterBoardStatus)}") + ( + f"Invalid level board status value: {status_value}." + f" Must be one of {list(LevelMeterBoardStatus)}" + ) ) def set_pressureboard_status(self, status_value: int) -> None: @@ -255,12 +264,12 @@ def set_pressureboard_status(self, status_value: int) -> None: self.pressureboard_status = status else: raise ValueError( - (f"Invalid pressure board status value: {status_value}." - f" Must be one of {list(PressureBoardStatus)}") + ( + f"Invalid pressure board status value: {status_value}." + f" Must be one of {list(PressureBoardStatus)}" + ) ) - def get_nitrogen_refilling(self) -> bool: """Returns whether the nitrogen refilling is in progress.""" return self.nitrogen_fill_start_level < self.nitrogen_level < self.nitrogen_fill_stop_level - \ No newline at end of file diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py index 543fa38..1f0efaf 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface.py @@ -77,12 +77,12 @@ def __init__(self) -> None: super(StreamInterface, self).__init__() self.device: "SimulatedIps" - def handle_error(self, request: str, error:str) -> None: + def handle_error(self, request: str, error: str) -> None: err_string = "command was: {}, error was: {}: {}\n".format( request, error.__class__.__name__, error ) print(err_string) - self.log.error(err_string) # pyright: ignore + self.log.error(err_string) # pyright: ignore @classmethod def get_version(cls) -> str: @@ -218,7 +218,7 @@ def set_field_sweep_rate(self, tesla: float) -> str: return "T" def set_sweep_mode(self, mode: int) -> str: - #self.device.sweep_mode = int(mode) + # self.device.sweep_mode = int(mode) if mode < len(list(SweepMode)): self.device.sweep_mode = list(SweepMode)[mode] return "M" diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index c2d9a4c..248266b 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -19,10 +19,10 @@ MODE_MAPPING = { - 'HOLD': Activity.HOLD, - 'RTOS': Activity.TO_SETPOINT, - 'RTOZ': Activity.TO_ZERO, - 'CLMP': Activity.CLAMP, + "HOLD": Activity.HOLD, + "RTOS": Activity.TO_SETPOINT, + "RTOZ": Activity.TO_ZERO, + "CLMP": Activity.CLAMP, } CONTROL_MODE_MAPPING = { @@ -37,6 +37,7 @@ class DeviceUID: """ Predefined UIDs for all devices attached to the iPS controller. """ + magnet_temperature_sensor = "MB1.T1" level_meter = "DB1.L1" magnet_supply = "GRPZ" @@ -52,135 +53,247 @@ class IpsStreamInterface(StreamInterface): # Commands that we expect via serial during normal operation commands = { - CmdBuilder("get_version") - .escape("*IDN?").eos().build(), + CmdBuilder("get_version").escape("*IDN?").eos().build(), CmdBuilder("get_magnet_supply_status") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:STAT").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:STAT") + .eos() + .build(), CmdBuilder("get_activity") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:ACTN") + .eos() + .build(), CmdBuilder("get_current") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CURR") + .eos() + .build(), CmdBuilder("get_supply_voltage") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT") + .eos() + .build(), CmdBuilder("get_current_setpoint") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET") + .eos() + .build(), CmdBuilder("get_current_sweep_rate") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RCST") + .eos() + .build(), CmdBuilder("get_field") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FLD") + .eos() + .build(), CmdBuilder("get_field_setpoint") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET") + .eos() + .build(), CmdBuilder("get_field_sweep_rate") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST") + .eos() + .build(), CmdBuilder("get_software_voltage_limit") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:VLIM").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:VLIM") + .eos() + .build(), CmdBuilder("get_persistent_magnet_current") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR") + .eos() + .build(), CmdBuilder("get_persistent_magnet_field") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PFLD") + .eos() + .build(), CmdBuilder("get_heater_current") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SHTC").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SHTC") + .eos() + .build(), CmdBuilder("get_pos_current_limit") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:CLIM").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:CLIM") + .eos() + .build(), CmdBuilder("get_lead_resistance") - .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:RES") + .eos() + .build(), CmdBuilder("get_magnet_inductance") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:IND") + .eos() + .build(), CmdBuilder("get_heater_status") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT") + .eos() + .build(), CmdBuilder("get_bipolar_mode") - .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL").eos().build(), - CmdBuilder("get_system_alarms") - .escape("READ:SYS:ALRM").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_supply}:PSU:BIPL") + .eos() + .build(), + CmdBuilder("get_system_alarms").escape("READ:SYS:ALRM").eos().build(), CmdBuilder("set_activity") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:").string().eos().build(), + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:") + .string() + .eos() + .build(), CmdBuilder("set_current") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:CSET:") + .float() + .eos() + .build(), CmdBuilder("set_field") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:FSET:") + .float() + .eos() + .build(), CmdBuilder("set_field_sweep_rate") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:RFST:") + .float() + .eos() + .build(), CmdBuilder("set_heater_on") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON").eos().build(), + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:ON") + .eos() + .build(), CmdBuilder("set_heater_off") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF").eos().build(), + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:SIG:SWHT:OFF") + .eos() + .build(), CmdBuilder("set_bipolar_mode") - .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:").string().eos().build(), - + .escape(f"SET:DEV:{DeviceUID.magnet_supply}:PSU:BIPL:") + .string() + .eos() + .build(), CmdBuilder("get_nit_read_interval") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS") + .eos() + .build(), CmdBuilder("set_nit_read_interval") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:").int().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:") + .int() + .eos() + .build(), CmdBuilder("get_nit_freq_zero") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO") + .eos() + .build(), CmdBuilder("set_nit_freq_zero") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:ZERO:") + .float() + .eos() + .build(), CmdBuilder("get_nit_freq_full") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL") + .eos() + .build(), CmdBuilder("set_nit_freq_full") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:FREQ:FULL:") + .float() + .eos() + .build(), CmdBuilder("get_nit_fill_start_level") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW") + .eos() + .build(), CmdBuilder("set_nit_fill_start_level") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:").int().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:") + .int() + .eos() + .build(), CmdBuilder("get_nit_fill_stop_level") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH") + .eos() + .build(), CmdBuilder("set_nit_fill_stop_level") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:").int().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:") + .int() + .eos() + .build(), CmdBuilder("get_nit_refilling") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL") + .eos() + .build(), CmdBuilder("get_nitrogen_level") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:NIT:LEV").eos().build(), - + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:NIT:LEV") + .eos() + .build(), CmdBuilder("get_helium_level") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:HEL:LEV").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:SIG:HEL:LEV") + .eos() + .build(), CmdBuilder("get_he_empty_resistance") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO") + .eos() + .build(), CmdBuilder("get_he_full_resistance") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL") + .eos() + .build(), CmdBuilder("set_he_empty_resistance") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:ZERO:") + .float() + .eos() + .build(), CmdBuilder("set_he_full_resistance") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:").float().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:RES:FULL:") + .float() + .eos() + .build(), CmdBuilder("get_he_fill_start_level") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW") + .eos() + .build(), CmdBuilder("set_he_fill_start_level") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:").int().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:LOW:") + .int() + .eos() + .build(), CmdBuilder("get_he_fill_stop_level") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH") + .eos() + .build(), CmdBuilder("set_he_fill_stop_level") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:").int().eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:HIGH:") + .int() + .eos() + .build(), CmdBuilder("get_he_refilling") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:RFL") + .eos() + .build(), CmdBuilder("get_he_read_rate") - .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW").eos().build(), + .escape(f"READ:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW") + .eos() + .build(), CmdBuilder("set_he_read_rate") - .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:") - .enum("OFF", "ON").eos().build(), + .escape(f"SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:") + .enum("OFF", "ON") + .eos() + .build(), CmdBuilder("get_magnet_temperature") - .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP").eos().build(), + .escape(f"READ:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP") + .eos() + .build(), CmdBuilder("get_lambda_plate_temperature") - .escape(f"READ:DEV:{DeviceUID.temperature_sensor_10t}:TEMP:SIG:TEMP").eos().build(), + .escape(f"READ:DEV:{DeviceUID.temperature_sensor_10t}:TEMP:SIG:TEMP") + .eos() + .build(), CmdBuilder("get_pressure") - .escape(f"READ:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES").eos().build(), - + .escape(f"READ:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES") + .eos() + .build(), } def __init__(self) -> None: super(StreamInterface, self).__init__() self.device: "SimulatedIps" - def handle_error(self, request: str, error: str) -> None: err_string = "command was: {}, error was: {}: {}\n".format( request, error.__class__.__name__, error ) print(err_string) - self.log.error(err_string) # pyright: ignore + self.log.error(err_string) # pyright: ignore @staticmethod def get_version() -> str: - """ get_version() + """get_version() The format of the reply is: IDN:OXFORD INSTRUMENTS:MERCURY dd:ss:ff Where: @@ -202,11 +315,11 @@ def get_activity(self) -> str: def set_activity(self, mode: str) -> str: # Set the default return value to invalid (guilty until proven innocent) ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" - + for testmode in MODE_MAPPING: if mode == MODE_MAPPING[testmode].value: break - + try: self.device.activity = MODE_MAPPING[mode] ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:VALID" @@ -243,35 +356,47 @@ def get_magnet_supply_status(self) -> str: This information is not published and was derived from direct questions to Oxford Instruments. """ - resp = (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:STAT:{self.device.magnet_supply_status:08x}") + resp = ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:STAT:{self.device.magnet_supply_status:08x}" + ) return resp def get_current_setpoint(self) -> str: - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:CSET:{self.device.current_setpoint:.4f}A") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:CSET:{self.device.current_setpoint:.4f}A" + ) def get_supply_voltage(self) -> str: return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:VOLT:{self.device.get_voltage():.4f}V" def get_current(self) -> str: """Gets the demand current of the PSU.""" - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:CURR:{self.device.measured_current:.4f}A") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:CURR:{self.device.measured_current:.4f}A" + ) def get_current_sweep_rate(self) -> str: # Returns the current ramp rate in amps per second. # of the form: STAT:DEV:GRPZ:PSU:SIG:RCST:5.3612A/m - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:RCST:{self.device.current_ramp_rate:.4f}A/m") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:RCST:{self.device.current_ramp_rate:.4f}A/m" + ) def get_field(self) -> str: - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:FLD:{amps_to_tesla(self.device.current):.4f}T") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:FLD:{amps_to_tesla(self.device.current):.4f}T" + ) def get_field_setpoint(self) -> str: - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:FSET:{amps_to_tesla(self.device.current_setpoint):.4f}T") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:FSET:{amps_to_tesla(self.device.current_setpoint):.4f}T" + ) def get_field_sweep_rate(self) -> str: field = amps_to_tesla(self.device.current_ramp_rate) @@ -290,8 +415,10 @@ def get_trip_current(self) -> str: return f"R{self.device.trip_current}" def get_persistent_magnet_field(self) -> str: - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current):.4f}T") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:PFLD:{amps_to_tesla(self.device.magnet_current):.4f}T" + ) def get_heater_current(self) -> str: return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SHTC:{self.device.heater_current:.4f}mA" @@ -305,8 +432,10 @@ def get_pos_current_limit(self) -> str: return ret def get_lead_resistance(self) -> str: - ret = (f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}" - f":TEMP:SIG:RES:{self.device.lead_resistance:.4f}R") + ret = ( + f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}" + f":TEMP:SIG:RES:{self.device.lead_resistance:.4f}R" + ) return ret def get_magnet_inductance(self) -> str: @@ -314,31 +443,53 @@ def get_magnet_inductance(self) -> str: return ret def get_heater_status(self) -> str: - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:SIG:SWHT:{'ON' if self.device.heater_on else 'OFF'}") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:SIG:SWHT:{'ON' if self.device.heater_on else 'OFF'}" + ) def get_bipolar_mode(self) -> str: - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + ) def get_system_alarms(self) -> str: """ Returns the system alarms in the format: STAT:SYS:ALRM:MB1.T1Open Circuit;DB1.L1Short Circuit; """ - alarms = ["STAT:SYS:ALRM:",] + alarms = [ + "STAT:SYS:ALRM:", + ] if self.device.tempboard_status != TemperatureBoardStatus.OK: - alarms.append((f"{DeviceUID.magnet_temperature_sensor}\t" - f"{TemperatureBoardStatus.names()[self.device.tempboard_status.value]};")) + alarms.append( + ( + f"{DeviceUID.magnet_temperature_sensor}\t" + f"{TemperatureBoardStatus.names()[self.device.tempboard_status.value]};" + ) + ) if self.device.tempboard_10T_status != TemperatureBoardStatus.OK: - alarms.append((f"{DeviceUID.temperature_sensor_10t}\t" - f"{TemperatureBoardStatus.names()[self.device.tempboard_10T_status.value]};")) + alarms.append( + ( + f"{DeviceUID.temperature_sensor_10t}\t" + f"{TemperatureBoardStatus.names()[self.device.tempboard_10T_status.value]};" + ) + ) if self.device.levelboard_status.value != LevelMeterBoardStatus.OK: - alarms.append((f"{DeviceUID.level_meter}\t" - f"{LevelMeterBoardStatus.names()[self.device.levelboard_status.value]};")) + alarms.append( + ( + f"{DeviceUID.level_meter}\t" + f"{LevelMeterBoardStatus.names()[self.device.levelboard_status.value]};" + ) + ) if self.device.pressureboard_status.value != PressureBoardStatus.OK: - alarms.append((f"{DeviceUID.pressure_sensor_10t}\t" - f"{PressureBoardStatus.names()[self.device.pressureboard_status.value]};")) + alarms.append( + ( + f"{DeviceUID.pressure_sensor_10t}\t" + f"{PressureBoardStatus.names()[self.device.pressureboard_status.value]};" + ) + ) alarm_list_str = "".join(alarms) return alarm_list_str @@ -368,17 +519,21 @@ def set_field_sweep_rate(self, tesla_per_min: float) -> str: def set_bipolar_mode(self, mode: bool) -> str: self.device.bipolar = bool(mode) - return (f"STAT:DEV:{DeviceUID.magnet_supply}" - f":PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}") + return ( + f"STAT:DEV:{DeviceUID.magnet_supply}" + f":PSU:BIPL:{'ON' if self.device.bipolar else 'OFF'}" + ) def get_nit_read_interval(self) -> str: """ Gets the nitrogen read interval in milliseconds. :return: A string indicating the nitrogen read interval. """ - return (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:NIT:PPS:{self.device.nitrogen_read_interval:d}") - + return ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:PPS:{self.device.nitrogen_read_interval:d}" + ) + def set_nit_read_interval(self, interval: int) -> str: """ Sets the nitrogen read interval in milliseconds. @@ -386,50 +541,66 @@ def set_nit_read_interval(self, interval: int) -> str: :return: A string indicating the success of the operation. """ self.device.nitrogen_read_interval = interval - return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:{interval:d}:VALID" - + return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:PPS:{interval:d}:VALID" + def get_nit_freq_zero(self) -> str: - ret = (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}") + ret = ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}" + ) return ret def set_nit_freq_zero(self, freq: float) -> str: self.device.nitrogen_frequency_at_zero = freq - ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" - f":LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}:VALID") + ret = ( + f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:ZERO:{self.device.nitrogen_frequency_at_zero:.2f}:VALID" + ) return ret def get_nit_freq_full(self) -> str: - ret = (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}") + ret = ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}" + ) return ret def set_nit_freq_full(self, freq: float) -> str: self.device.nitrogen_frequency_at_full = freq - ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" - f":LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}:VALID") + ret = ( + f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:FREQ:FULL:{self.device.nitrogen_frequency_at_full:.2f}:VALID" + ) return ret def get_he_empty_resistance(self) -> str: - ret = (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}") + ret = ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}" + ) return ret def get_he_full_resistance(self) -> str: - ret = (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}") + ret = ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}" + ) return ret def set_he_empty_resistance(self, resistance: float) -> str: self.device.helium_empty_resistance = resistance - ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}:VALID") + ret = ( + f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:ZERO:{self.device.helium_empty_resistance:.2f}:VALID" + ) return ret def set_he_full_resistance(self, resistance: float) -> str: self.device.helium_full_resistance = resistance - ret = (f"STAT:SET:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}:VALID") + ret = ( + f"STAT:SET:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:RES:FULL:{self.device.helium_full_resistance:.2f}:VALID" + ) return ret def get_he_fill_start_level(self) -> str: @@ -437,8 +608,10 @@ def get_he_fill_start_level(self) -> str: Gets the helium fill start level. :return: A string indicating the helium fill start level. """ - return (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:LOW:{self.device.helium_fill_start_level:d}") + return ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:LOW:{self.device.helium_fill_start_level:d}" + ) def set_he_fill_start_level(self, level: int) -> str: """ @@ -454,8 +627,10 @@ def get_he_fill_stop_level(self) -> str: Gets the helium fill stop level. :return: A string indicating the helium fill stop level. """ - return (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:HIGH:{self.device.helium_fill_stop_level:d}") + return ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:HEL:HIGH:{self.device.helium_fill_stop_level:d}" + ) def set_he_fill_stop_level(self, level: int) -> str: """ @@ -472,17 +647,18 @@ def get_he_refilling(self) -> str: :return: A string indicating whether helium is refilling. """ refilling: bool = self.device.helium_level <= self.device.helium_fill_start_level - - return (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:HEL:RFL:{'ON' if refilling else 'OFF'}") + + return f"STAT:DEV:{DeviceUID.level_meter}" f":LVL:HEL:RFL:{'ON' if refilling else 'OFF'}" def get_nit_fill_start_level(self) -> str: """ Gets the nitrogen fill start level. :return: A string indicating the nitrogen fill start level. """ - return (f"STAT:DEV:{DeviceUID.level_meter}" - f":LVL:NIT:LOW:{self.device.nitrogen_fill_start_level:d}") + return ( + f"STAT:DEV:{DeviceUID.level_meter}" + f":LVL:NIT:LOW:{self.device.nitrogen_fill_start_level:d}" + ) def set_nit_fill_start_level(self, level: int) -> str: """ @@ -492,15 +668,15 @@ def set_nit_fill_start_level(self, level: int) -> str: """ self.device.nitrogen_fill_start_level = level return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:NIT:LOW:{level:d}:VALID" - + def get_nit_fill_stop_level(self) -> str: """ Gets the nitrogen fill stop level. :return: A string indicating the nitrogen fill stop level. """ return ( - f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:" - f"{self.device.nitrogen_fill_stop_level:d}" + f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:HIGH:" + f"{self.device.nitrogen_fill_stop_level:d}" ) def set_nit_fill_stop_level(self, level: int) -> str: @@ -517,11 +693,11 @@ def get_nit_refilling(self) -> str: Gets the nitrogen refilling status. :return: A string indicating whether nitrogen is refilling. """ - state: str = 'ON' if (self.device.nitrogen_level - <= self.device.nitrogen_fill_start_level) else 'OFF' - - return ( - f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL:{state}") + state: str = ( + "ON" if (self.device.nitrogen_level <= self.device.nitrogen_fill_start_level) else "OFF" + ) + + return f"STAT:DEV:{DeviceUID.level_meter}:LVL:NIT:RFL:{state}" def get_nitrogen_level(self) -> str: """ @@ -542,8 +718,9 @@ def get_he_read_rate(self) -> str: Gets the helium read rate. :return: A string indicating the helium read rate. """ - state: str = 'ON' if (self.device.helium_read_rate == LevelMeterHeliumReadRate.SLOW.value)\ - else 'OFF' + state: str = ( + "ON" if (self.device.helium_read_rate == LevelMeterHeliumReadRate.SLOW.value) else "OFF" + ) return f"STAT:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:{state}" @@ -553,8 +730,11 @@ def set_he_read_rate(self, slow_rate: str) -> str: :param slow_rate: The helium read slow rate to set: OFF -> FAST, ON -> SLOW :return: A string indicating the success of the operation. """ - self.device.helium_read_rate = LevelMeterHeliumReadRate.FAST.value if (slow_rate == "OFF") \ + self.device.helium_read_rate = ( + LevelMeterHeliumReadRate.FAST.value + if (slow_rate == "OFF") else LevelMeterHeliumReadRate.SLOW.value + ) return f"STAT:SET:DEV:{DeviceUID.level_meter}:LVL:HEL:PULS:SLOW:{slow_rate}:VALID" @@ -563,23 +743,28 @@ def get_magnet_temperature(self) -> str: Gets the temperature of the magnet. :return: The temperature in Kelvin. """ - return (f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP:" - f"{self.device.magnet_temperature:.4f}K") + return ( + f"STAT:DEV:{DeviceUID.magnet_temperature_sensor}:TEMP:SIG:TEMP:" + f"{self.device.magnet_temperature:.4f}K" + ) def get_lambda_plate_temperature(self) -> str: """ Gets the temperature of the lambda plate. :return: The temperature in Kelvin. """ - return (f"STAT:DEV:{DeviceUID.temperature_sensor_10t}:TEMP:SIG:TEMP:" - f"{self.device.lambda_plate_temperature:.4f}K") - + return ( + f"STAT:DEV:{DeviceUID.temperature_sensor_10t}:TEMP:SIG:TEMP:" + f"{self.device.lambda_plate_temperature:.4f}K" + ) def get_pressure(self) -> str: """ Gets the pressure in mBar. :return: The pressure in mBar. """ - #return self.device.pressure - return (f"STAT:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES:" - f"{self.device.pressure:.4f}mB") + # return self.device.pressure + return ( + f"STAT:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES:" + f"{self.device.pressure:.4f}mB" + ) diff --git a/system_tests/lewis_emulators/ips/modes.py b/system_tests/lewis_emulators/ips/modes.py index f50e0b4..df7f035 100644 --- a/system_tests/lewis_emulators/ips/modes.py +++ b/system_tests/lewis_emulators/ips/modes.py @@ -51,7 +51,8 @@ class MagnetSupplyStatus(IntEnum): This information is not published and was derived from direct questions to Oxford Instruments. -""" + """ + OK = 0x00000000 SWITCH_HEATER_MISMATCH = 0x00000001 OVER_TEMPERATURE_RUNDOWN_RESISTORS = 0x00000002 @@ -70,6 +71,7 @@ class MagnetSupplyStatus(IntEnum): VOLTAGE_ADC_ERROR = 0x00010000 CURRENT_ADC_ERROR = 0x00020000 + class BoardStatus(IntEnum): """ Provides the base functionality for board status enums and provides methods to lookup @@ -78,12 +80,13 @@ class BoardStatus(IntEnum): to return a list of strings that represent the status values. This abstract method must be implemented in any subclass of BoardStatus. """ + @classmethod @abstractmethod - def names(cls)->list[str]: + def names(cls) -> list[str]: return [] - def text(self)->str: + def text(self) -> str: try: ret = self.__class__.names()[self.value] except IndexError: @@ -97,7 +100,7 @@ class TemperatureBoardStatus(BoardStatus): and is only applicable to the IPS SCPI protocol. These alarms are returned in response to the READ:SYS:ALRM commnand returning errors as strings with the following example: STAT:SYS:ALRM:MB1.T1Open Circuit; - + | Status | Description | Bit Value | Bit Position | |----------------------|--------------------------------------------|-----------|--------------| | Open Circuit | Heater off - Open circuit on sensor input | 00000001 | 0 | @@ -106,7 +109,8 @@ class TemperatureBoardStatus(BoardStatus): | Firmware Error | Error in board firmware: restart iPS | 00000008 | 3 | | Board Not Configured | Firmware not loaded correctly: update f/w | 00000010 | 4 | -""" + """ + OK = 0 OPEN_CIRCUIT = 1 SHORT_CIRCUIT = 2 @@ -116,9 +120,14 @@ class TemperatureBoardStatus(BoardStatus): @classmethod def names(cls) -> list[str]: - return ["", "Open circuit", "Short circuit", "Calibration error", - "Firmware error", "Board not configured"] - + return [ + "", + "Open circuit", + "Short circuit", + "Calibration error", + "Firmware error", + "Board not configured", + ] class LevelMeterBoardStatus(BoardStatus): @@ -139,7 +148,8 @@ class LevelMeterBoardStatus(BoardStatus): | Board Not Configured | Firmware not loaded correctly: update f/w | 00000040 | 6 | | No Reserve | Autofill valve open but not filling | 00000080 | 7 | -""" + """ + OK = 0 OPEN_CIRCUIT = 1 SHORT_CIRCUIT = 2 @@ -149,19 +159,30 @@ class LevelMeterBoardStatus(BoardStatus): FIRMWARE_ERROR = 6 BOARD_NOT_CONFIGURED = 7 NO_RESERVE = 8 - + @classmethod def names(cls) -> list[str]: - return ["", "Open circuit", "Short circuit", "ADC error", "Over demand", - "Over temperature", "Firmware error", "Board not configured", "No reserve"] + return [ + "", + "Open circuit", + "Short circuit", + "ADC error", + "Over demand", + "Over temperature", + "Firmware error", + "Board not configured", + "No reserve", + ] + class LevelMeterHeliumReadRate(IntEnum): """ This class represents the read rate of the helium level meter. It is only applicable to the IPS SCPI protocol. - The read rate is used to determine how often the helium level is read, using the + The read rate is used to determine how often the helium level is read, using the DEV::LVL:HEL:PULS:SLOW:[0|1] command. """ + SLOW = 0 FAST = 1 @@ -204,7 +225,8 @@ class PressureBoardStatus(BoardStatus): | Excitation + error | restart iPS | 00200000 | 22 | | Excitation - error | restart iPS | 00400000 | 23 | -""" + """ + OK = 0 OPEN_CIRCUIT = 1 SHORT_CIRCUIT = 2 @@ -233,10 +255,30 @@ class PressureBoardStatus(BoardStatus): @classmethod def names(cls) -> list[str]: - return ["", "Open circuit", "Short circuit", "Calibration error", "Firmware error", - "Board not configured", "Over current", "Current leakage", "Power on fail", - "Checksum fail", "Clock fail", "ADC fail", "Mains fail", - "Reference fail", "12V fail", "-12V fail", "8V fail", "-8V fail", - "Ampl gain error", "Amp offset error", "ADC offset error", "ADC PGA error", - "ADC XTAL error", "Excitation + error", "Excitation - error"] - + return [ + "", + "Open circuit", + "Short circuit", + "Calibration error", + "Firmware error", + "Board not configured", + "Over current", + "Current leakage", + "Power on fail", + "Checksum fail", + "Clock fail", + "ADC fail", + "Mains fail", + "Reference fail", + "12V fail", + "-12V fail", + "8V fail", + "-8V fail", + "Ampl gain error", + "Amp offset error", + "ADC offset error", + "ADC PGA error", + "ADC XTAL error", + "Excitation + error", + "Excitation - error", + ] diff --git a/system_tests/lewis_emulators/ips/states.py b/system_tests/lewis_emulators/ips/states.py index a3838a1..4a3d564 100644 --- a/system_tests/lewis_emulators/ips/states.py +++ b/system_tests/lewis_emulators/ips/states.py @@ -11,6 +11,7 @@ SECS_PER_MIN = 60 + class HeaterOnState(State): def in_state(self, dt: float) -> None: device = typing.cast("SimulatedIps", self._context) @@ -42,8 +43,7 @@ def in_state(self, dt: float) -> None: elif device.activity == Activity.TO_ZERO: device.current = approaches.linear(device.current, 0, curr_ramp_rate, dt) - device.magnet_current = approaches.linear(device.magnet_current, - 0, curr_ramp_rate, dt) + device.magnet_current = approaches.linear(device.magnet_current, 0, curr_ramp_rate, dt) class HeaterOffState(State): From f7e4543b1ee628c8971ec114998e9a4cce773052 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 11:19:51 +0100 Subject: [PATCH 52/61] OxinstIPS_SCPI.protocol: Removed no longer relevant commentary regarding legacy timeout issues. --- OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol | 5 ----- 1 file changed, 5 deletions(-) diff --git a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol index f14f811..42f5380 100644 --- a/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol +++ b/OxInstIPSApp/protocol/OxInstIPS_SCPI.protocol @@ -15,11 +15,6 @@ # Terminator = "\n"; -# The lagacy timeout values cribbed from OxInstCryojet module - had occasional -# problems with the default settings with one record timing out and -# another record seeing the reply - see if longer time out will fix it. -# Also see if this is still applicable to the SCPI protocol. -# readtimeout = 500; replytimeout = 5000; locktimeout = 20000; From ca1b80843b4f5eecaa16cc4a5bbdf20126f0ae11 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 11:26:45 +0100 Subject: [PATCH 53/61] alarms.cpp: PR review changes --- OxInstIPSApp/src/alarms.cpp | 63 ++++++++++++++----------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/OxInstIPSApp/src/alarms.cpp b/OxInstIPSApp/src/alarms.cpp index 3ce100e..8295f59 100644 --- a/OxInstIPSApp/src/alarms.cpp +++ b/OxInstIPSApp/src/alarms.cpp @@ -161,17 +161,6 @@ static long handle_system_alarm_status(aSubRecord *prec) BOARD_ARRAY.push_back("DB1.L1"); // Levels Controller Board BOARD_ARRAY.push_back("DB5.P1"); // Pressure Controller Board - // *** The following is not used as we are using fixed board IDs above. *** - // *** It would be better to pass these strings from the EPICS layer but the code below *** - // *** caused the subroutine to crash. Probably a pointer issue, but I have to draw the line *** - // *** somewhere with the intention to do it properly at a later date. *** - // Populate the BOARD_ARRAY with the board identifiers from the input fields. - // Typically: "MB1.T1", "DB8.T1", "DB1.L1", "DB5.P1" - //strcpy(BOARD_ARRAY[0], (char *)prec->b); // Magnet Temperature Controller Board - //strcpy(BOARD_ARRAY[1], (char *)prec->c); // 10T Magnet Temperature Controller Board - //strcpy(BOARD_ARRAY[2], (char *)prec->d); // Levels Controller Board - //strcpy(BOARD_ARRAY[3], (char *)prec->e); // Pressure Controller Board - string status = string((char *)((epicsOldString*)prec->a)); // Tokenise the input string to extract the list of board+status. @@ -194,10 +183,6 @@ static long handle_system_alarm_status(aSubRecord *prec) } } - // Debug output to show the tokens we have extracted - // for(int i = 0; i < token_list.size(); i++) - // errlogPrintf("%s: token %d: %s\n", prec->name, i, token_list[i].c_str()); - // Now we have a list of tokens, each of which is of the form "status message". // We will process each token to extract the board ID and corresponding status message. for (const auto& token : token_list) @@ -258,32 +243,32 @@ static long handle_system_alarm_status(aSubRecord *prec) out_bit_patterns[board_index] |= (1 << status_value); } // for each token - for (int board_index = 0; board_index < NBOARDS; ++board_index) + for (int board_index = 0; board_index < NBOARDS; ++board_index) + { + // Write the status value to the appropriate output field + switch (board_index) { - // Write the status value to the appropriate output field - switch (board_index) - { - case 0: - // Magnet Temperature Controller Board - *(epicsInt32*)prec->vala = out_bit_patterns[board_index]; - break; - case 1: - // 10T Magnet Temperature Controller Board - *(epicsInt32*)prec->valb = out_bit_patterns[board_index]; - break; - case 2: - // Levels Controller Board - *(epicsInt32*)prec->valc = out_bit_patterns[board_index]; - break; - case 3: - // Pressure Controller Board - *(epicsInt32*)prec->vald = out_bit_patterns[board_index]; - break; - default: - errlogPrintf("%s: Invalid board index: %d\n", prec->name, board_index); - break; - } + case 0: + // Magnet Temperature Controller Board + *(epicsInt32*)prec->vala = out_bit_patterns[board_index]; + break; + case 1: + // 10T Magnet Temperature Controller Board + *(epicsInt32*)prec->valb = out_bit_patterns[board_index]; + break; + case 2: + // Levels Controller Board + *(epicsInt32*)prec->valc = out_bit_patterns[board_index]; + break; + case 3: + // Pressure Controller Board + *(epicsInt32*)prec->vald = out_bit_patterns[board_index]; + break; + default: + errlogPrintf("%s: Invalid board index: %d\n", prec->name, board_index); + break; } + } return 0; // Process output links } From fe3233b939fd3453791b5b4ceaf7cc294d3904fa Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 12:32:35 +0100 Subject: [PATCH 54/61] Removed commented out code from interfaces/__init__.py --- system_tests/lewis_emulators/ips/interfaces/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/__init__.py b/system_tests/lewis_emulators/ips/interfaces/__init__.py index 2d20843..6169144 100644 --- a/system_tests/lewis_emulators/ips/interfaces/__init__.py +++ b/system_tests/lewis_emulators/ips/interfaces/__init__.py @@ -1,5 +1,4 @@ # from .stream_interface import IpsStreamInterface from .stream_interface_scpi import IpsStreamInterface -# __all__ = ["IpsStreamInterface", "IpsSCPIStreamInterface"] __all__ = ["IpsStreamInterface"] From 6f6869cceaf5973fd07f6a2b777143226928d643 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 12:45:24 +0100 Subject: [PATCH 55/61] stream_interface_scpi.py: Removed redundent code in set_activity() --- .../lewis_emulators/ips/interfaces/stream_interface_scpi.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 248266b..b8a0de2 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -316,10 +316,6 @@ def set_activity(self, mode: str) -> str: # Set the default return value to invalid (guilty until proven innocent) ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:INVALID" - for testmode in MODE_MAPPING: - if mode == MODE_MAPPING[testmode].value: - break - try: self.device.activity = MODE_MAPPING[mode] ret = f"STAT:SET:DEV:{DeviceUID.magnet_supply}:PSU:ACTN:{mode}:VALID" From 9132e72ac59e197f7071849c21f23ca34f35d66c Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 12:48:25 +0100 Subject: [PATCH 56/61] stream_interface_scpi.py: Removed redundent function get_trip_current() --- .../lewis_emulators/ips/interfaces/stream_interface_scpi.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index b8a0de2..47f43cd 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -406,10 +406,6 @@ def get_software_voltage_limit(self) -> str: def get_persistent_magnet_current(self) -> str: return f"STAT:DEV:{DeviceUID.magnet_supply}:PSU:SIG:PCUR:{self.device.magnet_current:.4f}A" - # TBD - def get_trip_current(self) -> str: - return f"R{self.device.trip_current}" - def get_persistent_magnet_field(self) -> str: return ( f"STAT:DEV:{DeviceUID.magnet_supply}" From 3db02a31e632a59adfbb1946035b50c1c5303cfa Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 12:50:06 +0100 Subject: [PATCH 57/61] stream_interface_scpi.py: Removed commentded out code in get_pressure() --- .../lewis_emulators/ips/interfaces/stream_interface_scpi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py index 47f43cd..fe29439 100644 --- a/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py +++ b/system_tests/lewis_emulators/ips/interfaces/stream_interface_scpi.py @@ -755,7 +755,6 @@ def get_pressure(self) -> str: Gets the pressure in mBar. :return: The pressure in mBar. """ - # return self.device.pressure return ( f"STAT:DEV:{DeviceUID.pressure_sensor_10t}:PRES:SIG:PRES:" f"{self.device.pressure:.4f}mB" From 14a174a19e440341f093407561f67bd80db5ad18 Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Tue, 16 Sep 2025 12:58:59 +0100 Subject: [PATCH 58/61] More Ruff formatting that somehow got missed --- system_tests/tests/ips.py | 16 ++++------ system_tests/tests/ips_common.py | 33 +++++++++++-------- system_tests/tests/ips_scpi.py | 55 +++++++++++++++----------------- 3 files changed, 53 insertions(+), 51 deletions(-) diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index e8fcb8f..1d6d0f7 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -66,6 +66,7 @@ class IpsLegacyTests(IpsBaseTests, unittest.TestCase): """ Tests for the Ips legacy protocol IOC. """ + def _get_device_prefix(self) -> str: return DEVICE_PREFIX @@ -116,7 +117,7 @@ def _assert_heater_is(self, heater_state: bool) -> None: @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( - self, _:str, field : float + self, _: str, field: float ) -> None: self._set_and_check_persistent_mode(False) self.ca.set_pv_value("FIELD:SP", field) @@ -136,9 +137,7 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s # (mirroring what the field ought to do in the real device) self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", - 0, - tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) # These tests for locking and unlocking the remote control are only applicable # to the legacy protocol. SCPI does not have a remote control lock. @@ -146,7 +145,7 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES) ) def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( - self, _:str, control_pv: str, set_value: str + self, _: str, control_pv: str, set_value: str ) -> None: self.ca.set_pv_value("CONTROL", "Local & Locked") self.ca.set_pv_value(control_pv, set_value) @@ -155,10 +154,9 @@ def test_WHEN_control_command_value_set_THEN_remote_unlocked_set( @parameterized.expand( control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES) ) - def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, - _:str, - control_pv: str) -> None: + def test_WHEN_control_command_processed_THEN_remote_unlocked_set( + self, _: str, control_pv: str + ) -> None: self.ca.set_pv_value("CONTROL", "Local & Locked") self.ca.process_pv(control_pv) self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked") - diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/ips_common.py index 639a9a8..60ed2bb 100644 --- a/system_tests/tests/ips_common.py +++ b/system_tests/tests/ips_common.py @@ -52,6 +52,7 @@ class IpsBaseTests(object, metaclass=ABCMeta): """ Tests for the Ips IOC. """ + @abstractmethod def _get_device_prefix(self) -> str: pass @@ -62,19 +63,19 @@ def _get_ioc_config(self) -> list[dict]: @abstractmethod def setUp(self) -> None: - self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) - self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX) + self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) + self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX) def tearDown(self) -> None: # Wait for statemachine to reach "at field" state after every test. self.ca.assert_that_pv_is("STATEMACHINE", "At field") - self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") # pyright: ignore + self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), "False") # pyright: ignore def test_WHEN_ioc_is_started_THEN_ioc_is_not_disabled(self) -> None: self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED") - def _assert_field_is(self, field: float, check_stable:bool=False) -> None: + def _assert_field_is(self, field: float, check_stable: bool = False) -> None: self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE) if check_stable: self.ca.assert_that_pv_value_is_unchanged("FIELD:USER", wait=30) @@ -83,13 +84,13 @@ def _assert_field_is(self, field: float, check_stable:bool=False) -> None: @abstractmethod def _assert_heater_is(self, heater_state: bool) -> None: pass - + def _set_and_check_persistent_mode(self, mode: bool) -> None: self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT") @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero( - self, _:str, val: float + self, _: str, val: float ) -> None: initial_field = 1 @@ -182,7 +183,9 @@ def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN self._assert_field_is(val, check_stable=True) @contextmanager - def _backdoor_magnet_quench(self, reason: str="Test framework quench") -> Generator[None, None, None]: + def _backdoor_magnet_quench( + self, reason: str = "Test framework quench" + ) -> Generator[None, None, None]: self._lewis.backdoor_run_function_on_device("quench", [reason]) try: yield @@ -194,14 +197,16 @@ def _backdoor_magnet_quench(self, reason: str="Test framework quench") -> Genera self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE) @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates\ - (self, _: str, val: float) -> None: + def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates( + self, _: str, val: float + ) -> None: self._lewis.backdoor_set_on_device("inductance", val) self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE) @parameterized.expand(val for val in parameterized_list(TEST_VALUES)) - def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates\ - (self, _: str, val:float) -> None: + def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates( + self, _: str, val: float + ) -> None: self._lewis.backdoor_set_on_device("measured_current", val) self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE) @@ -221,7 +226,7 @@ def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alar self.ca.set_pv_value("ACTIVITY:SP", "Clamp") else: self.ca.set_pv_value("ACTIVITY:SP", activity_state) - + self.ca.assert_that_pv_is("ACTIVITY", activity_state) if activity_state == "Clamped": self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR") @@ -230,7 +235,9 @@ def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alar # original problem/complaint: # in non-persistent mode, heater wait time always implemented, therefore too slow to set new fields - def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater(self) -> None: + def test_GIVEN_at_field_in_non_persistent_mode_WHEN_new_field_set_THEN_no_wait_for_heater( + self, + ) -> None: # arrange: set mode to non-persistent, set field self._set_and_check_persistent_mode(False) self.ca.set_pv_value("FIELD:SP", 3.21) diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 3f0c477..2abd59c 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -50,7 +50,9 @@ TOLERANCE = 0.0001 -HEATER_OFF_STATES = ["Off",] +HEATER_OFF_STATES = [ + "Off", +] ACTIVITY_STATES = ["Hold", "To Setpoint", "To Zero", "Clamped"] @@ -59,6 +61,7 @@ class IpsSCPITests(IpsBaseTests, unittest.TestCase): """ Tests for the Ips SCPI protocol IOC. """ + def _get_device_prefix(self) -> str: return DEVICE_PREFIX @@ -69,7 +72,7 @@ def setUp(self) -> None: ioc_config = self._get_ioc_config() # Time to wait for the heater to warm up/cool down (extracted from IOC macros above) - heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) # pyright: ignore + heater_wait_time = float((ioc_config[0].get("macros").get("HEATER_WAITTIME"))) # pyright: ignore self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX) # Some changes happen on the order of HEATER_WAIT_TIME seconds. @@ -84,12 +87,12 @@ def setUp(self) -> None: # Ensure in the correct mode # The following call was in the original legacy test code but is not needed # in the SCPI version. - # self.ca.set_pv_value("CONTROL:SP", "Off") + # self.ca.set_pv_value("CONTROL:SP", "Off") # Remote and Unlocked self.ca.set_pv_value("ACTIVITY:SP", "To Setpoint") - # Don't run reset as the sudden change of state confuses the IOC's state machine. + # Don't run reset as the sudden change of state confuses the IOC's state machine. # No matter what the initial state of the device the SNL should be able to deal with it. # self._lewis.backdoor_run_function_on_device("reset") @@ -114,11 +117,10 @@ def _assert_heater_is(self, heater_state: bool) -> None: "Off", ) - @parameterized.expand(field for field in parameterized_list(TEST_VALUES)) def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses( - self, _: str, field: float ) -> None: - + self, _: str, field: float + ) -> None: self._set_and_check_persistent_mode(False) self.ca.set_pv_value("FIELD:SP", field) self._assert_field_is(field) @@ -132,36 +134,33 @@ def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_s # (mirroring what the field ought to do in the real device) self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE) self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE) - self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", - 0, tolerance=TOLERANCE) + self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE) def test_GIVEN_magnet_temperature_sensor_open_circuit_THEN_ioc_states_open_circuit( - self) -> None: + self, + ) -> None: # Simulate an open circuit on the temperature sensor self._lewis.backdoor_run_function_on_device("set_tempboard_status", [1]) self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD.B0", "1", timeout=10) - def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit( - self) -> None: + def test_GIVEN_level_sensor_short_circuit_THEN_ioc_states_short_circuit(self) -> None: # Simulate an short circuit on the level sensor self._lewis.backdoor_run_function_on_device("set_levelboard_status", [2]) self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:LBOARD.B1", "1", timeout=10) - def test_WHEN_SYSALARM_Tboard_is_firmware_error_THEN_ioc_states_firmware_error( - self) -> None: + def test_WHEN_SYSALARM_Tboard_is_firmware_error_THEN_ioc_states_firmware_error(self) -> None: """ Test that the SYSALARM TBOARD PV is set to 'Firmware Error' when the temperature board firmware is in error state. """ self._lewis.backdoor_run_function_on_device("set_tempboard_status", [3]) - self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD.B2", '1', timeout=10) + self.ca.assert_that_pv_is("STS:SYSTEM:ALARM:TBOARD.B2", "1", timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) def test_GIVEN_level_freq_at_zero_THEN_ioc_states_freq(self, _: str, val: float) -> None: # Simulate the nitrogen frequency at zero self.ca.set_pv_value("LVL:NIT:FREQ:ZERO:SP", val) - self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:ZERO", val, - tolerance=TOLERANCE, timeout=10) + self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:ZERO", val, tolerance=TOLERANCE, timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_NITROGEN_LEVEL_FREQ_VALUES)) def test_GIVEN_level_freq_at_full_THEN_ioc_states_freq(self, _: str, val: float) -> None: @@ -170,20 +169,20 @@ def test_GIVEN_level_freq_at_full_THEN_ioc_states_freq(self, _: str, val: float) self.ca.assert_that_pv_is_number("LVL:NIT:FREQ:FULL", val, tolerance=TOLERANCE, timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_HE_LEVEL_RESISTANCE_VALUES)) - def test_given_level_resistance_empty_THEN_ioc_states_resistance(self, _: str, - val: float) -> None: + def test_given_level_resistance_empty_THEN_ioc_states_resistance( + self, _: str, val: float + ) -> None: # Simulate the helium level resistance when empty self.ca.set_pv_value("LVL:HE:EMPTY:RES:SP", val) - self.ca.assert_that_pv_is_number("LVL:HE:EMPTY:RES", val, - tolerance=TOLERANCE, timeout=10) + self.ca.assert_that_pv_is_number("LVL:HE:EMPTY:RES", val, tolerance=TOLERANCE, timeout=10) @parameterized.expand(val for val in parameterized_list(TEST_HE_LEVEL_RESISTANCE_VALUES)) - def test_given_level_resistance_full_THEN_ioc_states_resistance(self, _: str, - val: float) -> None: + def test_given_level_resistance_full_THEN_ioc_states_resistance( + self, _: str, val: float + ) -> None: # Simulate the helium level resistance when empty self.ca.set_pv_value("LVL:HE:FULL:RES:SP", val) - self.ca.assert_that_pv_is_number("LVL:HE:FULL:RES", val, - tolerance=TOLERANCE, timeout=10) + self.ca.assert_that_pv_is_number("LVL:HE:FULL:RES", val, tolerance=TOLERANCE, timeout=10) def test_WHEN_nitrogen_read_interval_set_THEN_ioc_updates_read_interval(self) -> None: """ @@ -191,9 +190,8 @@ def test_WHEN_nitrogen_read_interval_set_THEN_ioc_updates_read_interval(self) -> """ # Set the nitrogen read interval self.ca.set_pv_value("LVL:NIT:READ:INTERVAL:SP", 1000) - self.ca.assert_that_pv_is_number("LVL:NIT:READ:INTERVAL", - 1000, tolerance=TOLERANCE) - + self.ca.assert_that_pv_is_number("LVL:NIT:READ:INTERVAL", 1000, tolerance=TOLERANCE) + def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self) -> None: """ Test that the helium read rate can be set and is reflected in the IOC. @@ -203,4 +201,3 @@ def test_WHEN_helium_read_rate_set_THEN_ioc_updates_read_rate(self) -> None: self.ca.assert_that_pv_is("LVL:HE:PULSE:READ:RATE", "Slow") self.ca.set_pv_value("LVL:HE:PULSE:READ:RATE:SP", 0) self.ca.assert_that_pv_is("LVL:HE:PULSE:READ:RATE", "Fast") - From c48246c67836812498f90c5cb3a083543040f47b Mon Sep 17 00:00:00 2001 From: Ian Gillingham Date: Wed, 17 Sep 2025 13:00:05 +0100 Subject: [PATCH 59/61] ips.py: Commented out ioc_launcher_class: ProcServLauncher to improve loading efficiency at test time (as with the SCPI IOC) --- system_tests/tests/ips.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 1d6d0f7..470737f 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -23,7 +23,7 @@ "directory": get_default_ioc_dir("IPS"), "emulator": EMULATOR_NAME, "lewis_protocol": "ips_legacy", - "ioc_launcher_class": ProcServLauncher, + # "ioc_launcher_class": ProcServLauncher, "macros": { "STREAMPROTOCOL": "LEGACY", "MANAGER_ASG": "DEFAULT", From a5c5b76fc2a2a563763d4de726aad7800039e77f Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 15 Oct 2025 19:59:07 +0100 Subject: [PATCH 60/61] Remove unused parameters --- OxInstIPSApp/src/alarms.cpp | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/OxInstIPSApp/src/alarms.cpp b/OxInstIPSApp/src/alarms.cpp index 8295f59..99182fa 100644 --- a/OxInstIPSApp/src/alarms.cpp +++ b/OxInstIPSApp/src/alarms.cpp @@ -12,15 +12,11 @@ * where the bit patterns will be established according to active alarms. * * INPA - Input string containing the alarm message. - * INPB - Board identifier form the magnet temperature controller (e.g. "MB1.T1") - * INPC - Board identifier form the 10T magnet temperature controller (e.g. "DB8.T1") - * INPD - Board identifier form the Levels controller (e.g. "DB1.L1") - * INPE - Board identifier form the pressure controller (e.g. "DB5.P1") * * OUTA - Output field for the magnet temperature alarm status (mbbidirect). - * OUTA - Output field for the magnet 10T temperature alarm status (mbbidirect). - * OUTA - Output field for the levels alarm status (mbbidirect). - * OUTA - Output field for the pressure alarm status (mbbidirect). + * OUTB - Output field for the magnet 10T temperature alarm status (mbbidirect). + * OUTC - Output field for the levels alarm status (mbbidirect). + * OUTD - Output field for the pressure alarm status (mbbidirect). * * Incoming alarm messages are expected to be in the format: * "STAT:SYS:ALRM:DB8.T1<9>Open Circuit;MB1.T1<9>Open Circuit;" @@ -141,10 +137,6 @@ static long handle_system_alarm_status(aSubRecord *prec) if ( prec->fta != menuFtypeCHAR - || prec->ftb != menuFtypeSTRING - || prec->ftc != menuFtypeSTRING - || prec->ftd != menuFtypeSTRING - || prec->fte != menuFtypeSTRING || prec->ftva != menuFtypeLONG || prec->ftvb != menuFtypeLONG || prec->ftvc != menuFtypeLONG From 3590a5eb58b9b0a9338f8bbad0deadf425934c13 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 15 Oct 2025 20:20:12 +0100 Subject: [PATCH 61/61] Fix run_tests.bat & remove commented-out code --- system_tests/tests/common_tests/__init__.py | 0 system_tests/tests/{ => common_tests}/ips_common.py | 0 system_tests/tests/ips.py | 5 ++--- system_tests/tests/ips_scpi.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 system_tests/tests/common_tests/__init__.py rename system_tests/tests/{ => common_tests}/ips_common.py (100%) diff --git a/system_tests/tests/common_tests/__init__.py b/system_tests/tests/common_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system_tests/tests/ips_common.py b/system_tests/tests/common_tests/ips_common.py similarity index 100% rename from system_tests/tests/ips_common.py rename to system_tests/tests/common_tests/ips_common.py diff --git a/system_tests/tests/ips.py b/system_tests/tests/ips.py index 470737f..b44f4b5 100644 --- a/system_tests/tests/ips.py +++ b/system_tests/tests/ips.py @@ -2,11 +2,11 @@ from parameterized import parameterized # pyright: ignore from utils.channel_access import ChannelAccess # pyright: ignore -from utils.ioc_launcher import ProcServLauncher, get_default_ioc_dir # pyright: ignore +from utils.ioc_launcher import get_default_ioc_dir # pyright: ignore from utils.test_modes import TestModes # pyright: ignore from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore -from .ips_common import IpsBaseTests +from .common_tests.ips_common import IpsBaseTests # Tell ruff to ignore the N802 warning (function name should be lowercase). # Names contain GIVEN, WHEN, THEN @@ -23,7 +23,6 @@ "directory": get_default_ioc_dir("IPS"), "emulator": EMULATOR_NAME, "lewis_protocol": "ips_legacy", - # "ioc_launcher_class": ProcServLauncher, "macros": { "STREAMPROTOCOL": "LEGACY", "MANAGER_ASG": "DEFAULT", diff --git a/system_tests/tests/ips_scpi.py b/system_tests/tests/ips_scpi.py index 2abd59c..743ca00 100644 --- a/system_tests/tests/ips_scpi.py +++ b/system_tests/tests/ips_scpi.py @@ -6,7 +6,7 @@ from utils.test_modes import TestModes # pyright: ignore from utils.testing import get_running_lewis_and_ioc, parameterized_list # pyright: ignore -from .ips_common import IpsBaseTests +from .common_tests.ips_common import IpsBaseTests DEVICE_PREFIX = "IPS_01" EMULATOR_NAME = "ips"