You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
266 lines
7.0 KiB
266 lines
7.0 KiB
// Package pid provides a generic PID controller implementation with filtered derivative. |
|
// |
|
// This package implements a Proportional-Integral-Derivative controller suitable |
|
// for various dynamic adjustment scenarios: |
|
// - Rate limiting (memory/load-based throttling) |
|
// - PoW difficulty adjustment (block time targeting) |
|
// - Temperature control |
|
// - Motor speed control |
|
// - Any system requiring feedback-based regulation |
|
// |
|
// The controller features: |
|
// - Low-pass filtered derivative to suppress high-frequency noise |
|
// - Anti-windup on the integral term to prevent saturation |
|
// - Configurable output clamping |
|
// - Thread-safe operation |
|
// |
|
// # Control Theory Background |
|
// |
|
// The PID controller computes an output based on the error between the current |
|
// process variable and a target setpoint: |
|
// |
|
// output = Kp*error + Ki*∫error*dt + Kd*d(filtered_error)/dt |
|
// |
|
// Where: |
|
// - Proportional (P): Immediate response proportional to current error |
|
// - Integral (I): Accumulated error to eliminate steady-state offset |
|
// - Derivative (D): Rate of change to anticipate future error (filtered) |
|
// |
|
// # Filtered Derivative |
|
// |
|
// Raw derivative amplifies high-frequency noise. This implementation applies |
|
// an exponential moving average (low-pass filter) before computing the derivative: |
|
// |
|
// filtered_error = α*current_error + (1-α)*previous_filtered_error |
|
// derivative = (filtered_error - previous_filtered_error) / dt |
|
// |
|
// Lower α values provide stronger filtering (recommended: 0.1-0.3). |
|
package pid |
|
|
|
import ( |
|
"math" |
|
"sync" |
|
"time" |
|
|
|
pidif "next.orly.dev/pkg/interfaces/pid" |
|
) |
|
|
|
// Controller implements a PID controller with filtered derivative. |
|
// It is safe for concurrent use. |
|
type Controller struct { |
|
// Configuration (protected by mutex for dynamic updates) |
|
mu sync.Mutex |
|
tuning pidif.Tuning |
|
|
|
// Internal state |
|
integral float64 |
|
prevError float64 |
|
prevFilteredError float64 |
|
lastUpdate time.Time |
|
initialized bool |
|
} |
|
|
|
// Compile-time check that Controller implements pidif.Controller |
|
var _ pidif.Controller = (*Controller)(nil) |
|
|
|
// output implements pidif.Output |
|
type output struct { |
|
value float64 |
|
clamped bool |
|
pTerm float64 |
|
iTerm float64 |
|
dTerm float64 |
|
} |
|
|
|
func (o output) Value() float64 { return o.value } |
|
func (o output) Clamped() bool { return o.clamped } |
|
func (o output) Components() (p, i, d float64) { return o.pTerm, o.iTerm, o.dTerm } |
|
|
|
// New creates a new PID controller with the given tuning parameters. |
|
func New(tuning pidif.Tuning) *Controller { |
|
return &Controller{tuning: tuning} |
|
} |
|
|
|
// NewWithGains creates a new PID controller with specified gains and defaults for other parameters. |
|
func NewWithGains(kp, ki, kd, setpoint float64) *Controller { |
|
tuning := pidif.DefaultTuning() |
|
tuning.Kp = kp |
|
tuning.Ki = ki |
|
tuning.Kd = kd |
|
tuning.Setpoint = setpoint |
|
return &Controller{tuning: tuning} |
|
} |
|
|
|
// NewDefault creates a new PID controller with default tuning. |
|
func NewDefault() *Controller { |
|
return &Controller{tuning: pidif.DefaultTuning()} |
|
} |
|
|
|
// Update computes the controller output based on the current process variable. |
|
func (c *Controller) Update(pv pidif.ProcessVariable) pidif.Output { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
|
|
now := pv.Timestamp() |
|
value := pv.Value() |
|
|
|
// Initialize on first call |
|
if !c.initialized { |
|
c.lastUpdate = now |
|
c.prevError = value - c.tuning.Setpoint |
|
c.prevFilteredError = c.prevError |
|
c.initialized = true |
|
return output{value: 0, clamped: false} |
|
} |
|
|
|
// Calculate time delta |
|
dt := now.Sub(c.lastUpdate).Seconds() |
|
if dt <= 0 { |
|
dt = 0.001 // Minimum 1ms to avoid division by zero |
|
} |
|
c.lastUpdate = now |
|
|
|
// Calculate current error (positive when above setpoint) |
|
err := value - c.tuning.Setpoint |
|
|
|
// Proportional term |
|
pTerm := c.tuning.Kp * err |
|
|
|
// Integral term with anti-windup |
|
c.integral += err * dt |
|
c.integral = clamp(c.integral, c.tuning.IntegralMin, c.tuning.IntegralMax) |
|
iTerm := c.tuning.Ki * c.integral |
|
|
|
// Derivative term with low-pass filter |
|
alpha := c.tuning.DerivativeFilterAlpha |
|
if alpha <= 0 { |
|
alpha = 0.2 // Default if not set |
|
} |
|
filteredError := alpha*err + (1-alpha)*c.prevFilteredError |
|
|
|
var dTerm float64 |
|
if dt > 0 { |
|
dTerm = c.tuning.Kd * (filteredError - c.prevFilteredError) / dt |
|
} |
|
|
|
// Update previous values |
|
c.prevError = err |
|
c.prevFilteredError = filteredError |
|
|
|
// Compute total output |
|
rawOutput := pTerm + iTerm + dTerm |
|
clampedOutput := clamp(rawOutput, c.tuning.OutputMin, c.tuning.OutputMax) |
|
|
|
return output{ |
|
value: clampedOutput, |
|
clamped: rawOutput != clampedOutput, |
|
pTerm: pTerm, |
|
iTerm: iTerm, |
|
dTerm: dTerm, |
|
} |
|
} |
|
|
|
// UpdateValue is a convenience method that takes a raw float64 value. |
|
func (c *Controller) UpdateValue(value float64) pidif.Output { |
|
return c.Update(pidif.NewProcessVariable(value)) |
|
} |
|
|
|
// Reset clears all internal state. |
|
func (c *Controller) Reset() { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
|
|
c.integral = 0 |
|
c.prevError = 0 |
|
c.prevFilteredError = 0 |
|
c.initialized = false |
|
} |
|
|
|
// SetSetpoint updates the target value. |
|
func (c *Controller) SetSetpoint(setpoint float64) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
c.tuning.Setpoint = setpoint |
|
} |
|
|
|
// Setpoint returns the current setpoint. |
|
func (c *Controller) Setpoint() float64 { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
return c.tuning.Setpoint |
|
} |
|
|
|
// SetGains updates the PID gains. |
|
func (c *Controller) SetGains(kp, ki, kd float64) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
c.tuning.Kp = kp |
|
c.tuning.Ki = ki |
|
c.tuning.Kd = kd |
|
} |
|
|
|
// Gains returns the current PID gains. |
|
func (c *Controller) Gains() (kp, ki, kd float64) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
return c.tuning.Kp, c.tuning.Ki, c.tuning.Kd |
|
} |
|
|
|
// SetOutputLimits updates the output clamping limits. |
|
func (c *Controller) SetOutputLimits(min, max float64) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
c.tuning.OutputMin = min |
|
c.tuning.OutputMax = max |
|
} |
|
|
|
// SetIntegralLimits updates the anti-windup limits. |
|
func (c *Controller) SetIntegralLimits(min, max float64) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
c.tuning.IntegralMin = min |
|
c.tuning.IntegralMax = max |
|
} |
|
|
|
// SetDerivativeFilter updates the derivative filter coefficient. |
|
// Lower values provide stronger filtering (0.1-0.3 recommended). |
|
func (c *Controller) SetDerivativeFilter(alpha float64) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
c.tuning.DerivativeFilterAlpha = alpha |
|
} |
|
|
|
// Tuning returns a copy of the current tuning parameters. |
|
func (c *Controller) Tuning() pidif.Tuning { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
return c.tuning |
|
} |
|
|
|
// SetTuning updates all tuning parameters at once. |
|
func (c *Controller) SetTuning(tuning pidif.Tuning) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
c.tuning = tuning |
|
} |
|
|
|
// State returns the current internal state for monitoring/debugging. |
|
func (c *Controller) State() (integral, prevError, prevFilteredError float64, initialized bool) { |
|
c.mu.Lock() |
|
defer c.mu.Unlock() |
|
return c.integral, c.prevError, c.prevFilteredError, c.initialized |
|
} |
|
|
|
// clamp restricts a value to the range [min, max]. |
|
func clamp(value, min, max float64) float64 { |
|
if math.IsNaN(value) { |
|
return 0 |
|
} |
|
if value < min { |
|
return min |
|
} |
|
if value > max { |
|
return max |
|
} |
|
return value |
|
}
|
|
|