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)