From 28b41847a65b886e7bd08ca773c7870a587fd058 Mon Sep 17 00:00:00 2001 From: mleku Date: Thu, 11 Dec 2025 22:53:04 +0100 Subject: [PATCH] Generalize PID controller as reusable library with abstract interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pkg/interfaces/pid/pid.go | 133 ++++++++++++ pkg/pid/controller.go | 266 ++++++++++++++++++++++++ pkg/pid/controller_test.go | 402 +++++++++++++++++++++++++++++++++++++ pkg/pid/presets.go | 127 ++++++++++++ pkg/ratelimit/limiter.go | 99 +++++---- 5 files changed, 990 insertions(+), 37 deletions(-) create mode 100644 pkg/interfaces/pid/pid.go create mode 100644 pkg/pid/controller.go create mode 100644 pkg/pid/controller_test.go create mode 100644 pkg/pid/presets.go diff --git a/pkg/interfaces/pid/pid.go b/pkg/interfaces/pid/pid.go new file mode 100644 index 0000000..a805f5e --- /dev/null +++ b/pkg/interfaces/pid/pid.go @@ -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} +} diff --git a/pkg/pid/controller.go b/pkg/pid/controller.go new file mode 100644 index 0000000..67148e4 --- /dev/null +++ b/pkg/pid/controller.go @@ -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 +} diff --git a/pkg/pid/controller_test.go b/pkg/pid/controller_test.go new file mode 100644 index 0000000..690db87 --- /dev/null +++ b/pkg/pid/controller_test.go @@ -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") + } +} diff --git a/pkg/pid/presets.go b/pkg/pid/presets.go new file mode 100644 index 0000000..976c086 --- /dev/null +++ b/pkg/pid/presets.go @@ -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()) +} diff --git a/pkg/ratelimit/limiter.go b/pkg/ratelimit/limiter.go index 76e3179..37016be 100644 --- a/pkg/ratelimit/limiter.go +++ b/pkg/ratelimit/limiter.go @@ -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 {