Skip to content

Creating Sweeps

William Wood edited this page Apr 21, 2023 · 2 revisions

Creating Sweeps

Sweeps in FetCh are defined similarly to how Measurements are. That is, you define each type by creating a new class that extends the FetChSweep class.

This page serves to provide an overview of how this is done.

Introduction

Before diving into the code, we first need to understand the logic of a sweep. In general, sweeps work like so:

  1. The user is asked for the sweep parameters and any instruments needed
  2. The user is asked what should happen at each "sweep point"
  3. The values of the sweep are generated by using the parameters the user provided in step 1
  4. For each value generated in step 3, a list of actions is generated based on what the user provided in step 2
  5. The overall list of all generated actions is added to the queue contained in a "sweep" action

For instance, let's take a look at the Temperature Sweep as an example:

  1. The user is asked to input the temperature values to sweep over, as well as stability criteria and which temperature controller to use
  2. The user is asked what actions/measurements should be performed at each temperature
  3. The sweep knows the temperature values to sweep over because the user entered them directly in step 1
  4. For each temperature, the sweep adds a "Change Temperature" action which changes to that temperature and waits until the stability cirteria are met, followed by all the actions the user specified in step 2
  5. All the actions are added to the queue contained in a "Temperature Sweep" action.

To facilitate this, there's multiple parts of a FetchSweep to implement:

  1. Defining user-input parameters
  2. Defining instrument configurations
  3. Generating/retrieving a list of values to sweep over
  4. Generating a list of actions to perform for each sweep value
  5. Defining how to format the sweep values when displayed as text

Defining a sweep

To define a new sweep, in the org.oefet.fetch.sweep package create a new class that extends FetChSweep. For instance, let's say we have a voltage source and we want to run various measurements with this voltage source set to different voltage values (i.e. a "Voltage Sweep"). We will therefore start by defining a VoltageSweep class like so:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

}

There's a few things to note here. The first is that FetChSweep is a "generic" class, meaning we can specify different variable "types" in-between the <...> depending on what sort of values this sweep will be sweeping over. In our case, we're just sweeping over voltages, which are just numbers, so we say <Double> to indicate that the sweep values will just be floating-point numbers. The constructor arguments for FetChSweep are: the human-readable name of the sweep (i.e. "Voltage Sweep"), followed by the symbol used to represent the swept quantity (in this case voltage, hence "V").

Now the basic structure is defined, we can move onto implementing the various required methods and inputs. This is covered in the subsequent sections.

Defining required user-input parameters

Now that we have our class, we can start adding things to it. The first thing to think about and add is what parameters we want it to tell FetCh to ask the user for. For instance, if we continue with our two-wire conductivity example, we would want FetCh to ask the user for what values of current to sweep over.

This done by using a feature of Kotlin called "variable delegation". Essentially what we will do is declare a variable for each parameter but then instead of assigning it a value, we "delegate" it to FetCh to fill-in for us when it asks the user what they want it to be. We do this by using the by keyword and calling userInput(...) like so:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val parameter by userInput("Section", "Name", defaultValue)

}

FetCh will figure out what type of input you're after based on what you provide as the defaultValue. In our case, for a voltage sweep, we want a list of voltages and the amount of time to wait after setting each voltage before starting measurements/actions for that sweep value. We would therefore write:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val voltages by userInput("Voltages", "Values [V]", Range.linear(0, 10))
    val delay    by userInput("Timing", "Delay [ms]", 500)

}

Note that we have asked for the delay time in integer milliseconds. This is because the various sleep() methods used to cause the routine to pause require the time to be provided as such. However, this isn't very user friendly. People tend to prefer working in seconds, therefore we can use a mapping to convert an arbitrary amount of seconds into integer milliseconds automatically like so:

val delay by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }

By writing this, we're essentially telling FetCh that any time we try to read the value from delay it should instead tell us the value multiplied by 1000 and rounded to an integer. Therefore, our class now overall looks like:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val voltages by userInput("Voltages", "Values [V]", Range.linear(0, 10))
    val delay    by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }

}

Defining required instruments

Similar to how FetCh asks the user to input required parameters for the sweep, it can also ask the user to select which instruments/channels they would like to be used for specific purposes - allowing for said instruments to be configured in the process. Much like with parameters, this is done by delegation. However, this time there's two options: requiredInstrument and optionalInstrument:

val requiredInstrument by requiredInstrument("Name 1", Type::class)
val optionalInstrument by optionalInstrument("Name 2", Type::class)

The difference between the two is that an instrument delegated by use of requiredInstrument requires the user to select a connected device to be that instrument - causing the measurement to give an error if not - whereas the optionalInstrument can be left as "None" or disconnected. As an example, you may want the user to be able to optionally configure a thermometer to record temperature throughout a measurement or to just be ignored if they don't.

This is all explained in detail on the Measurements wiki page.

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val voltages by userInput("Voltages", "Values [V]", Range.linear(0, 10))
    val delay    by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }

    val vSource  by requiredInstrument("Voltage Source", VSource::class)

}

Generating sweep values

The next thing to define is how the sweep values should be generated. This is done by defining a getValues() method. In many cases this is quite simple - for instance, in our example of a Voltage Sweep, we just need to return what the user has entered as a list of Double objects like so:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val voltages by userInput("Voltages", "Values [V]", Range.linear(0, 10))
    val delay    by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }

    val vSource  by requiredInstrument("Voltage Source", VSource::class)

    override fun getValues(): List<Double> {
        return voltages.array().toList()
    }

}

If the way you get the user to define a sweep range is less direct, you may need to include some logic. For instance, if the user was only asked to provide "start", "stop" and "no. steps" values, this is where you would generate the list of values based off these values:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val start by userInput("Voltages", "Start [V]", 0.0)
    val stop  by userInput("Voltages", "Stop [V]", 10.0)
    val steps by userInput("Voltages", "No. Steps", 11)
    val delay by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }

    val vSource  by requiredInstrument("Voltage Source", VSource::class)

    override fun getValues(): List<Double> {
        return Range.linear(start, stop, steps).array().toList()
    }

}

As another example, let's say your sweep is over a 2-dimensional plane and the user is supposed to enter some initial co-ordinates, the spacing in x and y and the total number of "rows" and "columns". Your sweep would be generating a custom class of object that can hold an x and a y value like so:

class Position(val x: Double, val y: Double)

class PositionSweep : FetChSweep<Position>("Position Sweep", "r") {

    val startX by userInput("Start", "X [m]", 0.0)
    val startY by userInput("Start", "Y [m]", 0.0)

    val spaceX by userInput("Spacing", "X [m]", 1e-2)
    val spaceY by userInput("Spacing", "Y [m]", 1e-2)

    val countX by userInput("Count", "X", 10)
    val countY by userInput("Count", "Y", 5)

    override fun getValues(): List<Position> {

        val list = ArrayList<Position>()

        for (i in 0 until countX) {

            for (j in 0 until countY) {

                val x = startX + i * spaceX
                val y = startY + j * spaceY

                list += Position(x, y)

            }

        }

        return list

    }

}

Generating actions for each sweep value

Now that the sweep can generate the values it needs to sweep over, we need to tell it what to do at each value. This is done by using the generateForValue(...) method:

override fun generateForValue(value, actions): List<Action>

This methods takes two arguments. The first is the sweep value we are at and the second is a list of (copies of) the actions that the user has specified they want to happen at each sweep value. Generally, what you want to do is define a new list and sandwich the user-provided actions between whatever actions you need to do to perform the sweep. In our case, we just need to add a "Voltage Change" action which will set the voltage on the voltage source followed by all the actions the user has provided.

JISA provides a simple way to add a basic action to an ActionQueue by means of creating a SimpleAction object. To do this, you just need to specify a name for it along with code to execute when it is run. In our case this is just setting the voltage on the VSource instrument and then wait for the delay time.

Therefore, in our example we would write:

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val voltages by userInput("Voltages", "Values [V]", Range.linear(0, 10))
    val delay    by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }
    val vSource  by requiredInstrument("Voltage Source", VSource::class)

    override fun generateForValue(value: Double, actions: List<Action<*>>): List<Action<*>> {

        // Create new list
        val list = ArrayList<Action<*>>()

        // Add action to change voltage and wait
        list += SimpleAction("Change Voltage to $value V") {

            vSource.voltage = value
            sleep(delay)

        }

        // Add all the other actions the user wanted afterwards
        list += actions

        return list

    }

}

Defining sweep value formatting

The final thing left to do is to tell FetCh how it should format your sweep value when displaying it as text to the user. This is done by defining the formatValue(...) method which takes a sweep value and returns a String representation of it. In our voltage sweep example this is quite simple, we just need to stick the units of voltage (i.e. a "V") after the value:

override fun formatValue(value: Double): String {
    return "$value V"
}

In our previous, more complicated, example of a positional sweep, we could write something like:

override fun formatValue(value: Position): String {
    return "(x = ${value.x}, y = ${value.y})"
}

Final Actions (End-of-Sweep Actions)

If you want a sweep to always perform certain actions when it ends, whether it ended successfully, was interrupted, or there was an error, then you can define these as "final" actions. To do this, make your sweep class override the generateFinalActions() method like so:

override fun generateFinalActions(): List<Action<*>> {

    return listOf(action1, action2, etc...)

}

Any actions returned in the list from this method will be displayed in a separated "End-of-Sweep Actions" list when displayed in the queue.

Completed example

Our sweep is now completely defined and is ready to use.

class VoltageSweep : FetChSweep<Double>("Voltage Sweep", "V") {

    val voltages by userInput("Voltages", "Values [V]", Range.linear(0, 10))
    val delay    by userInput("Timing", "Delay [s]", 0.5) map { (it * 1e3).toInt() }
    val vSource  by requiredInstrument("Voltage Source", VSource::class)

    override fun getValues(): List<Double> {
        return voltages.array().toList()
    }

    override fun generateForValue(value: Double, actions: List<Action<*>>): List<Action<*>> {

        // Create new list
        val list = ArrayList<Action<*>>()

        // Add action to change voltage and wait
        list += SimpleAction("Change Voltage to $value V") {

            vSource.voltage = value
            sleep(delay)

        }

        // Add all the other actions the user wanted afterwards
        list += actions

        return list

    }

    override fun formatValue(value: Double): String {
        return "$value V"
    }

}