repos/kloeckner.com.ar

Backup of part of my webpage
Commits Files Refs README LICENSE
blog/owon-hds25s-bode-plot-using-python-and-scpi-commands/owon-hds25s-bode-plot-using-python-and-scpi-commands.md (221 lines)

%% 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*).

OWON HDS25S USB System Settings

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.

Test bench

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:

  1. Set the function generator to the current frequency.
  2. Measure the peak value of the output of the DUT.
  3. 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.

Bode

   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 ![OWON HDS25S USB System Settings](./system_usb_mode.png)
  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 ![Test bench](./test_bench.webp)
  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 ![Bode](./bode.png)