-
Notifications
You must be signed in to change notification settings - Fork 1
Measurements
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:
- Give the measurement a name and unique identifier
- Define what parameters we need to ask the user for before running
- Define what instruments need selection and configuration by the user before running
- Define what structure the data from the measurement should take
- Write the code for what we want the measurement to do when run
- Define how data from these measurements should be processed and plotted
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:
- Define what parmeters the user needs to give the measurement
- Define what instruments/channels the user needs to supply for the measurement
- Define what the measurement is going to do
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.
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:
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.
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:
-
decimalColumn("Name" [, "Units"])
: For columns that will contain decimal numbers (i.e.Double
) -
integerColumn("Name" [, "Units"])
: For columns that will contain integer numbers (i.e.Int
) -
booleanColumn("Name" [, "Units"])
: For columns that will contain boolean values (i.e.true
/false
) -
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.
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.
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 }
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.
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() }
}
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.
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.
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.
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: