Add synthetic array (waveform) DSP support + UX improvements

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>
This commit is contained in:
Martino Ferrari
2026-06-20 17:06:55 +02:00
parent 446de7f1ee
commit f7f297c3df
18 changed files with 1470 additions and 57 deletions
@@ -0,0 +1,222 @@
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)
}
}
}
@@ -126,6 +126,30 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
case "index":
return &dsp.IndexNode{I: int(floatParam(p, "i"))}, nil
case "slice":
return &dsp.SliceNode{Start: int(floatParam(p, "start")), End: int(floatParam(p, "end"))}, nil
case "sum":
return &dsp.SumNode{}, nil
case "mean":
return &dsp.MeanNode{}, nil
case "min":
return &dsp.MinNode{}, nil
case "max":
return &dsp.MaxNode{}, nil
case "length":
return &dsp.LengthNode{}, nil
case "fft":
return &dsp.FFTNode{}, nil
default:
return nil, fmt.Errorf("unknown node type %q", d.Type)
}
+75 -11
View File
@@ -13,9 +13,10 @@ import (
// each node's inputs already resolved. Op-node state maps persist across
// evaluations (for stateful nodes like moving_average / lua).
type runtimeGraph struct {
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
outType dsp.ValType // best-effort output type (scalar/array/unknown)
}
type rtNode struct {
@@ -41,24 +42,30 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
return refs
}
// eval computes the output value given the latest value for each source node
// (keyed by source node id). Nodes are visited in topological order so every
// input is already present in vals by the time a node is processed.
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
vals := make(map[string]float64, len(rg.order))
// evalSample computes the output Sample (scalar or array) given the latest
// value for each source node (keyed by source node id). Nodes are visited in
// topological order so every input is present by the time a node is processed.
//
// Op dispatch:
// - ArrayNode ops (reductions/producers) run natively on Samples.
// - stateless elementwise ops broadcast over array inputs.
// - stateful ops (filters) and lua are scalar-only; an array input errors,
// since their per-evaluation state cannot be split across array lanes.
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
vals := make(map[string]dsp.Sample, len(rg.order))
for id, v := range sourceVals {
vals[id] = v
}
for _, n := range rg.order {
switch n.kind {
case "op":
in := make([]float64, len(n.inputs))
in := make([]dsp.Sample, len(n.inputs))
for i, id := range n.inputs {
in[i] = vals[id]
}
r, err := n.op.Process(in, n.state)
r, err := evalOp(n, in)
if err != nil {
return 0, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
}
vals[n.id] = r
case "output":
@@ -70,6 +77,44 @@ func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
return vals[rg.outputID], nil
}
// evalOp runs a single op node over its Sample inputs, choosing the right
// execution path for the node type.
func evalOp(n *rtNode, in []dsp.Sample) (dsp.Sample, error) {
if an, ok := n.op.(dsp.ArrayNode); ok {
return an.ProcessSample(in, n.state)
}
if dsp.StatelessElementwise(n.op.Type()) {
return dsp.ApplyElementwise(n.op, in, n.state)
}
// Stateful / lua: scalar-only.
row := make([]float64, len(in))
for i, s := range in {
if s.IsArray {
return dsp.Sample{}, fmt.Errorf("does not accept an array input")
}
row[i] = s.F
}
r, err := n.op.Process(row, n.state)
if err != nil {
return dsp.Sample{}, err
}
return dsp.Scalar(r), nil
}
// eval is the scalar wrapper around evalSample, kept so callers and tests that
// deal purely in float64 (legacy linear graphs, scalar sources) are unchanged.
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
sv := make(map[string]dsp.Sample, len(sourceVals))
for id, v := range sourceVals {
sv[id] = dsp.Scalar(v)
}
out, err := rg.evalSample(sv)
if err != nil {
return 0, err
}
return out.F, nil
}
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
// def carries an explicit Graph it is used directly; otherwise the legacy
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
@@ -83,23 +128,42 @@ func compileGraph(def SignalDef) (*runtimeGraph, error) {
return nil, err
}
rg := &runtimeGraph{outputID: g.Output}
// nodeType tracks each node's best-effort output type for static
// propagation. Sources are unknown at compile time (their real type is
// only known once data flows), so type errors here are advisory; runtime
// Sample typing is authoritative.
nodeType := make(map[string]dsp.ValType, len(order))
for _, gn := range order {
switch gn.Kind {
case "source":
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
nodeType[gn.ID] = dsp.ValUnknown
case "op":
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
if err != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
}
inTypes := make([]dsp.ValType, len(gn.Inputs))
for i, id := range gn.Inputs {
inTypes[i] = nodeType[id]
}
ot, terr := dsp.OpOutputType(gn.Op, inTypes)
if terr != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, terr)
}
nodeType[gn.ID] = ot
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
case "output":
rg.outputID = gn.ID
if len(gn.Inputs) > 0 {
nodeType[gn.ID] = nodeType[gn.Inputs[0]]
}
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
default:
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
}
}
rg.outType = nodeType[rg.outputID]
return rg, nil
}
+50 -12
View File
@@ -13,6 +13,7 @@ import (
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
const definitionsFile = "synthetic.json"
@@ -78,7 +79,7 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, defToMetadata(st.def))
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
return out, nil
}
@@ -93,7 +94,7 @@ func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Met
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
if keep(st.def) {
out = append(out, defToMetadata(st.def))
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
}
return out
@@ -108,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return defToMetadata(st.def), nil
return defToMetadata(st.def, outTypeOf(st)), nil
}
// Subscribe registers ch to receive computed values for the named signal.
@@ -138,7 +139,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
defer cancel()
// Latest value and timestamp per source node id.
latest := make(map[string]float64, len(refs))
latest := make(map[string]dsp.Sample, len(refs))
latestTs := make([]time.Time, len(refs))
ready := make([]bool, len(refs))
@@ -163,7 +164,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
if !ok {
return
}
val := toFloat64(u.Value.Data)
val := toSample(u.Value.Data)
select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default:
@@ -224,7 +225,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
}
result, err := cur.rg.eval(latest)
result, err := cur.rg.evalSample(latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
@@ -232,7 +233,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
v := datasource.Value{
Timestamp: outTs,
Data: result,
Data: result.AsAny(),
Quality: datasource.QualityGood,
}
select {
@@ -421,11 +422,17 @@ func (s *Synthetic) startSignal(def SignalDef) error {
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
// defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
// compiled graph's best-effort output type; an array output is reported as a
// waveform (TypeFloat64Array) so widgets can pick a compatible view.
func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
dt := datasource.TypeFloat64
if outType == dsp.ValArray {
dt = datasource.TypeFloat64Array
}
return datasource.Metadata{
Name: def.Name,
Type: datasource.TypeFloat64,
Type: dt,
Unit: def.Meta.Unit,
Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow,
@@ -434,7 +441,38 @@ func defToMetadata(def SignalDef) datasource.Metadata {
}
}
// toFloat64 coerces any numeric value from a datasource.Value.Data to float64.
// outTypeOf returns the compiled output type for a signal state, or unknown.
func outTypeOf(st *signalState) dsp.ValType {
if st == nil || st.rg == nil {
return dsp.ValUnknown
}
return st.rg.outType
}
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
// array Samples (waveforms), everything else a scalar Sample.
func toSample(v any) dsp.Sample {
switch val := v.(type) {
case []float64:
return dsp.Array(val)
case []float32:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
case []int:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
default:
return dsp.Scalar(toFloat64(v))
}
}
// toFloat64 coerces any numeric scalar value from a datasource.Value.Data to float64.
func toFloat64(v any) float64 {
switch val := v.(type) {
case float64:
@@ -460,6 +498,6 @@ func toFloat64(v any) float64 {
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct {
idx int
val float64
val dsp.Sample
ts time.Time
}