Extending the code¶
Three common modification recipes. Each is small enough that the right way is documented; deviating is fine when there's a real reason.
Add a new tab to ADCL WinSoft¶
code/adcl_winsoft/src/adcl_winsoft/ui/tabs/ is where tabs live. The pattern is:
- Create
ui/tabs/<your_tab>.pywith aQWidgetsubclass. InjectAppStatein the constructor. - In
__init__, build the layout, connect to whicheverAppStatesignals are relevant, and start aQTimerif you need a refresh loop. - In
app.py'sMainWindow.__init__, instantiate the tab and add it to theQTabWidget. - Add the tab name to the sidebar list in
ui/widgets/sidebar.py. - If the tab needs admin gating, wrap the add step behind
state.is_adminand re-add onstate.admin_changed.
Use ui/tabs/placeholder.py as the smallest possible template.
Add a new Modbus register to read¶
If the new register is a quick-access (CW, REF1, SW, ACTn) register:
- Add a field to
VfdSnapshot(vfd/__init__.pyor wherever the dataclass lives). - In
VfdPoller.run()(vfd/worker.py), read the new address each tick and populate the field. - Update any UI tabs that should display it.
If the new register is a parameter (group/index):
- Use
VfdClient.read_param(group, index)which already does thegroup × 100 + index − 1calculation. - If you are reading the parameter on every poll, fold it into
VfdPollerso you do not multiply the Modbus traffic. - If you are reading the parameter once at startup (e.g. nameplate values), do it on the main thread before the workers spin up, or inside
AppState.initialize().
Update Reference → Parameter index and Reference → Register map when adding either kind.
Refresh the REF1 ↔ RPM calibration¶
When the calibration drifts (after a hardware change, a long idle period, or a motor swap), re-run the sweep scripts and update the constants.
- Pre-run checklist complete; test section empty.
- Launch
code/wind_tunnel_control/ref1_sweep.ps1: The script walks REF1 from 0 to a maximum, polling the drive's frequency feedback, writing a CSV row per setpoint. - Plot the CSV: REF1 on the x-axis, drive frequency on the y-axis. A linear fit gives the new constant: REF1 ≈ frequency × (
REF1_max/nominal_freq). - Convert the linear fit into a
REF1 = drive_RPM × Cconstant. The relationship between frequency and RPM is the motor's pole-pair count and slip; for our motor60 Hz = 880 RPM, so the constant comes out near 22.42 when the drive is in spec. - Run
rpm_velocity_sweep.ps1next to capture the RPM → wind speed mapping under the new drive calibration: - Update the two source-of-truth constants:
code/adcl_winsoft/src/adcl_winsoft/vfd/controller.py:REF1_PER_RPM = <new value>code/wind_tunnel_control/WindTunnelControl.ps1: the equivalent constant (search for22.42)docs/manual/content/reference/calibration_constants.md: update both numbers and the date.
- Commit the change with a message like
vfd: recalibrate REF1 = RPM × 22.51 (2026-05-20 sweep). Reference the sweep CSV in the commit body. - File the sweep CSVs in a new
WT_MS_<n>/captures/folder with a one-linedebug_session.mdsummarizing the date, the operator, and the resulting constants.
A note on changing the threading model¶
Don't, unless there is a concrete reason. The two-worker + command-worker arrangement in app.py survived F.1–F.6 and the explicit "belt and suspenders" pattern (signals and shared state) makes the UI robust to a class of bugs that the simpler single-path designs would expose. If you are tempted to consolidate into one worker or to remove the direct shared-state path, run a long-soak test first.