Skip to content

Measurements

William Wood edited this page Jan 26, 2024 · 37 revisions

Measurement Classes

Measurement routines in FetCh are defined by FetChMeasurement classes. These follow a specific format that allows FetCh to interface with them - automatically generating the necessary graphical user interfaces (i.e. text boxes, buttons etc) needed to facilitate their use by the user.

This means that the measurements themselves are completely decoupled from the user interface code so that you don't need to know a thing about how the GUI in FetCh works to introduce a new measurement type.

In total, we need to do six things:

  1. Give the measurement a name and unique identifier
  2. Define what parameters we need to ask the user for before running
  3. Define what instruments need selection and configuration by the user before running
  4. Define what structure the data from the measurement should take
  5. Write the code for what we want the measurement to do when run
  6. Define how data from these measurements should be processed and plotted

1. Giving it a name

The first thing to do is to define your class. All measurement classes in FetCh are located in the measurement package (org.oefet.fetch.measurement, i.e. in the ./src/org/oefet/fetch/measurement directory).

In this package you will find a file called MeasurementTemplate. Make a copy of it, giving it a new name (in this case we will choose MyMeasurement). Alternatively, you can just create a new Kotlin file, and copy the following code into it

package org.oefet.fetch.measurement

class MyMeasurement : FetChMeasurement("Measurement Name", "FileName") {

    companion object : Columns() {
        // Columns go here
    }

    override fun run(results: ResultTable) {
        // Measurement code goes here
    }

    override fun onFinish() {
        // Shutdown code goes here
    }

}

If you are unfamiliar with Kotlin's syntax, this essentially means: define a new class called "MyMeasurement" that extends from the FetChMeasurement class.

As we can see from the following line:

class MyMeasurement : FetChMeasurement("Measurement Name", "FileName")

FetChMeasurement needs to be provided with two parameters: a measurement name and a file name. These are defined as follows:

  • "Measurement Name": This should be the human-readable name of the measurement (i.e. what you want the user to see)
  • "FileName": This should be a short-hand name for the measurement, for FetCh to use internally

For instance, if we wanted to write a two-wire conductivity measurement:

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    companion object : Columns() {
        // Columns go here
    }

    override fun run(results: ResultTable) {
        // Measurement code goes here
    }

    override fun onFinish() {
        // Shutdown code goes here
    }

}

Therefore, when a measurement of this type is run, it will show up in any graphical user interface elements as a "Two-Wire Conductivity Measurement", will create files called "TWConductivity" (unless the user overrides that) and each of those files will have a label saying "TWConductivity" embedded in them to indentify them as two-wire conductivity result files to FetCh (i.e. so FetCh knows what to do with them if they get loaded back in later).

The pre-defined functions of run() and onFinish() are where we will write the code of the measurement routine itself. The "companion object" is where we shall define the columns for the results data table.

We have now defined a new measurement, by essentially giving it a name. However, we still need to:

  1. Define what parmeters the user needs to give the measurement
  2. Define what instruments/channels the user needs to supply for the measurement
  3. Define what the measurement is going to do

2. 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 TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    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. Let's say that we want to ask the user for:

  • How long to wait between setting current and measuring voltage
  • Any notes to attach to the measurement
  • The number of repeat measurements to take for averaging
  • Whether to use averaging
  • The values of source-drain current to sweep over

Then we would write:

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    val delay    by userInput("Basic", "Delay Time [s]", 0.5)
    val notes    by userInput("Basic", "Notes", "")
    val repeats  by userInput("Averaging", "Repeat Count", 10)
    val average  by userInput("Averaging", "Use Averaging", true)
    val currents by userInput("Source-Drain", "Current [A]", Range.step(-10e-6, +10e-6, 5e-6)) 

}

As a result of this, when the user gets to setting-up a measurement of this type, they will now see the following:

Note that a parameter called "Name" is always automatically added to the "Basic" section with its default value being what we defined as the fileName when defining the class. This is what's used by FetCh to determine the file name of the outputted CSV data from this measurement.

However, sometimes we don't want to use exactly the same value as entered by the user. For example, our delay time is to be input by user in seconds, but to make the program pause we need to tell it how long in integer milliseconds. Therefore, we can additionally define a mapping to convert the value into ms, by using map like so:

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

This way, when we access delay in our code, it will contain an integer value equal to the number of milliseconds we need to pause for - calculated from the value, in seconds, that the user entered.

Extra Input Types

FetCh lets you define user inputs of a specific form in a couple of cases. For instance, if you wanted to provide the user with a set of choices from a drop-down box, you can use the userChoice(...) method instead:

val choice by userChoice("Section", "Choice Name", "Option 1", "Option 2", "Option 3", etc...)

which will result in:

The value of choice will then be an integer representing the option selected (start at index 0, so "Option 1" would have a value of 0, "Option 2" would be 1, etc).

Similarly, if you wanted the user to specify a time in milliseconds, you can use the userTimeInput(...) method to display to them an input box that allows them to specify time in hours, minutes, seconds, and milliseconds:

val time by userTimeInput("Section", "Delay Time", 1500) // Default value of 1500 ms chosen

which would result in:

3. Defining required instruments

Similar to how FetCh asks the user to input required parameters for the measurement, 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 an 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, and simply ignoring it if they don't.

For the Type argument, you should choose one of the instrument types provided by JISA. Here's a list of the most common:

  • VMeter - Voltmeter
  • IMeter - Ammeter
  • VSource - Voltage Source
  • ISource - Current Source
  • IVMeter - Multimeter
  • IVSource - Voltage and Current Source
  • SMU - Source-Measure Unit
  • TMeter - Thermometer
  • TC.Loop - Temperature Control Loop
  • LockIn - Lock-in amplifier
  • DPLockIn - Dual-Phase Lock-in amplifier
  • Spectrometer - Spectrometer
  • EMController - Electromagnet Controller/Power Supply

You can also specify a condition under which an optional instrument will become required. This is done by using requiredIf. For instance, let's say that for our TWConductivity measurement, we want the user to be able to optionally apply a gate voltage to their device while it's being measured. In such a case, we would need a voltage source (VSource) to apply this voltage, but only if the user has chosen a non-zero gate voltage. Therefore we would write:

val gateVoltage by userInput("Basic", "Gate Voltage [V]", 0.0)

val gate by optionalInstrument("Gate Voltage Source", VSource::class) requiredIf { gateVoltage != 0.0 }

This should basically read as "acquire gate by optionally asking the user to configure it but require it if the gate voltage is non-zero". Therefore, if the user were to enter a non-zero gate voltage but not configure the gate voltage source, the measurement would throw an error saying so.

Taking our example of TWConductivity, we want a current source (ISource) and a voltmeter (VMeter), which are both required, and an optional thermometer (TMeter) to measure temperature if present. Therefore we would add:

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    val currentSource by requiredInstrument("Current Source", ISource::class)
    val voltMeter     by requiredInstrument("Voltmeter", VMeter::class)
    val thermometer   by optionalInstrument("Thermometer", TMeter::class)

    // User input parameters that we added in the previous section
    val delay    by userTimeInput("Basic", "Delay Time", 500)
    val notes    by userInput("Basic", "Notes", "")
    val repeats  by userInput("Averaging", "Repeat Count", 10)
    val average  by userInput("Averaging", "Use Averaging", true)
    val currents by userInput("Source-Drain", "Current [A]", Range.step(-10e-6, +10e-6, 5e-6)) 

}

The result is that now the "Instruments" tab of the measurement configuration window will now look like this:

Since "Current Source" and "Voltmeter" are both required, if the user were to run this measurement without changing them from being "None" an error would be thrown. As an example, here's a screenshot showing the user having chosen to use an SMU as both current source and voltmeter, but not having chosen any thermometer:

Configured like this, the measurement will run fine because the "Thermometer" instrument is only optional.

4. Defining the results structure

We now have our class, name/tag, input parameters and instrument configurations all defined:

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    // Instrument configs
    val currentSource by requiredInstrument("Current Source", ISource::class)
    val voltMeter     by requiredInstrument("Voltmeter", VMeter::class)
    val thermometer   by optionalInstrument("Thermometer", TMeter::class)

    // Input parameters
    val delay    by userTimeInput("Basic", "Delay Time", 500)
    val notes    by userInput("Basic", "Notes", "")
    val repeats  by userInput("Averaging", "Repeat Count", 10)
    val average  by userInput("Averaging", "Use Averaging", true)
    val currents by userInput("Source-Drain", "Current [A]", Range.step(-10e-6, +10e-6, 5e-6))
    
}

However, we also need to define the structure of the results table this measurement is supposed to output. This is done within the companion object defined in the template:

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    // ...parameters and instruments from before...

    companion object : Columns() {
        // Columns go here
    }

}

To define columns, you can use one of four methods:

  1. decimalColumn("Name" [, "Units"]): For columns that will contain decimal numbers (i.e. Double)
  2. integerColumn("Name" [, "Units"]): For columns that will contain integer numbers (i.e. Int)
  3. booleanColumn("Name" [, "Units"]): For columns that will contain boolean values (i.e. true/false)
  4. textColumn("Name" [, "Units"]): For columns that will contain text values (i.e. String)

where [...] indicates optional arguments.

In our example, we want to record:

  • Injected current
  • Measured voltage
  • Temperature (or NaN if no thermometer present)

Therefore we will define them using the above functions as constants in the companion object like so:

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    companion object : Columns() {

        val CURR = decimalColumn("Injected Current", "A")
        val VOLT = decimalColumn("Measured Voltage", "V")
        val TEMP = decimalColumn("Temperature", "K")

    }

}

The columns will appear in the results table in the order that they are defined here.

5. Writing the measurement code

Now we get to the meat of the endeavour: writing the code that runs the measurement itself. This is done by implementing two methods in our class: run(results: ResultTable) and onFinish():

class TWConductivity : FetChMeasurement(...) {

    ...
    
    override fun run(results: ResultTable) {

    }
    
    override fun onFinish() {

    }
    
}

In brief, run() is called when the measurement is to be run and onFinish() is always called after a measurement has finished regardless of whether it ran successfully, was interrupted or threw an exception.

This section will assume that you are already familiar with using the standard instrument control implementations from JISA to control your instruments. If you are not, then please go and visit the relevant JISA Wiki pages. For instance, for SMUs see here.

5.1 Optional instruments - nullable values in Kotlin

By the time run() is called, all the variables we delegated to be user-input values or instruments should be holding the correct values, so we can just use them directly in the run() code. However, there is one thing to note. If an instrument is optional, its value may be null if the user has chosen to not set it. However, Kotlin makes working will nullable values easy.

In Kotlin, if it's possible for a variable to be null you cannot simply just assume it to not be null - you will get a compiler error if you do so. Instead, Kotlin provides us with the ? operator. By putting a ? into an expression, you are telling Kotlin that you only want it to evaluate what's to the right of the ? if what's to the left of ? is not null.

In its most basic usage, you could write:

smu?.turnOn()

which is that same as writing:

if (smu != null) {
    smu.turnOn()
}

Slightly more complicated is when you want to retrieve a value using something that may be null. In this case, we need to make use of the ?: operator which tells Kotlin what to return instead when what's to the left of it is null. For instance, if we wanted to measure the voltage on our optional SMU or just assume it's 0.0 if smu is null, then we would write:

val measured = smu?.voltage ?: 0.0

which is the same as writing:

val attempt  = smu?.voltage
val measured = if (attempt != null) { attempt } else { 0.0 }

5.2 The run() method

The run() method is where the main bulk of the measurement code goes. This is where you should instruct your various instruments to apply and measure values, recording them in the results object that it is passed as an argument.

In our case we will want to sweep over the user-input values of current, waiting for the user-defined delay time before measuring the resulting voltages. We also want to record the temperature if a thermometer is configured at each point, otherwise just record NaN for temperature. Therefore, we'd add the following run() method to our class:

override fun run(results: ResultTable) {

    // Make sure current source is safe
    currentSource.current = 0.0

    // If the user ticked the box, then tell the voltmeter to use averaging
    if (average) {
        voltMeter.averageMode  = AMode.MEAN_REPEAT
        voltMeter.averageCount = repeats
    } else {
        voltMeter.averageMode  = AMode.NONE
    }

    // Make sure they're both on
    currentSource.turnOn()
    voltMeter.turnOn()

    for (current in currents) {

        // Source the value of current and wait delay time
        currentSource.current = current
        sleep(delay)

        // Measure and record all values
        results.mapRow(
            CURR to currentSource.current,
            VOLT to voltMeter.voltage,
            TEMP to (thermometer?.temperature ?: Double.NaN)
        )

    }

}

Note that we do not turn off the currentSource and voltMeter instruments here. This is because we are saving that for the onFinish() method.

Notice that we use the method sleep() to cause the measurement to pause for the configured delay time. This method is provided by FetChMeasurement and it is important that you use only this method for such pauses as it ties into FetCh's method of being able to stop a measurement early when the user presses the "Stop" button.

If your measurement does not contain any pauses, then you should include calls to checkPoint() periodically in your run() method. This makes the measurement check to see whether the "Stop" button has been pressed and causes the measurement to end if it has. Therefore, you should put checkPoint() calls wherever in your run() method it is safe for the measurement to end early if needed.

If your run() method contains no sleep() or checkPoint() methods then FetCh may be required to wait until your measurement has finished before ending the measurement queue.

5.3 The onFinish() method

This is where you should write whatever code you need to return the configured instruments to a safe state. This method will always run immediately after the run() method regardless of how it ended (i.e. it finished, it was interrupted early or it encountered an error).

In our case we have a current source and a voltmeter that will both need turning off:

override fun onFinish() {
    currentSource.turnOff()
    voltMeter.turnOff()
}

We can make this even safer, by wrapping each inside a runRegardless call so that if, say, currentSource.turnOff(), fails and throws an exception it will ignore it and still continue with turning off the voltmeter at least:

override fun onFinish() {
    runRegardless { currentSource.turnOff() }
    runRegardless { voltMeter.turnOff() }
}

6. Telling FetCh what to do with the data

At this point, you are technically done. You can stop writing, run FetCh and your measurement should be visible in the "Add" menu on the measurement queue. However, you will likely also want to configure what FetCh should do with the data your measurement produces. For instance, unless you configure it yourself, the plot of your measurement FetCh creates is liable to be rather poor. Additionally, if you want FetCh to be able to perform any sort of analysis on your results, you will need to define how it should do that too.

6.1 Defining plots or other displays

Unless we tell FetCh how to display your measurement data, will just plot the second numerical column of the measurement's ResultTable against its first. To instead define this outselves, we must override the createDisplay(...) method, returning a GUI Element object. In our case, we want a plot so we will return a FetChPlot object like so:

class TWConductivity : FetChMeasurement(...) {

    ...
    
    override fun createDisplay(data: ResultTable): FetChPlot {

    }
    
}

FetChPlot objects are just standard JISA Plot objects, but with the standard FetCh toolbar buttons added and mouse interactivity enabled by default etc. In this method we then just need to create whatever plot we want of the data in the data argument and return it.

If you are unfamiliar with jisa Plot objects then you can read-up about them on their JISA Wiki Page.

In our example, we want to plot "Injected Current" (CURR) on the x-axis and "Measured Voltage" (VOLT) on the y-axis. Therefore we would write:

override fun createDisplay(data: ResultTable): FetChPlot {

    val plot = FetChPlot("Two Wire Conductivity")

    plot.createSeries().watch(data, CURR, VOLT)

    return plot

}

Now, when the measurement is running and when looking at the measurement in the "Results" page, the plot will look like this:

You can make your measurement return any other type GUI Element object if a plot is not what you are after. However, in most cases it will probably be a plot.

6.2 Defining result analysis

This part is more complicated and it may very well be that you can do without FetCh being able ot analyse your data itself. As things stand I am leaving the writing of this section for a later date as it will probably require a wiki page of its own.

Completed Example

package org.oefet.fetch.measurement

import jisa.devices.interfaces.*
import jisa.enums.AMode
import jisa.results.ResultTable
import jisa.maths.Range
import org.oefet.fetch.gui.elements.FetChPlot

class TWConductivity : FetChMeasurement("Two-Wire Conductivity Measurement", "TWConductivity") {

    // Instrument configs
    val currentSource by requiredInstrument("Current Source", ISource::class)
    val voltMeter     by requiredInstrument("Voltmeter", VMeter::class)
    val thermometer   by optionalInstrument("Thermometer", TMeter::class)

    // Input parameters
    val delay    by userTimeInput("Basic", "Delay Time [s]", 500)
    val notes    by userInput("Basic", "Notes", "")
    val repeats  by userInput("Averaging", "Repeat Count", 10)
    val average  by userInput("Averaging", "Use Averaging", true)
    val currents by userInput("Source-Drain", "Current [A]", Range.step(-10e-6, +10e-6, 5e-6))

    companion object : Columns() {

        val CURR = decimalColumn("Injected Current", "A")
        val VOLT = decimalColumn("Measured Voltage", "V")
        val TEMP = decimalColumn("Temperature", "K")

    }

    override fun run(results: ResultTable) {

        // Make sure current source is safe
        currentSource.current = 0.0

        // If the user ticked the box, then tell the voltmeter to use averaging
        if (average) {
            voltMeter.averageMode  = AMode.MEAN_REPEAT
            voltMeter.averageCount = repeats
        } else {
            voltMeter.averageMode  = AMode.NONE
        }

        // Make sure they're both on
        currentSource.turnOn()
        voltMeter.turnOn()

        for (current in currents) {

            // Source the value of current and wait delay time
            currentSource.current = current
            sleep(delay)

            // Measure and record all values
            results.mapRow(
                CURR to currentSource.current,
                VOLT to voltMeter.voltage,
                TEMP to (thermometer?.temperature ?: Double.NaN)
            )

        }

    }

    override fun onFinish() {

        runRegardless { currentSource.turnOff() }
        runRegardless { voltMeter.turnOff() }

    }

    override fun createDisplay(data: ResultTable): FetChPlot {

        val plot = FetChPlot("Two Wire Conductivity")
        plot.createSeries().watch(data, CURR, VOLT)
        return plot

    }

}

And here it is in use: