229 lines
6.1 KiB
Go
229 lines
6.1 KiB
Go
// Package stub provides a synthetic data source that emits deterministic test
|
||
// signals (sine waves, ramp, boolean toggle) without any external dependencies.
|
||
// It is used during development and for integration tests.
|
||
package stub
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"math/rand/v2"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/uopi/uopi/internal/datasource"
|
||
)
|
||
|
||
const updateInterval = 100 * time.Millisecond // 10 Hz default
|
||
|
||
type signalDef struct {
|
||
meta datasource.Metadata
|
||
interval time.Duration // 0 → updateInterval
|
||
fn func(t time.Time) any
|
||
}
|
||
|
||
// Stub is a data source that emits canned signals for development and testing.
|
||
type Stub struct {
|
||
signals map[string]signalDef
|
||
|
||
// writable values (signal name → current value)
|
||
mu sync.RWMutex
|
||
values map[string]any
|
||
}
|
||
|
||
// New returns a Stub with a predefined set of test signals.
|
||
func New() *Stub {
|
||
s := &Stub{
|
||
signals: make(map[string]signalDef),
|
||
values: make(map[string]any),
|
||
}
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "sine_1hz", Type: datasource.TypeFloat64,
|
||
Unit: "V", DisplayLow: -1, DisplayHigh: 1,
|
||
Description: "1 Hz sine wave",
|
||
},
|
||
fn: func(t time.Time) any {
|
||
return math.Sin(2 * math.Pi * float64(t.UnixNano()) / 1e9)
|
||
},
|
||
})
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "sine_01hz", Type: datasource.TypeFloat64,
|
||
Unit: "A", DisplayLow: -1, DisplayHigh: 1,
|
||
Description: "0.1 Hz sine wave",
|
||
},
|
||
fn: func(t time.Time) any {
|
||
return math.Sin(2 * math.Pi * 0.1 * float64(t.UnixNano()) / 1e9)
|
||
},
|
||
})
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "ramp_10s", Type: datasource.TypeFloat64,
|
||
Unit: "mm", DisplayLow: 0, DisplayHigh: 100,
|
||
Description: "10-second sawtooth ramp, 0–100",
|
||
},
|
||
fn: func(t time.Time) any {
|
||
return math.Mod(float64(t.UnixNano())/1e10, 100)
|
||
},
|
||
})
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "toggle_1hz", Type: datasource.TypeBool,
|
||
Description: "1 Hz square wave (boolean)",
|
||
},
|
||
fn: func(t time.Time) any { return (t.Unix() % 2) == 0 },
|
||
})
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "noise", Type: datasource.TypeFloat64,
|
||
Unit: "dB", DisplayLow: -1, DisplayHigh: 1,
|
||
Description: "White noise",
|
||
},
|
||
fn: func(t time.Time) any {
|
||
// Seed a per-tick RNG from the timestamp so results are
|
||
// reproducible within a tick but vary across ticks.
|
||
r := rand.New(rand.NewPCG(uint64(t.UnixNano()), 0))
|
||
return r.Float64()*2 - 1
|
||
},
|
||
})
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "setpoint", Type: datasource.TypeFloat64,
|
||
Unit: "°C", DisplayLow: 0, DisplayHigh: 200,
|
||
DriveLow: 0, DriveHigh: 200,
|
||
Description: "Writable setpoint (stub)",
|
||
Writable: true,
|
||
},
|
||
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
|
||
})
|
||
s.values["setpoint"] = 25.0
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: "counter_fast", Type: datasource.TypeFloat64,
|
||
Description: "Fast monotonic counter (1 ms tick) for benchmarks",
|
||
},
|
||
interval: time.Millisecond,
|
||
fn: func(t time.Time) any { return float64(t.UnixMilli()) },
|
||
})
|
||
return s
|
||
}
|
||
|
||
// NewN creates a Stub with n dynamically generated signals named "pv_0" … "pv_{n-1}".
|
||
// Each signal is a sine wave at a distinct frequency, emitted at 10 Hz.
|
||
// Use this for stress and scale testing.
|
||
func NewN(n int) *Stub {
|
||
s := &Stub{
|
||
signals: make(map[string]signalDef),
|
||
values: make(map[string]any),
|
||
}
|
||
for i := range n {
|
||
freq := 0.1 + float64(i)*0.01 // spread frequencies so signals differ
|
||
s.register(signalDef{
|
||
meta: datasource.Metadata{
|
||
Name: fmt.Sprintf("pv_%d", i),
|
||
Type: datasource.TypeFloat64,
|
||
Unit: "V",
|
||
DisplayLow: -1,
|
||
DisplayHigh: 1,
|
||
Description: fmt.Sprintf("Synthetic PV #%d (%.2f Hz sine)", i, freq),
|
||
},
|
||
fn: func(t time.Time) any {
|
||
return math.Sin(2 * math.Pi * freq * float64(t.UnixNano()) / 1e9)
|
||
},
|
||
})
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (s *Stub) register(d signalDef) {
|
||
s.signals[d.meta.Name] = d
|
||
}
|
||
|
||
// Name implements datasource.DataSource.
|
||
func (s *Stub) Name() string { return "stub" }
|
||
|
||
// Connect is a no-op for the stub source.
|
||
func (s *Stub) Connect(_ context.Context) error { return nil }
|
||
|
||
// ListSignals returns metadata for all built-in stub signals.
|
||
func (s *Stub) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||
for _, d := range s.signals {
|
||
out = append(out, d.meta)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// GetMetadata returns the metadata for the named signal.
|
||
func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||
d, ok := s.signals[signal]
|
||
if !ok {
|
||
return datasource.Metadata{}, datasource.ErrNotFound
|
||
}
|
||
return d.meta, nil
|
||
}
|
||
|
||
// Subscribe starts a ticker that pushes generated values into ch.
|
||
// The tick interval is the signal's configured interval (default 10 Hz).
|
||
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||
def, ok := s.signals[signal]
|
||
if !ok {
|
||
return nil, datasource.ErrNotFound
|
||
}
|
||
|
||
interval := def.interval
|
||
if interval == 0 {
|
||
interval = updateInterval
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
go func() {
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case t := <-ticker.C:
|
||
var data any
|
||
if def.meta.Writable {
|
||
s.mu.RLock()
|
||
data = s.values[signal]
|
||
s.mu.RUnlock()
|
||
} else {
|
||
data = def.fn(t)
|
||
}
|
||
v := datasource.Value{Timestamp: t, Data: data, Quality: datasource.QualityGood}
|
||
select {
|
||
case ch <- v:
|
||
case <-ctx.Done():
|
||
return
|
||
}
|
||
case <-ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
return datasource.CancelFunc(cancel), nil
|
||
}
|
||
|
||
// Write updates the current value of a writable signal.
|
||
func (s *Stub) Write(_ context.Context, signal string, value any) error {
|
||
def, ok := s.signals[signal]
|
||
if !ok {
|
||
return datasource.ErrNotFound
|
||
}
|
||
if !def.meta.Writable {
|
||
return datasource.ErrNotWritable
|
||
}
|
||
s.mu.Lock()
|
||
s.values[signal] = value
|
||
s.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// History is not supported by the stub source.
|
||
func (s *Stub) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||
return nil, datasource.ErrHistoryUnavailable
|
||
}
|