diff --git a/internal/controllogic/config_node_test.go b/internal/controllogic/config_node_test.go index 88a3c96..f9c68f0 100644 --- a/internal/controllogic/config_node_test.go +++ b/internal/controllogic/config_node_test.go @@ -103,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) { func TestApplyConfigWritesEveryParam(t *testing.T) { e, src, instID := setupConfigEngine(t) - cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}} e.applyConfig(cg, instID) @@ -117,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) { func TestApplyConfigUnknownInstanceNoop(t *testing.T) { e, src, _ := setupConfigEngine(t) - cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}} e.applyConfig(cg, "does-not-exist") @@ -144,7 +144,7 @@ func TestReadConfigParam(t *testing.T) { func TestWriteConfigParam(t *testing.T) { e, _, instID := setupConfigEngine(t) - cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}} e.writeConfigParam(cg, instID, "gain", 7.5) @@ -163,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) { func TestCreateConfigInstance(t *testing.T) { e, _, srcID := setupConfigEngine(t) - cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}} src, err := e.cfg.GetInstance(srcID) if err != nil { @@ -197,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) { func TestSnapshotConfig(t *testing.T) { e, src, instID := setupConfigEngine(t) - cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}} // Seed current live values for the set's target signals. _ = src.Write(context.Background(), "GAIN", 3.5) diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go index a8e63c3..98e03a0 100644 --- a/internal/controllogic/engine.go +++ b/internal/controllogic/engine.go @@ -278,15 +278,19 @@ func (e *Engine) liveGet(ds, name string) float64 { // write applies an action.write/lua-set to a target: a bare name updates a // graph-local var; a ds:name target writes to the data source. -func (e *Engine) write(cg *compiledGraph, target string, val float64) { +func (e *Engine) write(cg *compiledGraph, target string, val Value) { ds, name, ok := parseRef(target) - if !ok || math.IsNaN(val) { + if !ok { return } if ds == "local" { cg.setLocal(name, val) return } + f, isNum := val.(float64) + if !isNum || math.IsNaN(f) { + return + } if cg.dryRun { return // simulate: no real data-source write } @@ -301,11 +305,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) { Action: "signal.write", DS: ds, Signal: name, - Value: strconv.FormatFloat(val, 'g', -1, 64), + Value: strconv.FormatFloat(f, 'g', -1, 64), Detail: "control logic: " + cg.name, Outcome: audit.OutcomeOK, } - if err := src.Write(e.root, name, val); err != nil { + if err := src.Write(e.root, name, f); err != nil { e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err) ev.Outcome = audit.OutcomeError ev.Error = err.Error() @@ -608,7 +612,8 @@ type compiledGraph struct { prevVal map[string]float64 // last value for change triggers hasVal map[string]bool lastFire map[string]int64 // ns wall clock each trigger last fired - locals map[string]float64 + locals map[string]Value + decls map[string]StateVar } func compile(g Graph) *compiledGraph { @@ -626,12 +631,21 @@ func compile(g Graph) *compiledGraph { prevVal: map[string]float64{}, hasVal: map[string]bool{}, lastFire: map[string]int64{}, - locals: map[string]float64{}, + locals: map[string]Value{}, + decls: map[string]StateVar{}, fireCh: make(chan string, 16), } for _, n := range g.Nodes { cg.byId[n.ID] = n } + for _, sv := range g.StateVars { + cg.decls[sv.Name] = sv + if sv.Type == "array" { + cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv) + } else { + cg.locals[sv.Name] = parseScalarInitial(sv) + } + } for _, w := range g.Wires { port := w.FromPort if port == "" { @@ -682,18 +696,38 @@ func compile(g Graph) *compiledGraph { return cg } -func (cg *compiledGraph) setLocal(name string, v float64) { +func parseScalarInitial(sv StateVar) float64 { + s := strings.TrimSpace(sv.Initial) + switch s { + case "true": + return 1 + case "false": + return 0 + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return f +} + +func (cg *compiledGraph) setLocal(name string, v Value) { cg.stateMu.Lock() + if sv, ok := cg.decls[name]; ok && sv.Type == "array" { + if arr, isArr := v.([]Value); isArr { + v = applySizing(arr, sv) + } + } cg.locals[name] = v cg.stateMu.Unlock() } -func (cg *compiledGraph) getLocal(name string) float64 { +func (cg *compiledGraph) getLocal(name string) Value { cg.stateMu.Lock() defer cg.stateMu.Unlock() v, ok := cg.locals[name] if !ok { - return 0 + return 0.0 } return v } @@ -897,7 +931,7 @@ func (cg *compiledGraph) activate(triggerID string) { dt = float64(now-last) / 1e9 } - resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes) + resolve := func(ds, name string) Value { switch ds { case "sys": if name == "dt" { @@ -986,8 +1020,12 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { cg.follow(node.ID, "done", ctx) case "action.write": - val := EvalExpr(node.param("expr"), ctx.resolve) - cg.emitDebug(node.ID, val, true) + val := EvalValue(node.param("expr"), ctx.resolve) + f, isNum := val.(float64) + if !isNum { + f = math.NaN() + } + cg.emitDebug(node.ID, f, true) cg.engine.write(cg, node.param("target"), val) cg.follow(node.ID, "out", ctx) diff --git a/internal/controllogic/engine_test.go b/internal/controllogic/engine_test.go new file mode 100644 index 0000000..8822588 --- /dev/null +++ b/internal/controllogic/engine_test.go @@ -0,0 +1,57 @@ +package controllogic + +import ( + "reflect" + "testing" +) + +func TestCompileInitsLocalsFromDecls(t *testing.T) { + g := Graph{ + ID: "g", Name: "n", + StateVars: []StateVar{ + {Name: "count", Type: "number", Initial: "7"}, + {Name: "flag", Type: "bool", Initial: "true"}, + {Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4}, + }, + } + cg := compile(g) + if got := cg.getLocal("count"); got != Value(7.0) { + t.Fatalf("count = %#v", got) + } + if got := cg.getLocal("flag"); got != Value(1.0) { + t.Fatalf("flag = %#v", got) + } + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) { + t.Fatalf("buf = %#v", got) + } +} + +func TestSetLocalAppliesSizing(t *testing.T) { + g := Graph{ID: "g", Name: "n", StateVars: []StateVar{ + {Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2}, + }} + cg := compile(g) + cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0}) + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) { + t.Fatalf("sized buf = %#v", got) + } +} + +func TestResolverReturnsLocalValue(t *testing.T) { + g := Graph{ID: "g", Name: "n", StateVars: []StateVar{ + {Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"}, + }} + cg := compile(g) + R := func(ds, name string) Value { + if ds == "local" { + return cg.getLocal(name) + } + return 0.0 + } + if v := EvalExpr("buf[1]", R); v != 20 { + t.Fatalf("buf[1] = %v", v) + } + if v := EvalExpr("sum(buf)", R); v != 30 { + t.Fatalf("sum(buf) = %v", v) + } +} diff --git a/internal/controllogic/expr_test.go b/internal/controllogic/expr_test.go index 2018d1f..081c6fa 100644 --- a/internal/controllogic/expr_test.go +++ b/internal/controllogic/expr_test.go @@ -7,7 +7,7 @@ import ( ) func TestEvalExpr(t *testing.T) { - resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes) + resolve := func(ds, name string) Value { switch { case ds == "stub" && name == "x": return 10 @@ -46,7 +46,7 @@ func TestEvalExpr(t *testing.T) { } func TestEvalExprErrors(t *testing.T) { - r := func(ds, name string) Value { return 0 } // shim: was float64 (Task 4 removes) + r := func(ds, name string) Value { return 0 } for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} { if v := EvalExpr(bad, r); !math.IsNaN(v) { t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)