A two-day course built from the PA Test Bench code
Bench Python Weekend
You start knowing no Python. By Sunday evening you can read drivers.py, test_engine.py and main_gui.py, explain what a function does, and write a driver class, a limit check and a small sequence runner of your own.
Every example here is a real line from the app, cited by file and line number, or a small piece of code shaped like one. The code boxes run real Python in this page, so you can change anything and press Run. Nothing here can reach the bench.
There are 12 lessons and 31 exercises. Each exercise has a Check button that tests your code against several cases, a Hint, and the Solution. Your progress and your code are kept in this browser.
Functions as values, why the engine runs on its own thread, the GUI's mailbox, and the Day 2 capstone.
75 min
How to get the most out of two days
Type, don't paste. Retyping an example is slower and it is the point: your fingers learn the colons and the indentation.
Break things on purpose. Delete a colon, misspell a name, run it, and read the error. Lesson 7 is easier when you have already met them.
Try before you peek. Give an exercise ten honest minutes, then the hint, then the solution. Then close the solution and write it again.
Keep the walkthroughs open. The PDFs on your machine explain the same code in depth. Each "From the bench" box tells you where to look.
Two days gets you to reading and changing this codebase with confidence. Fluency comes from using it the week after, so the Monday page lists real tasks to do in the repository.
print, comments, variables, numbers and math, using dBm and watts.
A Python program is a text file of instructions. Python reads it from the first line to the last and does each one in turn. That is all main_gui.py, test_engine.py and drivers.py are: about 14,000 lines, read top to bottom.
Every code box on this page is live. Change the code, then press Run (or Ctrl+Enter). Nothing you do here can touch the bench.
Try it
Ctrl+Enter runs
print(...) shows a value. Text goes in quotes; numbers do not. Separate several things with commas and print puts a space between them.
Variables are names you attach to values with =. The name goes on the left, the value on the right. Read = as "is now", not "equals".
Try it
Ctrl+Enter runs
Numbers. Whole numbers are int (3, -40); numbers with a decimal point are float (10.5, -999.0). The arithmetic operators are + - * /, plus ** for powers, // for whole-number division and % for the remainder. 1e9 means 1×10⁹, which is handy for hertz.
For logs and square roots you import the math module. import math gives you math.log10, math.sqrt, math.pi and friends.
Names written in ALL_CAPS are constants: a convention that says "set once, never change". TWO_TONE_SUM_DB is the 3.01 dB a broadband sensor sees when two equal tones add. You can already read this line.
Try it
Ctrl+Enter runs
Exercise 1.1
dBm to milliwatts
To do
Power in milliwatts is 10 ** (dBm / 10). Set p_mw to the power of a 30 dBm signal, then print it.
Write p_mw = 10 ** (p_dbm / 10). The brackets make the division happen before the power.
Tried it? Reading the answer after a real attempt is how this sticks.
p_dbm = 30
p_mw = 10 ** (p_dbm / 10)
print(p_mw)
Exercise 1.2
Watts back to dBm
To do
A supply delivers p_w = 2.0 watts. Compute p_dbm, the same power in dBm: convert to milliwatts (×1000), then take 10 * math.log10(...). You should get about 33.01.
p_dbm = 10 * math.log10(p_w * 1000)
Tried it? Reading the answer after a real attempt is how this sticks.
y = x attaches y to the value 5. Re-pointing x at 7 later does not move y.
Day 1 · Saturday · Lesson 2 · about 50 min
Text and f-strings
Strings, string methods, converting text to numbers, and the f-strings every log line uses.
Text in Python is a string (str). Use single or double quotes; they mean the same. Instruments speak text: every SCPI command you send is a string, and every reply comes back as one, usually with a newline on the end.
Try it
Ctrl+Enter runs
Methods are functions that belong to a value, written value.method(...). The ones you will see most in the bench code are .strip(), .split(), .upper(), .lower(), .startswith(), .replace() and .format().
float("12.5") and int("12") turn text into numbers. str(12.5) goes the other way. Text that is not a number makes float fail with a ValueError; Day 2 shows how to catch that.
f-strings are the most common thing in this codebase. Put f in front of the quotes and anything inside {braces} is replaced by its value. After a colon you can say how to format it:
{x:.3f} three decimal places
{x:+.2f} two places, always show the sign
{x:g} shortest sensible form
{x:,.0f} thousands separators, no decimals
{x!r} the raw representation, handy for debugging text
From the benchdrivers.py, line 190
class KeysightE8257D(InstrumentBase):
def set_frequency(self, freq_hz): self.write(f":SOUR:FREQ {freq_hz}")
This one line is the whole idea of drivers.py: Python values in, SCPI text out. The f-string drops the frequency into the command.
Try it
Ctrl+Enter runs
From the benchdrivers.py, line 2292
err = self.query(":SYST:ERR?").strip()
...
if not err.startswith(("+0", "0,")):
self.setup_errors.append(f"{cmd} -> {err}")
return False
The PNA-X driver asks the analyzer for its error queue after every setting. startswith accepts a tuple of choices, so this reads "unless the reply starts with +0 or 0,". Different firmware says "no error" in either form.
Exercise 2.1
Build a SCPI command
To do
Set cmd to the command that tunes the generator to freq_ghz, in hertz, as a whole number: for 10.5 GHz it must be exactly :SOUR:FREQ 10500000000.
Watch out: 10.5 * 1e9 is a float and prints as 10500000000.0. Wrap it in int(...).
cmd = f":SOUR:FREQ {int(freq_ghz * 1e9)}"
Tried it? Reading the answer after a real attempt is how this sticks.
Set line so it reads exactly Freq: 10.250 GHz | Pout: +31.46 dBm using the variables, with three decimals on the frequency and a signed two-decimal Pout.
line = f"Freq: {freq:.3f} GHz | Pout: {pout:+.2f} dBm"
Tried it? Reading the answer after a real attempt is how this sticks.
freq = 10.25
pout = 31.4567
line = f"Freq: {freq:.3f} GHz | Pout: {pout:+.2f} dBm"
print(line)
Exercise 2.3
Read an instrument reply
To do
An error-queue reply arrives as err_reply and a meter reading as reading. Set:
ok to True when the cleaned-up error reply starts with +0 or 0,
watts to the reading as a float
ok = err_reply.strip().startswith(("+0", "0,")) and watts = float(reading.strip()). float also tolerates the spaces, but stripping first is the bench habit.
Tried it? Reading the answer after a real attempt is how this sticks.
+ means show the sign; a negative number already has one. .1f rounds to one decimal.
Day 1 · Saturday · Lesson 3 · about 45 min
Making decisions
if / elif / else, comparisons, and/or/not, None and truthiness.
Programs choose with if. The line ends in a colon, and everything indented under it runs only when the condition is true. Indentation is the syntax: Python has no braces, so four spaces is how it knows what belongs to the if.
Try it
Ctrl+Enter runs
Comparisons give True or False: == equal (two signs; one sign assigns), != not equal, <, <=, >, >=. Combine with and, or, not. Python also allows chains like 10.0 <= f <= 12.0.
None is Python's "nothing here" value. The engine uses it for an instrument that is not connected.
Truthiness. An if accepts any value, not just True/False. These count as false: None, 0, 0.0, "" (empty text), [] (empty list), {} (empty dict). Everything else counts as true.
From the benchtest_engine.py, line 4415
if sg: sg.set_rf_output(True)
sg is the signal generator, or None when there isn't one on the bus. This reads "if we have a generator, turn its RF on". A short if can sit on one line like this.
A guard: if there is no DC power, dividing by it would crash, so the function answers 0.0 first. You will write this function yourself in lesson 5.
Exercise 3.1
Drain current status
To do
Set status from id_ma and limit_ma:
"NO READING" when id_ma is -999.0 (this bench's "no data" marker)
"OVERCURRENT" when it is above the limit
"OK" otherwise
Order matters: check for no reading first. The checker will try several values.
Three branches: if id_ma == -999.0: then elif id_ma > limit_ma: then else:. Each sets status = "...". Keep the first two lines of the starter unchanged; the checker swaps in other values there.
Tried it? Reading the answer after a real attempt is how this sticks.
id_ma = 412.0
limit_ma = 400.0
if id_ma == -999.0:
status = "NO READING"
elif id_ma > limit_ma:
status = "OVERCURRENT"
else:
status = "OK"
print(status)
Exercise 3.2
Classify compression
To do
Given comp_db, how far the gain has dropped below small-signal, set region:
"linear" below 1 dB
"P1dB region" from 1 dB up to (not including) 3 dB
"saturated" at 3 dB or more
if comp_db < 1: … elif comp_db < 3: … else:. Because elif only runs when the earlier test failed, the second test does not need comp_db >= 1.
Tried it? Reading the answer after a real attempt is how this sticks.
comp_db = 1.7
if comp_db < 1:
region = "linear"
elif comp_db < 3:
region = "P1dB region"
else:
region = "saturated"
print(region)
Quick check
Which of these values counts as true in an if?
"0" is a string with one character in it, so it is not empty. Only empty text "" is false.
Day 1 · Saturday · Lesson 4 · about 60 min
Lists and loops
Lists, for loops, range, enumerate, while, break, and nested loops like a Vd × Vg sweep.
A list holds several values in order, in square brackets. Positions count from 0, and negative positions count from the end.
Try it
Ctrl+Enter runs
A for loop runs its indented block once per item. This is how every sweep in the engine is written: for each frequency, measure.
Try it
Ctrl+Enter runs
Two more loop tools: break leaves the loop early, continue skips to the next item. A while loop repeats while a condition stays true; use it when you do not know the count up front, such as a search that stops when the current hits target. Make sure something in the loop changes the condition, or it runs forever (on this page that freezes the tab; reload to recover).
The "Generate Freq List" button. np.arange is numpy's version of range for floats. Notice stop + (step/2): arange stops *before* the stop value, and floating point can land a hair short, so the code reaches half a step past it to make sure the last frequency is included.
Exercise 4.1
Make a frequency list
To do
Build freqs, the list from start to stop inclusive in steps of step. For 10.0 to 12.0 by 0.5 that is [10.0, 10.5, 11.0, 11.5, 12.0].
Floating point trap: adding 0.1 ten times does not give exactly 1.0. So count the points first (n = round((stop - start) / step) + 1) and compute each one as start + i * step.
n = round((stop - start) / step) + 1
for i in range(n):
freqs.append(start + i * step)
Tried it? Reading the answer after a real attempt is how this sticks.
start, stop, step = 10.0, 12.0, 0.5
freqs = []
n = round((stop - start) / step) + 1
for i in range(n):
freqs.append(start + i * step)
print(freqs)
Exercise 4.2
Find the 1 dB compression point
To do
pins and gains are a Pin sweep and the gain measured at each step. Small-signal gain is the first one. Set p1db_pin to the first input power where the gain has fallen by 1 dB or more from small-signal, and stop looking there (break). Leave it as None if the part never compresses.
Save ss = gains[0]. Loop with for i in range(len(pins)): (or for pin, gain in zip(pins, gains):). When ss - gain >= 1, set p1db_pin and break.
Tried it? Reading the answer after a real attempt is how this sticks.
pins = [-10, -8, -6, -4, -2, 0, 2, 4]
gains = [13.0, 13.0, 12.9, 12.7, 12.3, 11.9, 11.2, 10.1]
p1db_pin = None
ss = gains[0]
for i in range(len(pins)):
if ss - gains[i] >= 1:
p1db_pin = pins[i]
break
print(p1db_pin)
From the benchtest_engine.py, line 1490
for vd in vds:
for vg in vgs:
suffix = f"_Vd{vd:.1f}_Vg{vg:.2f}"
generated_steps.append({'type': 'DC_BIAS', 'name': f"{loop_step['name']}{suffix}", ...})
A loop inside a loop: Vd is the outer loop, so for each drain voltage the engine runs every gate voltage. This is how a bias loop in the sequence becomes a list of individual steps.
Exercise 4.3
Name the steps of a bias loop
To do
Build names the way the engine does: Vd outer, Vg inner, each name Bias followed by the same suffix as above. For the starter values the first name is Bias_Vd5.0_Vg-2.50 and there are four in total.
for vd in vds:
for vg in vgs:
names.append(f"Bias_Vd{vd:.1f}_Vg{vg:.2f}")
Tried it? Reading the answer after a real attempt is how this sticks.
vds = [5.0, 6.0]
vgs = [-2.5, -2.0]
names = []
for vd in vds:
for vg in vgs:
names.append(f"Bias_Vd{vd:.1f}_Vg{vg:.2f}")
print(names)
Quick check
What does this print?
print(list(range(2, 8, 2)))
range(start, stop, step) stops *before* the stop value, so 8 is left out.
Day 1 · Saturday · Lesson 5 · about 60 min
Functions
def, parameters, return, default and keyword arguments, returning two values.
A function is a named, reusable block of code. You define it once with def and call it as often as you like. Values go in through parameters and come out with return.
Return is not print. print shows a value to you; return hands it back to the code that called the function, which can store it, test it or pass it on. A function with no return gives back None.
Try it
Ctrl+Enter runs
Default arguments let a caller leave a parameter out: def read(cmd, delay=None). Keyword arguments name the parameter at the call: read("FETC?", delay=0.1). The GUI builds the engine with keyword arguments such as log_cb=... so nobody has to remember the order.
A function can return two values at once by separating them with a comma. The caller unpacks them: value, ok = f().
From the benchtest_engine.py, line 48
def get_sa_offset(freq_ghz, cal_data, fallback_db):
"""Spectrum-analyser path offset (dB) at an arbitrary frequency ...
Returns (offset_db, is_calibrated). ..."""
if not cal_data:
return fallback_db, False
f_arr = cal_data.get('sa_freqs')
o_arr = cal_data.get('sa_offset')
if not f_arr or not o_arr:
return fallback_db, False
inside = (min(f_arr) - 1e-9) <= freq_ghz <= (max(f_arr) + 1e-9)
return float(np.interp(freq_ghz, f_arr, o_arr)), inside
Two answers: the offset, and whether it is trustworthy. np.interp quietly clamps outside the measured band, so the function also says whether the frequency was really inside it. The caller writes offset, cal_ok = get_sa_offset(...) and warns when cal_ok is False.
Try it
Ctrl+Enter runs
Exercise 5.1
Write calc_pae
To do
Write calc_pae(p_out_dbm, p_in_dbm, p_dc_w) that returns power-added efficiency in percent:
PAE = (Pout_W − Pin_W) / Pdc_W × 100
Convert dBm to watts with 10 ** (dbm / 10) / 1000. If p_dc_w is zero or negative, return 0.0 instead of dividing.
Guard first: if p_dc_w <= 0: return 0.0. Then p_out_w = 10 ** (p_out_dbm / 10) / 1000, the same for p_in_w, and return (p_out_w - p_in_w) / p_dc_w * 100.
Tried it? Reading the answer after a real attempt is how this sticks.
Noise near zero can make a power meter report 0 or slightly negative watts, which has no logarithm. The R&S NRX driver reports a floor instead. Write w_to_dbm(w, floor=-70.0) that returns floor when w <= 0, otherwise the power in dBm (10 * math.log10(w * 1000)).
if w <= 0: return floor, then return 10 * math.log10(w * 1000).
Tried it? Reading the answer after a real attempt is how this sticks.
import math
def w_to_dbm(w, floor=-70.0):
if w <= 0:
return floor
return 10 * math.log10(w * 1000)
print(w_to_dbm(1.0), w_to_dbm(0), w_to_dbm(-1e-9, floor=-90.0))
Exercise 5.3
Interpolate a cal table, and say if you clamped
To do
Write interp_cal(freq, freqs, losses) that returns two values: the loss at freq by straight-line interpolation, and True/False for whether freq was inside the table.
Outside the table, clamp like np.interp does: below the first frequency return the first loss, above the last return the last loss, with the flag False. freqs is sorted, lowest first.
Handle the edges first: if freq < freqs[0]: return losses[0], False and the same for above freqs[-1]. Then loop for i in range(len(freqs) - 1): and find the pair with freqs[i] <= freq <= freqs[i+1]. The fraction of the way along is t = (freq - freqs[i]) / (freqs[i+1] - freqs[i]), and the answer is losses[i] + t * (losses[i+1] - losses[i]).
Tried it? Reading the answer after a real attempt is how this sticks.
def interp_cal(freq, freqs, losses):
if freq < freqs[0]:
return losses[0], False
if freq > freqs[-1]:
return losses[-1], False
for i in range(len(freqs) - 1):
if freqs[i] <= freq <= freqs[i + 1]:
t = (freq - freqs[i]) / (freqs[i + 1] - freqs[i])
return losses[i] + t * (losses[i + 1] - losses[i]), True
return losses[-1], True # a one-point table
freqs = [10.0, 11.0, 12.0]
losses = [1.20, 1.40, 1.90]
print(interp_cal(10.5, freqs, losses))
print(interp_cal(13.0, freqs, losses))
Quick check
What does this print?
def f(x):
print(x * 2)
result = f(4)
print(result)
f prints 8 but has no return, so it hands back None, and result is None.
Day 1 · Saturday · Lesson 6 · about 70 min
Dictionaries: the step format
Dicts, [] versus .get(), looping over items, and a sequence as a list of dicts. Ends with the Day 1 capstone.
A dictionary (dict) maps keys to values, inside curly braces. It is the most important data structure in this codebase: every test step the GUI builds is a dict, the whole sequence is a list of them, results come back as dicts, and a calibration is a dict of lists.
Try it
Ctrl+Enter runs
From the benchtest_engine.py, line 3893
step.get('meas_delay', 1.0)
The rule this codebase follows: use step['key'] for fields every step must have (a missing one should crash loudly), and step.get('key', default) for options added later. A sequence saved before the "measurement delay" box existed has no meas_delay key, so .get gives the old fixed 1-second wait and old files keep working.
A list of dicts is a table: each dict is a row and the keys are columns. That is exactly what the sequence is.
Try it
Ctrl+Enter runs
Exercise 6.1
Read a step safely
To do
This step was saved by an older version, before max_comp existed. Set:
pin_start from the step (it must exist, so use square brackets)
max_comp from the step, defaulting to 3.0 when missing
Build counts, a dict from step type to how many steps of that type are in sequence. For the starter it is {'DC_BIAS': 2, 'COMPRESSION': 2, 'S_PARAMS': 1}.
Loop over the steps. t = s['type'], then counts[t] = counts.get(t, 0) + 1. The .get(t, 0) handles the first time you see a type.
Tried it? Reading the answer after a real attempt is how this sticks.
sequence = [
{'type': 'DC_BIAS'}, {'type': 'COMPRESSION'}, {'type': 'S_PARAMS'},
{'type': 'DC_BIAS'}, {'type': 'COMPRESSION'},
]
counts = {}
for s in sequence:
t = s['type']
counts[t] = counts.get(t, 0) + 1
print(counts)
Day 1 capstone. Everything from today in one function. The engine's evaluate_limits checks one result row against a step's pass/fail limits. Here it is, trimmed to its core, so you can compare when you are done.
From the benchtest_engine.py, line 260
def evaluate_limits(row, limits, skip_params=()):
if not limits:
return '', []
reasons = []
for lim in limits:
p = lim.get('param')
...
if v == -999.0 or math.isnan(v):
reasons.append(f"{p}: no valid reading")
continue
lo, hi = lim.get('min'), lim.get('max')
if lo is not None and v < float(lo):
reasons.append(f"{p} {sv} < min {sb}")
if hi is not None and v > float(hi):
reasons.append(f"{p} {sv} > max {sb}")
...
return ('FAIL' if reasons else 'PASS'), reasons
Notice it collects *every* breach rather than stopping at the first, so the operator sees gain and harmonic failures together. And a missing reading is a FAIL, never a silent pass.
Capstone
Capstone: evaluate_limits
To do
Write evaluate_limits(row, limits). row is a result dict such as {'Gain (dB)': 12.1, 'Id (mA)': 455.0}. Each limit is a dict with 'param' and optional 'min' and 'max' (either may be missing or None). Return two values, verdict, reasons:
If limits is empty, return '' and an empty list.
Skip a limit whose param is not in the row.
A reading of -999.0 adds "<param>: no valid reading".
Below min adds "<param> <value> < min <min>"; above max adds "<param> <value> > max <max>". Use plain {value} formatting.
Collect every reason. The verdict is 'FAIL' if there are any, else 'PASS'.
Start with if not limits: return '', []. Then reasons = [] and loop for lim in limits:. Inside: p = lim['param']; if p not in row: continue; v = row[p]; handle -999.0 with continue; then lo = lim.get('min') and hi = lim.get('max'), checking each with is not None. At the end, return ('FAIL' if reasons else 'PASS'), reasons.
Tried it? Reading the answer after a real attempt is how this sticks.
def evaluate_limits(row, limits):
if not limits:
return '', []
reasons = []
for lim in limits:
p = lim['param']
if p not in row:
continue
v = row[p]
if v == -999.0:
reasons.append(f"{p}: no valid reading")
continue
lo, hi = lim.get('min'), lim.get('max')
if lo is not None and v < lo:
reasons.append(f"{p} {v} < min {lo}")
if hi is not None and v > hi:
reasons.append(f"{p} {v} > max {hi}")
return ('FAIL' if reasons else 'PASS'), reasons
row = {'Gain (dB)': 12.1, 'Id (mA)': 455.0, 'PAE (%)': -999.0}
limits = [
{'param': 'Gain (dB)', 'min': 12.5, 'max': 15.0},
{'param': 'Id (mA)', 'max': 450.0},
{'param': 'PAE (%)', 'min': 30.0},
]
print(evaluate_limits(row, limits))
Quick check
What does this print?
d = {'a': 1}
print(d.get('b', 0) + d['a'])
d.get('b', 0) gives the default 0 because 'b' is missing; d['a'] is 1.
Day 2 · Sunday · Lesson 7 · about 55 min
When things go wrong
Reading errors, try / except / finally, raise, and the bench's rules for missing data.
When Python hits something it cannot do, it raises an exception and stops. The message names the kind of error and the line. Learning to read these is half of programming. Run this and read the error at the bottom.
Try it
Ctrl+Enter runs
The most common ones: NameError (a name you never set, often a typo), KeyError (missing dict key), IndexError (list position too big), TypeError (wrong kind of value, such as adding text to a number), ValueError (right kind, bad content, such as float("abc")), ZeroDivisionError, and IndentationError/SyntaxError (Python could not even read the code).
try / except lets you catch an exception and decide what to do instead. finally runs no matter what: after success, after an exception, even after return. This is the single most important construct in test_engine.py, because it is what guarantees the RF and the drain supplies get switched off when a test crashes halfway.
The drivers shorten the VISA timeout for a quick probe so a dead sensor costs 1.5 s, not 10 s. The finally puts the old value back whether the probe worked or blew up, so the short timeout never leaks into the rest of the run.
You can raise your own exceptions with raise ValueError("message"). Do it when carrying on would produce a wrong answer. get_cal_offset raises when a cal file has no input-side data, rather than guessing.
Missing data on this bench. Two markers mean "no reading", and neither must ever look like a real number:
-999.0 is the bench-wide "no data" sentinel.
float('nan') (Not a Number) is what a failed read becomes. NaN is the only value not equal to itself, so x == x is False for NaN. math.isnan(x) says the same thing more readably. Any comparison with NaN is False, which forces the caller to deal with it.
From the benchdrivers.py, line 3288
# Keithley 2430, measure_current
except Exception as e:
self.recover()
self.last_errors.append(f"gate current read failed ({q}): ...")
return float('nan')
The comment in the file explains why: the engine compares gate current against an overcurrent limit. Returning 0 mA on a failed read would quietly switch that protection off. NaN cannot pass any limit check by accident.
Try it
Ctrl+Enter runs
Exercise 7.1
safe_float
To do
Write safe_float(text, default=None) that returns float(text), or default when the text cannot be converted. Catch ValueError (bad text like "OVLD") and TypeError (when text is None). You can catch both with except (ValueError, TypeError):.
Write is_valid(x) that returns True only for a real number: not None, not -999.0, and not NaN. Check None first, because None == None is fine but math.isnan(None) crashes.
if x is None: return False, then return x != -999.0 and not math.isnan(x).
Tried it? Reading the answer after a real attempt is how this sticks.
import math
def is_valid(x):
if x is None:
return False
return x != -999.0 and not math.isnan(x)
print(is_valid(12.0), is_valid(-999.0), is_valid(float('nan')), is_valid(None))
Exercise 7.3
RF off, whatever happens
To do
Write measure_safely(measure, log). It must append "RF ON" to the list log, call measure() and return its result, and always append "RF OFF" afterwards, even when measure() raises. If measure() raises, append "measurement failed: <message>" and return None (the "RF OFF" still comes last).
NaN is the one value not equal to itself, so x == x is False and x != x is True.
Day 2 · Sunday · Lesson 8 · about 60 min
Classes: modelling an instrument
class, __init__, self, attributes and methods, built around a fake instrument.
So far data (variables, dicts) and behaviour (functions) have lived apart. A class bundles them: it is a template for objects that carry their own data and know how to act on it. drivers.py is 33 classes, one per instrument family. Each object is one physical instrument on the bus.
class Name: starts the template.
__init__ is the constructor. It runs once, when you create an object, and sets up its data.
self means "this particular object". self.inst = inst stores the connection on the object so every other method can reach it.
Methods are functions defined inside the class. Their first parameter is always self, and Python fills it in for you: sg.set_frequency(10e9) calls set_frequency(sg, 10e9).
The root of every driver. It opens the VISA connection and wraps the three things every instrument can do: write, query, close. Every other driver builds on this.
There is no bench here, so this lesson and the rest of the day use a FakeInstrument that is already defined for you. It behaves like a pyvisa connection: write records the command in .sent, and query answers from a table you give it (or raises TimeoutError, like a silent instrument).
Show the FakeInstrument code
class FakeInstrument:
"""Stands in for a pyvisa connection. Records every write and answers
queries from a table, so drivers can be tried with no bench attached."""
def __init__(self, answers=None):
self.sent = [] # every command, in order
self.answers = dict(answers or {})
self.timeout = 10000 # ms, like a real pyvisa resource
def write(self, cmd):
self.sent.append(cmd)
def query(self, cmd):
self.sent.append(cmd)
ans = self.answers.get(cmd)
if ans is None:
raise TimeoutError(f"no reply to {cmd!r}")
return ans() if callable(ans) else ans
def clear(self):
self.sent.append("<device clear>")
Try it
Ctrl+Enter runs
Try it
Ctrl+Enter runs
Exercise 8.1
A signal generator driver
To do
Write class SignalGenerator with:
__init__(self, inst) storing inst on the object as self.inst
set_frequency(self, freq_hz) writing :SOUR:FREQ <freq_hz> with the frequency as a whole number
set_power(self, dbm) writing :SOUR:POW <dbm> with two decimals
set_rf_output(self, on) writing :OUTP ON or :OUTP OFF
In def set_power(self, dbm):, what is self when you call sg.set_power(5)?
Calling a method on an object passes that object in as self automatically.
Day 2 · Sunday · Lesson 9 · about 60 min
Inheritance and duck typing
Subclasses, super(), class attributes, isinstance, mixins, getattr and hasattr.
Inheritance lets one class build on another: class KeysightE8257D(InstrumentBase): gets write, query and close for free and adds only what is specific to that generator. A subclass can also override a method by defining one with the same name. When an overriding method still wants the parent's version, it calls super().method(...), most often in __init__.
From the benchdrivers.py, line 3140
class Keithley2420(InstrumentBase):
CURR_RANGES_A = (1e-6, 10e-6, 100e-6, 1e-3, 10e-3, 100e-3, 1.0, 3.0) # 2420
def _fixed_current_range(self, compliance_a):
return next((r for r in self.CURR_RANGES_A if r >= float(compliance_a)),
self.CURR_RANGES_A[-1])
...
class Keithley2440(Keithley2420):
CURR_RANGES_A = (1e-6, 10e-6, 100e-6, 1e-3, 10e-3, 100e-3, 1.0, 5.0) # 5.25 A max
CURR_RANGES_A is a class attribute: written at class level, shared by every object of the class. The 2440 is the 2420 with one different line. Every method reads self.CURR_RANGES_A, and on a 2440 object Python finds the 2440's tuple first. One line per model, no copied code.
Try it
Ctrl+Enter runs
isinstance(obj, SomeClass) asks whether an object is of that class or a subclass. The engine uses it sparingly, for example to tell the operator an older PNA was detected.
Duck typing. More often the engine does not ask *what* an instrument is, only *whether it can do something*: "if it quacks, it is a duck". hasattr(obj, 'name') is True when the object has that attribute or method. getattr(obj, 'name', None) fetches it, or gives None. This lets one engine drive a mixed rack where some meters can zero and some cannot, without a table of who supports what.
A mixin is a small class that supplies behaviour but is not a complete thing on its own. class RohdeSchwarzNRX(DualSensorMixin, InstrumentBase): inherits from both.
From the benchdrivers.py, line 144
class DualSensorMixin:
last_read_fallback = False
def _read_with_fallback(self, primary, secondary):
val = self._get_reading(primary)
if val != -999.0:
self.last_read_fallback = False
return val
val = self._get_reading(secondary)
self.last_read_fallback = (val != -999.0)
return val
def read_sensor_A(self):
return self._read_with_fallback(1, 2)
The mixin calls self._get_reading(...), which it never defines. Each meter class that mixes it in supplies its own _get_reading. The mixin owns the policy (fall back to the other sensor and flag it); each driver owns the mechanism.
Exercise 9.1
Pick a current range, for two models
To do
Write class Keithley2420 with a class attribute CURR_RANGES_A = (1e-3, 10e-3, 100e-3, 1.0, 3.0) and a method pick_range(self, compliance_a) that returns the smallest range that is at leastcompliance_a, or the top range if none is big enough.
Then write class Keithley2440(Keithley2420) that changes only CURR_RANGES_A, to (1e-3, 10e-3, 100e-3, 1.0, 5.0). Neither class needs an __init__ here.
Inside pick_range, loop for r in self.CURR_RANGES_A: and return r as soon as r >= compliance_a. After the loop, return self.CURR_RANGES_A[-1]. Using self. (not Keithley2420.) is what lets the 2440's tuple win.
Tried it? Reading the answer after a real attempt is how this sticks.
class Keithley2420:
CURR_RANGES_A = (1e-3, 10e-3, 100e-3, 1.0, 3.0)
def pick_range(self, compliance_a):
for r in self.CURR_RANGES_A:
if r >= compliance_a:
return r
return self.CURR_RANGES_A[-1]
class Keithley2440(Keithley2420):
CURR_RANGES_A = (1e-3, 10e-3, 100e-3, 1.0, 5.0)
print(Keithley2420().pick_range(0.05), Keithley2420().pick_range(4.0), Keithley2440().pick_range(4.0))
Exercise 9.2
Zero the meter if it can
To do
Write zero_if_possible(meter, log). If the meter has a zero_all method, call it and append "zeroed <ClassName>" to log. Otherwise append "no zeroing on <ClassName>". Get the class name with type(meter).__name__. Use getattr or hasattr, not isinstance.
fn = getattr(meter, 'zero_all', None)
name = type(meter).__name__
if fn:
fn()
log.append(f"zeroed {name}")
else:
log.append(f"no zeroing on {name}")
Tried it? Reading the answer after a real attempt is how this sticks.
class NRX:
def zero_all(self):
self.zeroed = True
class ML2496A:
pass
def zero_if_possible(meter, log):
fn = getattr(meter, 'zero_all', None)
name = type(meter).__name__
if fn:
fn()
log.append(f"zeroed {name}")
else:
log.append(f"no zeroing on {name}")
log = []
zero_if_possible(NRX(), log)
zero_if_possible(ML2496A(), log)
print(log)
Exercise 9.3
Write the dual-sensor mixin
To do
Write DualSensorMixin from memory: a class attribute last_read_fallback = False, and _read_with_fallback(self, primary, secondary) that reads the primary with self._get_reading(primary); if that is not -999.0, clears the flag and returns it; otherwise reads the secondary, sets the flag to whether that one worked, and returns it. Add read_sensor_A (primary 1, secondary 2) and read_sensor_B (2, then 1).
The FakeMeter below mixes it in and supplies _get_reading.
Copy the shape from the bench snippet above, then add read_sensor_B with the channels swapped.
Tried it? Reading the answer after a real attempt is how this sticks.
class DualSensorMixin:
last_read_fallback = False
def _read_with_fallback(self, primary, secondary):
val = self._get_reading(primary)
if val != -999.0:
self.last_read_fallback = False
return val
val = self._get_reading(secondary)
self.last_read_fallback = (val != -999.0)
return val
def read_sensor_A(self):
return self._read_with_fallback(1, 2)
def read_sensor_B(self):
return self._read_with_fallback(2, 1)
class FakeMeter(DualSensorMixin):
def __init__(self, a, b):
self.values = {1: a, 2: b}
def _get_reading(self, ch):
return self.values[ch]
m = FakeMeter(-999.0, 12.5)
print(m.read_sensor_A(), m.last_read_fallback)
Quick check
class B(A): X = 2 and class A: X = 1 with a method def show(self): return self.X. What is B().show()?
show is inherited from A, but self.X looks on the object's own class first, which is B.
Day 2 · Sunday · Lesson 10 · about 60 min
Pythonic power tools
Comprehensions, zip, sorted and max with key=, lambda, ** dict merging, and the copy trap.
This lesson is the jump from "can write Python" to "can read other people's Python". These one-liners are all over the codebase.
A list comprehension builds a list in one expression: [expression for item in items if condition]. It reads left to right as "give me this, for each of those, where that".
Try it
Ctrl+Enter runs
lambda r: r['Pout (dBm)'] is a tiny unnamed function: it takes r and returns that expression. It shows up wherever a function is wanted for one moment, such as key= or a button command=.
next(item for item in things if test) finds the first match. Give it a second argument to return when nothing matches. That is how _fixed_current_range in the last lesson picks its range in one line.
** unpacks a dict into another: {**base, 'extra': 1} is a copy of base with one more key. * does the same for lists, and *args/**kwargs in a def collect any extra arguments.
From the benchmain_gui.py, line 3749
for test, sfx in (("DC_BIAS", "_DCBias"), ("DC2_BIAS", "_DC2Bias"),
("COMPRESSION", "_CompPoints"), ("PNA_COMPRESSION", "_PNAComp")):
exports.append(([r for r in self.data_summary if r.get('Test') == test], sfx))
From Export CSV: one file per test type. The loop unpacks each pair into test, sfx, and the comprehension keeps only that test's summary rows.
From the benchtest_engine.py, line 1495
generated_steps.append({'type': 'DC_BIAS', 'name': f"{loop_step['name']}{suffix}",
'fixed_vg': vg, 'fixed_vd': vd, 'loop_point': True,
**{k: copy.deepcopy(loop_step[k]) for k in self.DC_LOOP_CARRY
if k in loop_step}})
A dict comprehension (copy the keys named in DC_LOOP_CARRY that the loop actually has) unpacked with ** into a new step. Read it inside out and it is just "plus these settings carried over from the loop".
The copy trap. Assigning a list or dict does not copy it; both names point at the same object, so changing one changes "both". list(x) or dict(x) makes a shallow copy (a new outer container, the same items inside). copy.deepcopy(x) copies all the way down. The engine deep-copies the inner steps of a loop before renaming them, because otherwise every pass of the loop would rename the same dicts.
Try it
Ctrl+Enter runs
Exercise 10.1
Slice the results
To do
From rows, set:
gains_hi to the list of gains for rows at 11 GHz or above (use a comprehension)
worst to the whole row with the lowest gain (use min with key=)
spread to max gain minus min gain, rounded to 2 places
gains_hi = [r['Gain (dB)'] for r in rows if r['Freq (GHz)'] >= 11], worst = min(rows, key=lambda r: r['Gain (dB)']), and build a list of all gains for the spread.
Tried it? Reading the answer after a real attempt is how this sticks.
expand is meant to return a fresh copy of inner_steps for each loop point, with the suffix added to each name. It has a bug: run it and look at the names. Fix it so every copy gets exactly one suffix and inner_steps itself is left unchanged.
Replace copied = inner_steps with copied = copy.deepcopy(inner_steps). A shallow list(inner_steps) is not enough: the dicts inside would still be shared.
Tried it? Reading the answer after a real attempt is how this sticks.
import copy
def expand(inner_steps, suffixes):
out = []
for sfx in suffixes:
copied = copy.deepcopy(inner_steps)
for s in copied:
s['name'] = s['name'] + sfx
out.extend(copied)
return out
inner_steps = [{'type': 'COMPRESSION', 'name': 'Comp'}]
print(expand(inner_steps, ['_Vd26', '_Vd28']))
print(inner_steps)
Exercise 10.3
The command ladder's order
To do
Drivers try a list of SCPI spellings and remember which one worked. Write candidates(templates, cached) that returns the list to try: the cached winner first and then every other template in its original order, or just a copy of templates when cached is None. Do it with a comprehension, the way the 8990B driver does.
if cached: return [cached] + [t for t in templates if t != cached] and otherwise return list(templates).
Tried it? Reading the answer after a real attempt is how this sticks.
def candidates(templates, cached):
if cached:
return [cached] + [t for t in templates if t != cached]
return list(templates)
T = [":MEAS:RIS?", ":MEAS:RTIM?", ":MEAS:RISE?"]
print(candidates(T, None))
print(candidates(T, ":MEAS:RTIM?"))
Quick check
What does this print?
a = [1, 2]
b = a
b.append(3)
print(a)
b = a does not copy; both names point at one list, so the append shows through a.
Day 2 · Sunday · Lesson 11 · about 50 min
Modules, files and data
import, the __main__ guard, with open(...), JSON for sequences, CSV for results.
Modules. Every .py file is a module. import math loads one and you write math.log10. from drivers import KeysightE8257D pulls one name in directly. import numpy as np renames on the way in. from drivers import auto_connect as open_any_instrument does both. test_engine.py imports from drivers.py, and main_gui.py imports from test_engine.py; never the other way, which is what keeps the three layers clean.
The block at the bottom of main_gui.py, if __name__ == "__main__":, runs only when you start that file directly. When another file imports it, __name__ is the module's name instead, so the window does not pop open.
Files.with open(path, 'w') as f: opens a file and guarantees it is closed when the block ends, crash or not; it is a built-in try/finally. Modes: 'r' read, 'w' write (replaces the file), 'a' append. This page runs in a browser with no disk, so the examples use io.StringIO, an in-memory text file with the same methods. On your machine, swap it for open(...).
From the benchmain_gui.py, line 2709
def save_sequence(self):
...
with open(fp, 'w') as f: json.dump(self.sequence_steps, f, indent=4)
def load_sequence(self):
...
with open(fp, 'r') as f: steps = json.load(f)
# The limiter test was removed; drop its steps so older sequences still load.
self.sequence_steps = [s for s in steps if s.get('type') != "LIMITER_SWEEP"]
A saved sequence is just the list of step dicts written as JSON. Loading reads it back and, with a comprehension, drops a step type that no longer exists so old files still open.
Try it
Ctrl+Enter runs
Try it
Ctrl+Enter runs
Exercise 11.1
Load an old sequence file
To do
saved is the JSON text of a sequence written by an older version. Write load_steps(text) that parses it, drops any step whose type is "LIMITER_SWEEP", and gives every remaining step an 'enabled' key of True if it does not already have one (step.setdefault('enabled', True) does exactly that). Return the list.
steps = json.loads(text), then steps = [s for s in steps if s.get('type') != "LIMITER_SWEEP"], then loop and s.setdefault('enabled', True). Note JSON's false becomes Python's False.
Tried it? Reading the answer after a real attempt is how this sticks.
import json
saved = '[{"type": "DC_BIAS", "name": "Bias"}, {"type": "LIMITER_SWEEP", "name": "Lim"}, {"type": "S_PARAMS", "name": "SP", "enabled": false}]'
def load_steps(text):
steps = json.loads(text)
steps = [s for s in steps if s.get('type') != "LIMITER_SWEEP"]
for s in steps:
s.setdefault('enabled', True)
return steps
print(load_steps(saved))
Exercise 11.2
Pick a driver from *IDN?
To do
Every instrument answers *IDN? with maker,model,serial,firmware. Write parse_idn(reply) that returns a dict with keys 'maker', 'model', 'serial', 'fw' (each stripped of spaces). Then write pick_driver(reply) that returns "KeysightE8257D" when the model contains E8257D, "RohdeSchwarzNRX" when it contains NRX, and None otherwise. Compare in upper case so nrx also matches.
maker, model, serial, fw = [p.strip() for p in reply.strip().split(",")] unpacks four parts at once. In pick_driver, call parse_idn and test "E8257D" in model.upper().
Tried it? Reading the answer after a real attempt is how this sticks.
def parse_idn(reply):
maker, model, serial, fw = [p.strip() for p in reply.strip().split(",")]
return {'maker': maker, 'model': model, 'serial': serial, 'fw': fw}
def pick_driver(reply):
model = parse_idn(reply)['model'].upper()
if "E8257D" in model:
return "KeysightE8257D"
if "NRX" in model:
return "RohdeSchwarzNRX"
return None
r = "KEYSIGHT TECHNOLOGIES,E8257D,MY12345678,C.06.20\n"
print(parse_idn(r))
print(pick_driver(r), pick_driver("Rohde&Schwarz,NRX,101234,02.50"), pick_driver("ACME,X1,1,1"))
Quick check
Why does main_gui.py end with if __name__ == "__main__":?
The block runs only when the file is started directly, not when another file imports it.
Day 2 · Sunday · Lesson 12 · about 75 min
Callbacks, threads and the queue
Functions as values, why the engine runs on its own thread, the GUI's mailbox, and the Day 2 capstone.
Functions are values. You can store a function in a variable, put it in a dict, or pass it to another function, as long as you leave off the brackets. print is the function; print("x") *calls* it. A function you hand to someone else to call later is a callback.
Try it
Ctrl+Enter runs
That is the whole trick behind the engine being GUI-free. PATestEngine is given three callbacks, log_cb, result_cb and finish_cb, and just calls them. It never touches a widget.
Why a thread? A GUI has one thread running mainloop(). While your code runs, the window cannot redraw or respond. A 40-minute test run therefore has to run on a second thread. But tkinter is not thread-safe: a background thread must never touch a widget directly.
The fix is a queue, a thread-safe mailbox. The engine's callbacks only drop messages into it. The GUI thread empties it every 50 ms and does the drawing itself. This producer/consumer pattern is in every threaded GUI you will ever read.
The engine thinks it is logging. Really each call drops a (type, data) tuple in the mailbox. start() runs the engine's run() method on its own thread.
From the benchmain_gui.py, line 3253
def process_queue(self):
while not self.ui_queue.empty():
msg_type, data = self.ui_queue.get_nowait()
if msg_type == "LOG": self.log_msg(data)
...
self.after(50, self.process_queue)
The consumer. It handles everything waiting, then asks tkinter to call it again in 50 ms. Between calls, control returns to mainloop() and the window stays responsive.
Try it
Ctrl+Enter runs
This page's Python cannot start real threads, so here start() simply runs the engine's work to the end before carrying on. On your machine the engine really runs alongside the window, which is why the GUI drains the queue on a timer instead of calling join(). The pattern is what matters.
Day 2 capstone. Two exercises that are the engine in miniature. The first is the real algorithm from _unroll_sequence: turn a sequence with a loop in it into a flat list of steps. The second wires everything from this weekend together.
From the benchtest_engine.py, line 1524
for step in raw_sequence:
if step['type'] in ['LOOP_START', 'DC2_LOOP_START']:
loop_blocks.append((step, []))
elif step['type'] == 'LOOP_END':
if not loop_blocks:
self.log("WARNING: LOOP_END without LOOP_START. Ignoring.")
continue
loop_step, inner_steps = loop_blocks.pop()
emit(self._expand_loop(loop_step, inner_steps))
...
A list used as a stack: append pushes a loop when it opens, pop takes the most recent one off when it closes. That is what lets loops nest.
Capstone
Capstone 1: unroll a sequence
To do
Write unroll(sequence). Ordinary steps pass straight through. A LOOP_START step has a 'name' and a 'vds' list; the steps after it, up to the matching LOOP_END, are its body. Replace the whole block with, for each vd in vds:
1. a bias step {'type': 'DC_BIAS', 'name': f"{loop name}_Vd{vd:.1f}", 'fixed_vd': vd} 2. a deep copy of each body step, with f"_Vd{vd:.1f}" added to its name
Loops can nest; use a list as a stack the way the engine does. A LOOP_END with no open loop is ignored.
Keep out = [] and stack = []. A helper emit(steps) extends stack[-1][1] if a loop is open, else out. On LOOP_START: stack.append((step, [])). On LOOP_END: if the stack is empty, continue; otherwise loop, body = stack.pop(), build the expanded list (bias step, then deep-copied renamed body for each vd) and emit it. Anything else: emit([copy.deepcopy(step)]).
Tried it? Reading the answer after a real attempt is how this sticks.
import copy
def unroll(sequence):
out = []
stack = []
def emit(steps):
if stack:
stack[-1][1].extend(steps)
else:
out.extend(steps)
for step in sequence:
if step['type'] == 'LOOP_START':
stack.append((step, []))
elif step['type'] == 'LOOP_END':
if not stack:
continue
loop, body = stack.pop()
expanded = []
for vd in loop['vds']:
sfx = f"_Vd{vd:.1f}"
expanded.append({'type': 'DC_BIAS', 'name': f"{loop['name']}{sfx}", 'fixed_vd': vd})
for s in copy.deepcopy(body):
s['name'] = s['name'] + sfx
expanded.append(s)
emit(expanded)
else:
emit([copy.deepcopy(step)])
return out
seq = [
{'type': 'S_PARAMS', 'name': 'SP'},
{'type': 'LOOP_START', 'name': 'Bias', 'vds': [26.0, 28.0]},
{'type': 'COMPRESSION', 'name': 'Comp'},
{'type': 'LOOP_END'},
]
for s in unroll(seq):
print(s)
Capstone
Capstone 2: a mini engine
To do
Write class MiniEngine that runs a Pout sweep on fake instruments and reports only through callbacks.
run(self, freqs, pin_dbm): turn the generator's RF on (self.sg.set_rf_output(True)), then for each frequency call self.sg.set_frequency(f * 1e9), read pout = self.pm.read_dbm(), build row = {'Freq (GHz)': f, 'Pout (dBm)': pout, 'Gain (dB)': pout - pin_dbm} (gain is -999.0 when pout is -999.0), add 'Verdict' from evaluate_limits(row, self.limits) (already provided, from Day 1), call self.log_cb(...) with any message, and self.result_cb(row).
Whatever happens, finish with RF off and then self.finish_cb(). If anything raises mid-sweep, log "ABORT: <message>" and stop.
SignalGenerator, PowerMeter and evaluate_limits are already defined for you, from earlier lessons.
Structure run like the real engine's:
self.sg.set_rf_output(True)
try:
for f in freqs:
...
except Exception as e:
self.log_cb(f"ABORT: {e}")
finally:
self.sg.set_rf_output(False)
self.finish_cb()
verdict, reasons = evaluate_limits(row, self.limits) then row['Verdict'] = verdict.
Tried it? Reading the answer after a real attempt is how this sticks.
class MiniEngine:
def __init__(self, sg, pm, limits, log_cb, result_cb, finish_cb):
self.sg = sg
self.pm = pm
self.limits = limits
self.log_cb = log_cb
self.result_cb = result_cb
self.finish_cb = finish_cb
def run(self, freqs, pin_dbm):
self.sg.set_rf_output(True)
try:
for f in freqs:
self.sg.set_frequency(f * 1e9)
pout = self.pm.read_dbm()
gain = -999.0 if pout == -999.0 else pout - pin_dbm
row = {'Freq (GHz)': f, 'Pout (dBm)': pout, 'Gain (dB)': gain}
verdict, reasons = evaluate_limits(row, self.limits)
row['Verdict'] = verdict
self.log_cb(f"{f:.3f} GHz: Pout {pout:+.2f} dBm {verdict} {'; '.join(reasons)}")
self.result_cb(row)
except Exception as e:
self.log_cb(f"ABORT: {e}")
finally:
self.sg.set_rf_output(False)
self.finish_cb()
readings = iter(["31.0", "30.2", "OVLD"])
sg = SignalGenerator(FakeInstrument())
pm = PowerMeter(FakeInstrument({"FETC?": lambda: next(readings)}))
logs, rows, done = [], [], []
eng = MiniEngine(sg, pm, [{'param': 'Gain (dB)', 'min': 10.5}],
logs.append, rows.append, lambda: done.append(True))
eng.run([10.0, 11.0, 12.0], pin_dbm=20.0)
for r in rows:
print(r)
print(sg.inst.sent, done)
Quick check
Why must the engine thread never call tree.insert(...) on a GUI table directly?
Widgets belong to the GUI thread. Touching them from another thread causes random, unreproducible crashes, so the engine posts messages and the GUI thread draws.
After the weekend
Monday: the real code
Set up Python on your own machine, then work through the app with the walkthroughs. These tasks are all read-only or run without the bench.
1. Python on your machine
From a terminal in the folder that holds the PA_Test_ATE checkout:
python3 -m venv .venv # a private Python for this project
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pyvisa numpy pandas matplotlib
cd PA_Test_ATE
python3 # an interactive prompt; exit() to leave
At the >>> prompt you can type any line from this course and see the result at once. It is the fastest way to try something.
2. Read the walkthroughs in this order
The walkthrough PDFs are in your PA Test folder, next to the checkout.
drivers walkthrough, Parts 1 to 3: classes and the five recurring themes. You now know every construct in them.
test_engine walkthrough, Parts 1, 2 and the "Sequence unrolling" section of Part 5: the engine's vocabulary and the loop you wrote in capstone 1.
main_gui walkthrough, Parts 1, 2, 6, 8 and 9: tkinter, the queue, the step dicts, and the round trip of a result.
main_gui walkthrough Part 11 and test_engine walkthrough Part 7: trace one number from the instrument to the CSV.
3. Tasks in the real repository
Call the engine's own code. In the PA_Test_ATE folder, start python3 and run from test_engine import calc_pae, evaluate_limits, get_cal_offset. Call each with numbers of your own. Compare evaluate_limits with your Day 1 capstone: what does the real one handle that yours does not?
Map the Keithley family. Run grep -n "class Keithley" drivers.py. For each class, write down its parent and what it overrides. Which ones change only CURR_RANGES_A?
Find every default. In test_engine.py, search for step.get( inside the DC_BIAS branch. For each one, say what an old saved sequence would get.
Follow a button. In main_gui.py, find the "Generate Freq List" button, follow its command= to generate_freqs, and explain the lambda.
Write a fake driver. Copy FakeInstrument from lesson 8 into a file, build KeysightE8257D around it by giving it an object with write and query, and check the SCPI it sends. Do this in a scratch file, not in the repository.
4. What to learn next
numpy: arrays, np.interp, np.arange, complex numbers. Used everywhere in the engine.
pandas: tables of results and to_csv, used by Export.
matplotlib: the live plots.
git: branches and pull requests, so changes to the bench code are reviewable.
pytest: the exercises' Check buttons are tests. Writing your own for evaluate_limits is a good first one.
Reference
Cheat sheet
Everything from the weekend on one page, in the shapes the bench code uses.