diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 3ded8759..db2f96e8 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -3569,7 +3569,7 @@ Write the whole experiment to a file, its definition beside every cell's raw num |---|---|---|---| | `path` | string | optional, primary | where to write it; empty writes meshbench-experiment.json in the system temporary directory, and a path that cannot be written is refused | -**Answers** `path`, `bytes`. JSON, holding the arms, seeds, senders and timings alongside `results`, which is every cell including the per-second histogram and the at-risk shares, and `summary`, which is the means. It will write an empty one before anything has run. +**Answers** `path`, `bytes`. JSON, holding the arms, seeds, senders and timings alongside `results`, which is every cell including the per-second histogram, the at-risk shares and `chip`, what each node's radio counted over the cell and how it was set at the end, and `summary`, which is the means. It will write an empty one before anything has run. **Example** - keep the sweep, wherever the machine puts temporary files diff --git a/docs/verbs.json b/docs/verbs.json index f76815fa..dafe9da9 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -1450,7 +1450,7 @@ "path", "bytes" ], - "answers": "JSON, holding the arms, seeds, senders and timings alongside `results`, which is every cell including the per-second histogram and the at-risk shares, and `summary`, which is the means. It will write an empty one before anything has run.", + "answers": "JSON, holding the arms, seeds, senders and timings alongside `results`, which is every cell including the per-second histogram, the at-risk shares and `chip`, what each node's radio counted over the cell and how it was set at the end, and `summary`, which is the means. It will write an empty one before anything has run.", "example": { "params": {}, "what": "keep the sweep, wherever the machine puts temporary files", diff --git a/internal/app/session/experiment/cell.go b/internal/app/session/experiment/cell.go index 47456d7e..0e369eae 100644 --- a/internal/app/session/experiment/cell.go +++ b/internal/app/session/experiment/cell.go @@ -316,6 +316,7 @@ func runArm(ctx context.Context, s *session.Sim, e *experiment, arm session.ExpA // boot chatter above are receptions too, and a cell that inherited them // would report the mesh coming up rather than the flood it measured. eng.ResetSensitivity() + chipBefore := chipBaseline(eng) burstMs := uint32(0) // Sends waiting for their moment, when the senders are staggered. @@ -443,6 +444,7 @@ func runArm(ctx context.Context, s *session.Sim, e *experiment, arm session.ExpA for i := range engine.MarginEdgesDB { out.AtRisk[i] = sens.AtRisk(i) } + out.Chip = chipStats(eng, chipBefore) return out } diff --git a/internal/app/session/experiment/chipstats.go b/internal/app/session/experiment/chipstats.go new file mode 100644 index 00000000..d20a912c --- /dev/null +++ b/internal/app/session/experiment/chipstats.go @@ -0,0 +1,114 @@ +package experiment + +import ( + "github.com/MeshBench/meshbench/internal/firmware" + "github.com/MeshBench/meshbench/internal/sim/engine" +) + +// ChipStats is what each node's chip saw during a cell, summed and per node. +// +// Delivery totals say what the mesh did; these say what the firmware was told. +// They are the only way to tell a busy channel from a radio that cries busy, +// and - on a chip that applies IrqMask - a firmware that never enabled an +// interrupt from one that never had cause to see it. Two builds whose radio +// drivers differ and whose totals agree are not shown to behave alike until +// these agree too. +type ChipStats struct { + IRQReads uint64 `json:"irq_reads"` + BusyReads uint64 `json:"busy_reads"` + BusyMs uint64 `json:"busy_ms"` + Spurious uint64 `json:"spurious"` + IRQSuppressed uint64 `json:"irq_suppressed"` + // Reporting is how many nodes sent a suppressed count at all. A chip that + // ignores the mask sends none, and its total of zero means unknown. + Reporting int `json:"suppressed_reporting"` + Nodes []NodeChip `json:"nodes"` +} + +// NodeChip is what one node's chip counted over the cell, and how it was +// set when the cell ended. +type NodeChip struct { + Name string `json:"name"` + Kind string `json:"kind"` + IRQReads uint32 `json:"irq_reads"` + BusyReads uint32 `json:"busy_reads"` + BusyMs uint32 `json:"busy_ms"` + Spurious uint32 `json:"spurious"` + IRQSuppressed uint32 `json:"irq_suppressed"` + Reported bool `json:"suppressed_reported"` + IRQMask uint16 `json:"irq_mask"` + SF uint8 `json:"sf"` + BandwidthHz uint32 `json:"bandwidth_hz"` +} + +// chipBaseline is every running node's chip as the cell's own traffic begins. +// +// The chip counts from boot, and the cell's other numbers do not: the ledger +// and the margin counters both start after the adverts have settled. Without +// this, the counters would include the mesh coming up while the delivery +// totals beside them do not, and two arms that booted differently would look +// as though they flooded differently. +func chipBaseline(eng *engine.Engine) map[string]firmware.RadioStats { + out := map[string]firmware.RadioStats{} + for _, n := range eng.Nodes() { + if n.Firmware != nil { + out[n.Spec().Name] = n.Firmware.Bridge.Stats() + } + } + return out +} + +// chipStats reads every running node's chip since before. Called before the +// engine closes, while the bridges still hold the last record each node sent. +func chipStats(eng *engine.Engine, before map[string]firmware.RadioStats) *ChipStats { + out := &ChipStats{} + for _, n := range eng.Nodes() { + if n.Firmware == nil { + continue + } + name := n.Spec().Name + out.add(name, string(n.Spec().Kind), since(n.Firmware.Bridge.Stats(), before[name])) + } + return out +} + +// since is end with its counters measured from start. The mask, the modem +// settings and whether a suppressed count is kept are what the chip holds at +// the end, not counts, so they are end's alone. +func since(end, start firmware.RadioStats) firmware.RadioStats { + end.IRQReads = counted(end.IRQReads, start.IRQReads) + end.BusyReads = counted(end.BusyReads, start.BusyReads) + end.BusyMs = counted(end.BusyMs, start.BusyMs) + end.SpuriousUp = counted(end.SpuriousUp, start.SpuriousUp) + end.IRQSuppressed = counted(end.IRQSuppressed, start.IRQSuppressed) + return end +} + +// counted is how far a counter moved. One that went backwards was restarted +// with its node, so everything it holds was counted after the baseline; +// subtracting would wrap to four billion. +func counted(end, start uint32) uint32 { + if end < start { + return end + } + return end - start +} + +// add folds one node's radio record into the cell's counters. +func (c *ChipStats) add(name, kind string, r firmware.RadioStats) { + c.IRQReads += uint64(r.IRQReads) + c.BusyReads += uint64(r.BusyReads) + c.BusyMs += uint64(r.BusyMs) + c.Spurious += uint64(r.SpuriousUp) + if r.SuppressedReported { + c.IRQSuppressed += uint64(r.IRQSuppressed) + c.Reporting++ + } + c.Nodes = append(c.Nodes, NodeChip{ + Name: name, Kind: kind, + IRQReads: r.IRQReads, BusyReads: r.BusyReads, BusyMs: r.BusyMs, + Spurious: r.SpuriousUp, IRQSuppressed: r.IRQSuppressed, + Reported: r.SuppressedReported, IRQMask: r.IRQMask, + SF: r.SF, BandwidthHz: r.BandwidthHz, + }) +} diff --git a/internal/app/session/experiment/chipstats_test.go b/internal/app/session/experiment/chipstats_test.go new file mode 100644 index 00000000..a69ee84e --- /dev/null +++ b/internal/app/session/experiment/chipstats_test.go @@ -0,0 +1,95 @@ +package experiment + +import ( + "encoding/json" + "testing" + + "github.com/MeshBench/meshbench/internal/firmware" +) + +// A cell sums every node's radio counters and keeps one row per node, so two +// arms whose delivery totals agree can still be told apart by what each +// firmware was told. +func TestACellSumsEveryNodesChipAndKeepsEachRow(t *testing.T) { + var c ChipStats + c.add("A", "companion", firmware.RadioStats{ + IRQReads: 100, BusyReads: 10, BusyMs: 500, IRQMask: 0x0272, + IRQSuppressed: 7, SuppressedReported: true, SF: 8, BandwidthHz: 62500, + }) + c.add("B", "simple-repeater", firmware.RadioStats{ + IRQReads: 50, BusyReads: 5, BusyMs: 250, IRQMask: 0x0276, + IRQSuppressed: 3, SuppressedReported: true, SF: 8, BandwidthHz: 62500, + }) + if c.IRQReads != 150 || c.BusyReads != 15 || c.BusyMs != 750 { + t.Errorf("sums: reads %d busy %d busy_ms %d, want 150 15 750", c.IRQReads, c.BusyReads, c.BusyMs) + } + if c.IRQSuppressed != 10 || c.Reporting != 2 { + t.Errorf("suppressed %d from %d nodes, want 10 from 2", c.IRQSuppressed, c.Reporting) + } + if len(c.Nodes) != 2 || c.Nodes[1].Name != "B" || c.Nodes[1].IRQMask != 0x0276 { + t.Errorf("per-node rows: %+v", c.Nodes) + } +} + +// A chip that does not count suppressed events adds nothing to the total and +// is not counted as reporting, so a total of zero is not mistaken for a count +// of zero. +func TestAChipThatDoesNotCountIsNotCountedAsReporting(t *testing.T) { + var c ChipStats + c.add("old", "simple-repeater", firmware.RadioStats{IRQReads: 20, IRQSuppressed: 9}) + if c.IRQSuppressed != 0 || c.Reporting != 0 { + t.Errorf("suppressed %d from %d nodes, want 0 from 0", c.IRQSuppressed, c.Reporting) + } + if c.Nodes[0].Reported { + t.Error("the row says the chip reported a count it does not keep") + } +} + +// The counters reach the export under "chip", beside the cell's other numbers. +func TestTheExportCarriesEachCellsChipCounters(t *testing.T) { + r := Result{Arm: "a", Chip: &ChipStats{IRQReads: 1, Nodes: []NodeChip{{Name: "A"}}}} + raw, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + chip, ok := back["chip"].(map[string]any) + if !ok || chip["irq_reads"] != float64(1) { + t.Errorf("export row: %s", raw) + } +} + +// What the chip counted while the mesh came up stays out of the cell, as the +// boot chatter stays out of its delivery totals; what the chip holds at the end +// is reported as it stands. +func TestACellCountsTheChipFromItsBaselineNotFromBoot(t *testing.T) { + start := firmware.RadioStats{ + IRQReads: 40, BusyReads: 4, BusyMs: 200, SpuriousUp: 1, IRQSuppressed: 6, + SuppressedReported: true, IRQMask: 0x0272, SF: 8, BandwidthHz: 62500, + } + end := firmware.RadioStats{ + IRQReads: 100, BusyReads: 10, BusyMs: 500, SpuriousUp: 3, IRQSuppressed: 9, + SuppressedReported: true, IRQMask: 0x0276, SF: 8, BandwidthHz: 62500, + } + got := since(end, start) + if got.IRQReads != 60 || got.BusyReads != 6 || got.BusyMs != 300 || + got.SpuriousUp != 2 || got.IRQSuppressed != 3 { + t.Errorf("counters %+v, want 60 6 300 2 3 counted from the baseline", got) + } + if got.IRQMask != 0x0276 || got.SF != 8 || got.BandwidthHz != 62500 || !got.SuppressedReported { + t.Errorf("state %+v, want the chip as it ended", got) + } +} + +// A node that restarted mid-cell starts its counters again from zero, so +// everything it holds came after the baseline. Subtracting the larger figure +// from before would wrap to four billion. +func TestANodeThatRestartedMidCellReportsWhatItCountedSince(t *testing.T) { + got := since(firmware.RadioStats{IRQReads: 15}, firmware.RadioStats{IRQReads: 40}) + if got.IRQReads != 15 { + t.Errorf("irq_reads %d, want 15", got.IRQReads) + } +} diff --git a/internal/app/session/experiment/matrix.go b/internal/app/session/experiment/matrix.go index dcf22ad1..bcbbe476 100644 --- a/internal/app/session/experiment/matrix.go +++ b/internal/app/session/experiment/matrix.go @@ -69,6 +69,10 @@ type Result struct { // actually made, so it needs no second arm to compare against and no // assumption about what boosted gain is worth. AtRisk []float64 `json:"at_risk,omitempty"` + + // Chip is what every node's radio counted over the cell, from the same + // baseline as the totals above. + Chip *ChipStats `json:"chip,omitempty"` } // BuildRef identifies one firmware a cell ran: the version the arm asked for diff --git a/internal/app/session/experiment/results.verbs.json b/internal/app/session/experiment/results.verbs.json index 31f1f3d3..049c70a2 100644 --- a/internal/app/session/experiment/results.verbs.json +++ b/internal/app/session/experiment/results.verbs.json @@ -45,7 +45,7 @@ "path", "bytes" ], - "answers": "JSON, holding the arms, seeds, senders and timings alongside `results`, which is every cell including the per-second histogram and the at-risk shares, and `summary`, which is the means. It will write an empty one before anything has run.", + "answers": "JSON, holding the arms, seeds, senders and timings alongside `results`, which is every cell including the per-second histogram, the at-risk shares and `chip`, what each node's radio counted over the cell and how it was set at the end, and `summary`, which is the means. It will write an empty one before anything has run.", "example": { "params": {}, "what": "keep the sweep, wherever the machine puts temporary files",