Phase 2/4 done, working on phase 3/5
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
// Package dsp provides signal processing node primitives used by the synthetic
|
||||
// data source.
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// Node is a single processing stage in a synthetic signal pipeline.
|
||||
type Node interface {
|
||||
// Process receives the latest input values (one per upstream signal, in order)
|
||||
// and returns the computed output. state is node-local persistent state.
|
||||
Process(inputs []float64, state map[string]any) (float64, error)
|
||||
// Type returns the node type name used in JSON definitions.
|
||||
Type() string
|
||||
}
|
||||
|
||||
// ── GainNode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// GainNode multiplies input[0] by a fixed gain factor.
|
||||
type GainNode struct {
|
||||
Gain float64
|
||||
}
|
||||
|
||||
func (n *GainNode) Type() string { return "gain" }
|
||||
func (n *GainNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("gain: no inputs")
|
||||
}
|
||||
return inputs[0] * n.Gain, nil
|
||||
}
|
||||
|
||||
// ── OffsetNode ────────────────────────────────────────────────────────────────
|
||||
|
||||
// OffsetNode adds a fixed offset to input[0].
|
||||
type OffsetNode struct {
|
||||
Offset float64
|
||||
}
|
||||
|
||||
func (n *OffsetNode) Type() string { return "offset" }
|
||||
func (n *OffsetNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("offset: no inputs")
|
||||
}
|
||||
return inputs[0] + n.Offset, nil
|
||||
}
|
||||
|
||||
// ── AddNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// AddNode returns the sum of all inputs.
|
||||
type AddNode struct{}
|
||||
|
||||
func (n *AddNode) Type() string { return "add" }
|
||||
func (n *AddNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
var sum float64
|
||||
for _, v := range inputs {
|
||||
sum += v
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// ── SubtractNode ──────────────────────────────────────────────────────────────
|
||||
|
||||
// SubtractNode returns input[0] - input[1].
|
||||
type SubtractNode struct{}
|
||||
|
||||
func (n *SubtractNode) Type() string { return "subtract" }
|
||||
func (n *SubtractNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) < 2 {
|
||||
return 0, errors.New("subtract: need at least 2 inputs")
|
||||
}
|
||||
return inputs[0] - inputs[1], nil
|
||||
}
|
||||
|
||||
// ── MultiplyNode ──────────────────────────────────────────────────────────────
|
||||
|
||||
// MultiplyNode returns the product of all inputs.
|
||||
type MultiplyNode struct{}
|
||||
|
||||
func (n *MultiplyNode) Type() string { return "multiply" }
|
||||
func (n *MultiplyNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("multiply: no inputs")
|
||||
}
|
||||
product := 1.0
|
||||
for _, v := range inputs {
|
||||
product *= v
|
||||
}
|
||||
return product, nil
|
||||
}
|
||||
|
||||
// ── DivideNode ────────────────────────────────────────────────────────────────
|
||||
|
||||
// DivideNode returns input[0] / input[1]; returns 0 if denominator is 0.
|
||||
type DivideNode struct{}
|
||||
|
||||
func (n *DivideNode) Type() string { return "divide" }
|
||||
func (n *DivideNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) < 2 {
|
||||
return 0, errors.New("divide: need at least 2 inputs")
|
||||
}
|
||||
if inputs[1] == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return inputs[0] / inputs[1], nil
|
||||
}
|
||||
|
||||
// ── MovingAverageNode ─────────────────────────────────────────────────────────
|
||||
|
||||
// MovingAverageNode computes a running mean over the last Window samples.
|
||||
type MovingAverageNode struct {
|
||||
Window int
|
||||
}
|
||||
|
||||
func (n *MovingAverageNode) Type() string { return "moving_average" }
|
||||
func (n *MovingAverageNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("moving_average: no inputs")
|
||||
}
|
||||
w := n.Window
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
|
||||
buf, _ := state["buf"].([]float64)
|
||||
buf = append(buf, inputs[0])
|
||||
if len(buf) > w {
|
||||
buf = buf[len(buf)-w:]
|
||||
}
|
||||
state["buf"] = buf
|
||||
|
||||
var sum float64
|
||||
for _, v := range buf {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(buf)), nil
|
||||
}
|
||||
|
||||
// ── RMSNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// RMSNode computes the root-mean-square over the last Window samples.
|
||||
type RMSNode struct {
|
||||
Window int
|
||||
}
|
||||
|
||||
func (n *RMSNode) Type() string { return "rms" }
|
||||
func (n *RMSNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("rms: no inputs")
|
||||
}
|
||||
w := n.Window
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
|
||||
buf, _ := state["buf"].([]float64)
|
||||
buf = append(buf, inputs[0])
|
||||
if len(buf) > w {
|
||||
buf = buf[len(buf)-w:]
|
||||
}
|
||||
state["buf"] = buf
|
||||
|
||||
var sumSq float64
|
||||
for _, v := range buf {
|
||||
sumSq += v * v
|
||||
}
|
||||
return math.Sqrt(sumSq / float64(len(buf))), nil
|
||||
}
|
||||
|
||||
// ── DerivativeNode ────────────────────────────────────────────────────────────
|
||||
|
||||
// DerivativeNode computes the finite difference (current - previous) / dt where
|
||||
// dt is in seconds. On the first call it returns 0.
|
||||
type DerivativeNode struct{}
|
||||
|
||||
func (n *DerivativeNode) Type() string { return "derivative" }
|
||||
func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("derivative: no inputs")
|
||||
}
|
||||
now := time.Now()
|
||||
if prevVal, ok := state["prev_val"].(float64); ok {
|
||||
if prevTime, ok := state["prev_time"].(time.Time); ok {
|
||||
dt := now.Sub(prevTime).Seconds()
|
||||
if dt <= 0 {
|
||||
dt = 1e-9 // avoid division by zero
|
||||
}
|
||||
result := (inputs[0] - prevVal) / dt
|
||||
state["prev_val"] = inputs[0]
|
||||
state["prev_time"] = now
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
state["prev_val"] = inputs[0]
|
||||
state["prev_time"] = now
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ── ClampNode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ClampNode clamps the output to [Min, Max].
|
||||
type ClampNode struct {
|
||||
Min float64
|
||||
Max float64
|
||||
}
|
||||
|
||||
func (n *ClampNode) Type() string { return "clamp" }
|
||||
func (n *ClampNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("clamp: no inputs")
|
||||
}
|
||||
v := inputs[0]
|
||||
if v < n.Min {
|
||||
return n.Min, nil
|
||||
}
|
||||
if v > n.Max {
|
||||
return n.Max, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ── ThresholdNode ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ThresholdNode outputs High when input[0] >= Threshold, Low otherwise.
|
||||
type ThresholdNode struct {
|
||||
Threshold float64
|
||||
High float64
|
||||
Low float64
|
||||
}
|
||||
|
||||
func (n *ThresholdNode) Type() string { return "threshold" }
|
||||
func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("threshold: no inputs")
|
||||
}
|
||||
if inputs[0] >= n.Threshold {
|
||||
return n.High, nil
|
||||
}
|
||||
return n.Low, nil
|
||||
}
|
||||
|
||||
// ── ExprNode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
|
||||
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
|
||||
type ExprNode struct {
|
||||
Expr string
|
||||
}
|
||||
|
||||
func (n *ExprNode) Type() string { return "expr" }
|
||||
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
vars := map[string]float64{}
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
vars[name] = inputs[i]
|
||||
} else {
|
||||
vars[name] = 0
|
||||
}
|
||||
}
|
||||
p := &exprParser{src: strings.TrimSpace(n.Expr), vars: vars}
|
||||
result, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expr: %w", err)
|
||||
}
|
||||
if p.pos < len(p.src) {
|
||||
return 0, fmt.Errorf("expr: unexpected character %q at position %d", p.src[p.pos], p.pos)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// exprParser is a recursive-descent parser for simple arithmetic expressions.
|
||||
// Grammar:
|
||||
//
|
||||
// expr = term (('+' | '-') term)*
|
||||
// term = factor (('*' | '/') factor)*
|
||||
// factor = '(' expr ')' | number | variable
|
||||
type exprParser struct {
|
||||
src string
|
||||
pos int
|
||||
vars map[string]float64
|
||||
}
|
||||
|
||||
func (p *exprParser) skipSpaces() {
|
||||
for p.pos < len(p.src) && unicode.IsSpace(rune(p.src[p.pos])) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *exprParser) peek() (byte, bool) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.src) {
|
||||
return 0, false
|
||||
}
|
||||
return p.src[p.pos], true
|
||||
}
|
||||
|
||||
func (p *exprParser) parseExpr() (float64, error) {
|
||||
left, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
ch, ok := p.peek()
|
||||
if !ok || (ch != '+' && ch != '-') {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ch == '+' {
|
||||
left += right
|
||||
} else {
|
||||
left -= right
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *exprParser) parseTerm() (float64, error) {
|
||||
left, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
ch, ok := p.peek()
|
||||
if !ok || (ch != '*' && ch != '/') {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ch == '*' {
|
||||
left *= right
|
||||
} else {
|
||||
if right == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
left /= right
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *exprParser) parseFactor() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.src) {
|
||||
return 0, errors.New("unexpected end of expression")
|
||||
}
|
||||
|
||||
ch := p.src[p.pos]
|
||||
|
||||
// Parenthesised subexpression
|
||||
if ch == '(' {
|
||||
p.pos++
|
||||
val, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.src) || p.src[p.pos] != ')' {
|
||||
return 0, errors.New("missing closing parenthesis")
|
||||
}
|
||||
p.pos++
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Unary minus
|
||||
if ch == '-' {
|
||||
p.pos++
|
||||
val, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -val, nil
|
||||
}
|
||||
|
||||
// Variable (a, b, c, d only)
|
||||
if ch >= 'a' && ch <= 'z' {
|
||||
name := string(ch)
|
||||
p.pos++
|
||||
// Make sure we only accept single-letter variables
|
||||
if p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' || p.src[p.pos] == '_') {
|
||||
return 0, fmt.Errorf("unknown identifier starting with %q", name)
|
||||
}
|
||||
val, ok := p.vars[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Number (including optional decimal point and exponent)
|
||||
if ch >= '0' && ch <= '9' || ch == '.' {
|
||||
start := p.pos
|
||||
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' || ((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
|
||||
p.pos++
|
||||
}
|
||||
f, err := strconv.ParseFloat(p.src[start:p.pos], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number %q", p.src[start:p.pos])
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unexpected character %q at position %d", ch, p.pos)
|
||||
}
|
||||
|
||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
|
||||
// The os, io, package, and debug libraries are disabled.
|
||||
type LuaNode struct {
|
||||
Script string
|
||||
}
|
||||
|
||||
func (n *LuaNode) Type() string { return "lua" }
|
||||
|
||||
func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float64, retErr error) {
|
||||
// Retrieve or create the Lua VM.
|
||||
var L *lua.LState
|
||||
if existing, ok := state["L"].(*lua.LState); ok && existing != nil {
|
||||
L = existing
|
||||
} else {
|
||||
L = lua.NewState(lua.Options{SkipOpenLibs: true})
|
||||
// Open only safe libs.
|
||||
for _, pair := range []struct {
|
||||
name string
|
||||
fn lua.LGFunction
|
||||
}{
|
||||
{lua.LoadLibName, lua.OpenPackage}, // required by other libs
|
||||
{lua.BaseLibName, lua.OpenBase},
|
||||
{lua.MathLibName, lua.OpenMath},
|
||||
{lua.StringLibName, lua.OpenString},
|
||||
{lua.TabLibName, lua.OpenTable},
|
||||
} {
|
||||
if err := L.CallByParam(lua.P{
|
||||
Fn: L.NewFunction(pair.fn),
|
||||
NRet: 0,
|
||||
Protect: true,
|
||||
}, lua.LString(pair.name)); err != nil {
|
||||
L.Close()
|
||||
return 0, fmt.Errorf("lua init: %w", err)
|
||||
}
|
||||
}
|
||||
// Remove potentially dangerous globals that sneak in via base.
|
||||
L.SetGlobal("load", lua.LNil)
|
||||
L.SetGlobal("loadfile", lua.LNil)
|
||||
L.SetGlobal("dofile", lua.LNil)
|
||||
L.SetGlobal("require", lua.LNil)
|
||||
state["L"] = L
|
||||
}
|
||||
|
||||
// Catch panics from Lua execution.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
retErr = fmt.Errorf("lua panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Clear the stack.
|
||||
L.SetTop(0)
|
||||
|
||||
// Bind inputs.
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
L.SetGlobal(name, lua.LNumber(inputs[i]))
|
||||
} else {
|
||||
L.SetGlobal(name, lua.LNumber(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the script.
|
||||
if err := L.DoString(n.Script); err != nil {
|
||||
return 0, fmt.Errorf("lua: %w", err)
|
||||
}
|
||||
|
||||
// Read the return value (top of the stack after DoString leaves the chunk's
|
||||
// results there).
|
||||
top := L.GetTop()
|
||||
if top < 1 {
|
||||
return 0, errors.New("lua: script did not return a value")
|
||||
}
|
||||
lv := L.Get(top)
|
||||
num, ok := lv.(lua.LNumber)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("lua: script returned non-number %T", lv)
|
||||
}
|
||||
return float64(num), nil
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGainNode(t *testing.T) {
|
||||
n := &GainNode{Gain: 3.0}
|
||||
state := map[string]any{}
|
||||
got, err := n.Process([]float64{2.0}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 6.0 {
|
||||
t.Errorf("GainNode: want 6.0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGainNodeNoInputs(t *testing.T) {
|
||||
n := &GainNode{Gain: 1.0}
|
||||
_, err := n.Process(nil, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("GainNode: expected error with no inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOffsetNode(t *testing.T) {
|
||||
n := &OffsetNode{Offset: 5.0}
|
||||
state := map[string]any{}
|
||||
got, err := n.Process([]float64{3.0}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 8.0 {
|
||||
t.Errorf("OffsetNode: want 8.0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddNode(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
state := map[string]any{}
|
||||
got, err := n.Process([]float64{1.0, 2.0, 3.0}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 6.0 {
|
||||
t.Errorf("AddNode: want 6.0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtractNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputs []float64
|
||||
want float64
|
||||
errOk bool
|
||||
}{
|
||||
{"basic", []float64{10.0, 3.0}, 7.0, false},
|
||||
{"negative result", []float64{3.0, 10.0}, -7.0, false},
|
||||
{"too few inputs", []float64{1.0}, 0, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := &SubtractNode{}
|
||||
got, err := n.Process(tc.inputs, map[string]any{})
|
||||
if tc.errOk {
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("SubtractNode: want %v, got %v", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplyNode(t *testing.T) {
|
||||
n := &MultiplyNode{}
|
||||
got, err := n.Process([]float64{2.0, 3.0, 4.0}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 24.0 {
|
||||
t.Errorf("MultiplyNode: want 24.0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDivideNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputs []float64
|
||||
want float64
|
||||
errOk bool
|
||||
}{
|
||||
{"basic", []float64{10.0, 2.0}, 5.0, false},
|
||||
{"zero denominator", []float64{10.0, 0.0}, 0.0, false},
|
||||
{"too few inputs", []float64{1.0}, 0, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := &DivideNode{}
|
||||
got, err := n.Process(tc.inputs, map[string]any{})
|
||||
if tc.errOk {
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("DivideNode: want %v, got %v", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingAverageNode(t *testing.T) {
|
||||
n := &MovingAverageNode{Window: 3}
|
||||
state := map[string]any{}
|
||||
|
||||
// Feed 1, 2, 3; window fills up
|
||||
values := []float64{1, 2, 3}
|
||||
wants := []float64{1, 1.5, 2.0}
|
||||
for i, v := range values {
|
||||
got, err := n.Process([]float64{v}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", i, err)
|
||||
}
|
||||
if math.Abs(got-wants[i]) > 1e-9 {
|
||||
t.Errorf("step %d: want %v, got %v", i, wants[i], got)
|
||||
}
|
||||
}
|
||||
// Feed 4; window slides: [2, 3, 4] → avg 3.0
|
||||
got, err := n.Process([]float64{4}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if math.Abs(got-3.0) > 1e-9 {
|
||||
t.Errorf("sliding window: want 3.0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRMSNode(t *testing.T) {
|
||||
n := &RMSNode{Window: 2}
|
||||
state := map[string]any{}
|
||||
// Feed 3.0 and 4.0; RMS over [3,4] = sqrt((9+16)/2) = sqrt(12.5)
|
||||
n.Process([]float64{3.0}, state)
|
||||
got, err := n.Process([]float64{4.0}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := math.Sqrt(12.5)
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("RMSNode: want %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivativeNode(t *testing.T) {
|
||||
n := &DerivativeNode{}
|
||||
state := map[string]any{}
|
||||
|
||||
// First call should return 0
|
||||
got, err := n.Process([]float64{1.0}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 0.0 {
|
||||
t.Errorf("DerivativeNode first call: want 0, got %v", got)
|
||||
}
|
||||
|
||||
// Second call should return a non-zero derivative
|
||||
got, err = n.Process([]float64{2.0}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// dt is very small (nanoseconds), so derivative should be large and positive
|
||||
if got <= 0 {
|
||||
t.Errorf("DerivativeNode second call: expected positive derivative, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
min float64
|
||||
max float64
|
||||
want float64
|
||||
}{
|
||||
{5.0, 0.0, 10.0, 5.0},
|
||||
{-5.0, 0.0, 10.0, 0.0},
|
||||
{15.0, 0.0, 10.0, 10.0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
n := &ClampNode{Min: tc.min, Max: tc.max}
|
||||
got, err := n.Process([]float64{tc.input}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ClampNode(%v): want %v, got %v", tc.input, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThresholdNode(t *testing.T) {
|
||||
n := &ThresholdNode{Threshold: 5.0, High: 1.0, Low: 0.0}
|
||||
tests := []struct {
|
||||
input float64
|
||||
want float64
|
||||
}{
|
||||
{3.0, 0.0}, // below threshold
|
||||
{5.0, 1.0}, // at threshold (outputs High)
|
||||
{10.0, 1.0}, // above threshold
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := n.Process([]float64{tc.input}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ThresholdNode(%v): want %v, got %v", tc.input, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExprNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
inputs []float64
|
||||
want float64
|
||||
errOk bool
|
||||
}{
|
||||
{"addition", "a + b", []float64{3, 4}, 7.0, false},
|
||||
{"multiplication", "a * b", []float64{3, 4}, 12.0, false},
|
||||
{"complex", "(a + b) * c", []float64{1, 2, 3}, 9.0, false},
|
||||
{"division", "a / b", []float64{10, 2}, 5.0, false},
|
||||
{"subtraction", "a - b", []float64{10, 3}, 7.0, false},
|
||||
{"literal", "2 + 3", []float64{}, 5.0, false},
|
||||
{"negative factor", "-a + b", []float64{3, 5}, 2.0, false},
|
||||
{"nested parens", "(a + (b * c))", []float64{1, 2, 3}, 7.0, false},
|
||||
{"invalid var", "x + 1", []float64{1}, 0, true},
|
||||
{"invalid syntax", "a ++ b", []float64{1, 2}, 0, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := &ExprNode{Expr: tc.expr}
|
||||
got, err := n.Process(tc.inputs, map[string]any{})
|
||||
if tc.errOk {
|
||||
if err == nil {
|
||||
t.Errorf("ExprNode(%q): expected error", tc.expr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ExprNode(%q): %v", tc.expr, err)
|
||||
}
|
||||
if math.Abs(got-tc.want) > 1e-9 {
|
||||
t.Errorf("ExprNode(%q): want %v, got %v", tc.expr, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
script string
|
||||
inputs []float64
|
||||
want float64
|
||||
errOk bool
|
||||
}{
|
||||
{"multiply input", "return a * 2", []float64{3.0}, 6.0, false},
|
||||
{"add two inputs", "return a + b", []float64{3.0, 4.0}, 7.0, false},
|
||||
{"constant", "return 42", []float64{}, 42.0, false},
|
||||
{"math lib", "return math.sqrt(a)", []float64{4.0}, 2.0, false},
|
||||
{"no return", "local x = a + 1", []float64{1.0}, 0, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := &LuaNode{Script: tc.script}
|
||||
state := map[string]any{}
|
||||
got, err := n.Process(tc.inputs, state)
|
||||
if tc.errOk {
|
||||
if err == nil {
|
||||
t.Errorf("LuaNode(%q): expected error", tc.script)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("LuaNode(%q): %v", tc.script, err)
|
||||
}
|
||||
if math.Abs(got-tc.want) > 1e-9 {
|
||||
t.Errorf("LuaNode(%q): want %v, got %v", tc.script, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaNodeStateReuse(t *testing.T) {
|
||||
// Verify the Lua VM is reused across calls (stateful counter example)
|
||||
n := &LuaNode{Script: `
|
||||
count = (count or 0) + 1
|
||||
return count
|
||||
`}
|
||||
state := map[string]any{}
|
||||
for i := 1; i <= 3; i++ {
|
||||
got, err := n.Process(nil, state)
|
||||
if err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
if int(got) != i {
|
||||
t.Errorf("call %d: want %d, got %v", i, i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeTypes(t *testing.T) {
|
||||
nodes := []Node{
|
||||
&GainNode{},
|
||||
&OffsetNode{},
|
||||
&AddNode{},
|
||||
&SubtractNode{},
|
||||
&MultiplyNode{},
|
||||
&DivideNode{},
|
||||
&MovingAverageNode{},
|
||||
&RMSNode{},
|
||||
&DerivativeNode{},
|
||||
&ClampNode{},
|
||||
&ThresholdNode{},
|
||||
&ExprNode{},
|
||||
&LuaNode{},
|
||||
}
|
||||
types := []string{
|
||||
"gain", "offset", "add", "subtract", "multiply", "divide",
|
||||
"moving_average", "rms", "derivative", "clamp", "threshold", "expr", "lua",
|
||||
}
|
||||
for i, n := range nodes {
|
||||
if n.Type() != types[i] {
|
||||
t.Errorf("node %T: want type %q, got %q", n, types[i], n.Type())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user