controllogic: add Value union + sizing helpers (port of arraypolicy.ts)
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
// Value model for control-logic locals/expressions. A Value is either a scalar
|
||||||
|
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
|
||||||
|
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
|
||||||
|
// web/src/lib/expr.ts. Pure, dependency-free.
|
||||||
|
package controllogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Value is a scalar (float64) or an array ([]Value).
|
||||||
|
type Value = any
|
||||||
|
|
||||||
|
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
|
||||||
|
const ARRAY_MAX = 1_000_000
|
||||||
|
|
||||||
|
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
|
||||||
|
type StateVar struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
|
||||||
|
Initial string `json:"initial"` // initial value, stored as a string
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Low float64 `json:"low,omitempty"`
|
||||||
|
High float64 `json:"high,omitempty"`
|
||||||
|
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
|
||||||
|
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
|
||||||
|
Capacity int `json:"capacity,omitempty"` // array-only
|
||||||
|
}
|
||||||
|
|
||||||
|
func asNum(v Value) (float64, error) {
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("expected a number, got an array")
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asArr(v Value) ([]Value, error) {
|
||||||
|
a, ok := v.([]Value)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected an array, got a number")
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// idxResolve resolves a possibly-negative index against length; range-checked.
|
||||||
|
func idxResolve(i float64, length int) (int, error) {
|
||||||
|
k := int(i) // truncates toward zero, matching Math.trunc
|
||||||
|
if k < 0 {
|
||||||
|
k = length + k
|
||||||
|
}
|
||||||
|
if k < 0 || k >= length {
|
||||||
|
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
|
||||||
|
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
|
||||||
|
func normalizeValue(v any) Value {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return t
|
||||||
|
case float32:
|
||||||
|
return float64(t)
|
||||||
|
case int:
|
||||||
|
return float64(t)
|
||||||
|
case int64:
|
||||||
|
return float64(t)
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return 0.0
|
||||||
|
case []interface{}:
|
||||||
|
out := make([]Value, len(t))
|
||||||
|
for i, e := range t {
|
||||||
|
out[i] = normalizeValue(e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroFill(n int) []Value {
|
||||||
|
if n < 0 {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
out := make([]Value, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = 0.0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInitialArray returns the starting contents of an array local. Mirrors
|
||||||
|
// arraypolicy.ts parseInitialArray.
|
||||||
|
func parseInitialArray(sv StateVar) []Value {
|
||||||
|
cap := sv.Capacity
|
||||||
|
raw := strings.TrimSpace(sv.Initial)
|
||||||
|
var parsed []Value
|
||||||
|
if raw != "" {
|
||||||
|
var j interface{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &j); err == nil {
|
||||||
|
if arr, ok := j.([]interface{}); ok {
|
||||||
|
parsed = normalizeValue(arr).([]Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sv.Sizing == "fixed" {
|
||||||
|
if parsed == nil {
|
||||||
|
return zeroFill(cap)
|
||||||
|
}
|
||||||
|
out := make([]Value, 0, cap)
|
||||||
|
for i := 0; i < len(parsed) && i < cap; i++ {
|
||||||
|
out = append(out, parsed[i])
|
||||||
|
}
|
||||||
|
for len(out) < cap {
|
||||||
|
out = append(out, 0.0)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if parsed == nil {
|
||||||
|
return []Value{}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
|
||||||
|
func applySizing(arr []Value, sv StateVar) []Value {
|
||||||
|
cap := sv.Capacity
|
||||||
|
switch sv.Sizing {
|
||||||
|
case "fixed":
|
||||||
|
out := make([]Value, 0, cap)
|
||||||
|
for i := 0; i < len(arr) && i < cap; i++ {
|
||||||
|
out = append(out, arr[i])
|
||||||
|
}
|
||||||
|
for len(out) < cap {
|
||||||
|
out = append(out, 0.0)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case "capped":
|
||||||
|
if len(arr) > cap {
|
||||||
|
return arr[len(arr)-cap:]
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
default:
|
||||||
|
if len(arr) > ARRAY_MAX {
|
||||||
|
return arr[len(arr)-ARRAY_MAX:]
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package controllogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAsNumAsArr(t *testing.T) {
|
||||||
|
if n, err := asNum(3.0); err != nil || n != 3 {
|
||||||
|
t.Fatalf("asNum(3)=%v,%v", n, err)
|
||||||
|
}
|
||||||
|
if _, err := asNum([]Value{1.0}); err == nil {
|
||||||
|
t.Fatal("asNum(array) should error")
|
||||||
|
}
|
||||||
|
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
|
||||||
|
t.Fatalf("asArr=%v,%v", a, err)
|
||||||
|
}
|
||||||
|
if _, err := asArr(3.0); err == nil {
|
||||||
|
t.Fatal("asArr(number) should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdxResolve(t *testing.T) {
|
||||||
|
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
|
||||||
|
t.Fatalf("idx(-1,3)=%v,%v", k, err)
|
||||||
|
}
|
||||||
|
if _, err := idxResolve(3, 3); err == nil {
|
||||||
|
t.Fatal("idx(3,3) should be out of range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeValue(t *testing.T) {
|
||||||
|
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
|
||||||
|
want := []Value{1.0, 1.0, []Value{2.0}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("normalize=%#v want %#v", got, want)
|
||||||
|
}
|
||||||
|
if normalizeValue(5) != Value(5.0) {
|
||||||
|
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInitialArray(t *testing.T) {
|
||||||
|
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
|
||||||
|
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
|
||||||
|
t.Fatalf("fixed init = %#v", fixed)
|
||||||
|
}
|
||||||
|
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
|
||||||
|
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
|
||||||
|
t.Fatalf("dynamic init = %#v", dyn)
|
||||||
|
}
|
||||||
|
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
|
||||||
|
if len(empty) != 0 {
|
||||||
|
t.Fatalf("empty init = %#v", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySizing(t *testing.T) {
|
||||||
|
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
|
||||||
|
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
|
||||||
|
t.Fatalf("capped = %#v", capped)
|
||||||
|
}
|
||||||
|
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
|
||||||
|
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
|
||||||
|
t.Fatalf("fixed = %#v", fixed)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user