Qt Threading Architecture¶
The threading model is the deepest stratum in the codebase — a six-phase story from a template-copied monolith to a proper per-device worker hierarchy. Three diagrams follow; the historical narrative is below them.
1 · Runtime ownership¶
Who owns what, and in which thread it executes.
Diagram source (Graphviz)
This diagram is rendered from Graphviz DOT rather than Mermaid. Mermaid + Dagre produced unstable edge routing and stretched subgraphs in MkDocs Material; Graphviz gives a fixed left-to-right layout that stays consistent across builds.
Edit docs/assets/graphviz/runtime_architecture.dot, then regenerate:
Edge key: solid = ownership · dashed = Qt queued signals or dormant registry link · bold = DirectConnection · dotted = hardware bus links
Key structural notes:
MainAppuses multiple inheritance (QObject + UIWindow) — a pattern inherited fromechelle_spectra.UIWindowis a mixin inmainView.pythat keeps layout code separate while sharingself.*attribute space.- Worker threads are stored in
self.workers[name]["worker"]/["thread"].MainAppowns allQThreadlifetimes. - The MCP4725 sits on a separate, galvanically isolated I²C bus since April 2026 — plasma transients on bus A were corrupting the ADC and DAC8532.
- The
MAX6675/ heater path is instantiated in code but its entry indefine_devicesis commented out. The code is dormant, not deleted.
2 · Acquisition data flow¶
The ownership diagram above covers the core Qt workers. From 4.15.0 an
optional KikusuiLogger, owned separately by MainApp._kikusui_logger,
runs in a Python thread alongside them. It owns its read-only TCP socket
and separate run CSV; its only input from the application is a copy of the
locked RigStatus setpoints. Messages cross to the main thread through
kikusui_message, a queued Qt signal. It never touches a widget or a hardware
worker. ADC delivery does not wait for LAN reads, and telemetry continues
through an ADC retry. The hardware workers and GPIO shut down before the
main thread interrupts and joins this optional recorder. Socket deadlines
and interruptible waits bound its shutdown; a recorder that has not stopped
blocks a second telemetry start rather than overlapping files/connections.
The ADC thread follows monotonic sampling deadlines. Below one second it
records one scan per period; at one second and above it averages raw scans
through each window. STEP only batches completed rows for delivery to the
GUI thread for CSV logging and plotting. Timing summaries distinguish read,
processing and delivery costs; see the timing investigation.
flowchart LR
subgraph ADC_LOOP["ADC.acquisition_loop — QThread"]
SLEEP["wait until deadline\nwork inside period"]
COLLECT["collect_data\nadc_setter\nN channels\nPCA9554 mux"]
APPEND["append raw + converted rows\nplain bounded buffers"]
STEP_G{"buffer length\n>= STEP ?"}
PID_G{"Ip setpoint\n≠ 0 ?"}
PID_CALC["simple_pid\np=0.3 i=0.1 d=0\noutput 0–4500 mV\nbaseline 1000 mV"]
end
subgraph MFC_SIDE["DAC8532 worker — QThread"]
MFC_OP["set MFC voltage\nDAC8532Setter"]
MFC_SIG["send_presets_to_adc\nDirectConnection\nupdate_mfcs()"]
end
subgraph MAIN_SIDE["MainApp — Qt Main Thread"]
ON_STEP["on_worker_step\n_adc_step\ndatadict append"]
CSV_W["save_data\nCSV append\ncu_YYYYMMDD_HHMMSS.csv\nself-describing header"]
PLOT_W["graph.update\npyqtgraph\nplasma + pressure"]
SYNC["trigger_signal.py\nGPIO 26 edge\nQMS_signal col logged"]
end
WPC["MCP4725 worker\ndevices/mcp4725.py\ncathode supply"]
DORMANT["MAX6675 — NOT started\nheater PID dormant\ntemperature migrated\nto NI Windows"]
SLEEP --> COLLECT --> APPEND
APPEND --> PID_G
PID_G -- "yes" --> PID_CALC
PID_CALC -- "send_control_voltage\nQt queued → main.py\n_set_cathode_current" --> WPC
PID_G -- "no" --> STEP_G
APPEND --> STEP_G
STEP_G -- "n < STEP\naccumulate completed rows" --> SLEEP
STEP_G -- "n >= STEP\none typed DataFrame\nQt queued signal" --> ON_STEP
ON_STEP --> CSV_W
ON_STEP --> PLOT_W
ON_STEP --> SYNC
MFC_OP --> MFC_SIG
MFC_SIG -- "PresetV_mfc1/2\nper-row in CSV" --> APPEND
style DORMANT fill:#181818,stroke:#4a4a4a,stroke-dasharray:6 3,color:#606060
Batching amortises signal and main-thread costs; it does not average rows.
Averaging is governed independently by AVERAGE_FROM_SECONDS (1 s) and
INNER_SECONDS (0.2 s). Completed buffered rows are emitted when the worker
stops; interrupted scans and partial averaging windows are discarded.
# controlunit/devices/device.py
def set_sampling_time(self, sampling_time):
if sampling_time >= 0.9: self.STEP = 1
if sampling_time < 0.9: self.STEP = 3
if sampling_time < 0.1: self.STEP = 5
The send_presets_to_adc worker→worker signal uses DirectConnection (runs
in the emitter's thread) rather than the default queued connection, because
update_mfcs only writes to self._mfc_presets — a plain dict that never
touches Qt internals.
3 · Shutdown and safety sequence¶
Hardware safety is the primary concern: DAC outputs must reach zero before
threads die. The pattern is idempotent — turn_off_voltages can be called
multiple times safely, including when self.workers is empty.
sequenceDiagram
actor User
participant App as MainApp
participant ADC as ADC Worker
participant PC as MCP4725 Worker
participant MFCs as DAC8532 Worker
participant HW as Hardware DACs
User->>App: closeEvent() or stop button
App->>App: abort_all_threads()
rect rgb(40, 30, 10)
Note over App,HW: turn_off_voltages() — hardware first
Note over App: guard: if not self.workers → return early
App->>ADC: set_plasma_current.emit(0)
ADC->>HW: PID setpoint → 0
App->>PC: output_voltage_signal.emit(0)
PC->>HW: MCP4725 → 0 mV
App->>App: _mfc_presets = {1: 0, 2: 0}
App->>MFCs: output_voltage_signal.emit(1, 0)
App->>MFCs: output_voltage_signal.emit(2, 0)
MFCs->>HW: DAC8532 ch1 + ch2 → 0 V
Note over HW: all DAC outputs at zero — plasma and MFCs off
end
rect rgb(10, 25, 40)
Note over App,MFCs: thread teardown
loop each worker in self.workers
App->>ADC: worker.running = False
App->>MFCs: worker.running = False
App->>PC: worker.running = False
end
App->>ADC: thread.quit() · thread.wait()
App->>MFCs: thread.quit() · thread.wait()
App->>PC: thread.quit() · thread.wait()
end
Note over App: self.workers cleared — safe to call again
This sequence is the product of being bitten: _mfc_presets is zeroed in
multiple places, and turn_off_voltages is callable any time including before
workers are started. The hardware stop always precedes the software stop.
thread.wait() lasts as long as the worker takes to notice its abort flag.
The ADC and thermocouple loops used to sleep one whole sampling period
between steps and look at the flag only afterwards, so at the rig's
ten-second sampling the quit button came back ten seconds after Stop (owner
report 2026-09-07). A worker now sleeps through DeviceThread.pause, which
looks at the flag every tenth of a second (sleep_unless_aborted in
devices/device.py), and a sleeping worker notices Stop within that. A hardware read can take
longer: the ADC checks abort between channels, but a blocked kernel I²C call
cannot be interrupted by the Python polling deadline.
"Haha — yes, guilty. It may still be somewhere in the Qt signals." — Arseniy
Six phases of threading evolution¶
Phase 0 — The Echelle template (pre-2020)¶
The Worker(QtCore.QObject) shape, the ThreadType enum dispatch, the
STEP-batched numpy buffers, the app.processEvents() from inside the
worker, and the sys.path.append package hack were all ported from
echelle_spectra — Arseniy's earlier spectrograph-control application.
Ito-kun did not invent this shape; he extended it.
The _echelle_base variable in controlunit/__init__.py is a literal
fossil — the variable name was never changed after the copy.
Phase 1 — Monolithic worker (Feb 2020)¶
Initial commit: one Worker(QtCore.QObject) class for all devices,
dispatched by a ThreadType enum. Methods named __plotPresCur and
__plotT. Buffers are fixed-shape numpy arrays of STEP rows.
Ito-kun's extension (B4 student) introduced two patterns that became technical debt:
- A fresh I²C connection opened on every channel read — hurt acquisition throughput badly on a multi-channel scan.
- Device behaviour dispatched by
ThreadTypeenum, not by separate objects — impossible to trace which code path talked to which physical device.
Phase 1.5 — Untangling (Mar 2020)¶
Commit ed7cadb: +161/−107 in worker.py. Renames ThreadType → Signals,
factors read_settings() out of every constructor, renames methods to
readADC / readT. Threading topology unchanged; vocabulary becomes
consistent.
Phase 2 — Package + pdoc3 docs (Jun 2022)¶
Commit 4b7dcdc: files moved into controlunit/ directory. No structural
change. pdoc3 generates HTML for the then-current shape. That snapshot is
archived under archive/pdoc3/.
Phase 3 — ADC tuning storm (May 2023)¶
Eight commits rewrite the acquisition loop to read from AdcChannelProps
populated from settings.yml instead of hard-coded constants. Numpy arrays
replaced with pandas DataFrames. STEP batching clarified.
The historical batching code accumulated samples between worker and GUI
updates. Current STEP batching and per-period averaging are independent;
see the acquisition data flow above.
Phase 4 — Worker superclass split (Aug 2024)¶
Commit 5326e50: 662 lines deleted from controlunit/worker.py, replaced
with sensors/{worker.py, worker_adc.py, worker_dac8532.py, …}. Committed
by Miura-kun directly from the lab Raspberry Pi (pi <hasuo_kuzmin.lab@…>).
"When Miura-kun was here I thought about transitioning to pandas for sanity. I finally got what classes are: basically a box, a drawer. So you don't spill and lose your functions." — Arseniy
Phase 4.5 (Sep 2024): 10-day burst of renames — sensors/ → devices/,
components/ → ui/, terminology unified. Behaviour untouched; vocabulary
became consistent.
Phase 5 — Codex PRs (Aug 2025)¶
Two LLM-authored PRs (#20, #21):
- Moved
update_processed_signals_dataframeout ofmain.pyinto workers. - Cleaned the (still-unused)
core_logic.pystub.
These were not tested on hardware at merge time. The developer considered the Codex PR workflow an experiment, and moved to Cursor + direct on-rig testing afterward.
Phase 6 — Isolation hardware push (Apr 2026)¶
Commits 0b417cf and dfbc65c: galvanic I²C isolation for the MCP4725
plasma-current DAC. Kawabata-kun's plasma PID work landed alongside.
The isolation was critical: plasma transients on the cathode bus were affecting the rest of the I²C tree.
"Kawabata-kun did the final plasma current PID loop. I made and tested one before isolation. Isolation was critical, of course." — Arseniy
The UIWindow multiple-inheritance idiom¶
Unusual for Qt code. Inherited from echelle_spectra. Keeps layout code in
mainView.py physically separated from controller code without giving up
direct attribute access (self.control_dock, self.graph, etc.).
Worker→worker signalling¶
# controlunit/main.py
def start_cross_connections(self):
mfcs_worker.send_presets_to_adc.connect(
adc_worker.update_mfcs, type=QtCore.Qt.DirectConnection
)
The DAC8532 worker tells the ADC worker what voltage it just set, so the ADC
can log the commanded preset alongside the measured signal.
DirectConnection runs the slot in the emitter's thread — correct because
update_mfcs only touches self._mfc_presets.