controllogic: locals as Value, init from declarations, value-aware write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-24 19:40:29 +02:00
parent 519c1f2df4
commit a6fa4e7c7c
4 changed files with 114 additions and 19 deletions
+5 -5
View File
@@ -103,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
func TestApplyConfigWritesEveryParam(t *testing.T) { func TestApplyConfigWritesEveryParam(t *testing.T) {
e, src, instID := setupConfigEngine(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) e.applyConfig(cg, instID)
@@ -117,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) {
func TestApplyConfigUnknownInstanceNoop(t *testing.T) { func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
e, src, _ := setupConfigEngine(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") e.applyConfig(cg, "does-not-exist")
@@ -144,7 +144,7 @@ func TestReadConfigParam(t *testing.T) {
func TestWriteConfigParam(t *testing.T) { func TestWriteConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(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) e.writeConfigParam(cg, instID, "gain", 7.5)
@@ -163,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) {
func TestCreateConfigInstance(t *testing.T) { func TestCreateConfigInstance(t *testing.T) {
e, _, srcID := setupConfigEngine(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) src, err := e.cfg.GetInstance(srcID)
if err != nil { if err != nil {
@@ -197,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) {
func TestSnapshotConfig(t *testing.T) { func TestSnapshotConfig(t *testing.T) {
e, src, instID := setupConfigEngine(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. // Seed current live values for the set's target signals.
_ = src.Write(context.Background(), "GAIN", 3.5) _ = src.Write(context.Background(), "GAIN", 3.5)
+50 -12
View File
@@ -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 // 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. // 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) ds, name, ok := parseRef(target)
if !ok || math.IsNaN(val) { if !ok {
return return
} }
if ds == "local" { if ds == "local" {
cg.setLocal(name, val) cg.setLocal(name, val)
return return
} }
f, isNum := val.(float64)
if !isNum || math.IsNaN(f) {
return
}
if cg.dryRun { if cg.dryRun {
return // simulate: no real data-source write return // simulate: no real data-source write
} }
@@ -301,11 +305,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
Action: "signal.write", Action: "signal.write",
DS: ds, DS: ds,
Signal: name, Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64), Value: strconv.FormatFloat(f, 'g', -1, 64),
Detail: "control logic: " + cg.name, Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK, 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) e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError ev.Outcome = audit.OutcomeError
ev.Error = err.Error() ev.Error = err.Error()
@@ -608,7 +612,8 @@ type compiledGraph struct {
prevVal map[string]float64 // last value for change triggers prevVal map[string]float64 // last value for change triggers
hasVal map[string]bool hasVal map[string]bool
lastFire map[string]int64 // ns wall clock each trigger last fired 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 { func compile(g Graph) *compiledGraph {
@@ -626,12 +631,21 @@ func compile(g Graph) *compiledGraph {
prevVal: map[string]float64{}, prevVal: map[string]float64{},
hasVal: map[string]bool{}, hasVal: map[string]bool{},
lastFire: map[string]int64{}, lastFire: map[string]int64{},
locals: map[string]float64{}, locals: map[string]Value{},
decls: map[string]StateVar{},
fireCh: make(chan string, 16), fireCh: make(chan string, 16),
} }
for _, n := range g.Nodes { for _, n := range g.Nodes {
cg.byId[n.ID] = n 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 { for _, w := range g.Wires {
port := w.FromPort port := w.FromPort
if port == "" { if port == "" {
@@ -682,18 +696,38 @@ func compile(g Graph) *compiledGraph {
return cg 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() 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.locals[name] = v
cg.stateMu.Unlock() cg.stateMu.Unlock()
} }
func (cg *compiledGraph) getLocal(name string) float64 { func (cg *compiledGraph) getLocal(name string) Value {
cg.stateMu.Lock() cg.stateMu.Lock()
defer cg.stateMu.Unlock() defer cg.stateMu.Unlock()
v, ok := cg.locals[name] v, ok := cg.locals[name]
if !ok { if !ok {
return 0 return 0.0
} }
return v return v
} }
@@ -897,7 +931,7 @@ func (cg *compiledGraph) activate(triggerID string) {
dt = float64(now-last) / 1e9 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 { switch ds {
case "sys": case "sys":
if name == "dt" { if name == "dt" {
@@ -986,8 +1020,12 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.follow(node.ID, "done", ctx) cg.follow(node.ID, "done", ctx)
case "action.write": case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve) val := EvalValue(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true) f, isNum := val.(float64)
if !isNum {
f = math.NaN()
}
cg.emitDebug(node.ID, f, true)
cg.engine.write(cg, node.param("target"), val) cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx) cg.follow(node.ID, "out", ctx)
+57
View File
@@ -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)
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
) )
func TestEvalExpr(t *testing.T) { func TestEvalExpr(t *testing.T) {
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes) resolve := func(ds, name string) Value {
switch { switch {
case ds == "stub" && name == "x": case ds == "stub" && name == "x":
return 10 return 10
@@ -46,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
} }
func TestEvalExprErrors(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"} { for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
if v := EvalExpr(bad, r); !math.IsNaN(v) { if v := EvalExpr(bad, r); !math.IsNaN(v) {
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v) t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)