Rust, taught through the same problems the PA Test Bench code solves: drivers, readings that might be missing, limit checks and sweeps. By Sunday evening you can read and write ordinary Rust, understand ownership and borrowing, and use Option, Result and traits the way real Rust code does.
Take the Python course first if you can. This course compares with Python all the way through, and several exercises rewrite functions from the bench code you will already know.
Code runs on the official Rust Playground, so Run and Check need an internet connection and take a few seconds each. In an exercise you write the functions and types; the page adds a main that calls them. Copy gives you the whole program to paste into cargo on your own machine.
There are 8 lessons and 15 exercises, each with a Check, a Hint and the Solution. Progress is kept in this browser.
Programs, main, let and mut, the basic types, and println! formatting.
Rust is a compiled language: a tool called the compiler reads your whole program, checks it, and turns it into machine code before anything runs. That check is strict. It catches whole families of bugs (a value that might be missing, two threads writing the same data, using something after it was freed) before the program ever reaches the bench. The price is that the compiler says no more often than Python does, and learning Rust is mostly learning to read what it says.
Every program starts at fn main(). Press Run: your code is sent to the official Rust Playground, compiled, and run there, so the first run takes a few seconds and needs an internet connection.
Try it
Ctrl+Enter runs
Statements end with a semicolon. println! prints a line; the ! marks it as a macro, which you can treat as a function for now. {} is replaced by the next value, and the formatting codes match Python's f-strings: {:.3} three decimals, {:+.1} always show the sign, {:>8} right-align in 8 characters. You can also put a variable name inside: {pout:.2}.
Variables cannot change unless you say so.let x = 5; is fixed. let mut x = 5; can be changed later. This is the first thing that trips everyone up, and the compiler's error tells you exactly where to add mut.
Types. Every value has one fixed type: i32 (32-bit signed integer, the default for whole numbers), u32, u8, i64, usize (sizes and indexes), f64 (the default for decimals), bool, char, and text, which comes in lesson 3. Rust never converts between number types silently. as converts on purpose: code as f64.
Try it
Ctrl+Enter runs
A function is written fn name(param: Type) -> ReturnType { body }. Parameter types are required. The last expression in the body, written without a semicolon, is the return value. Lesson 2 goes further; this is enough for the exercises.
Try it
Ctrl+Enter runs
Exercise 1.1
Format a log line
To do
Write the body of log_line so it returns exactly Freq: 10.250 GHz | Pout: +31.46 dBm for log_line(10.25, 31.4567). format! works like println! but gives back a String instead of printing it.
format!("Freq: {:.3} GHz | Pout: {:+.2} dBm", freq, pout) as the last line, with no semicolon.
Tried it? Reading the answer after a real attempt is how this sticks.
Write w_to_dbm(w: f64) -> f64: 10 × log₁₀(w × 1000). In Rust, methods hang off the number: x.log10(). Remember 1000.0, not 1000: an f64 cannot be multiplied by an integer.
10.0 * (w * 1000.0).log10()
Tried it? Reading the answer after a real attempt is how this sticks.
let makes a fixed variable. It needs let mut count = 0; to change.
Day 1 · Saturday · Lesson 2 · about 55 min
Functions, if and loops
Expressions versus statements, if as a value, loop, while, for over ranges.
In Rust almost everything is an expression that produces a value, including if and blocks in braces. That is why a function can end with a bare value, and why you can write let label = if x > 0 { "on" } else { "off" };. Both branches must give the same type.
Conditions need no brackets but the body always needs braces. There is no truthiness: if count is an error; write if count > 0.
Try it
Ctrl+Enter runs
Three loops:
for i in 0..5 runs 0, 1, 2, 3, 4 (0..=5 includes 5). for x in list walks a collection.
while cond { } as usual.
loop { } repeats forever until break, and break value hands a value out of it.
You will write this in Rust next. The types in the signature say what Python only implied: three f64 in, one f64 out.
Exercise 2.1
calc_pae in Rust
To do
Write calc_pae(p_out_dbm: f64, p_in_dbm: f64, p_dc_w: f64) -> f64, returning PAE in percent, or 0.0 when p_dc_w <= 0.0. 10f64.powf(x) is 10 to the power x.
if p_dc_w <= 0.0 {
return 0.0;
}
let p_out_w = 10f64.powf(p_out_dbm / 10.0) / 1000.0;
Do the same for p_in_w, then end with (p_out_w - p_in_w) / p_dc_w * 100.0.
Tried it? Reading the answer after a real attempt is how this sticks.
drain_ma(vg) below fakes a transistor. Write find_vg(target_ma: f64) -> f64 that starts at −3.0 V and steps up by 0.01 V until drain_ma(vg) >= target_ma, then returns that vg. Give up at 0.0 V and return 0.0. Use loop with break vg, or while.
let mut vg = -3.0;
loop {
if drain_ma(vg) >= target_ma { break vg; }
if vg >= 0.0 { break 0.0; }
vg += 0.01;
}
The loop is the last expression, so its break value is returned.
Tried it? Reading the answer after a real attempt is how this sticks.
With the semicolon the body ends in a statement and returns (), not an i32. Drop the semicolon, or write return x * 2;.
Day 1 · Saturday · Lesson 3 · about 65 min
Ownership and borrowing
The idea Rust is built on: String versus &str, moves, clones, & and &mut.
In Python, two names can point at the same list and both change it; lesson 10 of the Python course showed the bug that causes. Rust prevents it with three rules the compiler enforces:
Every value has exactly one owner (a variable).
When the owner goes out of scope, the value is freed. No garbage collector, no leaks.
You can lend a value out as many read-only borrows (&x) as you like, or one writable borrow (&mut x), never both at once.
Assigning or passing a heap value like a String or Vecmoves it: the old name can no longer be used. Numbers and other small Copy types are copied instead, so you only meet moves with bigger things.
Try it
Ctrl+Enter runs
Text comes in two forms.String is owned text you can grow. &str is a borrowed view of text that lives somewhere else; string literals like "OUTP ON" are &str. A function that only reads text should take &str; it then accepts both.
Borrowing lets a function use a value without taking it: fn len_of(s: &str) or fn total(v: &[f64]) (a borrowed slice of numbers). To let a function change your value, lend it mutably: fn add_point(v: &mut Vec<f64>), called as add_point(&mut readings).
Try it
Ctrl+Enter runs
Exercise 3.1
Clean an instrument reply
To do
Write parse_reading(reply: &str) -> f64 that trims spaces and newlines and parses the number. reply.trim() gives a &str without them, and .parse::<f64>().unwrap() turns it into a number (crashing on bad text, which lesson 6 fixes).
reply.trim().parse::<f64>().unwrap()
Tried it? Reading the answer after a real attempt is how this sticks.
Write add_suffix(names: &mut Vec<String>, suffix: &str) that appends suffix to every name in place. Loop with for n in names.iter_mut() and use n.push_str(suffix). Compare with the Python course's deepcopy bug: here the compiler would not let two loops share the same list.
for n in names.iter_mut() {
n.push_str(suffix);
}
Tried it? Reading the answer after a real attempt is how this sticks.
fn add_suffix(names: &mut Vec<String>, suffix: &str) {
for n in names.iter_mut() {
n.push_str(suffix);
}
}
Quick check
Which call lets log add lines to your Vec<String> called lines?
Changing it needs a mutable borrow. &lines is read-only, a clone changes the copy, and passing lines moves it away.
Day 1 · Saturday · Lesson 4 · about 60 min
Structs and methods
struct, impl, new, &self and &mut self: a signal generator driver.
A struct groups named fields, like the data half of a Python class. Behaviour goes in a separate impl block. There is no inheritance; lesson 8 shows what Rust uses instead.
fn new(...) -> Self is the conventional constructor. It is an ordinary function, called as SignalGenerator::new().
Methods take &self to read the struct, &mut self to change it, or self to consume it. This is Python's self with the borrowing rules made explicit.
#[derive(Debug)] above a struct lets {:?} print it.
In Rust the fields are declared once with their types, and the compiler knows which methods may change them.
Try it
Ctrl+Enter runs
Exercise 4.1
A signal generator driver
To do
Complete SignalGenerator. It records every command in sent:
new() starts with an empty sent
set_frequency(&mut self, hz: f64) records :SOUR:FREQ <hz as a whole number> (use hz as u64)
set_power(&mut self, dbm: f64) records :SOUR:POW <dbm with 2 decimals>
set_rf(&mut self, on: bool) records :OUTP ON or :OUTP OFF
SignalGenerator { sent: Vec::new() } in new. Then self.sent.push(format!(":SOUR:FREQ {}", hz as u64));. For the RF switch, let s = if on { "ON" } else { "OFF" };.
Tried it? Reading the answer after a real attempt is how this sticks.
Write Keithley with a field ranges: Vec<f64> and a method pick_range(&self, compliance_a: f64) -> f64 that returns the smallest range at least compliance_a, or the largest range if none is big enough (the ranges are sorted). Then two constructors: k2420() with ranges [1e-3, 10e-3, 100e-3, 1.0, 3.0] and k2440() with [1e-3, 10e-3, 100e-3, 1.0, 5.0].
Loop for &r in &self.ranges { if r >= compliance_a { return r; } } and end with *self.ranges.last().unwrap(). Constructors: Keithley { ranges: vec![1e-3, 10e-3, 100e-3, 1.0, 3.0] }.
Tried it? Reading the answer after a real attempt is how this sticks.
A method only reports a value and never changes the struct. Which receiver should it take?
&self borrows read-only, so callers can use it on a value they have not marked mut.
Day 2 · Sunday · Lesson 5 · about 60 min
Enums, match and Option
Replacing -999.0 and NaN with types: enum, match, Option, if let.
The Python bench marks a missing reading with -999.0 or NaN, and the walkthroughs spend pages on making sure nobody mistakes those for real data. Rust removes the problem with types.
An enum is a value that is exactly one of several variants, and variants can carry data. match handles every variant, and the compiler refuses to build if you forget one.
Try it
Ctrl+Enter runs
The standard library's Option is that pattern made universal: Some(value) or None. A function that might have no answer returns Option<f64>, and the caller cannot use the number without first deciding what None means. There is no -999.0 to forget about.
match opt { Some(x) => ..., None => ... }
if let Some(x) = opt { ... } when you only care about one case
opt.unwrap_or(0.0) for a default
opt.is_some(), opt.is_none()
The Python versiondrivers.py, line 144
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
The sentinel and a separate flag carry the meaning here. In Rust both fit in the return type: Option<(f64, bool)>, the value and whether it came from the fallback sensor.
Exercise 5.1
Read with fallback
To do
Write read_with_fallback(primary: Option<f64>, secondary: Option<f64>) -> Option<(f64, bool)>. Return the primary reading with false; if it is None, the secondary with true; if both are None, return None.
Write verdict(value: Option<f64>, min: Option<f64>, max: Option<f64>) -> Verdict using the enum given. No reading is Fail(String::from("no valid reading")). Below min is Fail(format!("{v} < min {lo}")), above max Fail(format!("{v} > max {hi}")) (check min first). Otherwise Pass. A limit that is None is not checked.
let v = match value {
Some(v) => v,
None => return Verdict::Fail(String::from("no valid reading")),
};
if let Some(lo) = min {
if v < lo { return Verdict::Fail(format!("{v} < min {lo}")); }
}
Then the same for max, and finish with Verdict::Pass.
Tried it? Reading the answer after a real attempt is how this sticks.
#[derive(Debug, PartialEq)]
enum Verdict {
Pass,
Fail(String),
}
fn verdict(value: Option<f64>, min: Option<f64>, max: Option<f64>) -> Verdict {
let v = match value {
Some(v) => v,
None => return Verdict::Fail(String::from("no valid reading")),
};
if let Some(lo) = min {
if v < lo {
return Verdict::Fail(format!("{v} < min {lo}"));
}
}
if let Some(hi) = max {
if v > hi {
return Verdict::Fail(format!("{v} > max {hi}"));
}
}
Verdict::Pass
}
Quick check
You add a Reading::Timeout variant. What happens to the describe function above?
match must be exhaustive. The compiler lists every match that needs the new case, which is how Rust makes "forgot to handle it" a build error rather than a bench surprise.
Day 2 · Sunday · Lesson 6 · about 55 min
Errors with Result
Result, the ? operator, parse errors, and writing your own error type.
Python raises exceptions; Rust returns them. A function that can fail returns Result<T, E>: Ok(value) or Err(error). Like Option, you cannot reach the value without deciding what to do with the error.
The ? operator is the shortcut: let v = text.parse::<f64>()?; means "if this is an error, return it from my function now; otherwise give me the value". It is Python's let-it-propagate behaviour, but visible in the code and in the function's type.
.unwrap() takes the value and crashes on an error. It is fine in quick tests and wrong in bench code.
Try it
Ctrl+Enter runs
map_err converts one error into another, here a parse error into a readable String. For larger programs you define an error enum so callers can match on what went wrong, exactly like the Python engine's except ScopeNoTriggerError:.
Exercise 6.1
Parse *IDN?
To do
Write parse_idn(reply: &str) -> Result<Idn, String> that splits maker,model,serial,firmware on commas, trims each part, and fills the struct. If there are not exactly four parts, return Err(format!("bad IDN reply: {reply:?}")) with the reply exactly as given.
let parts: Vec<&str> = reply.trim().split(',').map(|p| p.trim()).collect(); then if parts.len() != 4 { return Err(...); } and build Idn { maker: parts[0].to_string(), ... } inside Ok(...).
Tried it? Reading the answer after a real attempt is how this sticks.
Write read_power(reply: Option<&str>) -> Result<f64, MeterError> using the error enum given: None (the meter never answered) is Err(MeterError::Timeout); text that does not parse is Err(MeterError::BadReply(text.to_string())) with the original text; a value of 9.9e37 or more (the instrument's "measurement unavailable" code) is Err(MeterError::Unavailable); otherwise Ok(value).
let text = reply.ok_or(MeterError::Timeout)?;
let v: f64 = text.trim().parse().map_err(|_| MeterError::BadReply(text.to_string()))?;
Then check v >= 9.9e37 and finish with Ok(v).
Tried it? Reading the answer after a real attempt is how this sticks.
#[derive(Debug, PartialEq)]
enum MeterError {
Timeout,
BadReply(String),
Unavailable,
}
fn read_power(reply: Option<&str>) -> Result<f64, MeterError> {
let text = reply.ok_or(MeterError::Timeout)?;
let v: f64 = text
.trim()
.parse()
.map_err(|_| MeterError::BadReply(text.to_string()))?;
if v >= 9.9e37 {
return Err(MeterError::Unavailable);
}
Ok(v)
}
Quick check
What does ? do on an Err inside a function returning Result?
? is early return for errors. unwrap is the one that crashes.
Day 2 · Sunday · Lesson 7 · about 60 min
Collections, iterators and closures
Vec, HashMap, closures, and the iterator chains that replace most loops.
Vec<T> is Rust's list and HashMap<K, V> its dict. Most loops over them are written as iterator chains: .iter() to walk, then adapters such as .map(), .filter(), .enumerate(), .zip(), and a final step like .sum(), .count(), .max_by() or .collect().
The |r| r.gain parts are closures: small unnamed functions, Python's lambda. They can use variables from around them.
Try it
Ctrl+Enter runs
Floats have no total ordering because of NaN, so .max() does not work on f64. Use .max_by(|a, b| a.total_cmp(b)) or f64::max in a fold. This is Rust making you decide about NaN up front, the same problem the Python code solved with x == x.
The Python versionmain_gui.py, line 3749
exports.append(([r for r in self.data_summary if r.get('Test') == test], sfx))
A list comprehension becomes data.iter().filter(|r| r.test == test).collect::<Vec<_>>().
Exercise 7.1
Summarise a sweep
To do
Write summary(rows: &[Row]) -> (f64, f64, usize) returning the mean gain, the gain spread (max − min), and how many rows are at 11 GHz or above. Assume at least one row. Use iterator chains, not index loops.
let mean = rows.iter().map(|r| r.gain).sum::<f64>() / rows.len() as f64;let max = rows.iter().map(|r| r.gain).fold(f64::MIN, f64::max); and the same with f64::MAX, f64::min. let n = rows.iter().filter(|r| r.freq >= 11.0).count();
Tried it? Reading the answer after a real attempt is how this sticks.
struct Row { freq: f64, gain: f64 }
fn summary(rows: &[Row]) -> (f64, f64, usize) {
let mean = rows.iter().map(|r| r.gain).sum::<f64>() / rows.len() as f64;
let max = rows.iter().map(|r| r.gain).fold(f64::MIN, f64::max);
let min = rows.iter().map(|r| r.gain).fold(f64::MAX, f64::min);
let n = rows.iter().filter(|r| r.freq >= 11.0).count();
(mean, max - min, n)
}
Exercise 7.2
Find P1dB with an iterator
To do
Write p1db(pins: &[f64], gains: &[f64]) -> Option<f64> returning the first input power where the gain has dropped 1 dB or more below the first gain, or None. Use zip and find.
let ss = *gains.first()?; returns None early for an empty sweep. Then pins.iter().zip(gains).find(|(_, &g)| ss - g >= 1.0).map(|(&p, _)| p).
Tried it? Reading the answer after a real attempt is how this sticks.
fn p1db(pins: &[f64], gains: &[f64]) -> Option<f64> {
let ss = *gains.first()?;
pins.iter()
.zip(gains)
.find(|(_, &g)| ss - g >= 1.0)
.map(|(&p, _)| p)
}
Quick check
Why does vec![1.5, 2.5].iter().max() not compile?
max needs Ord, and NaN breaks ordering for floats. max_by with total_cmp, or a fold with f64::max, makes the choice explicit.
Day 2 · Sunday · Lesson 8 · about 80 min
Traits and the capstone
Traits instead of inheritance, generics and dyn, then a mini test engine.
Rust has no class inheritance. A trait lists methods a type promises to provide, and any struct can implement it. Code then works with "anything that is an Instrument" rather than with one class tree.
impl Instrument for FakeInstrument { ... } provides it
fn configure<I: Instrument>(inst: &mut I) is generic: one copy compiled per concrete type, as fast as hand-written code
Box<dyn Instrument> holds any instrument chosen at run time, like Python's duck typing but checked
A trait can supply default methods built from the required ones, which covers most of what the Python mixins do
The Python versiondrivers.py, line 144
class DualSensorMixin:
def _read_with_fallback(self, primary, secondary):
val = self._get_reading(primary)
...
def read_sensor_A(self):
return self._read_with_fallback(1, 2)
The mixin calls _get_reading, which only the real driver defines, and Python finds out at run time whether it exists. A Rust trait lists get_reading as required, so a driver that forgets it does not compile.
Try it
Ctrl+Enter runs
Capstone
Capstone: a mini test engine
To do
Put the weekend together. The trait Bench (a generator and a meter in one) and a fake are given. Write run_sweep<B: Bench>(bench: &mut B, freqs: &[f64], pin: f64, min_gain: f64, log: &mut Vec<String>) -> Vec<Row>:
turn RF on, then for each frequency: set_freq_ghz(f), read pout = bench.read_pout() (an Option<f64>)
the row's gain is pout - pin when there is a reading, None otherwise
pass is true only when there is a gain and it is at least min_gain
push one log line per point (any text) and collect the rows
after each read, if bench.fault() is true, push "ABORT" to the log and stop the sweep without making a row for that point
turn RF off at the end, always, including after a fault
bench.rf(true);
let mut rows = Vec::new();
for &f in freqs {
bench.set_freq_ghz(f);
let pout = bench.read_pout();
if bench.fault() {
log.push("ABORT".to_string());
break;
}
let gain = pout.map(|p| p - pin);
let pass = gain.map_or(false, |g| g >= min_gain);
log.push(format!("{f} GHz: {gain:?}"));
rows.push(Row { freq: f, gain, pass });
}
bench.rf(false);
rows
Because nothing in the loop can return early, the line after it always runs: the Rust version of Python's finally.
Tried it? Reading the answer after a real attempt is how this sticks.
#[derive(Debug, PartialEq)]
struct Row { freq: f64, gain: Option<f64>, pass: bool }
trait Bench {
fn rf(&mut self, on: bool);
fn set_freq_ghz(&mut self, f: f64);
fn read_pout(&mut self) -> Option<f64>;
fn fault(&self) -> bool;
}
struct FakeBench { readings: Vec<Option<f64>>, i: usize, pub sent: Vec<String>, fault_at: Option<usize> }
impl Bench for FakeBench {
fn rf(&mut self, on: bool) { self.sent.push(format!("RF {}", if on { "ON" } else { "OFF" })); }
fn set_freq_ghz(&mut self, f: f64) { self.sent.push(format!("FREQ {f}")); }
fn read_pout(&mut self) -> Option<f64> { let r = self.readings.get(self.i).copied().flatten(); self.i += 1; r }
fn fault(&self) -> bool { self.fault_at.map_or(false, |k| self.i > k) }
}
fn run_sweep<B: Bench>(bench: &mut B, freqs: &[f64], pin: f64, min_gain: f64, log: &mut Vec<String>) -> Vec<Row> {
bench.rf(true);
let mut rows = Vec::new();
for &f in freqs {
bench.set_freq_ghz(f);
let pout = bench.read_pout();
if bench.fault() {
log.push("ABORT".to_string());
break;
}
let gain = pout.map(|p| p - pin);
let pass = gain.map_or(false, |g| g >= min_gain);
log.push(format!("{f} GHz: gain {gain:?} {}", if pass { "PASS" } else { "FAIL" }));
rows.push(Row { freq: f, gain, pass });
}
bench.rf(false);
rows
}
Quick check
When would you use Box<dyn Instrument> instead of a generic <I: Instrument>?
Generics are resolved at compile time. A list of whatever instruments auto_connect found needs dyn, which picks the method at run time like Python does.
After the weekend
Rust on your machine
Install the toolchain, then keep going with the standard resources.
1. Install
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo new bench_tools && cd bench_tools
cargo run # build and run src/main.rs
cargo test # run #[test] functions
cargo clippy # the linter: very good at teaching idiomatic Rust
2. Read and practise
The Rust Programming Language ("the book"): chapters 4 (ownership), 6 (enums), 9 (errors) and 10 (traits) go deeper on this weekend.
Rustlings: small exercises that run on your machine, in the same spirit as this page.
Port evaluate_limits and get_cal_offset to Rust with Option instead of -999.0, and write #[test]s for them.
Talk to a real instrument: the visa-rs crate wraps NI-VISA, or open a raw TCP socket to an instrument's port 5025 with std::net::TcpStream and send *IDN?.
Embedded Rust: the Embedded Rust Book and probe-rs run Rust on the same Cortex-M chips as the Assembly course.
Reference
Cheat sheet
The syntax from the course on one page.
Variables
let x = 5; // fixed
let mut n = 0; // changeable
let f: f64 = 1.5; // with a type
Print and format
println!("{} {:.3} {:+.2}", a, b, c);
let s = format!("{x:>8}");
Function
fn gain(pout: f64, pin: f64) -> f64 {
pout - pin // last expression is returned
}
if as a value
let s = if on { "ON" } else { "OFF" };
Loops
for i in 0..n { }
for x in &list { }
while cond { }
let v = loop { break 42; };
Text
let s: String = String::from("x");
fn f(t: &str) { } // borrow text
t.trim().split(',')