Skip to content

PID governance loop

A work process whose output a PID controller keeps at a set point against a disturbance.

  • Name: pid_loop
  • Field: engineering/control
  • Formalism: ordinary differential equations
  • References: Mobus 2022; Åström and Murray 2008

Factory

@register_model(
    field='engineering/control',
    tags=('control', 'feedback', 'pid', 'governance', 'ode'),
    references=('Mobus 2022', 'Åström and Murray 2008'),
    defaults={'until': 40.0, 'dt': 0.1},
)
def pid_loop(
    y: float = 0.0,
    *,
    tau: float = 1.0,
    gain: float = 1.0,
    Kp: float = 2.0,
    Ki: float = 1.0,
    Kd: float = 0.1,
    setpoint: float = 1.0,
    change: float = 0.0,
    disturbance: float = -0.5,
) -> System:
    """Return a work process whose output a PID controller keeps at a set point against a disturbance.

    The output follows the first-order process `τ y' = -y + gain u + d(t)`, and the controller sets
    `u = Kp e + Ki ∫ e dt - Kd y'` from the error `e = r(t) - y`, with the derivative taken on the output, so
    `(τ + gain Kd) y' = -y + gain (Kp e + Ki ∫ e dt) + d(t)`. The disturbance `d` steps in at time 10, and a
    coordinator moves the set point `r` by `change` at time 20. The integral action removes the error that the
    disturbance leaves under proportional control alone.

    Args:
        y:
            The initial output.

        tau:
            The time constant of the process.

        gain:
            The gain of the actuator.

        Kp:
            The proportional gain.

        Ki:
            The integral gain.

        Kd:
            The derivative gain.

        setpoint:
            The initial set point.

        change:
            The change of the set point at time 20.

        disturbance:
            The size of the disturbance from time 10.

    Returns:
        system:
            The model, with the output `process.y` and the integral of the error `process.integral`.

    Examples:
        >>> from skcomplex.models.engineering.control import pid_loop
        >>> pid_loop(Ki=0.0).elements[0].Ki
        0.0
    """
    process = ControlledProcessElement(
        'process',
        y=y,
        integral=0.0,
        tau=tau,
        gain=gain,
        Kp=Kp,
        Ki=Ki,
        Kd=Kd,
        setpoint=setpoint,
        change=change,
        disturbance=disturbance,
    )
    return System(elements=[process])

Components

class ControlledProcessElement(Element):
    """First-order process under a PID controller whose derivative term is solved into the time constant."""

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        y: Annotated[object, StateSpec()] = 0.0,
        integral: Annotated[object, StateSpec()] = 0.0,
        tau: Annotated[object, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
        gain: Annotated[object, ParameterSpec()] = 1.0,
        Kp: Annotated[object, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 2.0,
        Ki: Annotated[object, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
        Kd: Annotated[object, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
        setpoint: Annotated[object, ParameterSpec()] = 1.0,
        change: Annotated[object, ParameterSpec()] = 0.0,
        disturbance: Annotated[object, ParameterSpec()] = -0.5,
    ) -> None:
        super().__init__(label, visual=visual)
        self.y = y
        self.integral = integral
        self.tau = tau
        self.gain = gain
        self.Kp = Kp
        self.Ki = Ki
        self.Kd = Kd
        self.setpoint = setpoint
        self.change = change
        self.disturbance = disturbance

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the rates of the output `y` and of the integral of the error."""
        return [
            RateLaw(
                s.y,
                (
                    -s.y
                    + s.gain * (s.Kp * ControlledProcessElement._error(s) + s.Ki * s.integral)
                    + s.disturbance * step(s.t, DISTURBANCE_TIME)
                )
                / (s.tau + s.gain * s.Kd),
            ),
            RateLaw(s.integral, ControlledProcessElement._error(s)),
        ]

    @staticmethod
    def _error(s: SimpleNamespace) -> Expression:
        """Return the error of the controlled process, the set point after its change less the output."""
        return s.setpoint + s.change * step(s.t, CHANGE_TIME) - s.y

Equations

process.y: diff(y_process(t), t) == (((change_process*where(t >= 20.0, 1.0, 0) + setpoint_process - y_process(t))*Kp_process + Ki_process*integral_process(t))*gain_process + disturbance_process*where(t >= 10.0, 1.0, 0) - y_process(t))/(Kd_process*gain_process + tau_process)
process.integral: diff(integral_process(t), t) == change_process*where(t >= 20.0, 1.0, 0) + setpoint_process - y_process(t)