Lua Measurement Script Authoring Guide¶
This guide explains how experimenters create Lua measurement scripts for the FALCon instrument system.
Architecture Overview¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ FALCON (Autotuner) │
│ │
│ Sends measurement requests (JSON) based on falcon-measurement-lib schemas │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ falcon-instrument-hub │
│ │
│ • Parses incoming measurement requests │
│ • Orchestrates complex measurements (e.g., 2D sweep = N × 1D sweeps) │
│ • Dispatches script execution to instrument-script-server │
│ • Aggregates results and returns to FALCON │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ instrument-script-server │
│ │
│ • Executes user-provided Lua measurement scripts │
│ • Provides RuntimeContext API for instrument control │
│ • Manages instrument communication via plugins │
│ • Returns structured measurement results │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Physical Instruments │
│ │
│ QDAC, DMM, Lock-in Amplifier, Oscilloscope, etc. │
└─────────────────────────────────────────────────────────────────────────────┘
Key Principle¶
The hub does NOT auto-generate Lua scripts. Instead:
- Experimenters create reusable Lua measurement scripts
- The hub orchestrates complex measurements by calling simpler scripts multiple times
- Scripts are executed on the instrument-script-server
Script Location¶
Place your Lua scripts in the hub's scripts directory:
falcon-instrument-hub/
runtime/
scripts/
set_voltage.lua
get_voltage.lua
sweep_1d.lua
ramp_voltage.lua
measure_current.lua
dc_get_set.lua
your_custom_script.lua
RuntimeContext API¶
All scripts receive a RuntimeContext object that provides instrument control:
ctx:call(target, params)¶
Execute an instrument command and get a MeasurementResponse.
-- Set a voltage
ctx:call("QDAC1.SET_VOLTAGE", { channel = 1, voltage = -0.5 })
-- Read a voltage (returns MeasurementResponse)
local response = ctx:call("DMM1.GET_VOLTAGE", { channel = 0 })
local value = response:value() -- Extract the numeric value
-- Channel addressing via colon
ctx:call("QDAC1:1.SET_VOLTAGE", { voltage = -0.5 })
ctx:parallel(function)¶
Execute multiple commands in parallel for synchronized timing:
ctx:parallel(function()
ctx:call("QDAC1.SET_VOLTAGE", { channel = 1, voltage = -0.5 })
ctx:call("QDAC1.SET_VOLTAGE", { channel = 2, voltage = -0.6 })
ctx:call("QDAC1.SET_VOLTAGE", { channel = 3, voltage = -0.7 })
end)
-- All three voltage sets happen simultaneously
ctx:log(message)¶
Log a message visible in the hub:
ctx:log("Starting 1D sweep from -1V to 0V")
ctx:log(string.format("Progress: %d%%", progress))
ctx:error(message)¶
Report a non-fatal error:
if not response then
ctx:error("Failed to read current")
end
MeasurementResponse Methods¶
Responses from ctx:call() have these methods:
local response = ctx:call("DMM1.GET_VOLTAGE", { channel = 0 })
response:value() -- Get the raw value
response:type() -- "float", "integer", "string", "boolean", "buffer"
response:instrument() -- "DMM1"
response:verb() -- "GET_VOLTAGE"
-- Math on responses
local offset_response = response:add_offset(1e-9) -- Add nanoampere offset
local scaled_response = response:multiply_gain(1000) -- Scale by gain
Required Scripts¶
The hub expects these standard scripts to exist:
set_voltage.lua¶
Sets a single gate voltage.
---@param ctx RuntimeContext
---@param params {instrument: string, channel: number, voltage: number}
function main(ctx, params)
ctx:log(string.format("Setting %s:%d to %.4f V",
params.instrument, params.channel, params.voltage))
ctx:call(params.instrument .. ".SET_VOLTAGE", {
channel = params.channel,
voltage = params.voltage
})
return nil
end
get_voltage.lua¶
Reads voltage from an instrument.
---@param ctx RuntimeContext
---@param params {instrument: string, channel: number}
---@return MeasurementResponse
function main(ctx, params)
local response = ctx:call(params.instrument .. ".GET_VOLTAGE", {
channel = params.channel
})
return {
instrument = params.instrument,
channel = params.channel,
value = response:value(),
type = response:type()
}
end
sweep_1d.lua¶
Performs a 1D voltage sweep with current measurement.
---@param ctx RuntimeContext
---@param params {sweepInstrument: string, sweepChannel: number, startVoltage: number, stopVoltage: number, numPoints: number, settlingTimeMs: number, currentMeter: string, currentChannel: number}
---@return table Array of {voltage, current} pairs
function main(ctx, params)
ctx:log(string.format("1D sweep: %s:%d from %.4f to %.4f V (%d points)",
params.sweepInstrument, params.sweepChannel,
params.startVoltage, params.stopVoltage, params.numPoints))
local results = {}
local step = (params.stopVoltage - params.startVoltage) / (params.numPoints - 1)
for i = 0, params.numPoints - 1 do
local voltage = params.startVoltage + (i * step)
-- Set sweep voltage
ctx:call(params.sweepInstrument .. ".SET_VOLTAGE", {
channel = params.sweepChannel,
voltage = voltage
})
-- Wait for settling
if params.settlingTimeMs > 0 then
-- Note: ctx:sleep() may need to be implemented
-- For now, this is a placeholder
end
-- Read current
local current_resp = ctx:call(params.currentMeter .. ".GET_VOLTAGE", {
channel = params.currentChannel
})
table.insert(results, {
voltage = voltage,
current = current_resp:value()
})
-- Progress logging every 10%
if i % math.floor(params.numPoints / 10) == 0 then
ctx:log(string.format("Sweep progress: %d%%",
math.floor(i * 100 / params.numPoints)))
end
end
ctx:log(string.format("Sweep complete: %d points collected", #results))
return results
end
ramp_voltage.lua¶
Ramps a gate voltage at a specified slope (V/sec).
---@param ctx RuntimeContext
---@param params {instrument: string, channel: number, targetV: number, slopeVPerSec: number}
function main(ctx, params)
ctx:log(string.format("Ramping %s:%d to %.4f V at %.3f V/sec",
params.instrument, params.channel, params.targetV, params.slopeVPerSec))
-- Read current voltage
local current_resp = ctx:call(params.instrument .. ".GET_VOLTAGE", {
channel = params.channel
})
local currentV = current_resp:value()
-- Calculate ramp
local deltaV = params.targetV - currentV
local rampTime = math.abs(deltaV / params.slopeVPerSec)
local numSteps = math.max(1, math.floor(rampTime * 100)) -- 100 steps/sec
local stepV = deltaV / numSteps
for i = 1, numSteps do
local v = currentV + (i * stepV)
ctx:call(params.instrument .. ".SET_VOLTAGE", {
channel = params.channel,
voltage = v
})
end
-- Final set to exact target
ctx:call(params.instrument .. ".SET_VOLTAGE", {
channel = params.channel,
voltage = params.targetV
})
ctx:log("Ramp complete")
return nil
end
dc_get_set.lua¶
DC measurement: set voltages, then read currents.
---@param ctx RuntimeContext
---@param params {setters: table, getters: table, setVoltages: table, sampleRate: number}
function main(ctx, params)
ctx:log("Starting DC get/set measurement")
-- Set voltages in parallel
ctx:parallel(function()
for _, setter in ipairs(params.setters) do
local key = setter.id
if setter.channel ~= nil and setter.channel ~= 0 then
key = setter.id .. ":" .. tostring(setter.channel)
end
local voltage = params.setVoltages[key] or 0
ctx:call(setter.id .. ".SET_VOLTAGE", {
channel = setter.channel or 0,
voltage = voltage
})
end
end)
-- Brief settling
-- ctx:sleep(1) -- If available
-- Read measurements in parallel
local results = {}
ctx:parallel(function()
for _, getter in ipairs(params.getters) do
local resp = ctx:call(getter.id .. ".GET_VOLTAGE", {
channel = getter.channel or 0
})
table.insert(results, {
instrument = getter.id,
channel = getter.channel or 0,
value = resp:value()
})
end
end)
ctx:log(string.format("DC measurement complete: %d readings", #results))
return results
end
How 2D Sweeps Work¶
When FALCON requests a 2D voltage sweep (measure_2D_buffered), the hub orchestrates it:
FALCON Request: measure_2D_buffered
X-axis: QDAC1:1, -0.5V to 0.5V, 101 steps
Y-axis: QDAC1:2, -0.5V to 0.5V, 101 steps
Hub Orchestration:
FOR each Y value (0 to 100):
1. Call set_voltage.lua: QDAC1:2 = Y_voltage[y]
2. Wait for settling
3. Call sweep_1d.lua: sweep QDAC1:1 from -0.5V to 0.5V
4. Store the 1D current trace
5. Call ramp_voltage.lua: ramp QDAC1:1 back to -0.5V
Aggregate all 101 traces into 2D result
Return to FALCON: 101 × 101 current matrix
The experimenter only needs to provide the primitive scripts (set_voltage, sweep_1d, ramp_voltage). The hub handles the orchestration logic.
Type Definitions (for LSP)¶
If you want IDE autocomplete, use the Emmy headers from falcon-measurement-lib:
---@class RuntimeContext
---@field call fun(self: RuntimeContext, target: string, params: table): MeasurementResponse
---@field parallel fun(self: RuntimeContext, block: function)
---@field log fun(self: RuntimeContext, msg: string)
---@field error fun(self: RuntimeContext, msg: string)
---@class MeasurementResponse
---@field value fun(self: MeasurementResponse): any
---@field type fun(self: MeasurementResponse): string
---@field instrument fun(self: MeasurementResponse): string
---@field verb fun(self: MeasurementResponse): string
---@field add_offset fun(self: MeasurementResponse, offset: number): MeasurementResponse
---@field multiply_gain fun(self: MeasurementResponse, gain: number): MeasurementResponse
---@class InstrumentTarget
---@field id string
---@field channel number?
Testing Scripts¶
Test your scripts locally before deploying:
# Start instrument-script-server in test mode
cd instrument-script-server
./bin/instrument_server --test-mode --config examples/demo_instrument.yaml
# Execute a script
curl -X POST http://localhost:8080/api/v1/execute \
-H "Content-Type: application/json" \
-d '{
"scriptName": "sweep_1d",
"scriptPath": "/path/to/scripts/sweep_1d.lua",
"parameters": {
"sweepInstrument": "QDAC1",
"sweepChannel": 1,
"startVoltage": -1.0,
"stopVoltage": 0.0,
"numPoints": 101,
"settlingTimeMs": 1.0,
"currentMeter": "DMM1",
"currentChannel": 0
}
}'
Best Practices¶
- Keep scripts atomic - Each script should do one thing well
- Use parallel blocks - For synchronized timing on voltage sets
- Log progress - For long measurements, log progress every ~10%
- Handle errors gracefully - Use
ctx:error()for recoverable issues - Document parameters - Use Emmy annotations for IDE support
- Return structured data - Return tables with named fields, not just arrays
See Also¶
falcon-measurement-lib/schemas/scripts/- JSON schemas for measurement typesfalcon-measurement-lib/docs/USAGE.md- Schema documentationinstrument-script-server/examples/scripts/- Example scriptsfalcon-instrument-hub/runtime/internal/serverinterpreter/measurement_orchestrator.go- Hub orchestration code