- Create pkg/interfaces/pid for generic PID controller interfaces:
- ProcessVariable: abstract input (value + timestamp)
- Source: provides process variable samples
- Output: controller output with P/I/D components and clamping info
- Controller: generic PID interface with setpoint/gains
- Tuning: configuration struct for all PID parameters
- Create pkg/pid as standalone PID controller implementation:
- Thread-safe with mutex protection
- Low-pass filtered derivative to suppress high-frequency noise
- Anti-windup on integral term
- Configurable output clamping
- Presets for common use cases: rate limiting, PoW difficulty,
temperature control, motor speed
- Update pkg/ratelimit to use generic pkg/pid.Controller:
- Limiter now uses pidif.Controller interface
- Type assertions for monitoring/debugging state access
- Maintains backward compatibility with existing API
The generic PID package can now be used for any dynamic adjustment
scenario beyond rate limiting, such as blockchain PoW difficulty
adjustment, temperature regulation, or motor speed control.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
267 lines
7.0 KiB
Go
267 lines
7.0 KiB
Go
// 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
|
||
}
|