Differential Pressure Control
Overview
The MC runs a PID loop that reads one or both SDP31 differential pressure sensors and drives the supply and/or exhaust fans (and optionally stepper-actuated dampers) to hit a target pressure. Parameters are tunable over BLE.
Two BLE characteristics on the Vibrant service carry this:
| Characteristic | UUID suffix | Access | Purpose |
|---|---|---|---|
pressure_cfg |
0x00000103 |
Read / Write | Config — tune PID, set targets, en/disable dampers |
pressure_status |
(read from device_status char) | Read / Notify | Live readings — sensor values, error, PID output, actuator state |
Single-channel SISO setup (exhaust channel, supply fan only)
This is the starting point described in the experiment plan: one SDP31 as the error source, supply fan as the sole actuator, exhaust damper cracked open at a fixed position.
Goal state: - Exhaust SDP31 reading is the controlled variable - Supply fan is the manipulated variable - Exhaust damper is held at a fixed "cracked open" position and not PID-driven - Exhaust fan is held at base speed and not PID-driven
Config payload to write to pressure_cfg:
{
"target_delta_pa": 3.0,
"deadband_pa": 0.3,
"kp": 4.0,
"ki": 0.03,
"kd": 0.8,
"control_period_sec": 10,
"fan_base_percent": 20,
"fan_min_percent": 0,
"fan_max_percent": 100,
"damper_base_percent": 10,
"damper_min_percent": 10,
"damper_max_percent": 10,
"enable_dampers": false,
"dp_sample_period_sec": 10,
"dp_moving_average_window_sec": 120
}
Key choices in that payload:
damper_min_percent = damper_max_percent = 10— freezes both dampers at 10% open regardless of PID output; adjust this number to set the "cracked open" position before disabling power to the steppersenable_dampers: false— disables all stepper motion so the dampers hold whatever physical position they're infan_base_percent: 20— supply fan idles at 20% when the loop is settled (within deadband)target_delta_pa: 3.0— target pressure across the active sensor in Pascals; adjust based on what the sensor reads at the desired operating point
Note: Two config parameters are not yet implemented in firmware but are needed for true SISO operation:
active_channel(selects which single SDP31 is the error input — 0=supply, 1=exhaust) andsupply_fan_only(restricts PID output to the supply fan only). Currently the error is alwaysexhaust_pa - supply_paand both fans are adjusted. These need to be added toServicePositivePressure::Configand wired into the BLE parser before running the single-channel experiment.
Config reference
Write any subset of these fields to pressure_cfg. Fields not included are left unchanged.
Sensor sampling (ServiceDP)
| Field | Type | Default | Notes |
|---|---|---|---|
dp_sample_period_sec |
int | 10 | How often to read both SDP31s |
dp_moving_average_window_sec |
int | 120 | Window for the moving average fed to the PID |
PID control (ServicePositivePressure)
| Field | Type | Default | Notes |
|---|---|---|---|
control_period_sec |
int | 10 | How often the PID runs |
target_delta_pa |
float | 3.0 | Setpoint in Pa |
deadband_pa |
float | 0.3 | Error band within which no action is taken; fans walk back to base |
kp |
float | 4.0 | Proportional gain |
ki |
float | 0.03 | Integral gain |
kd |
float | 0.8 | Derivative gain |
integral_min |
float | -200 | Integral windup lower clamp |
integral_max |
float | 200 | Integral windup upper clamp |
output_limit_pct |
float | 25.0 | Symmetric output clamp (%) before scaling to fan step |
Fan control
| Field | Type | Default | Notes |
|---|---|---|---|
fan_base_percent |
int | 20 | Speed both fans settle to when inside deadband |
fan_min_percent |
int | 0 | Hard lower bound on fan speed |
fan_max_percent |
int | 100 | Hard upper bound on fan speed |
max_fan_step_pct_per_tick |
int | 8 | Max speed change per control period |
Damper control
| Field | Type | Default | Notes |
|---|---|---|---|
enable_dampers |
bool | true | Master enable; set false to freeze dampers in place |
damper_base_percent |
int | 50 | Position dampers settle to when inside deadband |
damper_min_percent |
int | 0 | Hard lower bound (use equal min/max to freeze at a position) |
damper_max_percent |
int | 100 | Hard upper bound |
max_damper_step_pct_per_tick |
int | 6 | Max position change per control period |
damper_gain |
float | 0.8 | Scales PID output to damper step size |
damper_full_travel_steps |
int | 8000 | Stepper steps for full 0→100% travel |
damper_step_half_period_us |
int | 1200 | Stepper pulse half-period in µs (lower = faster) |
supply_open_dir_high |
bool | true | DIR=HIGH opens the supply damper |
exhaust_open_dir_high |
bool | true | DIR=HIGH opens the exhaust damper |
Status response
Read from pressure_cfg or from the device_status notify payload:
{
"ready": true,
"have_dp_data": true,
"target_delta_pa": 3.0,
"dp_delta_avg_pa": 2.85,
"error_pa": 0.15,
"pid_output_pct": 0.6,
"action": 0,
"fan_supply_pct": 20,
"fan_exhaust_pct": 20,
"damper_supply_pct": 10,
"damper_exhaust_pct": 10
}
| Field | Meaning |
|---|---|
have_dp_data |
false until the moving average window has at least one sample |
dp_delta_avg_pa |
moving-average of exhaust_pa - supply_pa |
error_pa |
target - measured; positive means need more pressure |
pid_output_pct |
raw PID output before actuator mapping |
action |
-1 = supply fan active, 0 = settled (in deadband), +1 = exhaust fan active |
fan_supply_pct / fan_exhaust_pct |
current commanded fan speeds |
damper_supply_pct / damper_exhaust_pct |
current damper positions as tracked by firmware |
What the PID loop does each tick
- Reads
dp_delta_avg_pafrom the moving average (waits until enough samples exist) - Computes
error = target_delta_pa - dp_delta_avg_pa - If
|error| <= deadband_pa: both fans and dampers step back toward their base values one step per tick - If
error > 0(need more pressure): increases exhaust fan, backs off supply fan by half that step, closes supply damper - If
error < 0(too much pressure): increases supply fan, backs off exhaust fan by half that step, closes exhaust damper - All changes are rate-limited by
max_fan_step_pct_per_tickandmax_damper_step_pct_per_tick
For the single-channel experiment, step 4/5 behaviour will change once active_channel and supply_fan_only are implemented. the loop will only adjust the supply fan and use one sensor's reading directly rather than the delta between two.
Tuning tool (src/pressure_tune_test.py)
Requires bleak (pip install bleak). Scans for the nearest Vibrant-MC-* device automatically.
Read current config and status without changing anything:
python src/pressure_tune_test.py --dry-run
Apply the SISO exhaust-channel preset (matches the payload above):
python src/pressure_tune_test.py --preset siso-exhaust
Apply the supply-channel variant:
python src/pressure_tune_test.py --preset siso-supply
Apply a preset then override individual params:
python src/pressure_tune_test.py --preset siso-exhaust --param kp=6.0 --param target_delta_pa=2.5
Write and then watch status every 15 s (Ctrl+C to stop):
python src/pressure_tune_test.py --preset siso-exhaust --poll 15
Send arbitrary JSON — useful for tweaking a single param mid-run:
python src/pressure_tune_test.py --json '{"kp":6.0,"ki":0.01}'
On each write run the tool prints the config before and after the write, diffs the changed fields, and prints the live status response so you can confirm the device accepted the values.
CoAP pressure control
The firmware supports the same full set of pressure config params over CoAP. All paths ultimately call the same apply_pressure_settings_object as the BLE characteristic write, so every field in the config reference above works identically.
Message formats
Explicit command — send a CoAP POST to the device with a command field:
{
"command": "setPressureControl",
"pressureControl": {
"target_delta_pa": 3.0,
"kp": 4.0,
"ki": 0.03,
"kd": 0.8,
"enable_dampers": false
}
}
Params can also sit under a "settings" key or directly at the root alongside "command" — the firmware tries each nesting in order.
Settings push (via broker observe) — no command field needed; the broker pushes to device/{id}/settings and the firmware dispatches it automatically:
{
"pressureControl": {
"target_delta_pa": 3.0,
"deadband_pa": 0.3
}
}
Read back status — send getPressureControl:
{ "command": "getPressureControl" }
The device replies with the same JSON as the BLE pressure status characteristic.
Multi-message / partial updates
The device's CoAP receive buffer is 1024 bytes. Since every param is optional and applied independently, a large config can be sent as multiple smaller messages — each one is a valid partial update. Send them sequentially with a brief gap between messages so the firmware can process each before the next arrives.
device_id field
Including "device_id" or "deviceId" in the message causes the firmware to check it against its own serial. Omitting the field skips the check and the message is accepted by any device — useful on a bench but be careful on a shared network.