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
+25 -12
View File
@@ -1,7 +1,6 @@
# TODO # TODO
- [ ] Implement git style versioning for: synthetic variable, panels, control logic - [ ] **MAJOR** Implement configuration manager:
- [ ] Implement configuration manager:
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system - configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
- with configuration manager user should be able to: - with configuration manager user should be able to:
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set - Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
@@ -12,17 +11,31 @@
- user can fork an existing config instance to create a new one - user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff - user can compare two instances: show unified diff or side by side diff
- etc... - etc...
- user can apply and save configuration instances
- in logic editor and control loop add nodes to read/write/create/apply config instances
- [ ] Improve UX:
- [x] Synthetic editor:
- [x] color code the node link by type
- [x] hover on a block in error should show the reason
- [x] add proper array functionality
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- [x] in view mode the widgets should have no border/bg but blend with background
- [ ] add widgets such
- [ ] Implement git style versioning for: synthetic variable, panels, control logic:
- possibility to fork any version
- click to view the version
- possibility to view graphical diff between versions (side by side or unified diff)
- simple slick versioning pane:
- vertical tree like
- each version represented by a circle
- active (the one currently view/edited) version has circle bigger then rest
- selected (the one that will be executed/showed by user) version has circle full, not active only border
- unsaved / new version appear with connection line dashed
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc - [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [ ] Implement new datasources: - [ ] Implement new datasources:
- [ ] Finalize alarm service
- [ ] modbus tcp - [ ] modbus tcp
- [ ] scpi tcp - [ ] scpi tcp
- [ ] Improve UX: - [ ] udp? other?
- [ ] Synthetic editor: - [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
- color code the node link by type
- hover on a block in error should show the reason
- add proper array functionality
- add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- in view mode the widgets should have no border/bg but blend with background
- add widgets such
- [ ] Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
+1
View File
@@ -192,6 +192,7 @@ When multiple widgets are selected:
| Histogram | numeric scalar(s) | | Histogram | numeric scalar(s) |
| Bar chart | numeric scalar(s) | | Bar chart | numeric scalar(s) |
| Logic analyser | boolean / integer (bitset) | | Logic analyser | boolean / integer (bitset) |
| Waveform | 1-D numeric array (latest sample, x-vs-index) |
### 4.5 Widget Properties (Properties Pane) ### 4.5 Widget Properties (Properties Pane)
+26 -11
View File
@@ -41,7 +41,7 @@
| ---------------- | --------------------------------------------------------------- | | ---------------- | --------------------------------------------------------------- |
| `preact` 10 | Virtual DOM UI framework | | `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) | | `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots | | `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser, waveform plots |
| `uplot.css` | uPlot default stylesheet | | `uplot.css` | uPlot default stylesheet |
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime. **Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
@@ -234,15 +234,30 @@ endpoints and for any change to a panel's `<logic>` block.
**Built-in node types:** **Built-in node types:**
| Node type | Parameters | Description | | Node type | Parameters | In→Out | Description |
| ------------ | ----------------------------------- | ------------------------------------------------ | | ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
| `source` | `ds`, `name` | Reads a signal from any data source | | `source` | `ds`, `name` | — | Reads a signal from any data source |
| `gain` | `factor` | Multiplies by a constant | | `gain` | `gain` | elementwise | Multiplies by a constant |
| `offset` | `value` | Adds a constant | | `offset` | `offset` | elementwise | Adds a constant |
| `moving_avg` | `window` (samples) | Rolling mean | | `add`/`subtract` | — | elementwise | Sum of inputs / `a b` |
| `lowpass` | `freq` (Hz), `order` (18) | Cascaded IIR Butterworth-style low-pass filter | | `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) | | `clamp` | `min`, `max` | elementwise | Constrains to a range |
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state | | `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
| `integrate` | — | scalar-only | Trapezoidal integral |
| `lowpass` | `freq` (Hz), `order` (18) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
| `min`/`max` | — | array→scalar | Reduction of a waveform |
| `length` | — | array→scalar | Element count of a waveform |
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data. **Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
@@ -353,7 +368,7 @@ The edit canvas is a free-form HTML div with absolutely positioned widget compon
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div: View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change. - Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component. - uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser, waveform) manage their own canvas elements inside their widget component. The `waveform` plot renders a waveform (array) signal's latest `[]float64` as an x-vs-index trace, replacing the trace on each update.
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display. - Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly. - Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
@@ -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": case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil 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: default:
return nil, fmt.Errorf("unknown node type %q", d.Type) 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 // each node's inputs already resolved. Op-node state maps persist across
// evaluations (for stateful nodes like moving_average / lua). // evaluations (for stateful nodes like moving_average / lua).
type runtimeGraph struct { type runtimeGraph struct {
order []*rtNode // topological order (sources first, output last) order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order sources []rtSource // source nodes, in topological order
outputID string // id of the output node outputID string // id of the output node
outType dsp.ValType // best-effort output type (scalar/array/unknown)
} }
type rtNode struct { type rtNode struct {
@@ -41,24 +42,30 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
return refs return refs
} }
// eval computes the output value given the latest value for each source node // evalSample computes the output Sample (scalar or array) given the latest
// (keyed by source node id). Nodes are visited in topological order so every // value for each source node (keyed by source node id). Nodes are visited in
// input is already present in vals by the time a node is processed. // topological order so every input is present 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)) // 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 { for id, v := range sourceVals {
vals[id] = v vals[id] = v
} }
for _, n := range rg.order { for _, n := range rg.order {
switch n.kind { switch n.kind {
case "op": case "op":
in := make([]float64, len(n.inputs)) in := make([]dsp.Sample, len(n.inputs))
for i, id := range n.inputs { for i, id := range n.inputs {
in[i] = vals[id] in[i] = vals[id]
} }
r, err := n.op.Process(in, n.state) r, err := evalOp(n, in)
if err != nil { 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 vals[n.id] = r
case "output": case "output":
@@ -70,6 +77,44 @@ func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
return vals[rg.outputID], nil 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 // compileGraph converts a SignalDef into an executable runtimeGraph. When the
// def carries an explicit Graph it is used directly; otherwise the legacy // def carries an explicit Graph it is used directly; otherwise the legacy
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph). // 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 return nil, err
} }
rg := &runtimeGraph{outputID: g.Output} 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 { for _, gn := range order {
switch gn.Kind { switch gn.Kind {
case "source": case "source":
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}}) 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": case "op":
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params}) node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
if err != nil { if err != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, err) 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}) rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
case "output": case "output":
rg.outputID = gn.ID 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}) rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
default: default:
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind) return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
} }
} }
rg.outType = nodeType[rg.outputID]
return rg, nil return rg, nil
} }
+50 -12
View File
@@ -13,6 +13,7 @@ import (
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
) )
const definitionsFile = "synthetic.json" 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)) out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals { for _, st := range s.signals {
out = append(out, defToMetadata(st.def)) out = append(out, defToMetadata(st.def, outTypeOf(st)))
} }
return out, nil 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)) out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals { for _, st := range s.signals {
if keep(st.def) { if keep(st.def) {
out = append(out, defToMetadata(st.def)) out = append(out, defToMetadata(st.def, outTypeOf(st)))
} }
} }
return out return out
@@ -108,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
if !ok { if !ok {
return datasource.Metadata{}, datasource.ErrNotFound 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. // 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() defer cancel()
// Latest value and timestamp per source node id. // 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)) latestTs := make([]time.Time, len(refs))
ready := make([]bool, 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 { if !ok {
return return
} }
val := toFloat64(u.Value.Data) val := toSample(u.Value.Data)
select { select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}: case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default: default:
@@ -224,7 +225,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return return
} }
result, err := cur.rg.eval(latest) result, err := cur.rg.evalSample(latest)
if err != nil { if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err) s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue continue
@@ -232,7 +233,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
v := datasource.Value{ v := datasource.Value{
Timestamp: outTs, Timestamp: outTs,
Data: result, Data: result.AsAny(),
Quality: datasource.QualityGood, Quality: datasource.QualityGood,
} }
select { select {
@@ -421,11 +422,17 @@ func (s *Synthetic) startSignal(def SignalDef) error {
return nil return nil
} }
// defToMetadata converts a SignalDef into a datasource.Metadata. // defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
func defToMetadata(def SignalDef) datasource.Metadata { // 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{ return datasource.Metadata{
Name: def.Name, Name: def.Name,
Type: datasource.TypeFloat64, Type: dt,
Unit: def.Meta.Unit, Unit: def.Meta.Unit,
Description: def.Meta.Description, Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow, 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 { func toFloat64(v any) float64 {
switch val := v.(type) { switch val := v.(type) {
case float64: case float64:
@@ -460,6 +498,6 @@ func toFloat64(v any) float64 {
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner. // indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct { type indexedUpdate struct {
idx int idx int
val float64 val dsp.Sample
ts time.Time ts time.Time
} }
+230
View File
@@ -0,0 +1,230 @@
package dsp
import (
"errors"
"fmt"
"math"
)
// This file holds ArrayNode ops: those that operate natively on waveform
// (float64 array) Samples — reductions (array→scalar), producers (array→array),
// and element access. Each also implements the legacy scalar Node interface
// (treating a scalar as a single-element array) so it remains usable from the
// scalar eval path.
// reductionProcess adapts a scalar Process call to a reduction ArrayNode.
func reductionProcess(n ArrayNode, in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
return s.F, nil
}
// ── IndexNode ───────────────────────────────────────────────────────────────
// IndexNode extracts element I of an array input (array→scalar).
type IndexNode struct{ I int }
func (n *IndexNode) Type() string { return "index" }
func (n *IndexNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *IndexNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("index: no inputs")
}
arr := in[0].AsArray()
if n.I < 0 || n.I >= len(arr) {
return Sample{}, fmt.Errorf("index: %d out of range [0,%d)", n.I, len(arr))
}
return Scalar(arr[n.I]), nil
}
// ── SliceNode ───────────────────────────────────────────────────────────────
// SliceNode returns a sub-range [Start,End) of an array input (array→array),
// clamped to the array bounds. End <= 0 means "to the end".
type SliceNode struct{ Start, End int }
func (n *SliceNode) Type() string { return "slice" }
func (n *SliceNode) Process(in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
if len(s.Arr) == 0 {
return 0, nil
}
return s.Arr[0], nil
}
func (n *SliceNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("slice: no inputs")
}
arr := in[0].AsArray()
start := n.Start
if start < 0 {
start = 0
}
if start > len(arr) {
start = len(arr)
}
end := n.End
if end <= 0 || end > len(arr) {
end = len(arr)
}
if end < start {
end = start
}
out := make([]float64, end-start)
copy(out, arr[start:end])
return Array(out), nil
}
// ── reductions ────────────────────────────────────────────────────────────────
// SumNode sums an array input (array→scalar).
type SumNode struct{}
func (n *SumNode) Type() string { return "sum" }
func (n *SumNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *SumNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("sum: no inputs")
}
var s float64
for _, v := range in[0].AsArray() {
s += v
}
return Scalar(s), nil
}
// MeanNode averages an array input (array→scalar).
type MeanNode struct{}
func (n *MeanNode) Type() string { return "mean" }
func (n *MeanNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MeanNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("mean: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
var s float64
for _, v := range arr {
s += v
}
return Scalar(s / float64(len(arr))), nil
}
// MinNode returns the minimum element of an array input (array→scalar).
type MinNode struct{}
func (n *MinNode) Type() string { return "min" }
func (n *MinNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MinNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("min: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
m := arr[0]
for _, v := range arr[1:] {
if v < m {
m = v
}
}
return Scalar(m), nil
}
// MaxNode returns the maximum element of an array input (array→scalar).
type MaxNode struct{}
func (n *MaxNode) Type() string { return "max" }
func (n *MaxNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MaxNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("max: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
m := arr[0]
for _, v := range arr[1:] {
if v > m {
m = v
}
}
return Scalar(m), nil
}
// LengthNode returns the element count of an array input (array→scalar).
type LengthNode struct{}
func (n *LengthNode) Type() string { return "length" }
func (n *LengthNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *LengthNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("length: no inputs")
}
return Scalar(float64(len(in[0].AsArray()))), nil
}
// ── FFTNode ────────────────────────────────────────────────────────────────
// FFTNode computes the magnitude spectrum of an array input (array→array). The
// input is zero-padded to the next power of two; the output has that length and
// holds |X[k]| for each frequency bin.
type FFTNode struct{}
func (n *FFTNode) Type() string { return "fft" }
func (n *FFTNode) Process(in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
if len(s.Arr) == 0 {
return 0, nil
}
return s.Arr[0], nil
}
func (n *FFTNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("fft: no inputs")
}
return Array(fftMagnitude(in[0].AsArray())), nil
}
// fftMagnitude returns the magnitude spectrum of x, zero-padded to the next
// power of two. Returns an empty slice for empty input.
func fftMagnitude(x []float64) []float64 {
if len(x) == 0 {
return nil
}
n := nextPow2(len(x))
re := make([]float64, n)
im := make([]float64, n)
copy(re, x)
fftRadix2(re, im)
mag := make([]float64, n)
for i := range mag {
mag[i] = math.Hypot(re[i], im[i])
}
return mag
}
+213
View File
@@ -0,0 +1,213 @@
package dsp
import (
"math"
"testing"
)
func TestSampleRoundTrip(t *testing.T) {
s := Scalar(3.5)
if s.IsArray {
t.Error("Scalar should not be an array")
}
if s.Type() != ValScalar {
t.Errorf("Scalar type: want ValScalar, got %v", s.Type())
}
if s.AsAny() != 3.5 {
t.Errorf("Scalar AsAny: want 3.5, got %v", s.AsAny())
}
if got := s.AsArray(); len(got) != 1 || got[0] != 3.5 {
t.Errorf("Scalar AsArray: want [3.5], got %v", got)
}
a := Array([]float64{1, 2, 3})
if !a.IsArray {
t.Error("Array should be an array")
}
if a.Type() != ValArray {
t.Errorf("Array type: want ValArray, got %v", a.Type())
}
got, ok := a.AsAny().([]float64)
if !ok || len(got) != 3 {
t.Errorf("Array AsAny: want []float64 len 3, got %v", a.AsAny())
}
}
func TestApplyElementwiseAllScalar(t *testing.T) {
n := &AddNode{}
out, err := ApplyElementwise(n, []Sample{Scalar(2), Scalar(3)}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if out.IsArray || out.F != 5 {
t.Errorf("all-scalar add: want scalar 5, got %v", out)
}
}
func TestApplyElementwiseBroadcast(t *testing.T) {
// array ⊕ scalar: scalar is a constant broadcast across the array.
n := &AddNode{}
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Scalar(10)}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if !out.IsArray {
t.Fatalf("array+scalar: want array, got %v", out)
}
want := []float64{11, 12, 13}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("array+scalar[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestApplyElementwiseArrayArray(t *testing.T) {
n := &MultiplyNode{}
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Array([]float64{4, 5, 6})}, map[string]any{})
if err != nil {
t.Fatal(err)
}
want := []float64{4, 10, 18}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("array*array[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestApplyElementwiseLengthMismatch(t *testing.T) {
n := &AddNode{}
_, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2}), Array([]float64{1, 2, 3})}, map[string]any{})
if err == nil {
t.Error("expected length-mismatch error")
}
}
func TestReductionNodes(t *testing.T) {
arr := []Sample{Array([]float64{2, 4, 6, 8})}
cases := []struct {
name string
node ArrayNode
want float64
}{
{"sum", &SumNode{}, 20},
{"mean", &MeanNode{}, 5},
{"min", &MinNode{}, 2},
{"max", &MaxNode{}, 8},
{"length", &LengthNode{}, 4},
{"index", &IndexNode{I: 2}, 6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := tc.node.ProcessSample(arr, map[string]any{})
if err != nil {
t.Fatal(err)
}
if out.IsArray || out.F != tc.want {
t.Errorf("%s: want scalar %v, got %v", tc.name, tc.want, out)
}
})
}
}
func TestIndexNodeOutOfRange(t *testing.T) {
n := &IndexNode{I: 9}
_, err := n.ProcessSample([]Sample{Array([]float64{1, 2, 3})}, map[string]any{})
if err == nil {
t.Error("expected out-of-range error")
}
}
func TestSliceNode(t *testing.T) {
n := &SliceNode{Start: 1, End: 3}
out, err := n.ProcessSample([]Sample{Array([]float64{10, 20, 30, 40})}, map[string]any{})
if err != nil {
t.Fatal(err)
}
want := []float64{20, 30}
if len(out.Arr) != len(want) {
t.Fatalf("slice: want len %d, got %d", len(want), len(out.Arr))
}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("slice[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestFFTMagnitude(t *testing.T) {
// A constant signal has all energy in bin 0 (the DC term equals the sum).
x := []float64{1, 1, 1, 1}
mag := fftMagnitude(x)
if len(mag) != 4 {
t.Fatalf("fft len: want 4, got %d", len(mag))
}
if math.Abs(mag[0]-4) > 1e-9 {
t.Errorf("fft DC bin: want 4, got %v", mag[0])
}
for k := 1; k < len(mag); k++ {
if math.Abs(mag[k]) > 1e-9 {
t.Errorf("fft bin %d: want ~0, got %v", k, mag[k])
}
}
}
func TestFFTSingleTone(t *testing.T) {
// One full cycle of a cosine over 8 samples → energy in bins 1 and N-1.
n := 8
x := make([]float64, n)
for i := range x {
x[i] = math.Cos(2 * math.Pi * float64(i) / float64(n))
}
mag := fftMagnitude(x)
if math.Abs(mag[1]-float64(n)/2) > 1e-6 {
t.Errorf("fft tone bin 1: want %v, got %v", float64(n)/2, mag[1])
}
if math.Abs(mag[n-1]-float64(n)/2) > 1e-6 {
t.Errorf("fft tone bin %d: want %v, got %v", n-1, float64(n)/2, mag[n-1])
}
}
func TestOpOutputType(t *testing.T) {
cases := []struct {
op string
in []ValType
want ValType
wantErr bool
}{
// reductions → scalar regardless of input
{"sum", []ValType{ValArray}, ValScalar, false},
{"mean", []ValType{ValScalar}, ValScalar, false},
{"index", []ValType{ValUnknown}, ValScalar, false},
// array producers require array, yield array
{"fft", []ValType{ValArray}, ValArray, false},
{"slice", []ValType{ValUnknown}, ValArray, false},
{"fft", []ValType{ValScalar}, ValUnknown, true},
// scalar-only reject arrays
{"moving_average", []ValType{ValScalar}, ValScalar, false},
{"lua", []ValType{ValArray}, ValUnknown, true},
{"rms", []ValType{ValUnknown}, ValScalar, false},
// elementwise: array if any array, scalar if all scalar, else unknown
{"add", []ValType{ValScalar, ValScalar}, ValScalar, false},
{"add", []ValType{ValArray, ValScalar}, ValArray, false},
{"gain", []ValType{ValUnknown}, ValUnknown, false},
{"expr", []ValType{ValArray, ValScalar}, ValArray, false},
}
for _, tc := range cases {
got, err := OpOutputType(tc.op, tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("OpOutputType(%q,%v): expected error", tc.op, tc.in)
}
continue
}
if err != nil {
t.Errorf("OpOutputType(%q,%v): unexpected error %v", tc.op, tc.in, err)
continue
}
if got != tc.want {
t.Errorf("OpOutputType(%q,%v): want %v, got %v", tc.op, tc.in, tc.want, got)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package dsp
import "math"
// nextPow2 returns the smallest power of two >= n (and at least 1).
func nextPow2(n int) int {
p := 1
for p < n {
p <<= 1
}
return p
}
// fftRadix2 computes the in-place iterative radix-2 Cooley-Tukey FFT of the
// complex signal held in re/im. len(re) == len(im) must be a power of two. The
// transform overwrites re/im with the frequency-domain result. This is a small
// self-contained implementation (no external dependency) used by the synthetic
// fft op.
func fftRadix2(re, im []float64) {
n := len(re)
if n <= 1 {
return
}
// Bit-reversal permutation.
for i, j := 1, 0; i < n; i++ {
bit := n >> 1
for ; j&bit != 0; bit >>= 1 {
j ^= bit
}
j ^= bit
if i < j {
re[i], re[j] = re[j], re[i]
im[i], im[j] = im[j], im[i]
}
}
// Danielson-Lanczos butterflies.
for length := 2; length <= n; length <<= 1 {
ang := -2 * math.Pi / float64(length)
wReal, wImag := math.Cos(ang), math.Sin(ang)
for i := 0; i < n; i += length {
curReal, curImag := 1.0, 0.0
half := length >> 1
for k := 0; k < half; k++ {
a := i + k
b := i + k + half
tReal := curReal*re[b] - curImag*im[b]
tImag := curReal*im[b] + curImag*re[b]
re[b] = re[a] - tReal
im[b] = im[a] - tImag
re[a] += tReal
im[a] += tImag
curReal, curImag = curReal*wReal-curImag*wImag, curReal*wImag+curImag*wReal
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package dsp
import "fmt"
// ValType is the data type of a Sample: a scalar float, a float array
// (waveform), or — at graph-compile time, before a source's real type is
// known — unknown.
type ValType uint8
const (
ValUnknown ValType = iota
ValScalar
ValArray
)
func (t ValType) String() string {
switch t {
case ValScalar:
return "scalar"
case ValArray:
return "array"
default:
return "unknown"
}
}
// Sample is a value flowing through the synthetic DSP graph: either a scalar
// float64 or a float64 array (waveform). It is the array-aware counterpart of
// the bare float64 the legacy scalar Node interface uses.
type Sample struct {
F float64
Arr []float64
IsArray bool
}
// Scalar wraps a float64 as a scalar Sample.
func Scalar(f float64) Sample { return Sample{F: f} }
// Array wraps a []float64 as an array Sample.
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
// Type reports whether the sample is a scalar or an array.
func (s Sample) Type() ValType {
if s.IsArray {
return ValArray
}
return ValScalar
}
// AsAny returns the value in the form datasource.Value.Data expects: a
// []float64 for arrays, a float64 for scalars.
func (s Sample) AsAny() any {
if s.IsArray {
return s.Arr
}
return s.F
}
// AsArray returns the sample's data as a slice: the array itself, or a
// single-element slice for a scalar. Used by reductions that accept either.
func (s Sample) AsArray() []float64 {
if s.IsArray {
return s.Arr
}
return []float64{s.F}
}
// ArrayNode is an optional extension of Node implemented by ops that operate
// natively on Samples (reductions array→scalar, producers array→array, etc.).
// eval prefers ProcessSample when a node implements it.
type ArrayNode interface {
Node
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
}
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
// over array inputs: they hold no per-evaluation state, so running the legacy
// Process once per array lane is well-defined. Stateful ops (moving_average,
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
// state map cannot be meaningfully split across lanes.
var statelessElementwise = map[string]bool{
"gain": true, "offset": true, "add": true, "subtract": true,
"multiply": true, "divide": true, "clamp": true, "threshold": true,
"expr": true,
}
// StatelessElementwise reports whether a scalar op type may be broadcast over
// array inputs via ApplyElementwise.
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
func scalarInputs(in []float64) []Sample {
out := make([]Sample, len(in))
for i, v := range in {
out[i] = Scalar(v)
}
return out
}
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
// input is scalar it calls Process once and wraps the result. If any input is
// an array it broadcasts: scalar inputs act as constants, all array inputs must
// share a common length (else an error), and Process is invoked once per index.
//
// The node MUST be stateless (see StatelessElementwise) — a shared state map
// cannot be split across array lanes.
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
// Determine the array length, if any input is an array.
length := -1
for _, s := range inputs {
if !s.IsArray {
continue
}
if length == -1 {
length = len(s.Arr)
} else if len(s.Arr) != length {
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
}
}
if length == -1 {
// All scalar — single legacy call.
row := make([]float64, len(inputs))
for i, s := range inputs {
row[i] = s.F
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
return Scalar(r), nil
}
out := make([]float64, length)
row := make([]float64, len(inputs))
for i := 0; i < length; i++ {
for j, s := range inputs {
if s.IsArray {
row[j] = s.Arr[i]
} else {
row[j] = s.F
}
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
out[i] = r
}
return Array(out), nil
}
+74
View File
@@ -0,0 +1,74 @@
package dsp
import "fmt"
// Op type categories for static (compile-time / editor) type propagation.
// These mirror the runtime dispatch in the synthetic graph evaluator and the
// frontend's inferNodeTypes (web/src/lib/synthTypes.ts) — keep the three in
// sync; a parity test guards the Go/TS pair.
var (
// reductionOps collapse an array (or scalar) to a single scalar.
reductionOps = map[string]bool{
"index": true, "length": true, "sum": true,
"mean": true, "min": true, "max": true,
}
// arrayProducerOps require an array input and yield an array.
arrayProducerOps = map[string]bool{
"fft": true, "slice": true,
}
// scalarOnlyOps reject array inputs and yield a scalar. Stateful filters
// plus lua (whose state/closure cannot be broadcast per array lane).
scalarOnlyOps = map[string]bool{
"moving_average": true, "rms": true, "lowpass": true,
"derivative": true, "integrate": true, "lua": true,
}
)
// OpOutputType reports the output ValType of an op given its input types, and
// an error if the inputs are definitely incompatible with the op. Inputs may be
// ValUnknown (a source whose real type is not yet known at compile time); such
// inputs never trigger an error — runtime Sample typing is authoritative.
func OpOutputType(op string, in []ValType) (ValType, error) {
switch {
case reductionOps[op]:
return ValScalar, nil
case arrayProducerOps[op]:
for _, t := range in {
if t == ValScalar {
return ValUnknown, fmt.Errorf("%s requires an array input", op)
}
}
return ValArray, nil
case scalarOnlyOps[op]:
for _, t := range in {
if t == ValArray {
return ValUnknown, fmt.Errorf("%s does not accept an array input", op)
}
}
return ValScalar, nil
default:
// Elementwise stateless ops (gain, offset, add, subtract, multiply,
// divide, clamp, threshold, expr): array if any input is an array,
// scalar if all inputs are definitely scalar, otherwise unknown.
anyArray, anyUnknown := false, false
for _, t := range in {
switch t {
case ValArray:
anyArray = true
case ValUnknown:
anyUnknown = true
}
}
switch {
case anyArray:
return ValArray, nil
case anyUnknown:
return ValUnknown, nil
default:
return ValScalar, nil
}
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
return ( return (
<div class="canvas-container"> <div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => { {iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type]; const Comp = COMPONENTS[widget.type];
const inner = Comp const inner = Comp
+1
View File
@@ -302,6 +302,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="fft">FFT</option> <option value="fft">FFT</option>
<option value="waterfall">Waterfall</option> <option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option> <option value="logic">Logic analyser</option>
<option value="waveform">Waveform (array)</option>
</select> </select>
</Field> </Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && ( {(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
+124 -9
View File
@@ -3,9 +3,10 @@ import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker'; import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor'; import LuaEditor from './LuaEditor';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types'; import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
import { inferNodeTypes, SynthType } from './lib/synthTypes';
interface DataSource { name: string; } interface DataSource { name: string; }
interface SignalInfo { name: string; } interface SignalInfo { name: string; type?: string; }
interface Props { interface Props {
// Existing signal name (edit mode). Omitted/empty in create mode. // Existing signal name (edit mode). Omitted/empty in create mode.
@@ -52,6 +53,18 @@ const OPS: OpDef[] = [
]}, ]},
{ type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] }, { type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
{ type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] }, { type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
// ── Array (waveform) ops ──
{ type: 'index', label: 'Index (a[i])', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Index (i)', key: 'i', type: 'number', default: '0' }] },
{ type: 'slice', label: 'Slice', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Start', key: 'start', type: 'number', default: '0' },
{ label: 'End (0 = to end)', key: 'end', type: 'number', default: '0' },
]},
{ type: 'sum', label: 'Sum (Σ array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'mean', label: 'Mean (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'min', label: 'Min (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'max', label: 'Max (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'length', label: 'Length', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'fft', label: 'FFT (magnitude)', arity: { kind: 'fixed', n: 1 }, params: [] },
]; ];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o])); const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; } function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
@@ -297,6 +310,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Quick-add HUD (S = signal, N = node); null when closed.
const [hud, setHud] = useState<null | 'signal' | 'node'>(null);
const [hudFilter, setHudFilter] = useState('');
// Identity fields — editable only when creating a new signal. // Identity fields — editable only when creating a new signal.
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -308,7 +324,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const sigName = create ? newName.trim() : (name ?? ''); const sigName = create ? newName.trim() : (name ?? '');
const [dataSources, setDataSources] = useState<string[]>([]); const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({}); const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null); const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
@@ -338,7 +354,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`); const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return; if (!res.ok) return;
const sigs: SignalInfo[] = await res.json(); const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) })); setDsSignals(prev => ({ ...prev, [ds]: sigs }));
} catch {} } catch {}
} }
@@ -346,11 +362,36 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
// and a hook to lazily load all data sources' signals when a picker opens. // and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] { function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = []; const out: SignalOption[] = [];
for (const ds of dataSources) for (const name of (dsSignals[ds] ?? [])) out.push({ ds, name }); for (const ds of dataSources) for (const s of (dsSignals[ds] ?? [])) out.push({ ds, name: s.name });
return out; return out;
} }
function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); } function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }
// Resolve a source node's data type from the upstream signal's metadata.
// 'float64[]' is the only array type today; anything else is scalar, and an
// unresolved/unloaded source is 'unknown' (no error, runtime typing wins).
function sourceSynthType(n: SynthGraphNode): SynthType {
if (!n.ds || !n.signal) return 'unknown';
const sigs = dsSignals[n.ds];
if (!sigs) return 'unknown';
const info = sigs.find(s => s.name === n.signal);
if (!info || !info.type) return 'unknown';
return info.type === 'float64[]' ? 'array' : 'scalar';
}
// HUD filtering: case-insensitive substring over signal name/ds and op label/type.
function filteredSignalOptions(): SignalOption[] {
const q = hudFilter.trim().toLowerCase();
const all = allSignalOptions();
if (!q) return all;
return all.filter(o => o.name.toLowerCase().includes(q) || o.ds.toLowerCase().includes(q));
}
function filteredOps(): OpDef[] {
const q = hudFilter.trim().toLowerCase();
if (!q) return OPS;
return OPS.filter(o => o.label.toLowerCase().includes(q) || o.type.toLowerCase().includes(q));
}
useEffect(() => { useEffect(() => {
if (create) { if (create) {
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} }; const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
@@ -408,6 +449,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
commit({ nodes: [...graph.nodes, node], wires: graph.wires }); commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null); setSelected(node.id); setSelectedWire(null);
} }
// Add a source node pre-filled with a chosen ds:signal (used by the quick-add HUD).
function addSourceWith(ds: string, signal: string) {
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds, signal };
if (ds) loadSignals(ds);
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
// Open the quick-add HUD; for signals, lazily load every source's signal list.
function openHud(kind: 'signal' | 'node') {
if (kind === 'signal') openAllSignals();
setHudFilter('');
setHud(kind);
}
function closeHud() { setHud(null); setHudFilter(''); }
function addOp(op: OpDef, x?: number, y?: number) { function addOp(op: OpDef, x?: number, y?: number) {
const params: Record<string, any> = {}; const params: Record<string, any> = {};
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default; for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
@@ -582,22 +637,34 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod) return;
if (e.key === 'Escape') { if (hud) closeHud(); return; }
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return; if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire); if (selectedWire !== null) deleteWire(selectedWire);
else if (selected) deleteNode(selected); else if (selected) deleteNode(selected);
} }
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [selected, selectedWire, graph]); }, [selected, selectedWire, graph, hud]);
const byId = new Map(graph.nodes.map(n => [n.id, n])); const byId = new Map(graph.nodes.map(n => [n.id, n]));
const sel = graph.nodes.find(n => n.id === selected) ?? null; const sel = graph.nodes.find(n => n.id === selected) ?? null;
const { errors: nodeErrors, first: validationError } = validate(graph); const { errors: nodeErrors, first: validationError } = validate(graph);
// Static type propagation: infer each node's output type (scalar/array) to
// colour wires and surface type-incompatible wirings as node errors. Mirrors
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
const { types: nodeTypes, errors: typeErrors } = inferNodeTypes(compile(graph), sourceSynthType);
for (const [id, msg] of typeErrors) if (!nodeErrors.has(id)) nodeErrors.set(id, msg);
const typeError = !validationError ? [...typeErrors.values()][0] : undefined;
const firstError = validationError ?? typeError;
async function handleSave() { async function handleSave() {
if (!def) return; if (!def) return;
if (create && !sigName) { setError('Enter a name for the new signal.'); return; } if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
if (validationError) { setError(validationError); return; } if (firstError) { setError(firstError); return; }
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -692,9 +759,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const a = byId.get(w.from); const b = byId.get(w.to); const a = byId.get(w.from); const b = byId.get(w.to);
if (!a || !b) return null; if (!a || !b) return null;
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort); const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
return ( return (
<path key={idx} <path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`} class={`flow-wire${tClass}${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)} d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} /> onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
); );
@@ -712,6 +780,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
return ( return (
<div key={node.id} <div key={node.id}
class={nodeClass(node)} class={nodeClass(node)}
title={nodeErrors.get(node.id) || undefined}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`} style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)} onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}> onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
@@ -867,11 +936,57 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</div> </div>
)} )}
{hud && (
<div class="synth-hud-backdrop" onMouseDown={closeHud}>
<div class="synth-hud" onMouseDown={(e) => e.stopPropagation()}>
<input
class="synth-hud-input"
autoFocus
placeholder={hud === 'signal' ? 'Add signal filter by name' : 'Add node filter operations'}
value={hudFilter}
onInput={(e) => setHudFilter((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); closeHud(); return; }
if (e.key === 'Enter') {
e.preventDefault();
if (hud === 'signal') {
const opt = filteredSignalOptions()[0];
if (opt) { addSourceWith(opt.ds, opt.name); closeHud(); }
} else {
const op = filteredOps()[0];
if (op) { addOp(op); closeHud(); }
}
}
}} />
<div class="synth-hud-list">
{hud === 'signal'
? filteredSignalOptions().slice(0, 50).map(opt => (
<button key={`${opt.ds}:${opt.name}`} class="synth-hud-item"
onClick={() => { addSourceWith(opt.ds, opt.name); closeHud(); }}>
<span class="synth-hud-item-name">{opt.name}</span>
<span class="synth-hud-item-meta">{opt.ds}</span>
</button>
))
: filteredOps().map(op => (
<button key={op.type} class="synth-hud-item"
onClick={() => { addOp(op); closeHud(); }}>
<span class="synth-hud-item-name">{op.label}</span>
<span class="synth-hud-item-meta">{op.type}</span>
</button>
))}
{hud === 'signal' && filteredSignalOptions().length === 0 && (
<div class="hint synth-hud-empty">No matching signals.</div>
)}
</div>
</div>
</div>
)}
<div class="wizard-footer"> <div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>} {error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && validationError && <span class="hint" style="flex:1;">{validationError}</span>} {!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button> <button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!validationError}> <button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
{saving ? 'Saving' : 'Save Signal'} {saving ? 'Saving' : 'Save Signal'}
</button> </button>
</div> </div>
+86
View File
@@ -0,0 +1,86 @@
// Static type propagation for the synthetic node-graph editor. This mirrors the
// Go runtime/compile rules in internal/dsp/types.go (OpOutputType) so the editor
// can colour wires by data type and flag type-incompatible wirings before save.
// Keep the two in sync — a parity test guards the pair.
import type { SynthGraph, SynthGraphNode } from './types';
export type SynthType = 'scalar' | 'array' | 'unknown';
// reductionOps collapse an array (or scalar) to a single scalar.
const REDUCTION_OPS = new Set(['index', 'length', 'sum', 'mean', 'min', 'max']);
// arrayProducerOps require an array input and yield an array.
const ARRAY_PRODUCER_OPS = new Set(['fft', 'slice']);
// scalarOnlyOps reject array inputs and yield a scalar (stateful filters + lua).
const SCALAR_ONLY_OPS = new Set(['moving_average', 'rms', 'lowpass', 'derivative', 'integrate', 'lua']);
// opOutputType reports an op's output type given its input types, plus an error
// message when the inputs are definitely incompatible with the op. 'unknown'
// inputs (a source whose real type isn't known yet) never trigger an error —
// runtime typing is authoritative.
export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType; error?: string } {
if (REDUCTION_OPS.has(op)) return { type: 'scalar' };
if (ARRAY_PRODUCER_OPS.has(op)) {
if (inputs.some(t => t === 'scalar')) return { type: 'unknown', error: `${op} requires an array input` };
return { type: 'array' };
}
if (SCALAR_ONLY_OPS.has(op)) {
if (inputs.some(t => t === 'array')) return { type: 'unknown', error: `${op} does not accept an array input` };
return { type: 'scalar' };
}
// Elementwise stateless ops (gain, offset, add, subtract, multiply, divide,
// clamp, threshold, expr): array if any input is an array, scalar if all
// inputs are definitely scalar, otherwise unknown.
if (inputs.some(t => t === 'array')) return { type: 'array' };
if (inputs.some(t => t === 'unknown')) return { type: 'unknown' };
return { type: 'scalar' };
}
// inferNodeTypes walks the DAG in dependency order and assigns each node an
// output type. Source node types come from `sourceType` (resolved from upstream
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
// per-node type-conflict messages for the editor's validation.
export function inferNodeTypes(
g: SynthGraph,
sourceType: (n: SynthGraphNode) => SynthType,
): { types: Map<string, SynthType>; errors: Map<string, string> } {
const types = new Map<string, SynthType>();
const errors = new Map<string, string>();
const byId = new Map(g.nodes.map(n => [n.id, n]));
// Kahn topological order over node inputs.
const indeg = new Map<string, number>();
const succ = new Map<string, string[]>();
for (const n of g.nodes) indeg.set(n.id, 0);
for (const n of g.nodes) {
for (const from of n.inputs ?? []) {
if (!byId.has(from)) continue;
indeg.set(n.id, (indeg.get(n.id) ?? 0) + 1);
succ.set(from, [...(succ.get(from) ?? []), n.id]);
}
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
while (queue.length) {
const id = queue.shift()!;
const n = byId.get(id)!;
if (n.kind === 'source') {
types.set(id, sourceType(n));
} else if (n.kind === 'output') {
const from = (n.inputs ?? [])[0];
types.set(id, (from && types.get(from)) || 'unknown');
} else {
const inTypes = (n.inputs ?? []).map(f => types.get(f) ?? 'unknown');
const { type, error } = opOutputType(n.op ?? '', inTypes);
types.set(id, type);
if (error) errors.set(id, error);
}
for (const s of succ.get(id) ?? []) {
indeg.set(s, (indeg.get(s) ?? 0) - 1);
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
}
}
return { types, errors };
}
+79
View File
@@ -375,6 +375,23 @@ body {
/* width/height set inline from interface dimensions */ /* width/height set inline from interface dimensions */
} }
/* View mode: widgets shed their card chrome (border/background/shadow) so they
blend with the canvas background. Edit mode (EditCanvas) keeps the chrome so
widgets stay visible and selectable. Internal elements (gauge arc, bar track,
plot chart, inputs) keep their own styling. */
.canvas-view-bare .textview,
.canvas-view-bare .gauge,
.canvas-view-bare .barh,
.canvas-view-bare .barv,
.canvas-view-bare .led-widget,
.canvas-view-bare .multiled-widget,
.canvas-view-bare .setvalue,
.canvas-view-bare .plot-widget {
background: transparent;
border-color: transparent;
box-shadow: none;
}
/* ── TextLabel widget ──────────────────────────────────────────────────────── */ /* ── TextLabel widget ──────────────────────────────────────────────────────── */
.textlabel { .textlabel {
@@ -1248,6 +1265,12 @@ body {
.flow-wire-selected { stroke: #ef4444; stroke-width: 2.5; } .flow-wire-selected { stroke: #ef4444; stroke-width: 2.5; }
.flow-wire-pending { stroke: #3b82f6; stroke-dasharray: 5 4; } .flow-wire-pending { stroke: #3b82f6; stroke-dasharray: 5 4; }
/* Synthetic-editor data-type wire colouring (scoped to .synth-graph so the
Logic / ControlLogic editors that share .flow-wire are unaffected). Scalar
keeps the default slate; array (waveform) signals are purple. */
.synth-graph .flow-wire.synth-wire-array { stroke: #a855f7; }
.synth-graph .flow-wire.synth-wire-array:hover { stroke: #c084fc; }
/* Node block */ /* Node block */
.flow-node { .flow-node {
position: absolute; position: absolute;
@@ -2002,8 +2025,64 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
position: relative;
} }
/* Quick-add HUD (S = signal, N = node) */
.synth-hud-backdrop {
position: absolute;
inset: 0;
background: rgba(10, 14, 22, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
z-index: 20;
}
.synth-hud {
width: 26rem;
max-width: 80%;
background: #1a1f2e;
border: 1px solid #3a4660;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
display: flex;
flex-direction: column;
overflow: hidden;
}
.synth-hud-input {
border: none;
border-bottom: 1px solid #2d3748;
background: #0f1117;
color: #e2e8f0;
font-size: 0.9rem;
padding: 0.6rem 0.75rem;
outline: none;
}
.synth-hud-list {
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.synth-hud-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: none;
border: none;
color: #e2e8f0;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.synth-hud-item:hover { background: #243049; }
.synth-hud-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.synth-hud-item-meta { color: #64748b; font-size: 0.72rem; flex-shrink: 0; }
.synth-hud-empty { padding: 0.6rem 0.75rem; }
.wizard-header { .wizard-header {
display: flex; display: flex;
align-items: center; align-items: center;
+30 -1
View File
@@ -84,6 +84,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const showLegend = legendPos !== 'none'; const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] })); const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
const waveforms: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = []; const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? ''; const fmt = widget.options['format'] ?? '';
@@ -269,6 +271,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
}), }),
}; };
} }
case 'waveform': {
// Latest waveform (array) sample per signal, drawn as an x-vs-index
// trace. Each update replaces the trace rather than appending.
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
xAxis: { type: 'value', name: 'Index', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Value', axisLine, axisLabel, splitLine },
series: signals.map((s, i) => ({
name: s.name,
type: 'line',
data: (waveforms[i] ?? []).map((v, k) => [k, v]),
lineStyle: { color: s.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
})),
};
}
case 'logic': { case 'logic': {
// Logic-analyser style: each signal is a 0/1 step trace stacked on its // Logic-analyser style: each signal is a 0/1 step trace stacked on its
// own lane, with a relative-time x-axis (seconds before now). // own lane, with a relative-time x-axis (seconds before now).
@@ -423,7 +443,16 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (sv.value === null || sv.value === undefined || sv.ts === null) return; if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000; const ts = new Date(sv.ts).getTime() / 1000;
if (plotType === 'timeseries') { if (plotType === 'waveform') {
// Array-valued sample: keep only the latest waveform and redraw.
if (Array.isArray(sv.value)) {
waveforms[i] = sv.value.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
} else {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
waveforms[i] = isNaN(v) ? [] : [v];
}
if (echart) echart.setOption(echartsOption(), { notMerge: true });
} else if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)); const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return; if (isNaN(v)) return;
pushSample(buffers[i], ts, v); pushSample(buffers[i], ts, v);