wshub: add per-signal calibration store and flat config-file codec
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
package wshub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxUnitLen bounds the calibration unit override. Mirrored by kMaxUnitLen in
|
||||
// the C++ StreamHub and MAX_UNIT_LEN in the SPA's calibration.js.
|
||||
const maxUnitLen = 16
|
||||
|
||||
// CalConfig is one per-signal affine calibration: y = raw*Scale + Offset.
|
||||
//
|
||||
// The key is (Source label, base Signal name). It is deliberately the source
|
||||
// *label* and not the runtime id ("s1", "s2"): ids are assigned in add-order at
|
||||
// startup, so a calibration keyed by id would rebind to a different source
|
||||
// whenever the source list order changed.
|
||||
type CalConfig struct {
|
||||
Source string `json:"source"`
|
||||
Signal string `json:"signal"`
|
||||
Scale float64 `json:"scale"`
|
||||
Offset float64 `json:"offset"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// calKey builds the calTable map key. NUL cannot occur in either component,
|
||||
// so the concatenation is unambiguous.
|
||||
func calKey(source, signal string) string { return source + "\x00" + signal }
|
||||
|
||||
// Normalise trims and validates the entry in place, reporting whether it is
|
||||
// usable. A zero or non-finite Scale is rejected because it makes the
|
||||
// calibration non-invertible, which the trigger threshold path depends on.
|
||||
func (c *CalConfig) Normalise() bool {
|
||||
c.Source = strings.TrimSpace(c.Source)
|
||||
c.Signal = strings.TrimSpace(c.Signal)
|
||||
if c.Source == "" || c.Signal == "" {
|
||||
return false
|
||||
}
|
||||
if math.IsNaN(c.Scale) || math.IsInf(c.Scale, 0) || c.Scale == 0 {
|
||||
return false
|
||||
}
|
||||
if math.IsNaN(c.Offset) || math.IsInf(c.Offset, 0) {
|
||||
return false
|
||||
}
|
||||
c.Unit = strings.TrimSpace(c.Unit)
|
||||
if len(c.Unit) > maxUnitLen {
|
||||
c.Unit = c.Unit[:maxUnitLen]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsIdentity reports whether the entry carries no information and can be
|
||||
// dropped rather than stored and persisted.
|
||||
func (c CalConfig) IsIdentity() bool {
|
||||
return c.Scale == 1 && c.Offset == 0 && c.Unit == ""
|
||||
}
|
||||
|
||||
// calTable is the hub's calibration store, safe for concurrent use.
|
||||
type calTable struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]CalConfig
|
||||
}
|
||||
|
||||
func newCalTable() *calTable {
|
||||
return &calTable{entries: make(map[string]CalConfig)}
|
||||
}
|
||||
|
||||
// Set validates and stores one entry, reporting whether it was accepted.
|
||||
// Storing an identity entry removes any existing one for that key.
|
||||
func (t *calTable) Set(c CalConfig) bool {
|
||||
if !c.Normalise() {
|
||||
return false
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c.IsIdentity() {
|
||||
delete(t.entries, calKey(c.Source, c.Signal))
|
||||
} else {
|
||||
t.entries[calKey(c.Source, c.Signal)] = c
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Replace swaps the whole table for the given entries, silently dropping the
|
||||
// invalid and identity ones. Used by config load and reload.
|
||||
func (t *calTable) Replace(list []CalConfig) {
|
||||
next := make(map[string]CalConfig, len(list))
|
||||
for _, c := range list {
|
||||
if !c.Normalise() || c.IsIdentity() {
|
||||
continue
|
||||
}
|
||||
next[calKey(c.Source, c.Signal)] = c
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.entries = next
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// List returns the entries sorted by source then signal, so both the wire
|
||||
// message and the config file have a stable order.
|
||||
func (t *calTable) List() []CalConfig {
|
||||
t.mu.RLock()
|
||||
out := make([]CalConfig, 0, len(t.entries))
|
||||
for _, c := range t.entries {
|
||||
out = append(out, c)
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Source != out[j].Source {
|
||||
return out[i].Source < out[j].Source
|
||||
}
|
||||
return out[i].Signal < out[j].Signal
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── Config file codec ────────────────────────────────────────────────────────
|
||||
|
||||
// configFileEntry is the union of a source block and a calibration block.
|
||||
//
|
||||
// The file is one FLAT array of FLAT objects — never a nested one. The C++
|
||||
// StreamHub's LoadSourcesFile is a hand-rolled scanner that takes each "{" up
|
||||
// to the next "}" as one object, so a nested block would truncate the parse.
|
||||
// Scale and Offset are pointers so that an absent field can be told apart from
|
||||
// an explicit zero and defaulted to the identity values.
|
||||
type configFileEntry struct {
|
||||
// Source fields.
|
||||
Label string `json:"label,omitempty"`
|
||||
Addr string `json:"addr,omitempty"`
|
||||
MulticastGroup string `json:"multicastGroup,omitempty"`
|
||||
DataPort int `json:"dataPort,omitempty"`
|
||||
// Calibration fields.
|
||||
Source string `json:"source,omitempty"`
|
||||
Signal string `json:"signal,omitempty"`
|
||||
Scale *float64 `json:"scale,omitempty"`
|
||||
Offset *float64 `json:"offset,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// parseConfigFile splits the flat array into sources and calibration entries.
|
||||
// A block with "addr" is a source, one with "signal" is a calibration; anything
|
||||
// else is skipped with a warning.
|
||||
func parseConfigFile(data []byte) ([]SourceConfig, []CalConfig, error) {
|
||||
var raw []configFileEntry
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
srcs := make([]SourceConfig, 0, len(raw))
|
||||
cals := make([]CalConfig, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
switch {
|
||||
case e.Addr != "":
|
||||
srcs = append(srcs, SourceConfig{
|
||||
Label: e.Label,
|
||||
Addr: e.Addr,
|
||||
MulticastGroup: e.MulticastGroup,
|
||||
DataPort: e.DataPort,
|
||||
})
|
||||
case e.Signal != "":
|
||||
c := CalConfig{Source: e.Source, Signal: e.Signal, Scale: 1, Offset: 0, Unit: e.Unit}
|
||||
if e.Scale != nil {
|
||||
c.Scale = *e.Scale
|
||||
}
|
||||
if e.Offset != nil {
|
||||
c.Offset = *e.Offset
|
||||
}
|
||||
if !c.Normalise() {
|
||||
log.Printf("wshub: skipping invalid calibration entry %q/%q", e.Source, e.Signal)
|
||||
continue
|
||||
}
|
||||
cals = append(cals, c)
|
||||
default:
|
||||
log.Printf("wshub: skipping unrecognised config block")
|
||||
}
|
||||
}
|
||||
return srcs, cals, nil
|
||||
}
|
||||
|
||||
// encodeConfigFile renders the sources followed by the calibration entries as
|
||||
// one flat array, in the indented shape the existing files already use.
|
||||
func encodeConfigFile(srcs []SourceConfig, cals []CalConfig) ([]byte, error) {
|
||||
out := make([]configFileEntry, 0, len(srcs)+len(cals))
|
||||
for _, s := range srcs {
|
||||
out = append(out, configFileEntry{
|
||||
Label: s.Label,
|
||||
Addr: s.Addr,
|
||||
MulticastGroup: s.MulticastGroup,
|
||||
DataPort: s.DataPort,
|
||||
})
|
||||
}
|
||||
for _, c := range cals {
|
||||
scale, offset := c.Scale, c.Offset
|
||||
out = append(out, configFileEntry{
|
||||
Source: c.Source,
|
||||
Signal: c.Signal,
|
||||
Scale: &scale,
|
||||
Offset: &offset,
|
||||
Unit: c.Unit,
|
||||
})
|
||||
}
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
Reference in New Issue
Block a user