ADCL WinSoft (code/adcl_winsoft/)¶
The long-term AeroWare replacement: a PySide6 desktop application that controls the VFD and reads the cDAQ in one process. Built primarily on 2026-05-13; phases F.1–F.6 complete.
Companion notes that are mirrored from the assistant's development memory:
- Project notes → ADCL WinSoft overview
- Project notes → pymodbus quirks
- Project notes → PyInstaller quirks
Tech stack¶
| Component | Purpose |
|---|---|
| Python 3.12 | Language runtime. Pinned for the bundled .exe build. |
| PySide6 (Qt 6) | GUI framework. |
nidaqmx |
NI-DAQmx Python bindings for the cDAQ. |
pymodbus 3.13 |
Modbus/TCP client for the RETA-01. |
pyqtgraph |
Live plotting. |
argon2-cffi |
Admin password hash. |
numpy |
Calibration math. |
| PyInstaller | Packaging — produces a single-file ~65 MB .exe, console=False. |
Directory layout (in this repo)¶
code/adcl_winsoft/
├── README.md
├── DESIGN.md
├── CHANGELOG.md
├── pyproject.toml ← dev install (pip install -e .[dev])
├── adcl_winsoft.spec ← PyInstaller single-file spec
├── deploy/
│ ├── install_dev.cmd ← xcopy source into the Windows venv
│ ├── run_dev.cmd
│ ├── build_exe.cmd
│ ├── build_exe_nopause.cmd
│ ├── init_admin.cmd
│ └── build_exe.ps1
├── release/
│ ├── ADCL WinSoft.exe ← shipped binary
│ └── README.md
├── src/adcl_winsoft/
│ ├── __init__.py
│ ├── __main__.py ← entry point; first-launch dialogs; admin/model prompts
│ ├── app.py ← AppState central hub; owns workers; signal wiring
│ ├── units.py ← SI internal; user-facing display conversions
│ ├── auth/
│ │ └── admin.py ← Argon2id hash + verify with incremental backoff
│ ├── config/
│ │ └── settings.py ← JSON settings + AeroWare-path migration
│ ├── daq/
│ │ ├── cdaq_reader.py ← NI-DAQmx wrapper; simulated fallback
│ │ ├── worker.py ← QThread polling at 5 Hz
│ │ ├── calibration.py ← Bernoulli velocity, PGB matrix, Sutherland viscosity
│ │ └── channel_map.py ← default channel definitions
│ ├── data/
│ │ ├── format.py ← AeroWare-compatible CSV layout
│ │ └── recorder.py ← background CSV writer
│ ├── vfd/
│ │ ├── modbus_client.py ← persistent TCP socket, lock-guarded
│ │ ├── controller.py ← start/stop/E-Stop sequences, calibration constant
│ │ ├── command_worker.py ← async write queue, precondition gating
│ │ ├── worker.py ← async poller
│ │ └── __init__.py
│ ├── resources/fonts/ ← bundled Roboto + RobotoMono
│ └── ui/
│ ├── main_window.py ← QSplitter [sidebar | tabs | right column]
│ ├── theme.py ← 6 palettes via parameterized QSS
│ ├── admin/
│ │ ├── login_dialog.py
│ │ ├── setup_dialog.py
│ │ └── model_setup_dialog.py
│ ├── tabs/
│ │ ├── signals.py
│ │ ├── graphs.py
│ │ ├── vfd.py
│ │ ├── settings.py
│ │ ├── pgb_sting.py
│ │ └── placeholder.py
│ └── widgets/
│ ├── sidebar.py
│ ├── footer_bar.py
│ ├── quick_tunnel.py
│ ├── daq_gate.py
│ ├── control_panel.py
│ ├── collapsible.py
│ └── signal_list.py
├── tests/
│ └── test_smoke.py
└── tools/
└── init_admin.py
Architecture¶
AppState central hub (app.py)¶
AppState is the Qt object that owns the three worker threads, the rolling buffers, and the last-known snapshots from each hardware layer. UI tabs read from AppState via two paths:
- Qt signals — workers emit
daq_snapshot,vfd_snapshot,unit_system_changed, etc.; tabs connect their slots and update. - Direct shared state — workers also write into
AppState.last_daq/AppState.last_vfd/AppState.buffersdirectly. A 4 HzQTimerin the UI reads from those fields. This is GIL-safe —deque.appendand pointer assignment are atomic in CPython — and it keeps the UI responsive even if signal delivery is delayed.
Either path on its own would be enough most of the time. Having both is the "belt and suspenders" pattern; it survived the F.1–F.6 development without producing a stuck-UI bug.
Workers¶
| Worker | Thread | Rate | What it does |
|---|---|---|---|
DaqWorker (daq/worker.py) |
QThread | configurable, default 5 Hz | Reads one sample per channel, applies calibration, writes a DaqSnapshot into AppState and emits a signal. Also writes the row to the open CSV. |
VfdPoller (vfd/worker.py) |
QThread | 4 Hz (250 ms) | Reads CW, REF1, SW, ACT1, ACT2 from the drive; emits a VfdSnapshot. |
VfdCommandWorker (vfd/command_worker.py) |
QThread | event-driven | Receives StartCmd, StopCmd, EStopCmd, SetRpmCmd. Checks preconditions (REM mode, no FAULT). Executes the multi-step write sequence with the required delays. Emits step-by-step progress. |
The reason the command worker is its own thread is that the start sequence is write REF1 → sleep 0.2 s → write CW=0x0476 → sleep 0.5 s → write CW=0x047F. Done synchronously on the GUI thread, that is 0.7 s of stalled UI per Start press. Done on a worker, the GUI stays responsive and the operator sees per-step feedback.
VFD client (vfd/modbus_client.py)¶
VfdClient wraps a pymodbus.client.ModbusTcpClient with:
- one persistent connection maintained for the life of the client;
- a
threading.Lockaround every wire-format write/read; - a thin layer that translates parameter group/index pairs into Modbus addresses.
The persistent connection is the key architectural choice — see Architecture → Why persistent vs fresh sockets.
VFD controller (vfd/controller.py)¶
Owns the calibration constant (REF1_PER_RPM = 22.42) and the start/stop sequences. Three methods: start(rpm), stop(), estop(), and a set_rpm(rpm) for mid-run setpoint changes that writes only REF1.
DAQ reader (daq/cdaq_reader.py)¶
Wraps NI-DAQmx and degrades gracefully:
- If
nidaqmxis not importable (e.g. this Mac), the reader returns synthetic data. - If
ADCL_WINSOFT_SIMULATE=1is set, simulation mode is forced even with real hardware available. - The NI-9237 requires a continuous sample clock; the reader opens a continuous task and reads one sample per polling tick.
UI shell (ui/main_window.py)¶
QSplitter with three columns:
- Sidebar — list of tabs and theme selector.
- Tabs — Signals · Graphs · VFD · Settings · PGB Sting · Traverse (placeholder) · CTA (placeholder) · XY Balance (placeholder).
- Right column —
QuickTunnel(slider + Apply/Start/Stop/E-Stop) on top,DaqGate(Start/Stop DAQ) middle,ControlPanel(collapsible Acquisition + File panels) bottom.
Below the splitter is a FooterBar showing connection states, simulation mode, and active theme.
Theme system (ui/theme.py)¶
Six named palettes (Dark, Light, Monokai, Topaz, Obsidian, Nord) driven from one parameterized QSS template. Only color tokens change between themes. Bundled Roboto fonts load at startup so the look is identical to the bundled .exe regardless of system fonts.
Footguns baked into the code¶
These are the lessons from the project notes, summarized so they appear here for new contributors:
pymodbus3.13 renamedslave=todevice_id=. The old kwarg silently throwsTypeError. Treat any "type error from pymodbus" as a version-skew tell. Seevfd/modbus_client.pyand the pymodbus quirks note.- PyInstaller does not bundle package metadata by default.
nidaqmx's dependencynitypescallsimportlib.metadata.version("nitypes")and dies on a fresh bundle. The spec atadcl_winsoft.specusescopy_metadata()fornidaqmx,nitypes,hightime,deprecation,pymodbus,argon2_cffi,PySide6, andpyqtgraph. Adding a new dependency means adding it to that list. See PyInstaller quirks note. __main__.pycannot use relative imports when run by PyInstaller (no__package__). Absolute imports only in the entry point. Submodules may use relative imports freely.- Drive start sequence is
REF1 → CW=0x0476 (stops!) → CW=0x047F (starts). Re-issuing0x0476mid-run will stop the drive. For setpoint changes, write REF1 only —SetRpmCmddoes this;StartCmdis for the initial start only. - VFD UI buttons must respond to CW bit 3 (RUN). When the drive is running, the Start button disables, Stop becomes the primary visual action, Apply stays enabled so the operator can change RPM live. This is wired in
ui/widgets/quick_tunnel.pyandui/tabs/vfd.py. - Worker → UI signal delivery alone is not enough. Workers must also write into
AppState.last_*so the UI's 4 Hz refresh timer can pull state directly. This is the belt-and-suspenders pattern.
Where data is stored at runtime¶
| Item | Path |
|---|---|
| Application binary | E:\Wind_tunnel\ADCLWinSoft\ADCL WinSoft.exe |
| Settings JSON | %APPDATA%\AeroLab\ADCLWinSoft\settings.json |
| Admin hash | %APPDATA%\AeroLab\ADCLWinSoft\admin.hash |
| Calibration matrices (read) | E:\Wind_tunnel\AeroWare\Configs\N_C1_inv*.csv |
| Experiment CSVs (written) | E:\Wind_tunnel\ADCLWinSoft\Data\YYYY-MM-DD\ |
| Dev venv | C:\Users\kshit\.venvs\adcl_winsoft\ |
| Build staging | C:\Users\kshit\.adcl_winsoft_build\ |
Phase status¶
F.1–F.6 complete as of 2026-05-13. Open work:
- F.5 admin editors — calibration matrix UI, channel-map UI, password-change UI in admin mode.
- Closed-loop wind-speed control — read static-ring ΔP via cDAQ, run a PID against target velocity, write REF1.
- CTA, XY Balance, Traverse tabs — currently placeholders.
See code/adcl_winsoft/CHANGELOG.md for the per-phase notes.