interim-docs-update #3
133
pkg/interfaces/pid/pid.go
Normal file
133
pkg/interfaces/pid/pid.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package pid defines interfaces for PID controller process variable sources.
|
||||
// This abstraction allows the PID controller to be used for any dynamic
|
||||
// adjustment scenario - rate limiting, PoW difficulty adjustment, etc.
|
||||
package pid
|
||||
|
||||
import "time"
|
||||
|
||||
// ProcessVariable represents a measurable quantity that the PID controller
|
||||
// regulates. Implementations provide the current value and optional metadata.
|
||||
type ProcessVariable interface {
|
||||
// Value returns the current process variable value.
|
||||
// The value should typically be normalized to a range where the setpoint
|
||||
// makes sense (e.g., 0.0-1.0 for percentage-based control, or absolute
|
||||
// values for things like hash rate or block time).
|
||||
Value() float64
|
||||
|
||||
// Timestamp returns when this measurement was taken.
|
||||
// This is used for derivative calculations and staleness detection.
|
||||
Timestamp() time.Time
|
||||
}
|
||||
|
||||
// Source provides process variable measurements to the PID controller.
|
||||
// Implementations are domain-specific (e.g., memory monitor, hash rate tracker).
|
||||
type Source interface {
|
||||
// Sample returns the current process variable measurement.
|
||||
// This should be efficient as it may be called frequently.
|
||||
Sample() ProcessVariable
|
||||
|
||||
// Name returns a human-readable name for this source (for logging/debugging).
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Output represents the result of a PID controller update.
|
||||
type Output interface {
|
||||
// Value returns the computed output value.
|
||||
// The interpretation depends on the application:
|
||||
// - For rate limiting: delay in seconds
|
||||
// - For PoW difficulty: difficulty adjustment factor
|
||||
// - For temperature control: heater power level
|
||||
Value() float64
|
||||
|
||||
// Clamped returns true if the output was clamped to limits.
|
||||
Clamped() bool
|
||||
|
||||
// Components returns the individual P, I, D contributions for debugging.
|
||||
Components() (p, i, d float64)
|
||||
}
|
||||
|
||||
// Controller defines the interface for a PID controller.
|
||||
// This allows for different controller implementations (standard PID,
|
||||
// PID with filtered derivative, adaptive PID, etc.).
|
||||
type Controller interface {
|
||||
// Update computes the controller output based on the current process variable.
|
||||
// Returns the computed output.
|
||||
Update(pv ProcessVariable) Output
|
||||
|
||||
// UpdateValue is a convenience method that takes a raw float64 value.
|
||||
// Uses the current time as the timestamp.
|
||||
UpdateValue(value float64) Output
|
||||
|
||||
// Reset clears all internal state (integral accumulator, previous values).
|
||||
Reset()
|
||||
|
||||
// SetSetpoint updates the target value.
|
||||
SetSetpoint(setpoint float64)
|
||||
|
||||
// Setpoint returns the current setpoint.
|
||||
Setpoint() float64
|
||||
|
||||
// SetGains updates the PID gains.
|
||||
SetGains(kp, ki, kd float64)
|
||||
|
||||
// Gains returns the current PID gains.
|
||||
Gains() (kp, ki, kd float64)
|
||||
}
|
||||
|
||||
// Tuning holds PID tuning parameters.
|
||||
// This can be used for configuration or auto-tuning.
|
||||
type Tuning struct {
|
||||
Kp float64 // Proportional gain
|
||||
Ki float64 // Integral gain
|
||||
Kd float64 // Derivative gain
|
||||
|
||||
Setpoint float64 // Target value
|
||||
|
||||
// Derivative filtering (0.0-1.0, lower = more filtering)
|
||||
DerivativeFilterAlpha float64
|
||||
|
||||
// Anti-windup limits for integral term
|
||||
IntegralMin float64
|
||||
IntegralMax float64
|
||||
|
||||
// Output limits
|
||||
OutputMin float64
|
||||
OutputMax float64
|
||||
}
|
||||
|
||||
// DefaultTuning returns sensible defaults for a normalized (0-1) process variable.
|
||||
func DefaultTuning() Tuning {
|
||||
return Tuning{
|
||||
Kp: 0.5,
|
||||
Ki: 0.1,
|
||||
Kd: 0.05,
|
||||
Setpoint: 0.5,
|
||||
DerivativeFilterAlpha: 0.2,
|
||||
IntegralMin: -10.0,
|
||||
IntegralMax: 10.0,
|
||||
OutputMin: 0.0,
|
||||
OutputMax: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// SimpleProcessVariable is a basic implementation of ProcessVariable.
|
||||
type SimpleProcessVariable struct {
|
||||
V float64
|
||||
T time.Time
|
||||
}
|
||||
|
||||
// Value returns the process variable value.
|
||||
func (p SimpleProcessVariable) Value() float64 { return p.V }
|
||||
|
||||
// Timestamp returns when this measurement was taken.
|
||||
func (p SimpleProcessVariable) Timestamp() time.Time { return p.T }
|
||||
|
||||
// NewProcessVariable creates a SimpleProcessVariable with the current time.
|
||||
func NewProcessVariable(value float64) SimpleProcessVariable {
|
||||
return SimpleProcessVariable{V: value, T: time.Now()}
|
||||
}
|
||||
|
||||
// NewProcessVariableAt creates a SimpleProcessVariable with a specific time.
|
||||
func NewProcessVariableAt(value float64, t time.Time) SimpleProcessVariable {
|
||||
return SimpleProcessVariable{V: value, T: t}
|
||||
}
|
||||
266
pkg/pid/controller.go
Normal file
266
pkg/pid/controller.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// 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
|
||||
}
|
||||
402
pkg/pid/controller_test.go
Normal file
402
pkg/pid/controller_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package pid
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pidif "next.orly.dev/pkg/interfaces/pid"
|
||||
)
|
||||
|
||||
func TestController_BasicOperation(t *testing.T) {
|
||||
ctrl := New(RateLimitWriteTuning())
|
||||
|
||||
// First call should return 0 (initialization)
|
||||
out := ctrl.UpdateValue(0.5)
|
||||
if out.Value() != 0 {
|
||||
t.Errorf("expected 0 on first call, got %v", out.Value())
|
||||
}
|
||||
|
||||
// Sleep a bit to ensure dt > 0
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Process variable below setpoint (0.5 < 0.85) should return 0 or negative (clamped to 0)
|
||||
out = ctrl.UpdateValue(0.5)
|
||||
if out.Value() != 0 {
|
||||
t.Errorf("expected 0 when below setpoint, got %v", out.Value())
|
||||
}
|
||||
|
||||
// Process variable above setpoint should return positive output
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
out = ctrl.UpdateValue(0.95) // 0.95 > 0.85 setpoint
|
||||
if out.Value() <= 0 {
|
||||
t.Errorf("expected positive output when above setpoint, got %v", out.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_IntegralAccumulation(t *testing.T) {
|
||||
tuning := pidif.Tuning{
|
||||
Kp: 0.5,
|
||||
Ki: 0.5, // High Ki
|
||||
Kd: 0.0, // No Kd
|
||||
Setpoint: 0.5,
|
||||
DerivativeFilterAlpha: 0.2,
|
||||
IntegralMin: -10,
|
||||
IntegralMax: 10,
|
||||
OutputMin: 0,
|
||||
OutputMax: 1.0,
|
||||
}
|
||||
ctrl := New(tuning)
|
||||
|
||||
// Initialize
|
||||
ctrl.UpdateValue(0.5)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Continuously above setpoint should accumulate integral
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
ctrl.UpdateValue(0.8) // 0.3 above setpoint
|
||||
}
|
||||
|
||||
integral, _, _, _ := ctrl.State()
|
||||
if integral <= 0 {
|
||||
t.Errorf("expected positive integral after sustained error, got %v", integral)
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_FilteredDerivative(t *testing.T) {
|
||||
tuning := pidif.Tuning{
|
||||
Kp: 0.0,
|
||||
Ki: 0.0,
|
||||
Kd: 1.0, // Only Kd
|
||||
Setpoint: 0.5,
|
||||
DerivativeFilterAlpha: 0.5, // 50% filtering
|
||||
IntegralMin: -10,
|
||||
IntegralMax: 10,
|
||||
OutputMin: 0,
|
||||
OutputMax: 1.0,
|
||||
}
|
||||
ctrl := New(tuning)
|
||||
|
||||
// Initialize with low value
|
||||
ctrl.UpdateValue(0.5)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Second call with same value - derivative should be near zero
|
||||
ctrl.UpdateValue(0.5)
|
||||
_, _, prevFiltered, _ := ctrl.State()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Big jump - filtered derivative should be dampened
|
||||
out := ctrl.UpdateValue(1.0)
|
||||
|
||||
// The filtered derivative should cause some response, but dampened
|
||||
if out.Value() < 0 {
|
||||
t.Errorf("expected non-negative output, got %v", out.Value())
|
||||
}
|
||||
|
||||
_, _, newFiltered, _ := ctrl.State()
|
||||
// Filtered error should have moved toward the new error but not fully
|
||||
if newFiltered <= prevFiltered {
|
||||
t.Errorf("filtered error should increase with rising process variable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_AntiWindup(t *testing.T) {
|
||||
tuning := pidif.Tuning{
|
||||
Kp: 0.0,
|
||||
Ki: 1.0, // Only Ki
|
||||
Kd: 0.0,
|
||||
Setpoint: 0.5,
|
||||
DerivativeFilterAlpha: 0.2,
|
||||
IntegralMin: -1.0, // Tight integral bounds
|
||||
IntegralMax: 1.0,
|
||||
OutputMin: 0,
|
||||
OutputMax: 10.0, // Wide output bounds
|
||||
}
|
||||
ctrl := New(tuning)
|
||||
|
||||
// Initialize
|
||||
ctrl.UpdateValue(0.5)
|
||||
|
||||
// Drive the integral to its limit
|
||||
for i := 0; i < 100; i++ {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
ctrl.UpdateValue(1.0) // Large positive error
|
||||
}
|
||||
|
||||
integral, _, _, _ := ctrl.State()
|
||||
if integral > 1.0 {
|
||||
t.Errorf("integral should be clamped at 1.0, got %v", integral)
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_Reset(t *testing.T) {
|
||||
ctrl := New(RateLimitWriteTuning())
|
||||
|
||||
// Build up some state
|
||||
ctrl.UpdateValue(0.5)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
ctrl.UpdateValue(0.9)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
ctrl.UpdateValue(0.95)
|
||||
|
||||
// Reset
|
||||
ctrl.Reset()
|
||||
|
||||
integral, prevErr, prevFiltered, initialized := ctrl.State()
|
||||
if integral != 0 || prevErr != 0 || prevFiltered != 0 || initialized {
|
||||
t.Errorf("expected all state to be zero after reset, got integral=%v, prevErr=%v, prevFiltered=%v, initialized=%v",
|
||||
integral, prevErr, prevFiltered, initialized)
|
||||
}
|
||||
|
||||
// Next call should behave like first call
|
||||
out := ctrl.UpdateValue(0.9)
|
||||
if out.Value() != 0 {
|
||||
t.Errorf("expected 0 on first call after reset, got %v", out.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_SetGains(t *testing.T) {
|
||||
ctrl := New(RateLimitWriteTuning())
|
||||
|
||||
// Change gains
|
||||
ctrl.SetGains(1.0, 0.5, 0.1)
|
||||
|
||||
kp, ki, kd := ctrl.Gains()
|
||||
if kp != 1.0 || ki != 0.5 || kd != 0.1 {
|
||||
t.Errorf("gains not updated correctly: kp=%v, ki=%v, kd=%v", kp, ki, kd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_SetSetpoint(t *testing.T) {
|
||||
ctrl := New(RateLimitWriteTuning())
|
||||
|
||||
ctrl.SetSetpoint(0.7)
|
||||
|
||||
if ctrl.Setpoint() != 0.7 {
|
||||
t.Errorf("setpoint not updated, got %v", ctrl.Setpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_OutputClamping(t *testing.T) {
|
||||
tuning := pidif.Tuning{
|
||||
Kp: 10.0, // Very high Kp
|
||||
Ki: 0.0,
|
||||
Kd: 0.0,
|
||||
Setpoint: 0.5,
|
||||
DerivativeFilterAlpha: 0.2,
|
||||
IntegralMin: -10,
|
||||
IntegralMax: 10,
|
||||
OutputMin: 0,
|
||||
OutputMax: 1.0, // Strict output max
|
||||
}
|
||||
ctrl := New(tuning)
|
||||
|
||||
// Initialize
|
||||
ctrl.UpdateValue(0.5)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Very high error should be clamped
|
||||
out := ctrl.UpdateValue(2.0) // 1.5 error * 10 Kp = 15, should clamp to 1.0
|
||||
if out.Value() > 1.0 {
|
||||
t.Errorf("output should be clamped to 1.0, got %v", out.Value())
|
||||
}
|
||||
if !out.Clamped() {
|
||||
t.Errorf("expected output to be flagged as clamped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_Components(t *testing.T) {
|
||||
tuning := pidif.Tuning{
|
||||
Kp: 1.0,
|
||||
Ki: 0.5,
|
||||
Kd: 0.1,
|
||||
Setpoint: 0.5,
|
||||
DerivativeFilterAlpha: 0.2,
|
||||
IntegralMin: -10,
|
||||
IntegralMax: 10,
|
||||
OutputMin: -100,
|
||||
OutputMax: 100,
|
||||
}
|
||||
ctrl := New(tuning)
|
||||
|
||||
// Initialize
|
||||
ctrl.UpdateValue(0.5)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get components
|
||||
out := ctrl.UpdateValue(0.8)
|
||||
p, i, d := out.Components()
|
||||
|
||||
// Proportional should be positive (0.3 * 1.0 = 0.3)
|
||||
expectedP := 0.3
|
||||
if p < expectedP*0.9 || p > expectedP*1.1 {
|
||||
t.Errorf("expected P term ~%v, got %v", expectedP, p)
|
||||
}
|
||||
|
||||
// Integral should be small but positive (accumulated over ~10ms)
|
||||
if i <= 0 {
|
||||
t.Errorf("expected positive I term, got %v", i)
|
||||
}
|
||||
|
||||
// Derivative should be non-zero (error changed)
|
||||
// The sign depends on filtering and timing
|
||||
_ = d // Just verify it's accessible
|
||||
}
|
||||
|
||||
func TestPresets(t *testing.T) {
|
||||
// Test that all presets create valid controllers
|
||||
tests := []struct {
|
||||
name string
|
||||
tuning pidif.Tuning
|
||||
}{
|
||||
{"RateLimitWrite", RateLimitWriteTuning()},
|
||||
{"RateLimitRead", RateLimitReadTuning()},
|
||||
{"DifficultyAdjustment", DifficultyAdjustmentTuning()},
|
||||
{"TemperatureControl", TemperatureControlTuning(25.0)},
|
||||
{"MotorSpeed", MotorSpeedTuning()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := New(tt.tuning)
|
||||
if ctrl == nil {
|
||||
t.Error("expected non-nil controller")
|
||||
return
|
||||
}
|
||||
|
||||
// Basic sanity check
|
||||
out := ctrl.UpdateValue(tt.tuning.Setpoint)
|
||||
if out == nil {
|
||||
t.Error("expected non-nil output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryFunctions(t *testing.T) {
|
||||
// Test convenience factory functions
|
||||
writeCtrl := NewRateLimitWriteController()
|
||||
if writeCtrl == nil {
|
||||
t.Error("NewRateLimitWriteController returned nil")
|
||||
}
|
||||
|
||||
readCtrl := NewRateLimitReadController()
|
||||
if readCtrl == nil {
|
||||
t.Error("NewRateLimitReadController returned nil")
|
||||
}
|
||||
|
||||
diffCtrl := NewDifficultyAdjustmentController()
|
||||
if diffCtrl == nil {
|
||||
t.Error("NewDifficultyAdjustmentController returned nil")
|
||||
}
|
||||
|
||||
tempCtrl := NewTemperatureController(72.0)
|
||||
if tempCtrl == nil {
|
||||
t.Error("NewTemperatureController returned nil")
|
||||
}
|
||||
|
||||
motorCtrl := NewMotorSpeedController()
|
||||
if motorCtrl == nil {
|
||||
t.Error("NewMotorSpeedController returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_ProcessVariableInterface(t *testing.T) {
|
||||
ctrl := New(RateLimitWriteTuning())
|
||||
|
||||
// Test using the full ProcessVariable interface
|
||||
pv := pidif.NewProcessVariableAt(0.9, time.Now())
|
||||
out := ctrl.Update(pv)
|
||||
|
||||
// First call returns 0
|
||||
if out.Value() != 0 {
|
||||
t.Errorf("expected 0 on first call, got %v", out.Value())
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
pv2 := pidif.NewProcessVariableAt(0.95, time.Now())
|
||||
out2 := ctrl.Update(pv2)
|
||||
|
||||
// Above setpoint should produce positive output
|
||||
if out2.Value() <= 0 {
|
||||
t.Errorf("expected positive output above setpoint, got %v", out2.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_NewWithGains(t *testing.T) {
|
||||
ctrl := NewWithGains(1.0, 0.5, 0.1, 0.7)
|
||||
|
||||
kp, ki, kd := ctrl.Gains()
|
||||
if kp != 1.0 || ki != 0.5 || kd != 0.1 {
|
||||
t.Errorf("gains not set correctly: kp=%v, ki=%v, kd=%v", kp, ki, kd)
|
||||
}
|
||||
|
||||
if ctrl.Setpoint() != 0.7 {
|
||||
t.Errorf("setpoint not set correctly, got %v", ctrl.Setpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_SetTuning(t *testing.T) {
|
||||
ctrl := NewDefault()
|
||||
|
||||
newTuning := RateLimitWriteTuning()
|
||||
ctrl.SetTuning(newTuning)
|
||||
|
||||
tuning := ctrl.Tuning()
|
||||
if tuning.Kp != newTuning.Kp || tuning.Ki != newTuning.Ki || tuning.Setpoint != newTuning.Setpoint {
|
||||
t.Errorf("tuning not updated correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_SetOutputLimits(t *testing.T) {
|
||||
ctrl := NewDefault()
|
||||
ctrl.SetOutputLimits(-5.0, 5.0)
|
||||
|
||||
tuning := ctrl.Tuning()
|
||||
if tuning.OutputMin != -5.0 || tuning.OutputMax != 5.0 {
|
||||
t.Errorf("output limits not updated: min=%v, max=%v", tuning.OutputMin, tuning.OutputMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_SetIntegralLimits(t *testing.T) {
|
||||
ctrl := NewDefault()
|
||||
ctrl.SetIntegralLimits(-2.0, 2.0)
|
||||
|
||||
tuning := ctrl.Tuning()
|
||||
if tuning.IntegralMin != -2.0 || tuning.IntegralMax != 2.0 {
|
||||
t.Errorf("integral limits not updated: min=%v, max=%v", tuning.IntegralMin, tuning.IntegralMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestController_SetDerivativeFilter(t *testing.T) {
|
||||
ctrl := NewDefault()
|
||||
ctrl.SetDerivativeFilter(0.5)
|
||||
|
||||
tuning := ctrl.Tuning()
|
||||
if tuning.DerivativeFilterAlpha != 0.5 {
|
||||
t.Errorf("derivative filter alpha not updated: %v", tuning.DerivativeFilterAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTuning(t *testing.T) {
|
||||
tuning := pidif.DefaultTuning()
|
||||
|
||||
if tuning.Kp <= 0 || tuning.Ki <= 0 || tuning.Kd <= 0 {
|
||||
t.Error("default tuning should have positive gains")
|
||||
}
|
||||
|
||||
if tuning.DerivativeFilterAlpha <= 0 || tuning.DerivativeFilterAlpha > 1.0 {
|
||||
t.Errorf("default derivative filter alpha should be in (0, 1], got %v", tuning.DerivativeFilterAlpha)
|
||||
}
|
||||
|
||||
if tuning.OutputMin >= tuning.OutputMax {
|
||||
t.Error("default output min should be less than max")
|
||||
}
|
||||
|
||||
if tuning.IntegralMin >= tuning.IntegralMax {
|
||||
t.Error("default integral min should be less than max")
|
||||
}
|
||||
}
|
||||
127
pkg/pid/presets.go
Normal file
127
pkg/pid/presets.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package pid
|
||||
|
||||
import (
|
||||
pidif "next.orly.dev/pkg/interfaces/pid"
|
||||
)
|
||||
|
||||
// Presets for common PID controller use cases.
|
||||
// These provide good starting points that can be fine-tuned for specific applications.
|
||||
|
||||
// RateLimitWriteTuning returns tuning optimized for write rate limiting.
|
||||
// - Aggressive response to prevent memory exhaustion
|
||||
// - Moderate integral for sustained load handling
|
||||
// - Small derivative with strong filtering
|
||||
func RateLimitWriteTuning() pidif.Tuning {
|
||||
return pidif.Tuning{
|
||||
Kp: 0.5,
|
||||
Ki: 0.1,
|
||||
Kd: 0.05,
|
||||
Setpoint: 0.85, // Target 85% of limit
|
||||
DerivativeFilterAlpha: 0.2, // Strong filtering
|
||||
IntegralMin: -2.0,
|
||||
IntegralMax: 10.0,
|
||||
OutputMin: 0.0,
|
||||
OutputMax: 1.0, // Max 1 second delay
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitReadTuning returns tuning optimized for read rate limiting.
|
||||
// - Less aggressive than writes (reads are more latency-sensitive)
|
||||
// - Lower gains to avoid over-throttling queries
|
||||
func RateLimitReadTuning() pidif.Tuning {
|
||||
return pidif.Tuning{
|
||||
Kp: 0.3,
|
||||
Ki: 0.05,
|
||||
Kd: 0.02,
|
||||
Setpoint: 0.90, // Target 90% of limit
|
||||
DerivativeFilterAlpha: 0.15, // Very strong filtering
|
||||
IntegralMin: -1.0,
|
||||
IntegralMax: 5.0,
|
||||
OutputMin: 0.0,
|
||||
OutputMax: 0.5, // Max 500ms delay
|
||||
}
|
||||
}
|
||||
|
||||
// DifficultyAdjustmentTuning returns tuning for PoW difficulty adjustment.
|
||||
// Designed for block time targeting where:
|
||||
// - Process variable: actual_block_time / target_block_time (1.0 = on target)
|
||||
// - Output: difficulty multiplier (1.0 = no change, >1 = harder, <1 = easier)
|
||||
//
|
||||
// This uses:
|
||||
// - Low Kp to avoid overreacting to individual blocks
|
||||
// - Moderate Ki to converge on target over time
|
||||
// - Small Kd with strong filtering to anticipate trends
|
||||
func DifficultyAdjustmentTuning() pidif.Tuning {
|
||||
return pidif.Tuning{
|
||||
Kp: 0.1, // Low proportional (blocks are noisy)
|
||||
Ki: 0.05, // Moderate integral for convergence
|
||||
Kd: 0.02, // Small derivative
|
||||
Setpoint: 1.0, // Target: actual == expected block time
|
||||
DerivativeFilterAlpha: 0.1, // Very strong filtering (blocks are noisy)
|
||||
IntegralMin: -0.5, // Limit integral windup
|
||||
IntegralMax: 0.5,
|
||||
OutputMin: 0.5, // Min 50% difficulty change
|
||||
OutputMax: 2.0, // Max 200% difficulty change
|
||||
}
|
||||
}
|
||||
|
||||
// TemperatureControlTuning returns tuning for temperature regulation.
|
||||
// Suitable for heating/cooling systems where:
|
||||
// - Process variable: current temperature
|
||||
// - Setpoint: target temperature
|
||||
// - Output: heater/cooler power level (0-1)
|
||||
func TemperatureControlTuning(targetTemp float64) pidif.Tuning {
|
||||
return pidif.Tuning{
|
||||
Kp: 0.1, // Moderate response
|
||||
Ki: 0.01, // Slow integral (thermal inertia)
|
||||
Kd: 0.05, // Some anticipation
|
||||
Setpoint: targetTemp,
|
||||
DerivativeFilterAlpha: 0.3, // Moderate filtering
|
||||
IntegralMin: -100.0,
|
||||
IntegralMax: 100.0,
|
||||
OutputMin: 0.0,
|
||||
OutputMax: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// MotorSpeedTuning returns tuning for motor speed control.
|
||||
// - Process variable: actual RPM / target RPM
|
||||
// - Output: motor power level
|
||||
func MotorSpeedTuning() pidif.Tuning {
|
||||
return pidif.Tuning{
|
||||
Kp: 0.5, // Quick response
|
||||
Ki: 0.2, // Eliminate steady-state error
|
||||
Kd: 0.1, // Dampen oscillations
|
||||
Setpoint: 1.0, // Target: actual == desired speed
|
||||
DerivativeFilterAlpha: 0.4, // Moderate filtering
|
||||
IntegralMin: -1.0,
|
||||
IntegralMax: 1.0,
|
||||
OutputMin: 0.0,
|
||||
OutputMax: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRateLimitWriteController creates a controller for write rate limiting.
|
||||
func NewRateLimitWriteController() *Controller {
|
||||
return New(RateLimitWriteTuning())
|
||||
}
|
||||
|
||||
// NewRateLimitReadController creates a controller for read rate limiting.
|
||||
func NewRateLimitReadController() *Controller {
|
||||
return New(RateLimitReadTuning())
|
||||
}
|
||||
|
||||
// NewDifficultyAdjustmentController creates a controller for PoW difficulty.
|
||||
func NewDifficultyAdjustmentController() *Controller {
|
||||
return New(DifficultyAdjustmentTuning())
|
||||
}
|
||||
|
||||
// NewTemperatureController creates a controller for temperature regulation.
|
||||
func NewTemperatureController(targetTemp float64) *Controller {
|
||||
return New(TemperatureControlTuning(targetTemp))
|
||||
}
|
||||
|
||||
// NewMotorSpeedController creates a controller for motor speed control.
|
||||
func NewMotorSpeedController() *Controller {
|
||||
return New(MotorSpeedTuning())
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/interfaces/loadmonitor"
|
||||
pidif "next.orly.dev/pkg/interfaces/pid"
|
||||
"next.orly.dev/pkg/pid"
|
||||
)
|
||||
|
||||
// OperationType distinguishes between read and write operations
|
||||
@@ -129,9 +131,9 @@ type Limiter struct {
|
||||
config Config
|
||||
monitor loadmonitor.Monitor
|
||||
|
||||
// PID controllers for reads and writes
|
||||
writePID *PIDController
|
||||
readPID *PIDController
|
||||
// PID controllers for reads and writes (using generic pid.Controller)
|
||||
writePID pidif.Controller
|
||||
readPID pidif.Controller
|
||||
|
||||
// Cached metrics (updated periodically)
|
||||
metricsLock sync.RWMutex
|
||||
@@ -164,22 +166,30 @@ func NewLimiter(config Config, monitor loadmonitor.Monitor) *Limiter {
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Create PID controllers with configured gains
|
||||
l.writePID = NewPIDController(
|
||||
config.WriteKp, config.WriteKi, config.WriteKd,
|
||||
config.WriteSetpoint,
|
||||
0.2, // Strong filtering for writes
|
||||
-2.0, float64(config.MaxWriteDelayMs)/1000.0*2, // Anti-windup limits
|
||||
0, float64(config.MaxWriteDelayMs)/1000.0,
|
||||
)
|
||||
// Create PID controllers with configured gains using the generic pid package
|
||||
l.writePID = pid.New(pidif.Tuning{
|
||||
Kp: config.WriteKp,
|
||||
Ki: config.WriteKi,
|
||||
Kd: config.WriteKd,
|
||||
Setpoint: config.WriteSetpoint,
|
||||
DerivativeFilterAlpha: 0.2, // Strong filtering for writes
|
||||
IntegralMin: -2.0,
|
||||
IntegralMax: float64(config.MaxWriteDelayMs) / 1000.0 * 2, // Anti-windup limits
|
||||
OutputMin: 0,
|
||||
OutputMax: float64(config.MaxWriteDelayMs) / 1000.0,
|
||||
})
|
||||
|
||||
l.readPID = NewPIDController(
|
||||
config.ReadKp, config.ReadKi, config.ReadKd,
|
||||
config.ReadSetpoint,
|
||||
0.15, // Very strong filtering for reads
|
||||
-1.0, float64(config.MaxReadDelayMs)/1000.0*2,
|
||||
0, float64(config.MaxReadDelayMs)/1000.0,
|
||||
)
|
||||
l.readPID = pid.New(pidif.Tuning{
|
||||
Kp: config.ReadKp,
|
||||
Ki: config.ReadKi,
|
||||
Kd: config.ReadKd,
|
||||
Setpoint: config.ReadSetpoint,
|
||||
DerivativeFilterAlpha: 0.15, // Very strong filtering for reads
|
||||
IntegralMin: -1.0,
|
||||
IntegralMax: float64(config.MaxReadDelayMs) / 1000.0 * 2,
|
||||
OutputMin: 0,
|
||||
OutputMax: float64(config.MaxReadDelayMs) / 1000.0,
|
||||
})
|
||||
|
||||
// Set memory target on monitor
|
||||
if monitor != nil && config.TargetMemoryMB > 0 {
|
||||
@@ -293,13 +303,15 @@ func (l *Limiter) ComputeDelay(opType OperationType) time.Duration {
|
||||
var delaySec float64
|
||||
switch opType {
|
||||
case Write:
|
||||
delaySec = l.writePID.Update(pv)
|
||||
out := l.writePID.UpdateValue(pv)
|
||||
delaySec = out.Value()
|
||||
if delaySec > 0 {
|
||||
l.writeThrottles.Add(1)
|
||||
l.totalWriteDelayMs.Add(int64(delaySec * 1000))
|
||||
}
|
||||
case Read:
|
||||
delaySec = l.readPID.Update(pv)
|
||||
out := l.readPID.UpdateValue(pv)
|
||||
delaySec = out.Value()
|
||||
if delaySec > 0 {
|
||||
l.readThrottles.Add(1)
|
||||
l.totalReadDelayMs.Add(int64(delaySec * 1000))
|
||||
@@ -351,26 +363,34 @@ func (l *Limiter) GetStats() Stats {
|
||||
metrics := l.currentMetrics
|
||||
l.metricsLock.RUnlock()
|
||||
|
||||
wIntegral, wPrevErr, wPrevFiltered := l.writePID.GetState()
|
||||
rIntegral, rPrevErr, rPrevFiltered := l.readPID.GetState()
|
||||
|
||||
return Stats{
|
||||
stats := Stats{
|
||||
WriteThrottles: l.writeThrottles.Load(),
|
||||
ReadThrottles: l.readThrottles.Load(),
|
||||
TotalWriteDelayMs: l.totalWriteDelayMs.Load(),
|
||||
TotalReadDelayMs: l.totalReadDelayMs.Load(),
|
||||
CurrentMetrics: metrics,
|
||||
WritePIDState: PIDState{
|
||||
Integral: wIntegral,
|
||||
PrevError: wPrevErr,
|
||||
PrevFilteredError: wPrevFiltered,
|
||||
},
|
||||
ReadPIDState: PIDState{
|
||||
Integral: rIntegral,
|
||||
PrevError: rPrevErr,
|
||||
PrevFilteredError: rPrevFiltered,
|
||||
},
|
||||
}
|
||||
|
||||
// Type assert to concrete pid.Controller to access State() method
|
||||
// This is for monitoring/debugging only
|
||||
if wCtrl, ok := l.writePID.(*pid.Controller); ok {
|
||||
integral, prevErr, prevFiltered, _ := wCtrl.State()
|
||||
stats.WritePIDState = PIDState{
|
||||
Integral: integral,
|
||||
PrevError: prevErr,
|
||||
PrevFilteredError: prevFiltered,
|
||||
}
|
||||
}
|
||||
if rCtrl, ok := l.readPID.(*pid.Controller); ok {
|
||||
integral, prevErr, prevFiltered, _ := rCtrl.State()
|
||||
stats.ReadPIDState = PIDState{
|
||||
Integral: integral,
|
||||
PrevError: prevErr,
|
||||
PrevFilteredError: prevFiltered,
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Reset clears all PID controller state and statistics.
|
||||
@@ -393,14 +413,19 @@ func (l *Limiter) IsEnabled() bool {
|
||||
func (l *Limiter) UpdateConfig(config Config) {
|
||||
l.config = config
|
||||
|
||||
// Update PID controllers
|
||||
// Update PID controllers - use interface methods for setpoint and gains
|
||||
l.writePID.SetSetpoint(config.WriteSetpoint)
|
||||
l.writePID.SetGains(config.WriteKp, config.WriteKi, config.WriteKd)
|
||||
l.writePID.OutputMax = float64(config.MaxWriteDelayMs) / 1000.0
|
||||
// Type assert to set output limits (not part of base interface)
|
||||
if wCtrl, ok := l.writePID.(*pid.Controller); ok {
|
||||
wCtrl.SetOutputLimits(0, float64(config.MaxWriteDelayMs)/1000.0)
|
||||
}
|
||||
|
||||
l.readPID.SetSetpoint(config.ReadSetpoint)
|
||||
l.readPID.SetGains(config.ReadKp, config.ReadKi, config.ReadKd)
|
||||
l.readPID.OutputMax = float64(config.MaxReadDelayMs) / 1000.0
|
||||
if rCtrl, ok := l.readPID.(*pid.Controller); ok {
|
||||
rCtrl.SetOutputLimits(0, float64(config.MaxReadDelayMs)/1000.0)
|
||||
}
|
||||
|
||||
// Update memory target
|
||||
if l.monitor != nil && config.TargetMemoryMB > 0 {
|
||||
|
||||
Reference in New Issue
Block a user