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"
|
"time"
|
||||||
|
|
||||||
"next.orly.dev/pkg/interfaces/loadmonitor"
|
"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
|
// OperationType distinguishes between read and write operations
|
||||||
@@ -129,9 +131,9 @@ type Limiter struct {
|
|||||||
config Config
|
config Config
|
||||||
monitor loadmonitor.Monitor
|
monitor loadmonitor.Monitor
|
||||||
|
|
||||||
// PID controllers for reads and writes
|
// PID controllers for reads and writes (using generic pid.Controller)
|
||||||
writePID *PIDController
|
writePID pidif.Controller
|
||||||
readPID *PIDController
|
readPID pidif.Controller
|
||||||
|
|
||||||
// Cached metrics (updated periodically)
|
// Cached metrics (updated periodically)
|
||||||
metricsLock sync.RWMutex
|
metricsLock sync.RWMutex
|
||||||
@@ -164,22 +166,30 @@ func NewLimiter(config Config, monitor loadmonitor.Monitor) *Limiter {
|
|||||||
stopped: make(chan struct{}),
|
stopped: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create PID controllers with configured gains
|
// Create PID controllers with configured gains using the generic pid package
|
||||||
l.writePID = NewPIDController(
|
l.writePID = pid.New(pidif.Tuning{
|
||||||
config.WriteKp, config.WriteKi, config.WriteKd,
|
Kp: config.WriteKp,
|
||||||
config.WriteSetpoint,
|
Ki: config.WriteKi,
|
||||||
0.2, // Strong filtering for writes
|
Kd: config.WriteKd,
|
||||||
-2.0, float64(config.MaxWriteDelayMs)/1000.0*2, // Anti-windup limits
|
Setpoint: config.WriteSetpoint,
|
||||||
0, float64(config.MaxWriteDelayMs)/1000.0,
|
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(
|
l.readPID = pid.New(pidif.Tuning{
|
||||||
config.ReadKp, config.ReadKi, config.ReadKd,
|
Kp: config.ReadKp,
|
||||||
config.ReadSetpoint,
|
Ki: config.ReadKi,
|
||||||
0.15, // Very strong filtering for reads
|
Kd: config.ReadKd,
|
||||||
-1.0, float64(config.MaxReadDelayMs)/1000.0*2,
|
Setpoint: config.ReadSetpoint,
|
||||||
0, float64(config.MaxReadDelayMs)/1000.0,
|
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
|
// Set memory target on monitor
|
||||||
if monitor != nil && config.TargetMemoryMB > 0 {
|
if monitor != nil && config.TargetMemoryMB > 0 {
|
||||||
@@ -293,13 +303,15 @@ func (l *Limiter) ComputeDelay(opType OperationType) time.Duration {
|
|||||||
var delaySec float64
|
var delaySec float64
|
||||||
switch opType {
|
switch opType {
|
||||||
case Write:
|
case Write:
|
||||||
delaySec = l.writePID.Update(pv)
|
out := l.writePID.UpdateValue(pv)
|
||||||
|
delaySec = out.Value()
|
||||||
if delaySec > 0 {
|
if delaySec > 0 {
|
||||||
l.writeThrottles.Add(1)
|
l.writeThrottles.Add(1)
|
||||||
l.totalWriteDelayMs.Add(int64(delaySec * 1000))
|
l.totalWriteDelayMs.Add(int64(delaySec * 1000))
|
||||||
}
|
}
|
||||||
case Read:
|
case Read:
|
||||||
delaySec = l.readPID.Update(pv)
|
out := l.readPID.UpdateValue(pv)
|
||||||
|
delaySec = out.Value()
|
||||||
if delaySec > 0 {
|
if delaySec > 0 {
|
||||||
l.readThrottles.Add(1)
|
l.readThrottles.Add(1)
|
||||||
l.totalReadDelayMs.Add(int64(delaySec * 1000))
|
l.totalReadDelayMs.Add(int64(delaySec * 1000))
|
||||||
@@ -351,26 +363,34 @@ func (l *Limiter) GetStats() Stats {
|
|||||||
metrics := l.currentMetrics
|
metrics := l.currentMetrics
|
||||||
l.metricsLock.RUnlock()
|
l.metricsLock.RUnlock()
|
||||||
|
|
||||||
wIntegral, wPrevErr, wPrevFiltered := l.writePID.GetState()
|
stats := Stats{
|
||||||
rIntegral, rPrevErr, rPrevFiltered := l.readPID.GetState()
|
|
||||||
|
|
||||||
return Stats{
|
|
||||||
WriteThrottles: l.writeThrottles.Load(),
|
WriteThrottles: l.writeThrottles.Load(),
|
||||||
ReadThrottles: l.readThrottles.Load(),
|
ReadThrottles: l.readThrottles.Load(),
|
||||||
TotalWriteDelayMs: l.totalWriteDelayMs.Load(),
|
TotalWriteDelayMs: l.totalWriteDelayMs.Load(),
|
||||||
TotalReadDelayMs: l.totalReadDelayMs.Load(),
|
TotalReadDelayMs: l.totalReadDelayMs.Load(),
|
||||||
CurrentMetrics: metrics,
|
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.
|
// Reset clears all PID controller state and statistics.
|
||||||
@@ -393,14 +413,19 @@ func (l *Limiter) IsEnabled() bool {
|
|||||||
func (l *Limiter) UpdateConfig(config Config) {
|
func (l *Limiter) UpdateConfig(config Config) {
|
||||||
l.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.SetSetpoint(config.WriteSetpoint)
|
||||||
l.writePID.SetGains(config.WriteKp, config.WriteKi, config.WriteKd)
|
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.SetSetpoint(config.ReadSetpoint)
|
||||||
l.readPID.SetGains(config.ReadKp, config.ReadKi, config.ReadKd)
|
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
|
// Update memory target
|
||||||
if l.monitor != nil && config.TargetMemoryMB > 0 {
|
if l.monitor != nil && config.TargetMemoryMB > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user