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

// 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
}