diff --git a/.changeset/grid-phases-on-the-wire.md b/.changeset/grid-phases-on-the-wire.md new file mode 100644 index 00000000..2a2c651f --- /dev/null +++ b/.changeset/grid-phases-on-the-wire.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The box streams the grid's three phase currents to the app and to FTW Cloud, so the phone can show each phase against the main fuse. They go as extra fields 11–13 in tenths of an ampere, signed by the site convention, with the main fuse as field 14. A meter that gives only the size of each current, such as Sungrow, gets its sign from the power on the same phase. The box sends only the phases the site has, and only while the site meter is online. When a field stops coming, the box resends the whole snapshot once, so a phone never keeps a phase at its last value. Lane 0 keeps its 512-byte frame, and these fields are the first to give way when a delta does not fit. diff --git a/contract/registry.yaml b/contract/registry.yaml index 18f3cd52..0cfdaecc 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -49,6 +49,29 @@ frozen_fields: # by the site convention: the charger consumes like any other load. 10: { name: ev_w, kind: power, unit: W } +# --------------------------------------------------------------------------- +# Field ids after the frozen set. They travel like the frozen ids, in lane +# 0's delta and in FTW Cloud's live body, under the same rules: integers, +# sent only while the box has the reading, and an id never changes meaning. +# They are depth for a node's own view, not the core view, so an app draws +# Now without them and a box may stop sending one. Lane 0 drops the highest +# id first when a delta does not fit its bucket, so these give way first. +# +# The grid's phase currents come from the site meter in tenths of an ampere, +# signed by the site convention: positive while the phase draws from the +# grid. A meter that gives magnitudes and no power per phase sends positive +# values both ways, so a positive current alone does not prove import. The +# box sends only the phases the site has, each while the meter is online and +# reports it, and their freshness is the grid meter's. grid_fuse_da is the +# main fuse per phase from the box's config, sent with the phases as their +# scale. +# --------------------------------------------------------------------------- +extra_fields: + 11: { name: grid_l1_da, kind: current, unit: dA } + 12: { name: grid_l2_da, kind: current, unit: dA } + 13: { name: grid_l3_da, kind: current, unit: dA } + 14: { name: grid_fuse_da, kind: current, unit: dA } + # --------------------------------------------------------------------------- # Capabilities. A set of names, not version numbers. Absent means the box does # not have it; the UI hides the feature and never crashes. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 6c75361e..30050d8e 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -232,7 +232,7 @@ list still show what kinds of equipment a site has. | Field | Type | Meaning | |---|---|---| -| `fields` | map text→int | Frozen field ids 1–6 and 10, keyed and encoded as the app protocol's `delta` does: field 1 is the mode index, power in W | +| `fields` | map text→int | Frozen field ids 1–6 and 10, and the extra fields 11–14 when the site meter gives phases, keyed and encoded as the app protocol's `delta` does: field 1 is the mode index, power in W, phase currents and the main fuse in tenths of an ampere | | `fieldSources` | map text→[text] | The sources each field's freshness follows. Sent only with `sources` | | `sources` | map text→Source | The source table. Sent whenever `srcRev` changes, and in every full message | | `srcRev` | uint | Rises by 1, per boot, each time the source table changes | diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 7916367d..4cd92892 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -2,8 +2,10 @@ package main import ( "context" + "encoding/json" "errors" "log/slog" + "math" "net/http" "sync" "sync/atomic" @@ -59,9 +61,10 @@ func (a *appSite) Snapshot() appproto.Snapshot { mode := a.ctrl.Mode siteMeterDriver := a.ctrl.SiteMeterDriver rev := a.revision.Observe(a.ctrl) + fuseA, fusePhases := a.ctrl.SiteFuseAmps, a.ctrl.SiteFusePhases a.ctrlMu.Unlock() - snap := appproto.Snapshot{Mode: mode, ControlRev: rev} + snap := appproto.Snapshot{Mode: mode, ControlRev: rev, FuseA: fuseA} uptimeMs := time.Since(a.started).Milliseconds() seen := map[string]bool{} @@ -97,6 +100,7 @@ func (a *appSite) Snapshot() appproto.Snapshot { if health := a.tel.DriverHealth(siteMeterDriver); health != nil && health.IsOnline() { if reading := a.tel.Get(siteMeterDriver, telemetry.DerMeter); reading != nil { snap.GridW = reading.SmoothedW + snap.GridPhaseA = gridPhaseAmps(reading, fusePhases) } } else { // The invariant is that stale site-meter data stops dispatch. @@ -166,6 +170,53 @@ func (a *appSite) Snapshot() appproto.Snapshot { return snap } +// gridPhaseAmps reads the site meter's current on each of the site's phases +// and gives it the site convention's sign. +// +// Meters differ here. Some sign the current; many, Sungrow among them, give +// only its size. The power on the same phase already carries the site's +// sign, because the driver converted it, so where the meter reports that +// power its sign is the phase's direction. Otherwise the driver's own sign +// stands. This converts no vendor sign: it copies a site sign onto a size, +// and a current the driver already signed keeps its sign. +// +// Only the site's phases are read, in L1 to L3 order, as the fuse guard +// reads them: a one-phase site owns no L2, whatever the payload holds. +func gridPhaseAmps(r *telemetry.DerReading, phases int) [3]*float64 { + var out [3]*float64 + if r == nil || len(r.Data) == 0 { + return out + } + var d struct { + L1A *float64 `json:"l1_a"` + L2A *float64 `json:"l2_a"` + L3A *float64 `json:"l3_a"` + L1W *float64 `json:"l1_w"` + L2W *float64 `json:"l2_w"` + L3W *float64 `json:"l3_w"` + } + if err := json.Unmarshal(r.Data, &d); err != nil { + return out + } + if phases <= 0 || phases > len(out) { + phases = len(out) + } + amps := [3]*float64{d.L1A, d.L2A, d.L3A} + watts := [3]*float64{d.L1W, d.L2W, d.L3W} + finite := func(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } + for i := 0; i < phases; i++ { + if amps[i] == nil || !finite(*amps[i]) { + continue + } + a := *amps[i] + if w := watts[i]; w != nil && finite(*w) && *w != 0 { + a = math.Copysign(math.Abs(a), *w) + } + out[i] = &a + } + return out +} + // appBoxInfo is what the box calls itself. type appBoxInfo struct { id string diff --git a/go/cmd/ftw/app_link_test.go b/go/cmd/ftw/app_link_test.go index db3b5576..cbeae1bc 100644 --- a/go/cmd/ftw/app_link_test.go +++ b/go/cmd/ftw/app_link_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "math" "path/filepath" "sync" @@ -344,3 +345,127 @@ func TestAppLoadpointsFlipSurplusOnlyThroughTheManager(t *testing.T) { t.Fatal("a loadpoint the box does not have took a flag") } } + +// The home box's Sungrow, read on 14 October 2026: it gives each phase's +// current as a size and each phase's power with the site's sign. One phase +// imported while the other two exported, so the wire must say which is +// which, and the fuse travels with them as their scale. +func TestAppSnapshotSignsEachPhaseByItsPower(t *testing.T) { + tel, ctrl := seedSite(t) + tel.Update("meter", telemetry.DerMeter, 160, nil, json.RawMessage( + `{"w":160,"l1_w":-367,"l2_w":908,"l3_w":-381,"l1_a":2.13,"l2_a":4.76,"l3_a":1.61}`)) + ctrl.SiteFuseAmps, ctrl.SiteFusePhases = 20, 3 + site := &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, + revision: &control.Revision{}, started: time.Now(), + siteMeterStale: time.Minute, + } + + snap := site.Snapshot() + + want := [3]float64{-2.13, 4.76, -1.61} + for i, w := range want { + if snap.GridPhaseA[i] == nil || *snap.GridPhaseA[i] != w { + t.Fatalf("L%d = %v, want %v", i+1, snap.GridPhaseA[i], w) + } + } + if snap.FuseA != 20 { + t.Fatalf("fuse = %v, want 20", snap.FuseA) + } +} + +func TestGridPhaseAmps(t *testing.T) { + reading := func(data string) *telemetry.DerReading { + return &telemetry.DerReading{Data: json.RawMessage(data)} + } + show := func(p [3]*float64) []any { + out := make([]any, 3) + for i, v := range p { + if v != nil { + out[i] = *v + } + } + return out + } + cases := []struct { + name string + data string + phases int + want []any + }{ + { + // A meter that signs its currents and gives no power keeps them. + name: "signed by the driver", phases: 3, + data: `{"l1_a":-17,"l2_a":3,"l3_a":0}`, + want: []any{-17.0, 3.0, 0.0}, + }, + { + // A P1 meter: sizes only. Positive is all it can say. + name: "sizes only", phases: 3, + data: `{"l1_a":4.2,"l2_a":0.4,"l3_a":1.1}`, + want: []any{4.2, 0.4, 1.1}, + }, + { + // The power is the site's sign, set by the driver. Copying it + // onto a current already signed the same way changes nothing. + name: "power agrees with a signed current", phases: 3, + data: `{"l1_a":-6,"l1_w":-1380,"l2_a":2,"l2_w":460,"l3_a":1,"l3_w":0}`, + want: []any{-6.0, 2.0, 1.0}, + }, + { + // A one-phase site owns no L2, whatever the payload holds. + name: "one phase", phases: 1, + data: `{"l1_a":9.5,"l1_w":2185,"l2_a":3,"l3_a":3}`, + want: []any{9.5, nil, nil}, + }, + { + name: "a phase the meter does not report", phases: 3, + data: `{"l1_a":1,"l3_a":2}`, + want: []any{1.0, nil, 2.0}, + }, + { + // Harnesses that leave fuse.phases unset read three, as the + // fuse guard does. + name: "phases unset", phases: 0, + data: `{"l1_a":1,"l2_a":2,"l3_a":3}`, + want: []any{1.0, 2.0, 3.0}, + }, + { + name: "no phases at all", phases: 3, + data: `{"w":1200}`, + want: []any{nil, nil, nil}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := show(gridPhaseAmps(reading(c.data), c.phases)) + for i := range c.want { + if got[i] != c.want[i] { + t.Fatalf("L%d = %v, want %v (all: %v)", i+1, got[i], c.want[i], got) + } + } + }) + } + if got := gridPhaseAmps(nil, 3); got != [3]*float64{} { + t.Fatalf("no reading gave %v", show(got)) + } +} + +// An offline meter's phases are as old as its power. The power already goes +// unsent; the phases go with it, so the app is never handed a phase it would +// have to age by itself. +func TestAnOfflineSiteMeterSendsNoPhases(t *testing.T) { + tel, ctrl := seedSite(t) + tel.Update("meter", telemetry.DerMeter, 160, nil, json.RawMessage(`{"l1_a":2.13,"l2_a":4.76,"l3_a":1.61}`)) + tel.DriverHealthMut("meter").SetOffline() + ctrl.SiteFuseAmps, ctrl.SiteFusePhases = 20, 3 + site := &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, + revision: &control.Revision{}, started: time.Now(), + siteMeterStale: time.Minute, + } + + if got := site.Snapshot().GridPhaseA; got != [3]*float64{} { + t.Fatalf("an offline meter sent phases %v", got) + } +} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index db771687..4731c787 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -73,7 +73,8 @@ func (c *cloudSite) Live() cloudlink.Live { } // Each field follows the drivers that produce it. Load is what is left - // of the grid after every other flow, so it follows all of them. + // of the grid after every other flow, so it follows all of them. The + // fuse is config and follows none. meter := siteMeterName(c.site.ctrl, c.site.ctrlMu) byType := func(t telemetry.DerType) []string { var names []string @@ -95,6 +96,9 @@ func (c *cloudSite) Live() cloudlink.Live { meterOnly = []string{meter} } set(appproto.FidGridW, meterOnly) + set(appproto.FidGridL1Da, meterOnly) + set(appproto.FidGridL2Da, meterOnly) + set(appproto.FidGridL3Da, meterOnly) set(appproto.FidPvW, pv) set(appproto.FidBatteryW, battery) set(appproto.FidBatterySoc, battery) diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go index c268185b..f9715d26 100644 --- a/go/cmd/ftw/cloud_link_test.go +++ b/go/cmd/ftw/cloud_link_test.go @@ -5,17 +5,22 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "encoding/json" "io" "log/slog" "path/filepath" "slices" + "strconv" + "sync" "testing" "time" "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" "github.com/srcfl/ftw/go/protocol/cloudwire" ) @@ -158,3 +163,39 @@ func TestAppSessionsHearOfFTWCloudOnlyWhenItCanRun(t *testing.T) { } } } + +// FTW Cloud's live body is the same field set the app session streams, and +// each phase follows the site meter's freshness there too. The fuse is +// config, so it names no driver. +func TestCloudLiveCarriesThePhasesUnderTheMeter(t *testing.T) { + tel, ctrl := seedSite(t) + tel.Update("meter", telemetry.DerMeter, 160, nil, json.RawMessage( + `{"l1_w":-367,"l2_w":908,"l3_w":-381,"l1_a":2.13,"l2_a":4.76,"l3_a":1.61}`)) + ctrl.SiteFuseAmps, ctrl.SiteFusePhases = 20, 3 + c := &cloudSite{site: &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, + revision: &control.Revision{}, started: time.Now(), + siteMeterStale: time.Minute, + }} + + live := c.Live() + + want := map[appproto.Fid]int64{ + appproto.FidGridL1Da: -21, appproto.FidGridL2Da: 48, appproto.FidGridL3Da: -16, appproto.FidGridFuseDa: 200, + } + for fid, v := range want { + key := strconv.Itoa(int(fid)) + if got, ok := live.Fields[key]; !ok || got != v { + t.Fatalf("field %s = %d (present %v), want %d", key, got, ok, v) + } + } + for _, fid := range []appproto.Fid{appproto.FidGridL1Da, appproto.FidGridL2Da, appproto.FidGridL3Da} { + key := strconv.Itoa(int(fid)) + if got := live.FieldSources[key]; !slices.Equal(got, []string{"meter"}) { + t.Fatalf("field %s follows %v, want the meter", key, got) + } + } + if got, ok := live.FieldSources[strconv.Itoa(int(appproto.FidGridFuseDa))]; ok { + t.Fatalf("the fuse follows %v; it is config", got) + } +} diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index 979d173e..924dce64 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -23,13 +23,12 @@ import ( // (carrier states) are deliberately absent: generating a constant the box // never uses only invites someone to use it. type Registry struct { - Version int `yaml:"version"` - FrozenFields map[int]struct { - Name string `yaml:"name"` - Kind string `yaml:"kind"` - Unit string `yaml:"unit"` - } `yaml:"frozen_fields"` - Capabilities []string `yaml:"capabilities"` + Version int `yaml:"version"` + FrozenFields map[int]field `yaml:"frozen_fields"` + // ExtraFields share the frozen set's ids on the wire but are not part of + // the core view: depth a node's own view draws when the box sends it. + ExtraFields map[int]field `yaml:"extra_fields"` + Capabilities []string `yaml:"capabilities"` Scopes []struct { Name string `yaml:"name"` Desc string `yaml:"desc"` @@ -57,13 +56,20 @@ type Registry struct { // with FTW Cloud, and what for. CloudScopes []string `yaml:"cloud_scopes"` CloudPurposes []string `yaml:"cloud_purposes"` - Resolutions map[string]struct { + Resolutions map[string]struct { TileSpanMs int64 `yaml:"tile_span_ms"` RetentionDays int `yaml:"retention_days"` Source string `yaml:"source"` } `yaml:"resolutions"` } +// field is one entry of frozen_fields or extra_fields. +type field struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Unit string `yaml:"unit"` +} + // Generate renders the constant file for package protocol. Exported and byte-deterministic so the // drift test can re-run it in memory and compare, rather than shelling out. func Generate(raw []byte) ([]byte, error) { @@ -72,6 +78,10 @@ func Generate(raw []byte) ([]byte, error) { return nil, fmt.Errorf("parse registry: %w", err) } + if err := checkFields(reg); err != nil { + return nil, err + } + var b bytes.Buffer b.WriteString("// Code generated by gencontract from contract/registry.yaml. DO NOT EDIT.\n\n") b.WriteString("package protocol\n\n") @@ -112,19 +122,58 @@ func GenerateRoles(raw []byte) ([]byte, error) { return format.Source(b.Bytes()) } -func writeFields(b *bytes.Buffer, reg Registry) { - ids := make([]int, 0, len(reg.FrozenFields)) - for id := range reg.FrozenFields { +// checkFields refuses a registry where one id or one name means two things. +// A delta and FTW Cloud's live body key fields by id alone, so the frozen +// set and the extra fields are one space on the wire. Extra fields sit above +// the frozen set too: lane 0 drops the highest id first when a delta does not +// fit, and the core view must be the last thing to go. +func checkFields(reg Registry) error { + highestFrozen := 0 + names := map[string]int{} + for id, f := range reg.FrozenFields { + highestFrozen = max(highestFrozen, id) + names[f.Name] = id + } + for id, f := range reg.ExtraFields { + if _, ok := reg.FrozenFields[id]; ok { + return fmt.Errorf("field id %d is both frozen and extra", id) + } + if id <= highestFrozen { + return fmt.Errorf("extra field %d (%s) sits among the frozen ids", id, f.Name) + } + if other, ok := names[f.Name]; ok { + return fmt.Errorf("field name %s is both %d and %d", f.Name, other, id) + } + names[f.Name] = id + } + return nil +} + +func sortedIDs(fields map[int]field) []int { + ids := make([]int, 0, len(fields)) + for id := range fields { ids = append(ids, id) } sort.Ints(ids) + return ids +} + +func writeFields(b *bytes.Buffer, reg Registry) { + all := make(map[int]field, len(reg.FrozenFields)+len(reg.ExtraFields)) + for id, f := range reg.FrozenFields { + all[id] = f + } + for id, f := range reg.ExtraFields { + all[id] = f + } b.WriteString("// Fid is a telemetry field id. 1-9 are frozen permanently as of v1, so an\n") - b.WriteString("// app of any age can draw the core view from them alone.\n") + b.WriteString("// app of any age can draw the core view from them alone. Ids after the\n") + b.WriteString("// frozen set are depth for a node's own view.\n") b.WriteString("type Fid uint32\n\n") b.WriteString("const (\n") - for _, id := range ids { - f := reg.FrozenFields[id] + for _, id := range sortedIDs(all) { + f := all[id] unit := f.Unit if unit == "" { unit = "none" @@ -134,17 +183,23 @@ func writeFields(b *bytes.Buffer, reg Registry) { } b.WriteString(")\n\n") - b.WriteString("// FrozenFieldNames maps a frozen field id to its registry name.\n") - b.WriteString("var FrozenFieldNames = map[Fid]string{\n") + writeFieldMaps(b, "FrozenFieldNames", "FrozenFieldUnits", "a frozen field id", reg.FrozenFields) + writeFieldMaps(b, "FieldNames", "FieldUnits", "every field id, frozen or extra,", all) +} + +func writeFieldMaps(b *bytes.Buffer, names, units, what string, fields map[int]field) { + ids := sortedIDs(fields) + fmt.Fprintf(b, "// %s maps %s to its registry name.\n", names, what) + fmt.Fprintf(b, "var %s = map[Fid]string{\n", names) for _, id := range ids { - fmt.Fprintf(b, "\tFid%s: %q,\n", goName(reg.FrozenFields[id].Name), reg.FrozenFields[id].Name) + fmt.Fprintf(b, "\tFid%s: %q,\n", goName(fields[id].Name), fields[id].Name) } b.WriteString("}\n\n") - b.WriteString("// FrozenFieldUnits maps a frozen field id to its unit, empty when it has none.\n") - b.WriteString("var FrozenFieldUnits = map[Fid]string{\n") + fmt.Fprintf(b, "// %s maps %s to its unit, empty when it has none.\n", units, what) + fmt.Fprintf(b, "var %s = map[Fid]string{\n", units) for _, id := range ids { - fmt.Fprintf(b, "\tFid%s: %q,\n", goName(reg.FrozenFields[id].Name), reg.FrozenFields[id].Unit) + fmt.Fprintf(b, "\tFid%s: %q,\n", goName(fields[id].Name), fields[id].Unit) } b.WriteString("}\n\n") } diff --git a/go/internal/appproto/gencontract/gencontract_test.go b/go/internal/appproto/gencontract/gencontract_test.go new file mode 100644 index 00000000..44d14275 --- /dev/null +++ b/go/internal/appproto/gencontract/gencontract_test.go @@ -0,0 +1,40 @@ +package gencontract + +import ( + "strings" + "testing" +) + +// A delta and FTW Cloud's live body key fields by id alone, so one id or one +// name in both blocks is two meanings for one number. An extra field among +// the frozen ids would be dropped after the core view when lane 0 runs out of +// room, which is the wrong way round. +func TestGenerateRefusesFieldsThatCollide(t *testing.T) { + const frozen = "version: 1\nfrozen_fields:\n 1: { name: mode, kind: enum, unit: null }\n 10: { name: ev_w, kind: power, unit: W }\n" + cases := map[string]string{ + "same id": "extra_fields:\n 10: { name: grid_l1_da, kind: current, unit: dA }\n", + "among frozen": "extra_fields:\n 5: { name: grid_l1_da, kind: current, unit: dA }\n", + "same name": "extra_fields:\n 11: { name: ev_w, kind: power, unit: W }\n", + "two extra names": "extra_fields:\n 11: { name: x, kind: current, unit: dA }\n 12: { name: x, kind: current, unit: dA }\n", + } + for name, extra := range cases { + if _, err := Generate([]byte(frozen + extra)); err == nil { + t.Errorf("%s: generated without complaint", name) + } + } + + out, err := Generate([]byte(frozen + "extra_fields:\n 11: { name: grid_l1_da, kind: current, unit: dA }\n")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"FidGridL1Da Fid = 11", `FidGridL1Da: "grid_l1_da"`, `FidGridL1Da: "dA"`} { + if !strings.Contains(string(out), want) { + t.Fatalf("generated file lacks %s", want) + } + } + // The frozen table stays the frozen set alone. + frozenTable := string(out)[strings.Index(string(out), "var FrozenFieldNames"):strings.Index(string(out), "var FrozenFieldUnits")] + if strings.Contains(frozenTable, "grid_l1_da") { + t.Fatal("an extra field landed in the frozen table") + } +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index 8cc72d0b..31d39532 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -626,8 +626,14 @@ func (h *Handler) Tick() error { // command will be refused for a conflict it cannot see. Only the snapshot // carries the number, so a change in it costs one bulk frame — which is // also how the client resyncs mid-session rather than on reconnect. + // + // A field that stopped coming is the same kind of news. A delta carries + // values, not absences, so a phase the meter no longer reports would stay + // on the client at its last value; the snapshot replaces the client's + // whole set instead. + fields := fieldValues(snap, h.modes) h.mu.Lock() - stale := snap.ControlRev != h.lastRev + stale := snap.ControlRev != h.lastRev || anyGone(h.lastSent, fields) h.mu.Unlock() if stale { if err := h.sendSnapshot(); err != nil { @@ -635,7 +641,6 @@ func (h *Handler) Tick() error { } } - fields := fieldValues(snap, h.modes) sources := wireSources(snap, uptimeMs) blocked := snap.DispatchBlockedBy if blocked == nil { @@ -759,6 +764,16 @@ func (h *Handler) fitDelta(body *Delta, bucket int) (map[string]int64, uint8, er return nil, 0, fmt.Errorf("appproto: an empty delta does not fit a %d-byte bucket", bucket) } +// anyGone reports whether a field the client was sent is missing now. +func anyGone(sent, now map[string]int64) bool { + for k := range sent { + if _, ok := now[k]; !ok { + return true + } + } + return false +} + func fidOf(key string) uint64 { v, err := strconv.ParseUint(key, 10, 32) if err != nil { diff --git a/go/internal/appproto/phases_test.go b/go/internal/appproto/phases_test.go new file mode 100644 index 00000000..0e29597e --- /dev/null +++ b/go/internal/appproto/phases_test.go @@ -0,0 +1,183 @@ +package appproto + +import ( + "math" + "testing" +) + +func amps(a ...float64) [3]*float64 { + var out [3]*float64 + for i := range a { + out[i] = &a[i] + } + return out +} + +// The phases cross in tenths of an ampere with the sign the box gave them, +// and the fuse rides with them as their scale. +func TestPhasesAreTenthsOfAnAmpereOnTheWire(t *testing.T) { + f := fieldValues(Snapshot{GridPhaseA: amps(-2.13, 4.76, -1.61), FuseA: 20}, nil) + want := map[Fid]int64{FidGridL1Da: -21, FidGridL2Da: 48, FidGridL3Da: -16, FidGridFuseDa: 200} + for fid, v := range want { + if got, ok := f[fidKey(fid)]; !ok || got != v { + t.Fatalf("field %d = %d (present %v), want %d", fid, got, ok, v) + } + } +} + +// No phases, no fuse: the fuse alone is a scale with nothing on it, and a +// site whose meter gives no phases must not look like one with three idle +// ones. +func TestTheFuseTravelsOnlyWithPhases(t *testing.T) { + f := fieldValues(Snapshot{FuseA: 20}, nil) + for _, fid := range []Fid{FidGridL1Da, FidGridL2Da, FidGridL3Da, FidGridFuseDa} { + if _, ok := f[fidKey(fid)]; ok { + t.Fatalf("field %d went out from a meter with no phases", fid) + } + } + + nan := math.NaN() + f = fieldValues(Snapshot{GridPhaseA: [3]*float64{&nan}, FuseA: 20}, nil) + if _, ok := f[fidKey(FidGridFuseDa)]; ok { + t.Fatal("a phase that is not a number counted as a phase") + } + + f = fieldValues(Snapshot{GridPhaseA: amps(3.2)}, nil) + if got := f[fidKey(FidGridL1Da)]; got != 32 { + t.Fatalf("L1 = %d, want 32", got) + } + if _, ok := f[fidKey(FidGridL2Da)]; ok { + t.Fatal("a one-phase site sent L2") + } +} + +// The phases are the site meter's readings, so their age is the meter's. +// The fuse is the box's config and has no age. +func TestThePhasesFollowTheGridMetersFreshness(t *testing.T) { + dict := fieldDict("meter.p1", "inv", "bat") + for fid, name := range map[Fid]string{FidGridL1Da: "grid_l1_da", FidGridL2Da: "grid_l2_da", FidGridL3Da: "grid_l3_da"} { + def := dict[fidKey(fid)] + if def.Name != name || def.Unit == nil || *def.Unit != "dA" || def.SrcID == nil || *def.SrcID != "meter.p1" { + t.Fatalf("field %d = %+v, want %s in dA from meter.p1", fid, def, name) + } + } + def := dict[fidKey(FidGridFuseDa)] + if def.Name != "grid_fuse_da" || def.SrcID != nil { + t.Fatalf("fuse = %+v, want grid_fuse_da with no source", def) + } +} + +// The extra fields cost lane 0 nothing: a delta carrying every field at the +// largest values a home could show still fills one 512-byte bucket. +func TestEveryFieldAtOnceFitsTheBucket(t *testing.T) { + h, box, rec, clock := newRig(t) + box.snap.GridPhaseA = amps(1, 1, 1) + box.snap.FuseA = 20 + subscribe(t, h, rec) + + clock.uptimeMs += 1000 + box.snap.Mode = "planner_arbitrage" + box.snap.GridW, box.snap.PVW, box.snap.BatteryW, box.snap.LoadW = -99_999, -99_999, -99_999, 99_999 + box.snap.BatterySoC, box.snap.EVWKnown, box.snap.EVW = 1, true, 99_999 + box.snap.GridPhaseA = amps(-999.9, -999.9, -999.9) + box.snap.FuseA = 999.9 + if err := h.Tick(); err != nil { + t.Fatal(err) + } + + f := rec.only(t, MsgDelta) + if f.size != 512 || f.flags&FlagTrunc != 0 { + t.Fatalf("delta is %d bytes, truncated %v; want the bucket, whole", f.size, f.flags&FlagTrunc != 0) + } + d := body[Delta](t, f) + for _, fid := range []Fid{FidGridL1Da, FidGridL2Da, FidGridL3Da, FidGridFuseDa} { + if _, ok := d.Fields[fidKey(fid)]; !ok { + t.Fatalf("field %d missing from %v", fid, d.Fields) + } + } +} + +// When a delta must shrink, the depth goes before the core view: fields +// leave from the highest id, and the extra fields sit above the frozen set. +func TestTheExtraFieldsGiveWayFirst(t *testing.T) { + h, _, _, _ := newRig(t) + full := fieldValues(Snapshot{ + GridW: 1200, PVW: -3400, BatteryW: 900, LoadW: 3700, + BatterySoC: 0.62, BatterySoCKnown: true, EVWKnown: true, EVW: 7360, + GridPhaseA: amps(-21.3, 47.6, -16.1), FuseA: 20, + }, nil) + + // The smallest bucket that still holds the frozen fields alone. + frozenOnly := map[string]int64{} + for k, v := range full { + if fidOf(k) <= uint64(FidEvW) { + frozenOnly[k] = v + } + } + env, err := newEnvelope(MsgDelta, nil, Delta{Seq: 1, UptimeMs: 1, Fields: frozenOnly}) + if err != nil { + t.Fatal(err) + } + raw, err := EncodeEnvelope(env) + if err != nil { + t.Fatal(err) + } + + body := Delta{Seq: 1, UptimeMs: 1, Fields: full} + kept, flags, err := h.fitDelta(&body, len(raw)+6) + if err != nil { + t.Fatal(err) + } + if flags&FlagTrunc == 0 { + t.Fatal("a delta that did not fit went out without TRUNC") + } + if len(kept) != len(frozenOnly) { + t.Fatalf("kept %v, want the frozen fields and nothing else", kept) + } + for k := range frozenOnly { + if _, ok := kept[k]; !ok { + t.Fatalf("frozen field %s was dropped while extra fields remained", k) + } + } +} + +// A delta carries values, never an absence. A phase the meter stopped giving +// would stay on the phone at its last value, so the box resends the whole set +// in a snapshot, once. +func TestAFieldThatStopsComingResendsTheSnapshot(t *testing.T) { + h, box, rec, _ := newRig(t) + box.snap.GridPhaseA = amps(2.1, 4.8, 1.6) + box.snap.FuseA = 20 + subscribe(t, h, rec) + + if err := h.Tick(); err != nil { + t.Fatal(err) + } + if rec.has(MsgSnap) { + t.Fatal("nothing changed and a snapshot went out") + } + + box.snap.GridPhaseA = [3]*float64{} + rec.reset() + if err := h.Tick(); err != nil { + t.Fatal(err) + } + snap := body[Snap](t, rec.only(t, MsgSnap)) + for _, fid := range []Fid{FidGridL1Da, FidGridL2Da, FidGridL3Da, FidGridFuseDa} { + if _, ok := snap.Fields[fidKey(fid)]; ok { + t.Fatalf("the snapshot still carries field %d", fid) + } + } + // Lane 0 keeps its beat: the second's frame still goes. + if f := rec.frames[len(rec.frames)-1]; f.lane != LaneControl || f.size != 512 { + t.Fatalf("the second ended on lane %d with %d bytes", f.lane, f.size) + } + + rec.reset() + if err := h.Tick(); err != nil { + t.Fatal(err) + } + if rec.has(MsgSnap) { + t.Fatal("the snapshot went out again with nothing gone") + } +} diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 44b41be7..4f092c94 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -93,6 +93,16 @@ type Snapshot struct { EVW float64 EVWKnown bool + // GridPhaseA is the site meter's current on L1, L2 and L3 in amps, signed + // by the site convention: positive while the phase draws from the grid. + // Nil for a phase the site does not have or the meter does not report, + // and all nil while the meter is offline. The wire carries tenths of an + // ampere, converted at the edge of this package. + GridPhaseA [3]*float64 + // FuseA is the main fuse per phase, the scale the phases are read + // against. Sent only with at least one phase. + FuseA float64 + Sources []Source // DispatchBlockedBy names the source ids currently stopping dispatch. diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index ff3b7e7b..26783d47 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -203,6 +203,10 @@ const ( FidSrcPv = protocol.FidSrcPv FidSrcBattery = protocol.FidSrcBattery FidEvW = protocol.FidEvW + FidGridL1Da = protocol.FidGridL1Da + FidGridL2Da = protocol.FidGridL2Da + FidGridL3Da = protocol.FidGridL3Da + FidGridFuseDa = protocol.FidGridFuseDa CapStatusCore = protocol.CapStatusCore CapStatusPhases = protocol.CapStatusPhases CapStatusDrivers = protocol.CapStatusDrivers @@ -257,6 +261,8 @@ const ( var ( FrozenFieldNames = protocol.FrozenFieldNames FrozenFieldUnits = protocol.FrozenFieldUnits + FieldNames = protocol.FieldNames + FieldUnits = protocol.FieldUnits AllCapabilities = protocol.AllCapabilities WriteScopes = protocol.WriteScopes RegistryOps = protocol.RegistryOps diff --git a/go/internal/appproto/telemetry.go b/go/internal/appproto/telemetry.go index 39b63eb4..be008375 100644 --- a/go/internal/appproto/telemetry.go +++ b/go/internal/appproto/telemetry.go @@ -111,9 +111,29 @@ func fieldValues(snap Snapshot, modes []ModeInfo) map[string]int64 { if snap.EVWKnown { f[fidKey(FidEvW)] = roundW(snap.EVW) } + phases := false + for i, a := range snap.GridPhaseA { + if a == nil || math.IsNaN(*a) || math.IsInf(*a, 0) { + continue + } + f[fidKey(gridPhaseFids[i])] = deciamps(*a) + phases = true + } + if phases && snap.FuseA > 0 { + f[fidKey(FidGridFuseDa)] = deciamps(snap.FuseA) + } return f } +// gridPhaseFids are the extra fields for L1, L2 and L3, in that order. +var gridPhaseFids = [3]Fid{FidGridL1Da, FidGridL2Da, FidGridL3Da} + +// deciamps is the one place amps become integers: tenths of an ampere, which +// is finer than any meter's useful resolution and keeps lane 0 numeric. +func deciamps(a float64) int64 { + return int64(math.Round(a * 10)) +} + // roundW is the one place watts become integers. A NaN from a driver that lost // its device becomes 0 rather than an encoding failure that would drop the // whole frame — the source state is what tells the app not to trust it. @@ -127,7 +147,7 @@ func roundW(w float64) int64 { // fidKey renders a field id as the text map key the wire uses. func fidKey(f Fid) string { return strconv.FormatUint(uint64(f), 10) } -// fieldDict maps each frozen field to its name, unit and source. +// fieldDict maps each field to its name, unit and source. // // The client caches this across sessions, which is what lets a delta be bare // id-to-value pairs. srcId is what turns a reading into a freshness claim: @@ -141,8 +161,8 @@ func fieldDict(srcGrid, srcPV, srcBattery string) map[string]FieldDef { } def := func(f Fid, srcID string) FieldDef { return FieldDef{ - Name: FrozenFieldNames[f], - Unit: orNil(FrozenFieldUnits[f]), + Name: FieldNames[f], + Unit: orNil(FieldUnits[f]), SrcID: orNil(srcID), } } @@ -161,6 +181,12 @@ func fieldDict(srcGrid, srcPV, srcBattery string) map[string]FieldDef { // so it carries none, like the mode. The value's absence, not a // per-driver age, is what says the site has no charger. fidKey(FidEvW): def(FidEvW, ""), + // The phases are the site meter's own readings, as fresh as it is. + fidKey(FidGridL1Da): def(FidGridL1Da, srcGrid), + fidKey(FidGridL2Da): def(FidGridL2Da, srcGrid), + fidKey(FidGridL3Da): def(FidGridL3Da, srcGrid), + // The fuse is the box's config, not a reading. + fidKey(FidGridFuseDa): def(FidGridFuseDa, ""), } } diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 100786b2..8b89033d 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -153,8 +153,9 @@ type Hello struct { // Live is the site's flows and the freshness of every source behind them. type Live struct { Header - // Fields uses the app protocol's frozen ids 1–6 and 10, keyed and - // encoded as its delta does. + // Fields uses the app protocol's frozen ids 1–6 and 10, and its extra + // fields 11–14 when the site meter gives phases, keyed and encoded as its + // delta does. Fields map[string]int64 `cbor:"fields"` // FieldSources and Sources travel only when the source table changed and // in every full message. diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index de4a0402..f808a477 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -6,7 +6,8 @@ package protocol const ContractVersion = 1 // Fid is a telemetry field id. 1-9 are frozen permanently as of v1, so an -// app of any age can draw the core view from them alone. +// app of any age can draw the core view from them alone. Ids after the +// frozen set are depth for a node's own view. type Fid uint32 const ( @@ -30,6 +31,14 @@ const ( FidSrcBattery Fid = 9 // FidEvW — power, unit W. FidEvW Fid = 10 + // FidGridL1Da — current, unit dA. + FidGridL1Da Fid = 11 + // FidGridL2Da — current, unit dA. + FidGridL2Da Fid = 12 + // FidGridL3Da — current, unit dA. + FidGridL3Da Fid = 13 + // FidGridFuseDa — current, unit dA. + FidGridFuseDa Fid = 14 ) // FrozenFieldNames maps a frozen field id to its registry name. @@ -60,6 +69,42 @@ var FrozenFieldUnits = map[Fid]string{ FidEvW: "W", } +// FieldNames maps every field id, frozen or extra, to its registry name. +var FieldNames = map[Fid]string{ + FidMode: "mode", + FidGridW: "grid_w", + FidPvW: "pv_w", + FidBatteryW: "battery_w", + FidBatterySoc: "battery_soc", + FidLoadW: "load_w", + FidSrcGrid: "src_grid", + FidSrcPv: "src_pv", + FidSrcBattery: "src_battery", + FidEvW: "ev_w", + FidGridL1Da: "grid_l1_da", + FidGridL2Da: "grid_l2_da", + FidGridL3Da: "grid_l3_da", + FidGridFuseDa: "grid_fuse_da", +} + +// FieldUnits maps every field id, frozen or extra, to its unit, empty when it has none. +var FieldUnits = map[Fid]string{ + FidMode: "", + FidGridW: "W", + FidPvW: "W", + FidBatteryW: "W", + FidBatterySoc: "permille", + FidLoadW: "W", + FidSrcGrid: "", + FidSrcPv: "", + FidSrcBattery: "", + FidEvW: "W", + FidGridL1Da: "dA", + FidGridL2Da: "dA", + FidGridL3Da: "dA", + FidGridFuseDa: "dA", +} + // Capabilities are presence, not levels. An app hides what is absent and // never compares versions. const (