// 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 }