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 {