Skip to content

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:

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:

  1. Qt signals — workers emit daq_snapshot, vfd_snapshot, unit_system_changed, etc.; tabs connect their slots and update.
  2. Direct shared state — workers also write into AppState.last_daq / AppState.last_vfd / AppState.buffers directly. A 4 Hz QTimer in the UI reads from those fields. This is GIL-safe — deque.append and 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.Lock around 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 nidaqmx is not importable (e.g. this Mac), the reader returns synthetic data.
  • If ADCL_WINSOFT_SIMULATE=1 is 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:

  1. Sidebar — list of tabs and theme selector.
  2. Tabs — Signals · Graphs · VFD · Settings · PGB Sting · Traverse (placeholder) · CTA (placeholder) · XY Balance (placeholder).
  3. Right columnQuickTunnel (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:

  • pymodbus 3.13 renamed slave= to device_id=. The old kwarg silently throws TypeError. Treat any "type error from pymodbus" as a version-skew tell. See vfd/modbus_client.py and the pymodbus quirks note.
  • PyInstaller does not bundle package metadata by default. nidaqmx's dependency nitypes calls importlib.metadata.version("nitypes") and dies on a fresh bundle. The spec at adcl_winsoft.spec uses copy_metadata() for nidaqmx, nitypes, hightime, deprecation, pymodbus, argon2_cffi, PySide6, and pyqtgraph. Adding a new dependency means adding it to that list. See PyInstaller quirks note.
  • __main__.py cannot 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-issuing 0x0476 mid-run will stop the drive. For setpoint changes, write REF1 only — SetRpmCmd does this; StartCmd is 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.py and ui/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.