f7f297c3df
Adds full array/waveform support through the synthetic DSP engine: a dsp.Sample value model (scalar or []float64), array ops (index, slice, sum, mean, min, max, length, fft) with an in-tree radix-2 FFT, and static type propagation (OpOutputType) that the editor mirrors to colour wires by data type and flag invalid wirings. Stateful filters and lua stay scalar-only. Adds a waveform plot mode (x-vs-index trace). Also: errored-node hover reasons, S/N add-signal/add-node HUD shortcuts in the synthetic editor, and view-mode widgets that blend with the canvas background (chrome kept in edit mode). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
6.5 KiB
Go
223 lines
6.5 KiB
Go
package synthetic
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/uopi/uopi/internal/broker"
|
|
"github.com/uopi/uopi/internal/datasource"
|
|
"github.com/uopi/uopi/internal/dsp"
|
|
)
|
|
|
|
// evalSampleDef compiles a SignalDef and evaluates it against per-source
|
|
// Samples keyed by source node id, returning the output Sample.
|
|
func evalSampleDef(t *testing.T, def SignalDef, srcVals map[string]dsp.Sample) dsp.Sample {
|
|
t.Helper()
|
|
rg, err := compileGraph(def)
|
|
if err != nil {
|
|
t.Fatalf("compileGraph: %v", err)
|
|
}
|
|
out, err := rg.evalSample(srcVals)
|
|
if err != nil {
|
|
t.Fatalf("evalSample: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestArrayElementwiseChain runs an array source through an elementwise op
|
|
// (gain) and asserts the output stays an array, broadcast per element.
|
|
func TestArrayElementwiseChain(t *testing.T) {
|
|
def := SignalDef{
|
|
Name: "scaled",
|
|
Graph: &Graph{
|
|
Output: "out",
|
|
Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
|
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"g"}},
|
|
},
|
|
},
|
|
}
|
|
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})})
|
|
if !out.IsArray {
|
|
t.Fatalf("want array output, got %v", out)
|
|
}
|
|
want := []float64{2, 4, 6}
|
|
for i, v := range want {
|
|
if out.Arr[i] != v {
|
|
t.Errorf("scaled[%d]: want %v, got %v", i, v, out.Arr[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestArrayReductionToScalar runs an array source into mean (array→scalar) and
|
|
// asserts a scalar output and an array-output compile type for the producer.
|
|
func TestArrayReductionToScalar(t *testing.T) {
|
|
def := SignalDef{
|
|
Name: "avg",
|
|
Graph: &Graph{
|
|
Output: "out",
|
|
Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
|
{ID: "m", Kind: "op", Op: "mean", Inputs: []string{"a"}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"m"}},
|
|
},
|
|
},
|
|
}
|
|
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{2, 4, 6, 8})})
|
|
if out.IsArray {
|
|
t.Fatalf("want scalar output, got array %v", out.Arr)
|
|
}
|
|
if math.Abs(out.F-5) > 1e-9 {
|
|
t.Errorf("mean: want 5, got %v", out.F)
|
|
}
|
|
}
|
|
|
|
// TestArrayOutTypeMetadata verifies compileGraph reports an array output type
|
|
// for a pure-elementwise array graph and scalar for a reduction graph.
|
|
func TestArrayOutTypeMetadata(t *testing.T) {
|
|
arrayGraph := SignalDef{
|
|
Name: "fftout",
|
|
Graph: &Graph{
|
|
Output: "out",
|
|
Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
|
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"f"}},
|
|
},
|
|
},
|
|
}
|
|
rg, err := compileGraph(arrayGraph)
|
|
if err != nil {
|
|
t.Fatalf("compileGraph: %v", err)
|
|
}
|
|
if rg.outType != dsp.ValArray {
|
|
t.Errorf("fft graph outType: want ValArray, got %v", rg.outType)
|
|
}
|
|
|
|
reduction := SignalDef{
|
|
Name: "sumout",
|
|
Graph: &Graph{
|
|
Output: "out",
|
|
Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
|
{ID: "s", Kind: "op", Op: "sum", Inputs: []string{"a"}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"s"}},
|
|
},
|
|
},
|
|
}
|
|
rg2, err := compileGraph(reduction)
|
|
if err != nil {
|
|
t.Fatalf("compileGraph: %v", err)
|
|
}
|
|
if rg2.outType != dsp.ValScalar {
|
|
t.Errorf("sum graph outType: want ValScalar, got %v", rg2.outType)
|
|
}
|
|
}
|
|
|
|
// TestStatefulRejectsArray verifies a stateful op (moving_average) errors when
|
|
// fed an array input at runtime.
|
|
func TestStatefulRejectsArray(t *testing.T) {
|
|
def := SignalDef{
|
|
Name: "ma",
|
|
Graph: &Graph{
|
|
Output: "out",
|
|
Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
|
{ID: "m", Kind: "op", Op: "moving_average", Inputs: []string{"a"}, Params: map[string]any{"window": 3.0}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"m"}},
|
|
},
|
|
},
|
|
}
|
|
rg, err := compileGraph(def)
|
|
if err != nil {
|
|
t.Fatalf("compileGraph: %v", err)
|
|
}
|
|
if _, err := rg.evalSample(map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})}); err == nil {
|
|
t.Error("expected moving_average to reject an array input")
|
|
}
|
|
}
|
|
|
|
// TestSubscribeArrayPassthrough is an end-to-end check that a synthetic with an
|
|
// array-valued source and an elementwise op emits a []float64 over the broker,
|
|
// and that GetMetadata reports the waveform type.
|
|
func TestSubscribeArrayPassthrough(t *testing.T) {
|
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
|
|
src := &seqSource{name: "src", seq: []datasource.Value{
|
|
{Timestamp: base.Add(1 * time.Second), Data: []float64{1, 2, 3}, Quality: datasource.QualityGood},
|
|
{Timestamp: base.Add(2 * time.Second), Data: []float64{4, 5, 6}, Quality: datasource.QualityGood},
|
|
}}
|
|
|
|
brk := broker.New(ctx, log)
|
|
brk.Register(src)
|
|
syn := New(t.TempDir(), brk, log)
|
|
if err := syn.Connect(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// gain x2 elementwise keeps the value an array.
|
|
if err := syn.AddSignal(SignalDef{
|
|
Name: "scaled",
|
|
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
|
|
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"g"}},
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// An fft-based signal has a statically-known array output type (the source's
|
|
// runtime type need not be known), so its metadata reports the waveform type.
|
|
if err := syn.AddSignal(SignalDef{
|
|
Name: "spectrum",
|
|
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
|
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
|
|
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
|
|
{ID: "out", Kind: "output", Inputs: []string{"f"}},
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
meta, err := syn.GetMetadata(ctx, "spectrum")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meta.Type != datasource.TypeFloat64Array {
|
|
t.Errorf("metadata type: want TypeFloat64Array, got %v", meta.Type)
|
|
}
|
|
|
|
ch := make(chan datasource.Value, 8)
|
|
if _, err := syn.Subscribe(ctx, "scaled", ch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := [][]float64{{2, 4, 6}, {8, 10, 12}}
|
|
for i, w := range want {
|
|
select {
|
|
case v := <-ch:
|
|
arr, ok := v.Data.([]float64)
|
|
if !ok {
|
|
t.Fatalf("emit #%d: want []float64, got %T", i, v.Data)
|
|
}
|
|
if len(arr) != len(w) {
|
|
t.Fatalf("emit #%d: want len %d, got %d", i, len(w), len(arr))
|
|
}
|
|
for k, val := range w {
|
|
if arr[k] != val {
|
|
t.Errorf("emit #%d [%d]: want %v, got %v", i, k, val, arr[k])
|
|
}
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("timeout waiting for emit #%d", i)
|
|
}
|
|
}
|
|
}
|