Skip to content

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 steppers
  • enable_dampers: false — disables all stepper motion so the dampers hold whatever physical position they're in
  • fan_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) and supply_fan_only (restricts PID output to the supply fan only). Currently the error is always exhaust_pa - supply_pa and both fans are adjusted. These need to be added to ServicePositivePressure::Config and 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

  1. Reads dp_delta_avg_pa from the moving average (waits until enough samples exist)
  2. Computes error = target_delta_pa - dp_delta_avg_pa
  3. If |error| <= deadband_pa: both fans and dampers step back toward their base values one step per tick
  4. If error > 0 (need more pressure): increases exhaust fan, backs off supply fan by half that step, closes supply damper
  5. If error < 0 (too much pressure): increases supply fan, backs off exhaust fan by half that step, closes exhaust damper
  6. All changes are rate-limited by max_fan_step_pct_per_tick and max_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.