HvacPid.Controller (HVAC PID v0.1.0)

Copy Markdown View Source

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.

  • :direct is the default. The signed error is setpoint - 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.
  • :reverse uses measurement - 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.0

After 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.2

With 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.

One configuration setting. See new/1 for defaults and units.

t()

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

error()

@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.

mode()

@type mode() :: :disabled | :automatic | {:manual, number()}

A controller mode or a manual command in output units.

option()

@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.

t()

@opaque t()

Opaque controller state returned by the controller operations.

Functions

configure(state, opts)

@spec configure(t(), [option()]) :: {:ok, t()} | {:error, error()}

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

new(opts)

@spec new([option()]) :: {:ok, t()} | {:error, error()}

Creates a disabled controller with the specified settings.

OptionDefaultMeaning
:out_min, :out_maxRequiredLower and upper output bounds. Equal bounds give a fixed command.
:kp1.0Shared, nonnegative gain in output units per measurement unit.
:p_enabledtrueWhether to include the proportional term.
:tnnilPositive integral time in seconds, or nil to disable I.
:tdnilPositive derivative time in seconds, or nil to disable D.
:derivative_on:measurementUse changes in measurement, or include setpoint changes with :error.
:bias0.0Constant contribution in output units.
:deadzone0.0Nonnegative hold radius in measurement units. Zero disables the hold.
:direction:directSigned error is setpoint minus measurement in :direct and measurement minus setpoint in :reverse.
:start_valuenilOptional output for the first automatic sample after enabling.
:stop_valuenilOptional 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}}

output(controller)

@spec output(t()) :: float()

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.

set_mode(state, mode)

@spec set_mode(t(), mode()) :: {:ok, t()} | {:error, error()}

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.

step(state, setpoint, measurement, elapsed_seconds)

@spec step(t(), number(), number(), number()) ::
  {:ok, float(), t()} | {:error, error()}

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.

track_output(state, applied_output)

@spec track_output(t(), number()) :: {:ok, t()} | {:error, error()}

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.