
Get the complete source, dependencies and setup instructions from the template repository.
Open the source on GitHub ↗Introduction
Magnetorquer Acceptance Overview
A magnetorquer is a coil on a high-permeability core. Pass a current through it and it becomes a magnet whose moment, crossed with the Earth's field, is the torque a small satellite uses to dump momentum and to point when the wheels are off. The attitude control law assumes a moment proportional to the current, with a known gain, a known sign along the rod's marked axis, and nothing left when the current is cut. The acceptance test measures those four things, plus the two electrical numbers that say the coil is whole and insulated from the case.

The rod in its cradle with the fluxgate on the axis at a fixed distance. The distance is the one number that scales every dipole reading, so it is machined into the fixture and written to the unit metadata.
The moment is read as a field. On the axis of a dipole, B = (μ0 / 2π) m / r³, so 1 A·m² at 0.5 m is 1600 nT, and the sensor reads that on top of whatever the room and the Earth give it, which is 50 000 nT. A Helmholtz cage nulls the ambient field to a few tens of nanotesla, and the zero-current reading is subtracted as the residual. The vendor sheets this rod class is modelled on (NewSpace Systems, ZARM, CubeSpace) quote the dipole at rated current, a linearity figure, the remanent dipole with the current off, the coil resistance and the insulation to the case; none of these come from a standard. The remanent dipole is the one that matters most and is the least advertised: the core keeps a fraction of a percent of the rated moment after a pulse, and that is a permanent disturbance torque the attitude model has to carry.
Test Purpose
The procedure records one acceptance record per rod:
- Cage residual with the rod unpowered, and the rod's axis marking, before anything else
- Coil resistance four-wire and insulation to the case at 500 V DC
- A current sweep from -150 to +150 mA, the axial field at each step converted to a dipole, judged on the value at rated current, the gain of the straight fit, the worst deviation from it, and the polarity against the marking
- The remanent dipole with the current cut after a rated pulse

The mock rod's sweep: 1.027 A·m² at +150 mA, a gain of 6.87 A·m²/A, a worst deviation of 0.70 % of rated as the core begins to saturate at the ends, and 4.3 mA·m² of remanence after the positive pulse. A rod with a cracked core reads low and bends early; a rod wired backwards reads a perfect curve of the wrong sign, which is what the polarity measurement exists for.
The framework mechanics on show are a physical conversion done in the phase so the record stores the dipole rather than the field, three aggregations on one curve (a value, a slope and a deviation), a string measurement validated with ==, a facility floor validated in setup: and written to the unit metadata, a tolerance window as two validators, and a zero reference re-read in the phase that needs it.
Equipment & Setup
To accept magnetorquers on a production bench, the following are required:
- A three-axis Helmholtz cage with bipolar supplies that null the ambient field at the sensor
- A fluxgate magnetometer on the rod's axis at a fixed, measured distance
- A precision current source for the coil, a micro-ohm meter and an insulation tester through a switch box
- The Device Under Test (DUT): a 1 A·m² torque rod, 5 V, 150 mA rated
- A TofuPilot Framework procedure to sequence the cage check, the electrical tests, the sweep and the remanence reading
- The TofuPilot Dashboard to trend gain, linearity and remanence across the lot
Hardware Components
Helmholtz Cage and Fluxgate
A cage of three nested coil pairs, driven by three bipolar supplies, cancels the Earth's field in a volume large enough to hold the rod and the sensor. The residual it leaves is the floor of the measurement, and the procedure reads it in setup and again as the zero of every sweep. A Bartington Mag-03 or Mag-13 fluxgate read through a Spectramag or an NI DAQ resolves well under a nanotesla, which against 1600 nT per A·m² at 0.5 m is a resolution of a fraction of a milli-ampere-square-metre. What limits the measurement in practice is the cage's stability over the minute the sweep takes, which is why the sweep is short and the zero is read right before it.

The bench: the rod and the sensor at the centre of the cage, the source and the meters beside it. Nothing ferromagnetic goes on the stand.
Source and Meters
A Keithley 2450 drives the coil with the sweep currents and reads them back. A Hioki RM3545 reads the coil four-wire, which on a 33 Ω coil is not about resolution but about the connector and cable resistance a two-wire reading would add. A Megger MIT applies 500 V DC between the coil and the case for the insulation reading. A switch box routes the coil between the three instruments so the rod is connected once.
Where the Limits Come From
The rated dipole, the gain, the 2 % linearity, the 10 mA·m² remanence, the 33 Ω coil with 5 % and the 100 MΩ insulation are the rod's data sheet. The 50 nT cage residual is what the facility achieves and what the dipole numbers are only as good as. A customer sets the remanence limit from the attitude control budget, because that is the disturbance the controller carries for the mission, and tightens the linearity from the distribution the dashboard shows after a few dozen rods.
Test Procedure
Overview
The procedure maps the acceptance test onto the framework's three stages. The cage check lives in setup: so no sweep is read against a bad zero. The three main phases are chained with depends_on because they share the rod, the source and the sensor. Cutting the current and reading the field one last time live in teardown: so the next rod starts from a known cage.
- Setup: rod marking, sensor distance on the metadata, cage residual.
- Main: coil resistance, insulation at 500 V.
- Main: sweep from -150 to +150 mA, dipole at rated, gain, linearity, polarity.
- Main: remanent dipole after a rated pulse.
- Teardown: current off, field at the sensor.
Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot for per-serial trending across the lot.
Why TofuPilot Framework?
TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:
- Automatic Python environment management (via
uv) - Operator UI (no frontend code needed)
- Measurement validation and live charts
- Process isolation between phases and equipment plugs
Project Structure
You can find the full source on GitHub. The TorquerBench plug is a mock of the cage, the fluxgate, the source and the meters together, synthesizing a healthy rod with a gain of about 7 A·m²/A, a core that begins to saturate gently at the ends of the sweep, 0.6 % remanence after a rated pulse, a 33 Ω coil, over 1 GΩ of insulation and a cage that nulls to 12 nT, so the procedure runs end-to-end without a cage or a rod connected.
tofupilot run .For CI or bench automation, the same run executes headless:
tofupilot run . --no-tui --no-kiosk --jsonThe Procedure File
procedure.yaml declares the unit, the bench plug, and the three stages with every measurement and limit:
procedure.yaml · 109 lines
name: Magnetorquer Dipole Moment Testversion: 0.1.0description: Acceptance of a 1 A·m² torque rod in a Helmholtz cage. Cage residual and rod marking in setup, coil resistance and 500 V insulation, a current sweep from -150 to +150 mA with the axial field converted to dipole through the on-axis law and judged on the value at rated current, the straightness, the gain and the polarity, then the remanent dipole with the current cut.unit: auto_identify: true serial_number: description: "Scan the rod's label" placeholder: "MTQ-1-0000" pattern: "^MTQ-1-\\d{4}$" default_value: "MTQ-1-0416" part_number: default_value: "MTQ-1-5V" batch_number: default_value: "LOT-2026-09"plugs: - name: Torquer Bench description: "Helmholtz cage, fluxgate on the rod's axis, coil current source, micro-ohm meter, insulation tester (mock, one plug per bench)" python: plugs.torquer_bench:TorquerBench key: benchsetup: - name: Identify key: identify python: phases.identify measurements: - name: Rod Marking key: rod_marking validators: - {operator: matches, expected_value: "^MTQ-1-[XYZ]$"} - name: Cage Residual key: cage_residual_nt unit: nT description: Field left at the sensor with the cage nulling and the rod unpowered; the floor of every dipole number below. validators: - {operator: "<=", expected_value: 50.0}main: - name: Electrical key: electrical python: phases.electrical measurements: - name: Coil Resistance key: coil_resistance_ohm unit: Ω validators: - {operator: ">=", expected_value: 31.35} - {operator: "<=", expected_value: 34.65} - name: Insulation key: insulation_mohm unit: MΩ validators: - {operator: ">=", expected_value: 100.0} - name: Dipole Sweep key: dipole_sweep python: phases.dipole_sweep depends_on: [electrical] timeout: 10m measurements: - name: Sweep key: sweep title: Dipole moment against coil current, -150 to +150 mA x_axis: legend: Current unit: A y_axis: - legend: Dipole key: dipole unit: A·m² aggregations: - type: at_rated_am2 unit: A·m² validators: - {operator: ">=", expected_value: 0.95} - type: gain_am2_per_a unit: A·m²/A validators: - {operator: ">=", expected_value: 6.3} - type: linearity_pct unit: "%" validators: - {operator: "<=", expected_value: 2.0} - name: Polarity key: polarity description: Sign of the moment at positive current against the rod's +X marking; a rod wired backwards passes every other number. validators: - {operator: "==", expected_value: "+X"} - name: Residual key: residual python: phases.residual depends_on: [dipole_sweep] measurements: - name: Residual Dipole key: residual_am2 unit: A·m² validators: - {operator: "<=", expected_value: 0.010}teardown: - name: Power Off key: power_off python: phases.power_off measurements: - name: Field After key: field_after_nt unit: nTFramework features to notice:
- Field in, dipole out. The instrument reads nanotesla; the phase applies the on-axis law with the sensor distance from the recipe, and the record stores ampere-square-metres. The dashboard trends the number the attitude engineer wants.
- A value, a slope and a deviation on one curve.
sweep.dipolecarriesat_rated_am2,gain_am2_per_afrom the straight fit andlinearity_pctas the worst departure from it, each with its own limit. - A string with
==.polarityis"+X"or"-X"; the validator compares it to the marking. A number would have hidden the question. - A facility floor in setup.
cage_residual_ntis not a property of the rod; it gates the run and goes on the unit metadata with the sensor distance, so every dipole on the record can be traced to its zero and its scale. - Re-read the zero where it is used. Phases run in separate processes and cannot read each other's measurements back, so
residualreads the cage again instead of reaching for the setup value.
Identify
The setup phase reads the rod's marking and the sensor distance, nulls the cage with the rod unpowered, and puts the distance on the unit metadata:
def identify(measurements, bench, unit, log): """Setup: rod marking, sensor distance on the record, cage nulled with the rod unpowered. The dipole numbers are only as good as the field the cage leaves at the sensor.""" ident = bench.identify() measurements.rod_marking = ident["rod_marking"] measurements.cage_residual_nt = bench.cage_null() unit.metadata["sensor_distance_m"] = ident["sensor_distance_m"] log.info(f"Rod {unit.serial_number} marked {ident['rod_marking']}, sensor at {ident['sensor_distance_m']:.2f} m, cage residual {measurements.cage_residual_nt} nT")Electrical
Coil resistance four-wire and insulation to the case at 500 V, before any current goes through the rod:
from utils.recipe import INSULATION_Vdef electrical(measurements, bench, log): """Coil resistance 4-wire and insulation coil-to-case at 500 V DC, before any current goes through the rod.""" r = bench.coil_resistance_ohm() ir = bench.insulation_mohm(INSULATION_V) measurements.coil_resistance_ohm = r measurements.insulation_mohm = ir log.info(f"Coil {r:.3f} ohm, insulation {ir:.0f} Mohm at {INSULATION_V:.0f} V")Dipole Sweep
The zero-current field is read first, then the current is stepped and the field read at each step. The phase subtracts the zero, converts to dipole, fits a straight line, and derives the four judged numbers:
phases/dipole_sweep.py · 34 lines
import numpy as npfrom utils.recipe import (CAGE_RESIDUAL_NT_MAX, MU0_OVER_2PI, POLARITY_AXIS, RATED_CURRENT_A, RATED_DIPOLE_AM2, SENSOR_DISTANCE_M, SWEEP_CURRENT_A)def dipole_sweep(measurements, bench, log): """Current stepped from -rated to +rated, the axial field read at each step and converted to a dipole through the on-axis law. The zero-current field is subtracted as the cage residual. Judged on the dipole at rated current, the straightness of the curve, the gain and the polarity against the rod's marking.""" bench.set_current(0.0) b0 = bench.field_nt() field = [] for amps in SWEEP_CURRENT_A: bench.set_current(amps) field.append(bench.field_nt()) bench.set_current(0.0) b = np.array(field) - b0 m = b * 1e-9 * SENSOR_DISTANCE_M ** 3 / MU0_OVER_2PI # A·m² i = np.array(SWEEP_CURRENT_A) gain, offset = np.polyfit(i, m, 1) resid_pct = 100.0 * np.abs(m - (gain * i + offset)).max() / RATED_DIPOLE_AM2 at_rated = float(m[np.argmin(np.abs(i - RATED_CURRENT_A))]) polarity = "+X" if at_rated > 0 else "-X" measurements.sweep.x_axis = SWEEP_CURRENT_A measurements.sweep.y_axis.dipole = m.round(4).tolist() measurements.sweep.y_axis.dipole.aggregations.at_rated_am2 = at_rated measurements.sweep.y_axis.dipole.aggregations.gain_am2_per_a = float(gain) measurements.sweep.y_axis.dipole.aggregations.linearity_pct = float(resid_pct) measurements.polarity = polarity log.info(f"Dipole {at_rated:.3f} A·m² at {RATED_CURRENT_A * 1000:.0f} mA, gain {gain:.2f} A·m²/A, worst deviation from straight {resid_pct:.2f} % of rated, polarity {polarity} (marking {POLARITY_AXIS})")Residual
The rod is driven to +150 mA, the current is cut, and the field is read against a fresh cage zero. What is left is the core's remanence:
from utils.recipe import MU0_OVER_2PI, RATED_CURRENT_A, SENSOR_DISTANCE_Mdef residual(measurements, bench, log): """Remanent dipole: the rod driven to +rated, the current cut, the field read with the cage still nulling. What the core keeps is a permanent disturbance torque the attitude model has to carry. The zero reference is the cage residual read again here, not the setup value: phases cannot read each other's measurements back.""" bench.set_current(RATED_CURRENT_A) bench.set_current(0.0) b = bench.field_nt() - bench.cage_null() m = b * 1e-9 * SENSOR_DISTANCE_M ** 3 / MU0_OVER_2PI measurements.residual_am2 = float(abs(m)) log.info(f"Residual dipole {abs(m) * 1000:.1f} mA·m² after +{RATED_CURRENT_A * 1000:.0f} mA")Mock Plug
TorquerBench stands in for the cage, the fluxgate, the source and the two meters. It draws a rod whose moment follows a hyperbolic tangent of the current with a small-signal gain near 7 A·m²/A and a knee well above the rated current, a core that keeps 0.6 % of the rated moment after a rated pulse, a 33.2 Ω coil, 2.4 GΩ of insulation, a cage residual of 12 nT and 1.5 nT of sensor noise. Every method returns plain Python types because plug calls cross a JSON boundary; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines.
On a real station, the class reads the fluxgate through the DAQ, drives the cage's three supplies to null the field, speaks SCPI to the Keithley source and the Hioki meter and drives the insulation tester through the switch box. Set SENSOR_DISTANCE_M to the distance measured on the fixture, set CAGE_RESIDUAL_NT_MAX to what the facility achieves, and take the rod's limits from its data sheet. The phases, measurements and limits stay the same.
