Oscilloscope¶
Instantiation¶
Oscilloscope class is instantiated like so
from inctrl import oscilloscope, Oscilloscope
scope: Oscilloscope = oscilloscope("$address")
Address argument allows to access a particular oscilloscope connected to your computer or present on the network. This address string could be a VISA address or an alias. In the case of alias actual address (and some other parameters) has to be resolved by some means. Details of that are in the "Instrument name and parameters resolution" page.
Returned object (assigned to the variable scope
) is an instance of Oscilloscope
class. Which instance, will
depend on identification of the scope or can be forced though name and parameter resolution.
To ensure that oscilloscope to which you are connecting has required properties you can also pass capability
argument.
For example, let say you require scope to have 8 channels. You can do it like so:
scope: Oscilloscope = oscilloscope(
"$address", capabilities = {"num_channels": 8}
)
Another way to ensure that you got instrument and a class that you want is to call scope.as_class(...)
method. For
example if you want to ensure that you are working with LeCroy scope you do following.
from inctrl.drivers.oscilloscope import LeCroyOscilloscope
scope: LeCroyOscilloscope = oscilloscope("$address").as_class(LeCroyOscilloscope)
If oscilloscope that you are connecting here is not a LeCroy scope then calling as_class(LeCroyOscilloscope)
will
raise RuntimeError. This way you can ensure you connect to a specific make of the oscilloscope and obtain access to
methods unique to that particual scope.
Capabilities¶
TBD
Interacting with the scope¶
An oscilloscope has several logical components, primary being (1) the scope itself as a whole, (2) channels and (3) a trigger.
Scope as a whole¶
At this level you can set time window for scope capture like so:
from inctrl.model import Duration
time_window: Duration = scope.set_time_window("22 us")
Argument to this function is a string containing time interval in the human-readable form or an instance of
class Duration
. Since often not every time interval can be set, this function returns actually set duration
for the capture window. However, we guarantee that actually set time window will always be greater or equal
to the requested value and never less.
Alternatively you can set duration per time division, also known as time scale
from inctrl.model import Duration
time_per_div: Duration = scope.set_time_scale("5 us")
Similarly to set_time_window(...)
function set_time_scale(...)
also returns actually set value.
Configured values can also be retrieved by calling scope.get_time_window()
and scope.get_time_scale()
.
Channel¶
To obtain handles to a given scope channel you can call scope.channel(...)
function. This function accepts
either channel number of channel name. In the later case channel name has to be defined through mechanisms
described in "Instrument name and parameters resolution" page.
ch3 = scope.channel(3)
clk = scope.channel("CLK")
Using these variables you can now interact with this channel and adjust various properties associated it.
Coupling¶
Channel coupling can by set by calling set_coupling(coupling: ChannelCoupling, fail_on_error: bool = False)
method.
from inctrl import ChannelCoupling
ch3.set_coupling(ChannelCoupling.AC)
Not every kind of coupling might be supported on the scope. Possible coupling constants are AC, DC and GND.
If fail_on_error
argument is set to False (default), then simply return configured coupling even if unable
to set requested coupling type. If If fail_on_error
is True and fail to set requested coupling, then
raise RuntimeError
.
Use companion function channel.get_coupling()
to obtain currently configured coupling on the channel.
Vertical scaling and offset¶
To ensure that signal you are trying to capture does not clip you can call function set_range(...)
. This function
is provided as an alternative to setting voltage per division and offset.
ch3_Vmin, ch3_Vmax = ch3.set_range_V(-1, 10)
This function guaranteed to set voltage per division and vertical offset such that requested voltage range fits
on the screen. It returns a tuple of actually set min and max values. These will usually match with voltage range
and scope screen. Voltage range as configured on the scope can always be retrieved by calling get_range_V()
.
ch3_Vmin, ch3_Vmax = ch3.get_range_V()
Alternatively you can call lower level functions ch.set_offset_V(...)
and ch.scale_V(...)
(both return actually set values) and their companions ch.get_offset_V()
and ch.get_scale_V()
.
ch3_offset_V = ch3.set_offset_V(0.0023)
ch3_V_per_div = ch3.get_scale_V(0.008)
Impedance¶
Channel input impedance can be set by calling function
set_impedance_oHm(impedance_oHm: float, fail_on_error: bool = False) -> float
.
Different oscilloscopes offer different impedance values that can be set. Typically, that is 50 Ohm and 1 MOhm.
Calling this function like so is guaranteed to return actually set impedance value.
ch3_impedance = ch3.set_impedance_oHm(100)
The above call might behave differently depending on the scope make and model. It might set impedance to the nearest allowed value or simply reject it keeping whatever configured intact. Users can use returned value to decide what do to about it. Alternatively call like so will ensure that requested impedance is set or error raised.
ch3.set_impedance_oHm(50, fail_on_error = True)
List of allowed impedance values can be obtained from the scope.properties
namespace like so
allowed_impedance_values: list[float] = scope.properties.get_impedance_list()
Alternative to above calls is to call functions
ch3_impedance = ch3.set_impedance_min()
or
ch3_impedance = ch3.set_impedance_max()
which set minimum and maximum impedance value valid on a particular scope.
Downloading waveform¶
When tigger (see below) is configured and enabled on the scope, then waveform can be captured and downloaded
as a Waveform
class like so:
c1_waveform: Waveform = ch3.get_waveform()
Returned object (instance of Waveform
class) will have various metadata mostly related to how it is to be rendered.
Among this metadata Waveform will have name. By default, name given to the waveform will be name of the channel if
channel does have a name. If channel does not have a name, but is simply referred to by a number, then waveform name
will be "Channel $channel_number".
To give waveform a custom name you can either call get_waveform($name)
with name
argument
mosi_waveform: Waveform = ch3.get_waveform("mosi")
or set name on the already obtained waveform object
from inctrl import Waveform
c1_waveform: Waveform = ch3.get_waveform()
c1_waveform.name = "mosi"
To ensure that you download valid waveform call scope.trigger.wait_for_waveform(...)
method (see below).
Trigger¶
Interaction with triggers consist of two parts: (a) configuring the trigger and (2) interacting with configured trigger.
All of this is available under scope.trigger
namespace.
Configuration¶
To configure trigger call function scope.trigger.configure(trigger: ScopeTrigger) -> None
.
Argument passed to this function is an instance of ScopeTrigger
class corresponding to a particular trigger type.
At minimum, every oscilloscope offers edge trigger where signal capture happens on either raising or
falling edge of signal. Other triggers can trigger signal capture on pulse evens, signal patterns and so on.
At the moment we only provide code for triggering on the signal edges.
Below is a minimal edge trigger configuration, which configures scope to trigger capture on the raising edge
of signal ch3
(that is known as trigger source) when it crosses 0.5 volts level.
from inctrl import ScopeTrigger
scope.trigger.configure(ScopeTrigger.EDGE(trigger_source = ch3, level_V = 0.5))
Method ScopeTrigger.EDGE(...)
has the following signature:
def EDGE(
trigger_source: TriggerSource,
level_V: float,
slope: TriggerSlope = TriggerSlope.RISING,
delay: str | Duration = Duration.value_of("0 s")
) -> ScopeEdgeTrigger:
As you can see default slope
is TriggerSlope.RISING
but you can change to TriggerSlope.FALLING
.
Parameter delay
refer to position on the screen when trigger is fired. Default value of 0 seconds places trigger
position in the middle of the screen, i.e. there is a same duration of captured signal before and after trigger.
Setting delay to positive value moves trigger position to the left, i.e. there is more data points captured after
the trigger than before. Setting delay to negative value moves trigger position to the right, i.e. there is fewer
data points captured after the trigger than before.
Operations¶
Once trigger is configured you can arm the trigger to enable it. You can think of this as trigger modes.
For a single shot capture call
scope.trigger.arm_single()
This function is non-blocking and it might time a bit of time until trigger is actually armed. To check if trigger is armed call
is_armed: bool = scope.trigger.is_armed()
To manually disarm trigger call
scope.trigger.disarm()
For continuous periodic capture call
scope.trigger.arm_auto()
For normal capture
scope.trigger.arm_normal()
Once trigger is fired and waveforms are available they can be downloaded from the scope. To check if waveforms are available call
scope.trigger.wait_for_waveform()
Call like above will block indefinitely until trigger is fired and waveform is available for downloading. Other optional arguments to this function can modify this behaviour. Signature for this function is
def wait_for_waveform(
timeout: str | Duration | None = None,
error_on_timeout: bool = False
) -> bool
If timeout
is not provided, then block wait indefinitely. If timeout
is provided, then wait for that duration and
(if error_on_timeout
is False (default)) return True or False if waveform is actually available for downloading, or,
if error_on_timeout
is True, then raise RuntimeError after requested duration or return True on success.
Examples¶
Capturing I2C data¶
from inctrl import ScopeTrigger, oscilloscope, TriggerSlope
# Obtain handle to the connected oscilloscope
scope = oscilloscope("$address")
# We want to capture a 20 us waveform, hence we set time window
# to a bit larger value
scope.set_time_window("23 us")
# Channel 1 refers to SCL (system clock) line
scl = scope.channel(1)
scl.set_impedance_max()
# Channel 1 refers to SDC (data) line
sdc = scope.channel(2)
sdc.set_impedance_max()
# Signals will be from 0 to 3.3 V. Setting range from -0.2 to 4 volts
# will ensure that all data is captured including noise.
scl.set_range_V(-0.2, 4)
sdc.set_range_V(-0.2, 4)
scope.trigger.configure(ScopeTrigger.EDGE(
# trigger on voltage change in data line
trigger_source = sdc,
# trigger when voltage crosses 1.6 volt on the way down
# to 0 from 3.3 volts
level_V = 1.6,
# we are interested in mosty what happens after the trigger point
delay = "20 us",
# capture on the falling edge as by default signal is pulled up
slope = TriggerSlope.FALLING
))
# arm trigger, download waveforms and save them to files.
scope.trigger.arm_single()
scope.trigger.wait_for_waveform("10 s", error_on_timeout = True)
csl_waveform = scl.get_waveform()
sdc_waveform = sdc.get_waveform()
csl_waveform.save_to_file("csl.wfm")
sdc_waveform.save_to_file("sdc.wfm")