A general-purpose PID controller for discrete control loops. The caller supplies the clock, samples, and actuator interface.
Keep one state per loop and pass the state returned by each operation into
the next one. The controller starts disabled. Once enabled, it computes
commands as samples arrive through step/4.
Choosing a direction
Choose the direction based on how a larger command affects the measured value.
:directis the default. The signed error issetpoint - measurement, so it is positive when the measured value is below the setpoint. Use this direction when a larger command raises the measured value, as with a heater.:reverseusesmeasurement - setpoint, so the error is positive when the measured value is above the setpoint. Use this direction when a larger command lowers the measured value, as with a cooler.
The direction sets the sign convention for all three PID terms.
Control law
The controller uses the positional form of PID. The integral uses the current error over the whole sample interval (rectangular integration). The derivative uses the difference between successive samples.
s = +1 for direct action, -1 for reverse action
e = s * (setpoint - measurement)
P = Kp * e
delta_I = Kp * (elapsed_seconds / Tn) * e
change = -s * (measurement - previous_measurement) on measurement
change = e - previous_error on error
D = Kp * Td * (change / elapsed_seconds)
raw = bias + tracking_adjustment + P + I + D
output = clamp(raw, out_min, out_max)Kp is in output units per measurement unit. Tn and Td are in seconds.
P, I, D, the bias, and the tracking adjustment are all in output
units. Set tn to enable integral action and td to enable derivative
action. Set either to nil to switch that term off. You can switch off the
proportional term with p_enabled: false while keeping kp for I and D.
The derivative defaults to :measurement, so moving the setpoint alone
leaves D at zero. With derivative_on: :error, a changing setpoint also
contributes to D. That can help a loop follow a ramped setpoint. A sudden
setpoint change can produce a large one-sample response called derivative
kick. Both sources give the same derivative at a fixed setpoint.
Saturation and the deadzone
Before adding the new integral increment, the controller computes the raw output with the previous integral. A positive increment can use the headroom up to the upper bound. A negative increment can use the headroom down to the lower bound. The controller discards any remainder, then clamps the output. This prevents the integral from accumulating in the direction of saturation while allowing it to help bring the output back into range. The limits apply to the combined output. For instance, I can be negative with an output range of 0 to 100.
A positive deadzone holds the previous output when the absolute error is
at or below that radius. The integral and tracking adjustment stay fixed.
Each held sample still updates the derivative history, so on exit the
derivative uses the sample immediately before the exit. A zero radius
disables the hold. Startup and reanchoring take precedence over the hold.
Startup, manual control, and retuning
Call set_mode/2 with :automatic to start. On the first valid sample,
the controller emits the clamped start_value if one is configured.
Otherwise it emits the clamped sum of the bias and P. I and D start at zero
and run from the second sample onward. Setting :disabled clears the
controller's history and immediately selects the clamped stop_value or,
by default, the lower bound. Read that command with output/1.
Manual mode selects a command immediately. If an external override applies a different command, report the effective value before returning to automatic control. For example, the controller can request 35% while an actuator's priority logic holds it at 42%:
iex> {:ok, pid} = HvacPid.Controller.new(out_min: 0, out_max: 100, kp: 10)
iex> {:ok, pid} = HvacPid.Controller.set_mode(pid, {:manual, 35})
iex> {:ok, pid} = HvacPid.Controller.track_output(pid, 42)
iex> HvacPid.Controller.output(pid)
35.0
iex> {:ok, pid} = HvacPid.Controller.set_mode(pid, :automatic)
iex> {:ok, 42.0, pid} = HvacPid.Controller.step(pid, 21, 19, 1)
iex> {:ok, command, _pid} = HvacPid.Controller.step(pid, 21, 20, 1)
iex> command
32.0After a manual transfer, a change to the active control law, or an explicit
call to track_output/2, the next automatic sample emits the clamped
target. The controller resets I and D and sets its tracking adjustment to
target - bias - P using the fresh sample. The adjustment remains in the
control law on later samples. This gives proportional-only loops the same
smooth transfer as loops with an integral term. Tuning changes can be
grouped in one call to configure/2.
Timing, pauses, and retries
Pass the actual elapsed seconds between accepted samples. If you timestamp
samples with System.monotonic_time(:microsecond), divide the difference by
1_000_000. For millisecond timestamps, divide by 1_000. For example,
1 ms is 0.001 seconds and 250 ms is 0.25 seconds. On the first sample
after startup or reanchoring, any valid positive interval works because
that step skips the time-dependent terms.
You can skip a sample if its timestamp matches the previous one. For a
longer pause, decide how the actuator should behave while data is missing.
When you resume, call track_output/2 with the command currently in effect.
The next step uses the fresh measurement to seed D and starts a new
integration interval without integrating across the gap.
A rejected sample leaves the old state available for retry. If you continue from that state, measure the next interval from the last accepted sample. Similarly, if a write fails and you need to resume control from a command the actuator actually applied, report that value before stepping again.
A PI example
With a gain of 8, each unit of signed error contributes 8 units to the proportional term. The default direct direction gives a positive error of 2 when the measurement is 19 and the setpoint is 21.
iex> {:ok, pid} = HvacPid.Controller.new(out_min: 0, out_max: 100, kp: 8, tn: 20)
iex> HvacPid.Controller.output(pid)
0.0
iex> {:ok, pid} = HvacPid.Controller.set_mode(pid, :automatic)
iex> {:ok, command, pid} = HvacPid.Controller.step(pid, 21, 19, 0.25)
iex> command
16.0
iex> {:ok, command, _pid} = HvacPid.Controller.step(pid, 21, 19, 0.25)
iex> command
16.2With the same tuning in reverse, a measurement of 23 gives the same positive error and first command.
iex> {:ok, pid} = HvacPid.Controller.new(out_min: 0, out_max: 100, kp: 8, tn: 20, direction: :reverse)
iex> {:ok, pid} = HvacPid.Controller.set_mode(pid, :automatic)
iex> {:ok, command, _pid} = HvacPid.Controller.step(pid, 21, 23, 0.25)
iex> command
16.0
Summary
Types
The reason an operation was rejected.
A controller mode or a manual command in output units.
Opaque controller state returned by the controller operations.
Functions
Applies a batch of settings to an existing controller.
Creates a disabled controller with the specified settings.
Returns the most recent output command in output units.
Selects disabled, automatic, or manual operation.
Advances the controller using a setpoint, measurement, and elapsed seconds.
Reports the output currently applied at the actuator.
Types
@type error() :: :invalid_options | {:unknown_option, atom()} | {:duplicate_option, atom()} | {:missing_option, :out_min | :out_max} | {:invalid_option, atom()} | :invalid_output_limits | :invalid_mode | {:invalid_input, :setpoint | :measurement | :elapsed_seconds | :manual_output | :applied_output} | :numeric_overflow
The reason an operation was rejected.
Option errors identify a missing bound, an unknown or duplicate key, or an
invalid value. :invalid_options means the options were given in a form
other than a keyword list. :invalid_output_limits means the lower bound
exceeded the upper bound.
An invalid input error identifies the affected argument. A
:numeric_overflow means a calculation exceeded the available floating
point range even though the supplied inputs were valid.
@type mode() :: :disabled | :automatic | {:manual, number()}
A controller mode or a manual command in output units.
@type option() :: {:out_min | :out_max | :kp | :bias | :deadzone, number()} | {:tn | :td | :start_value | :stop_value, number() | nil} | {:p_enabled, boolean()} | {:direction, :direct | :reverse} | {:derivative_on, :measurement | :error}
One configuration setting. See new/1 for defaults and units.
@opaque t()
Opaque controller state returned by the controller operations.
Functions
Applies a batch of settings to an existing controller.
Any options omitted from the batch keep their current values. Both bounds are checked together, so a range can be moved in one operation. An invalid batch returns an error and leaves the caller's state available.
During automatic control, a change to gain, term selection, derivative source, direction, bias, or bounds reanchors the next sample to the current command or pending tracked value. Further changes before that sample retain the target within the new bounds. Changing only the deadzone or the start and stop values keeps the current control history. A controller still awaiting its startup sample uses the latest settings for startup.
Bounds clamp the current command immediately. Widening the range later keeps the clamped manual command. In disabled mode the command is recalculated from the new stop value and bounds.
iex> {:ok, pid} = HvacPid.Controller.new(out_min: 0, out_max: 100)
iex> {:ok, pid} = HvacPid.Controller.configure(pid, out_min: 120, out_max: 200, kp: 3)
iex> HvacPid.Controller.output(pid)
120.0
Creates a disabled controller with the specified settings.
| Option | Default | Meaning |
|---|---|---|
:out_min, :out_max | Required | Lower and upper output bounds. Equal bounds give a fixed command. |
:kp | 1.0 | Shared, nonnegative gain in output units per measurement unit. |
:p_enabled | true | Whether to include the proportional term. |
:tn | nil | Positive integral time in seconds, or nil to disable I. |
:td | nil | Positive derivative time in seconds, or nil to disable D. |
:derivative_on | :measurement | Use changes in measurement, or include setpoint changes with :error. |
:bias | 0.0 | Constant contribution in output units. |
:deadzone | 0.0 | Nonnegative hold radius in measurement units. Zero disables the hold. |
:direction | :direct | Signed error is setpoint minus measurement in :direct and measurement minus setpoint in :reverse. |
:start_value | nil | Optional output for the first automatic sample after enabling. |
:stop_value | nil | Optional output for disabled mode. The lower bound is used when omitted. |
Numeric options accept integers and floats that can be represented as
finite floats. Each key can appear once. See error/0 for errors.
iex> {:ok, pid} = HvacPid.Controller.new(out_min: -20, out_max: 80, stop_value: 0)
iex> HvacPid.Controller.output(pid)
0.0
iex> HvacPid.Controller.new(out_max: 100)
{:error, {:missing_option, :out_min}}
Returns the most recent output command in output units.
Manual and disabled mode changes update the command immediately. This
value represents the controller's command. Use track_output/2 to report
the effective actuator output when an override applies.
Selects disabled, automatic, or manual operation.
Disabled and manual commands take effect immediately. Automatic operation waits for a fresh sample. When coming from manual mode it anchors to the last manual command or to the most recent tracked value. Reapplying the current mode is idempotent. Two manual requests that clamp to the same command count as the same mode.
Returns {:error, :invalid_mode} for an unsupported mode, or
{:error, {:invalid_input, :manual_output}} for an invalid manual value.
Advances the controller using a setpoint, measurement, and elapsed seconds.
The setpoint and measurement must share units. All three arguments must be finite numbers, and elapsed seconds must be strictly positive. Fractional intervals are used as supplied. Manual and disabled steps validate their inputs and return the current command. Manual steps also remember the sample, while transfer to automatic control seeds history from a fresh one.
Returns {:ok, command, state} on success, or {:error, reason} for
invalid input or numeric overflow. See error/0.
Reports the output currently applied at the actuator.
In automatic mode the latest report becomes the target for the next sample, including when reported between enabling and the startup sample. In manual mode it supplies the later transfer target while the manual command stays in effect. Selecting a different manual command clears that target. Disabled mode keeps its stop command and starts afresh on enable.
Tracking clamps the reported value to the bounds and leaves output/1
reporting the current command until the next automatic sample. Returns
{:error, {:invalid_input, :applied_output}} for an invalid value.