%% title: "Owon HDS25S Bode Plot Using Python and SCPI Commands" date: "19-Sep-2026" %%
Owon HDS25S Bode Plot Using Python and SCPI Commands
As you may know from my previous post, I bought the Owon HDS25S handheld oscilloscope, and I think one of its key features is the SCPI interface, which allows the device to be controlled over USB by sending text commands. In this post I will show how to get a Bode plot (for now, only the magnitude of the frequency response) using the Owon HDS25S and the built-in function generator, varying the frequency and measuring the output value of the device under test in a completely automated way by using a Python script that sends SCPI commands.
SCPI Commands
SCPI stands for Standard Commands for Programmable Instruments and are used for controlling devices, mainly electronic labs equipment, like oscilloscopes, multimeters, bench power supplies, functions generators, etc. The control of the device is made from a prompt or query, typically over a serial port or USB, but modern devices also support SCPI over the network.
SCPI commands can be send interactively, i.e. typing the commands in a serial
monitor, or in a non interactive way, i.e. through a script which sends the
commands line by line. This last method of sending the SCPI commands is where
you get the true power of the commands, because you can automate test runs,
achieving the same behaviour of the device on every run, or even the same
behaviour on multiple devices. Unfortunately, only a small set of SCPI commands
are standardized across different manufacturers. For example, *IDN? to query
the identification of the device. Beyond these common commands and standardized
SCPI subsystems, manufacturers often implement their own instrument-specific
commands.
Owon HDS25S
The Owon HDS25S supports SCPI commands over the USB port, and the HDS200(S)
Series SCPI standard can be downloaded from Owon's website (also from
https://git.kloeckner.com.ar/hds25s/).
To make the oscilloscope accept the SCPI commands the USB mode must be set to
HID on the oscilloscope system settings, in this way when connecting it via USB,
it reports as a serial device (on Linux based systems as /dev/ttyUSB*).

The cool thing about this oscilloscope is that it accepts SCPI commands for all of it three modes: multimeter, function generator and oscilloscope, and also that the function generator can be used at the same time as the oscilloscope. This last thing is a key point for achieving our goal.
Bode Plot
On a Bode plot, the magnitude and phase of the frequency response of a device is plotted as a function of frequency, with the frequency logarithmically spaced and the magnitude typically scaled in decibels. To achieve a bode plot, the traditional way is to connect a frequency generator on the input of the device under test (DUT for now on) and vary the frequency of the function generator, measuring and writing down the amplitude of the output of the DUT for every frequency.
Given that the HDS25S supports SCPI commands for both the function generator and the oscilloscope, the connections for the automated and traditional methods are the same. The difference is that, instead of varying the frequency and measuring the output manually, SCPI commands are used. Consider the test bench shown below.

To automate the process of varying the frequency and measuring the output, a script is used. To obtain the magnitude data, the script must iterate over a list of logarithmically spaced frequencies and for every frequency do the following:
- Set the function generator to the current frequency.
- Measure the peak value of the output of the DUT.
- Save the current frequency and the measured value on a list of results.
For example, consider the following pseudo-code.
frequencies = [1 10 100 1e3 10e3 100e3]
magnitude = []
for frequency in frequencies:
func_generator.set_freq(frequency)
measured_val = scope.measure_peak_val()
magnitude.append(measured_val)
Getting the Data with PyVISA
The script used is written in Python using the PyVISA library to send and receive SCPI commands to and from the instrument. The PyVISA library abstracts away the communication interface used by the instrument (RS-232, USB, Ethernet, etc.), making it easy to establish communication with the instrument.
To use PyVISA, we need to know the device location beforehand. On Linux-based
systems, the Owon HDS25S appears as /dev/ttyUSB* (when USB mode is set to HID,
as mentioned). Knowing the device location, the following snippet sends the
identification query. If everything is good the console should print the device
response, for example "OWON,HDS25S,24531234,V8.8.2".
import pyvisa
DEV_PATH="/dev/ttyUSB0"
rm = pyvisa.ResourceManager('@py')
dev = rm.open_resource(f"ASRL{DEV_PATH}::INSTR")
print("Identification: ", dev.query("*IDN?"), end="")
If the previous snippet worked it means that commands can be sent to and received from the instrument. To configure the oscilloscope, the folloing commands sets the channel 1 scale to 2 V per division, DC coupling and probe attenuation to X10.
dev.write(":CH1:SCALe 2.00V")
dev.write(":CH1:COUPling DC")
dev.write(":CH1:PROBE 10X")
Similarly for the function generator, the following sets the output to a sinusoidal wave with 1 V peak value and 1 kHz of frequency.
dev.write(":FUNCtion:AMPLitude 1.00")
dev.write(":FUNCtion SINE")
dev.write(":FUNCtion FREQuency 1000")
To vary the frequency, a list of logarithmically spaced frequencies is needed to
iterate over. For this, the NumPy library and its
logspace
function are used to create the freqs list. An empty list amps is also
created to append the measured amplitudes.
import numpy as np
freqs = np.logspace(0, 5, num=20)
amps = []
Given the list of frequencies freqs, the following snippet steps through every
frequency, setting the function generator to that frequency. After a small
time delay, the peak value of the channel to which the output of the DUT is
connected is measured and appended to the list of measured amplitudes amps.
The time delay is needed because the measurement function of the scope is not
instant after setting a new frequency.
for freq in freqs:
scope.write(f":FUNCtion:FREQuency {freq}")
tb_str = get_best_timebase(freq)
scope.write(f":HORizontal:SCALe {tb_str}")
time.sleep(0.5)
val = float(scope.query(":MEASurement:CH1:MAX?").strip())
amps.append(val)
The function get_best_timebase(freq) returns the best horizontal scale for the
given frequency freq. Note that the peak value is measured using the channel 1
voltage 'max' measurement, equivalent to the voltage 'max' measurement from the
physical menu. This has the disadvantage that the waveform must fit within the
visual area to obtain the actual value. If it does not fit, the measurement
returns, for example, ">10.00", meaning that the peak value is greater than 10
volts and cannot be measured. A fix for this is to write a function similar to
get_best_timebase, but for the vertical channel scale, setting the best fit
for the vertical view space.
Plotting the Data
To get a figure of the Bode plot from the data we can use a python plotting library, for example matplotlib. Another possibility it to export the data and use another tool. I like Octave which is similar to MATLAB. To export the data as CSV consider the following snippet.
data = np.column_stack((freqs, amps))
np.savetxt("data.csv",
data,
delimiter=",",
fmt=["%d", "%.2f"])
In Octave, plotting the data read from the CSV file is easy. In the following
snippet, after loading the data, the measured values are normalized. This is
achieved by dividing all the values by the function generator peak value (the
peak value set in the function generator). Next, the values are converted to
decibels, given that the values are voltage measurements, the conversion is done
by taking the base 10 logarithm of every value times 20. Lastly, and optional,
to smooth the curve a mean between contiguous values can be calculated using
the movmean function, in the following snippet the mean is taken for every 5
contiguous values.
data = csvread("data.csv");
freq = data(:, 1);
mag = data(:, 2);
mag_db = 20 * log10(mag);
mag_db_smooth = movmean(mag_db, 5);
figure();
semilogx(freq, mag_db_smooth);
In a similar manner as above but tweaking the appearance of the figure, the following bode plot is obtained. The orange trace represents the frequency response obtained with this method. The red trace, "H", is the frequency response of the theoretical transfer function of the DUT. The blue trace, "H normalizada", is the frequency response of the theoretical transfer function of the DUT but with normalized commercial component values.

1 %% 2 title: "Owon HDS25S Bode Plot Using Python and SCPI Commands" 3 date: "19-Sep-2026" 4 %% 5 6 # Owon HDS25S Bode Plot Using Python and SCPI Commands 7 8 As you may know from my previous post, I bought the Owon HDS25S handheld 9 oscilloscope, and I think one of its key features is the SCPI interface, which 10 allows the device to be controlled over USB by sending text commands. In this 11 post I will show how to get a Bode plot (for now, only the magnitude of the 12 frequency response) using the Owon HDS25S and the built-in function generator, 13 varying the frequency and measuring the output value of the device under test in 14 a completely automated way by using a Python script that sends SCPI commands. 15 16 ## SCPI Commands 17 18 SCPI stands for Standard Commands for Programmable Instruments and are used for 19 controlling devices, mainly electronic labs equipment, like oscilloscopes, 20 multimeters, bench power supplies, functions generators, etc. The control of the 21 device is made from a prompt or query, typically over a serial port or USB, but 22 modern devices also support SCPI over the network. 23 24 SCPI commands can be send interactively, i.e. typing the commands in a serial 25 monitor, or in a non interactive way, i.e. through a script which sends the 26 commands line by line. This last method of sending the SCPI commands is where 27 you get the true power of the commands, because you can automate test runs, 28 achieving the same behaviour of the device on every run, or even the same 29 behaviour on multiple devices. Unfortunately, only a small set of SCPI commands 30 are standardized across different manufacturers. For example, `*IDN?` to query 31 the identification of the device. Beyond these common commands and standardized 32 SCPI subsystems, manufacturers often implement their own instrument-specific 33 commands. 34 35 ## Owon HDS25S 36 37 The Owon HDS25S supports SCPI commands over the USB port, and the HDS200(S) 38 Series SCPI standard can be downloaded from Owon's website (also from 39 [https://git.kloeckner.com.ar/hds25s/](https://git.kloeckner.com.ar/hds25s/)). 40 To make the oscilloscope accept the SCPI commands the USB mode must be set to 41 HID on the oscilloscope system settings, in this way when connecting it via USB, 42 it reports as a serial device (on Linux based systems as `/dev/ttyUSB*`). 43 44  45 46 The cool thing about this oscilloscope is that it accepts SCPI commands for all 47 of it three modes: multimeter, function generator and oscilloscope, and also 48 that the function generator can be used at the same time as the oscilloscope. 49 This last thing is a key point for achieving our goal. 50 51 ## Bode Plot 52 53 54 On a Bode plot, the magnitude and phase of the frequency response of a device is 55 plotted as a function of frequency, with the frequency logarithmically spaced 56 and the magnitude typically scaled in decibels. To achieve a bode plot, the 57 traditional way is to connect a frequency generator on the input of the device 58 under test (DUT for now on) and vary the frequency of the function generator, 59 measuring and writing down the amplitude of the output of the DUT for every 60 frequency. 61 62 Given that the HDS25S supports SCPI commands for both the function generator and 63 the oscilloscope, the connections for the automated and traditional methods are 64 the same. The difference is that, instead of varying the frequency and measuring 65 the output manually, SCPI commands are used. Consider the test bench shown 66 below. 67 68  69 70 To automate the process of varying the frequency and measuring the output, a 71 script is used. To obtain the magnitude data, the script must iterate over a 72 list of logarithmically spaced frequencies and for every frequency do the 73 following: 74 75 1. Set the function generator to the current frequency. 76 2. Measure the peak value of the output of the DUT. 77 3. Save the current frequency and the measured value on a list of results. 78 79 For example, consider the following pseudo-code. 80 81 ```python 82 frequencies = [1 10 100 1e3 10e3 100e3] 83 magnitude = [] 84 85 for frequency in frequencies: 86 func_generator.set_freq(frequency) 87 measured_val = scope.measure_peak_val() 88 magnitude.append(measured_val) 89 ``` 90 91 ## Getting the Data with PyVISA 92 93 The script used is written in Python using the 94 [PyVISA](https://pyvisa.readthedocs.io/en/latest/) library to send and receive 95 SCPI commands to and from the instrument. The PyVISA library abstracts away the 96 communication interface used by the instrument (RS-232, USB, Ethernet, etc.), 97 making it easy to establish communication with the instrument. 98 99 To use PyVISA, we need to know the device location beforehand. On Linux-based 100 systems, the Owon HDS25S appears as `/dev/ttyUSB*` (when USB mode is set to HID, 101 as mentioned). Knowing the device location, the following snippet sends the 102 identification query. If everything is good the console should print the device 103 response, for example "OWON,HDS25S,24531234,V8.8.2". 104 105 ```python 106 import pyvisa 107 108 DEV_PATH="/dev/ttyUSB0" 109 110 rm = pyvisa.ResourceManager('@py') 111 dev = rm.open_resource(f"ASRL{DEV_PATH}::INSTR") 112 print("Identification: ", dev.query("*IDN?"), end="") 113 ``` 114 115 If the previous snippet worked it means that commands can be sent to and 116 received from the instrument. To configure the oscilloscope, the folloing 117 commands sets the channel 1 scale to 2 V per division, DC coupling and probe 118 attenuation to X10. 119 120 ```python 121 dev.write(":CH1:SCALe 2.00V") 122 dev.write(":CH1:COUPling DC") 123 dev.write(":CH1:PROBE 10X") 124 ``` 125 126 Similarly for the function generator, the following sets the output to a 127 sinusoidal wave with 1 V peak value and 1 kHz of frequency. 128 129 ```python 130 dev.write(":FUNCtion:AMPLitude 1.00") 131 dev.write(":FUNCtion SINE") 132 dev.write(":FUNCtion FREQuency 1000") 133 ``` 134 135 To vary the frequency, a list of logarithmically spaced frequencies is needed to 136 iterate over. For this, the [NumPy](https://numpy.org/) library and its 137 [logspace](https://numpy.org/doc/stable/reference/generated/numpy.logspace.html#numpy-logspace) 138 function are used to create the `freqs` list. An empty list `amps` is also 139 created to append the measured amplitudes. 140 141 ```python 142 import numpy as np 143 144 freqs = np.logspace(0, 5, num=20) 145 amps = [] 146 ``` 147 148 Given the list of frequencies `freqs`, the following snippet steps through every 149 frequency, setting the function generator to that frequency. After a small 150 time delay, the peak value of the channel to which the output of the DUT is 151 connected is measured and appended to the list of measured amplitudes `amps`. 152 The time delay is needed because the measurement function of the scope is not 153 instant after setting a new frequency. 154 155 ```python 156 for freq in freqs: 157 scope.write(f":FUNCtion:FREQuency {freq}") 158 tb_str = get_best_timebase(freq) 159 scope.write(f":HORizontal:SCALe {tb_str}") 160 time.sleep(0.5) 161 val = float(scope.query(":MEASurement:CH1:MAX?").strip()) 162 amps.append(val) 163 ``` 164 165 The function `get_best_timebase(freq)` returns the best horizontal scale for the 166 given frequency `freq`. Note that the peak value is measured using the channel 1 167 voltage 'max' measurement, equivalent to the voltage 'max' measurement from the 168 physical menu. This has the disadvantage that the waveform must fit within the 169 visual area to obtain the actual value. If it does not fit, the measurement 170 returns, for example, ">10.00", meaning that the peak value is greater than 10 171 volts and cannot be measured. A fix for this is to write a function similar to 172 `get_best_timebase`, but for the vertical channel scale, setting the best fit 173 for the vertical view space. 174 175 ## Plotting the Data 176 177 To get a figure of the Bode plot from the data we can use a python plotting 178 library, for example [matplotlib](https://matplotlib.org/stable/). Another 179 possibility it to export the data and use another tool. I like 180 [Octave](https://octave.org/) which is similar to 181 [MATLAB](https://la.mathworks.com/products/matlab.html). To export the data as 182 CSV consider the following snippet. 183 184 ```python 185 data = np.column_stack((freqs, amps)) 186 np.savetxt("data.csv", 187 data, 188 delimiter=",", 189 fmt=["%d", "%.2f"]) 190 ``` 191 192 In Octave, plotting the data read from the CSV file is easy. In the following 193 snippet, after loading the data, the measured values are normalized. This is 194 achieved by dividing all the values by the function generator peak value (the 195 peak value set in the function generator). Next, the values are converted to 196 decibels, given that the values are voltage measurements, the conversion is done 197 by taking the base 10 logarithm of every value times 20. Lastly, and optional, 198 to smooth the curve a mean between contiguous values can be calculated using 199 the `movmean` function, in the following snippet the mean is taken for every 5 200 contiguous values. 201 202 ```octave 203 data = csvread("data.csv"); 204 freq = data(:, 1); 205 mag = data(:, 2); 206 207 mag_db = 20 * log10(mag); 208 mag_db_smooth = movmean(mag_db, 5); 209 210 figure(); 211 semilogx(freq, mag_db_smooth); 212 ``` 213 214 In a similar manner as above but tweaking the appearance of the figure, the 215 following bode plot is obtained. The orange trace represents the 216 frequency response obtained with this method. The red trace, "H", is the 217 frequency response of the theoretical transfer function of the DUT. The blue 218 trace, "H normalizada", is the frequency response of the theoretical transfer 219 function of the DUT but with normalized commercial component values. 220 221 
