interim-docs-update #3

Merged
mleku merged 7 commits from interim-docs-update into main 2025-12-12 08:04:26 +00:00
5 changed files with 990 additions and 37 deletions
Showing only changes of commit 28b41847a6 - Show all commits

133
pkg/interfaces/pid/pid.go Normal file
View 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
View 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
View 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
View 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())
}

View File

@@ -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 {