Skip to content

visualizer: display several series of a statistic with any indicator figure, and the IEEE 802.11 per-station rate visualizer - #1125

Open
adamgeorge309 wants to merge 4 commits into
topic/gy/ieee80211-per-station-rate-controlfrom
topic/gy/statistic-visualizer-bar-charts
Open

visualizer: display several series of a statistic with any indicator figure, and the IEEE 802.11 per-station rate visualizer#1125
adamgeorge309 wants to merge 4 commits into
topic/gy/ieee80211-per-station-rate-controlfrom
topic/gy/statistic-visualizer-bar-charts

Conversation

@adamgeorge309

@adamgeorge309 adamgeorge309 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Lets StatisticVisualizer display several values of a statistic at once, and lets any indicator figure display them; adds an 802.11 per-station data rate visualizer configured from it.

Why. StatisticVisualizer could show one number per signal source. For a quantity that exists per peer, per source or per flow, that forces a choice between an aggregate that hides the distribution and one visualizer instance per series with no visual relationship between them — while the recording side already handles this with demux(), visible only after the run.

Reworked after review (thanks — the point was that a visualizer must not carry the parameters of every kind of figure it might use). The split is now: the visualizer produces the data (named series of values), the figure decides how they are displayed, and each figure keeps its own parameters as figure attributes.

seriesBy determines how the values of the signal are grouped into the series of one visualization:

  • "details" — one series per distinct details object emitted with the value. The live counterpart of the demux() result filter.
  • "sources" — one series per matching source module, the visualizations being per network node.
  • "flow" — one series per packet flow, demultiplexing one source's signal by the flow tag.

In the latter two the values come from result recorders built from statisticExpression, so a series can show a count or a throughput rather than the raw value.

The figure is given as the attributes of an @figure property, either in a figure template property along the module path (propertyName, which already existed and was the mechanism the review pointed at) or in a new figure object parameter:

*.visualizer.statisticVisualizer.figure = {type: "barChart", maxValue: 1, valueFormat: "%.2f", barColor: "gold"}
*.visualizer.statisticVisualizer.figure = {type: "gauge", size: [60, 60], maxValue: 100, tickSize: 20}

Both funnel into cCanvas::createFigure() + cFigure::parse(), so there is one attribute vocabulary and no second dialect. The parameter, unlike the template, can be set from an ini file and can refer to module parameters; the template, unlike the parameter, can replace a figure a derived visualizer defaults to, and so takes precedence.

The missing piece for charts driven by a live demux was on the figure side: IIndicatorFigure had a fixed series count and integer-indexed series only. getSeriesIndex(label, createIfMissing) resolves a label to a series index and creates it if the figure supports a changing set of series; the default implementation maps the empty label to series 0, so existing figures are unaffected. BarChartFigure (Register_Figure("barChart")) is the first such figure, and owns all its appearance parameters.

Also here: figure types are now resolved through the Register_Figure() registry as well as the legacy inet::<Type>Figure class name; an indicator figure gets the value in the display unit (the value the text label would show) rather than the raw one; and the size reserved for a figure among a node's annotations is updated when it changes, as it does in a chart gaining series.

Ieee80211RateVisualizer shows the data rate an access point is using towards each associated station. It is a NED configuration of the above — datarateChanged (tagged with the receiving station by the base branch), seriesBy = "details", and a bar chart figure with the unit, scale, gradient and label format that suit a data rate. Pointing signalName at datarateSelected extends it to fixed and per-receiver configured rates; the NED documentation gives that configuration. Wired into IntegratedCanvasVisualizer and IntegratedMultiCanvasVisualizer, inert by default (displayRates = false).

Based on topic/gy/ieee80211-per-station-rate-control — please review/merge that first.

Test

Builds at every commit. examples/visualizer/statisticbars (new) covers all of it: Packets and Throughput (sources), Flow (flow), Gauge (a gauge instead of a bar chart), plus autoscaled variants. Each was run in Qtenv and checked on the canvas; the Flow config shows one throughput bar per flow (fast 0.80, medium 0.32, slow 0.16 Mbps). The 802.11 visualizer was run both with per-receiver configured rates (54/18/6 Mbps, one bar per station) and with AarfRateControl. The plain text label and the figure template path were re-checked for regressions.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +344 to +349
double range = maxForScale - minValue;
double fraction = range > 0 ? (value - minValue) / range : 0;
if (fraction < 0) fraction = 0;
if (fraction > 1) fraction = 1;
double pos = fraction * (barColors.size() - 1);
int index = (int)std::floor(pos);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Charts with a not-yet-measured bar can read past the end of the color list and crash

A bar whose value is still unknown is fed into the color picker (getBarColor() at src/inet/visualizer/base/StatisticVisualizerBase.cc:344-349) without any check for the "no value yet" case, so the color index becomes garbage and the program can read outside the color list and crash.
Impact: Simulations using the bar chart display can crash or draw corrupt charts as soon as a bar exists before its first measurement.

NaN propagation into the gradient index and into figure geometry

In sources mode every newly registered bar is initialized with values[label] = NaN (src/inet/visualizer/base/StatisticVisualizerBase.cc:378), and refreshGroupedBarValues() keeps NaN until the recorder produces a value; refreshFlowBarValues() and processBarValue() can also store NaN.

In getBarColor(), with value = NaN: fraction = (NaN - minValue)/range = NaN; both clamps if (fraction < 0) and if (fraction > 1) are false for NaN, so pos = NaN, and int index = (int)std::floor(NaN) is undefined behaviour (typically INT_MIN). index >= (int)barColors.size() - 1 is then false, so barColors[index] / barColors[index + 1] index the vector far out of range.

The same NaN also reaches StatisticCanvasVisualizer::refreshChart() (src/inet/visualizer/canvas/common/StatisticCanvasVisualizer.cc:242-248), producing h = NaN and a cRectangleFigure with NaN bounds.

Both places need an explicit NaN check (e.g. skip drawing the bar, or draw a zero-height bar in a neutral color), like the text mode does in DirectiveResolver::resolveDirective which renders "-" for NaN.

Suggested change
double range = maxForScale - minValue;
double fraction = range > 0 ? (value - minValue) / range : 0;
if (fraction < 0) fraction = 0;
if (fraction > 1) fraction = 1;
double pos = fraction * (barColors.size() - 1);
int index = (int)std::floor(pos);
double fraction = range > 0 ? (value - minValue) / range : 0;
if (std::isnan(fraction)) fraction = 0;
if (fraction < 0) fraction = 0;
if (fraction > 1) fraction = 1;
double pos = fraction * (barColors.size() - 1);
int index = (int)std::floor(pos);
if (index >= (int)barColors.size() - 1)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +366 to +374
// attach a result recorder (statisticExpression, e.g. count or throughput) whose value the bar will show
addResultRecorder(source, signal);
auto recorder = getResultRecorder(source, signal);
auto networkNode = getContainingNode(module);
auto barSetVisualization = getBarSetVisualization(networkNode->getId());
if (barSetVisualization == nullptr) {
barSetVisualization = createBarSetVisualization(networkNode);
if (barSetVisualization == nullptr)
return; // bar charts not supported by this concrete visualizer (e.g. osg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Statistic collectors pile up without limit when a display cannot draw bar charts

A new hidden statistic collector is attached to the same module on every single signal (addResultRecorder() at src/inet/visualizer/base/StatisticVisualizerBase.cc:366) before it is known whether the chart can actually be created, so when charts are unsupported the collectors grow without bound.
Impact: With a display that does not support bar charts, memory use and per-signal processing cost grow continuously until the run slows to a crawl or runs out of memory.

Early return skips the registration bookkeeping

processGroupedBarSource() first checks groupedBarSourceIds and the source filter, then calls addResultRecorder(source, signal). If createBarSetVisualization() returns nullptr (the base-class default, i.e. any visualizer that does not implement bar charts, e.g. StatisticOsgVisualizer), it returns at src/inet/visualizer/base/StatisticVisualizerBase.cc:373 before groupedBarSourceIds.insert(module->getId()) at line 379. Consequently the guard at line 361 never fires, and each subsequent emission of the signal appends yet another recorder chain to the source module.

processFlowBarSource() has exactly the same ordering problem: addResultRecorder() at line 404, early return at line 409, groupedBarSourceIds.insert() at line 412.

A fix is to determine whether a bar set can be created (or mark the source as handled / disable bar mode) before attaching the recorder.

Prompt for agents
In StatisticVisualizerBase::processGroupedBarSource() and processFlowBarSource() (src/inet/visualizer/base/StatisticVisualizerBase.cc), addResultRecorder() is called before the code checks whether createBarSetVisualization() succeeds. When the concrete visualizer does not support bar charts (base implementation returns nullptr), the function returns early without inserting the module id into groupedBarSourceIds, so the duplicate-registration guard never trips and a new recorder chain is attached to the source module on every signal emission, growing memory and per-signal cost without bound. Reorder so the bar set is obtained/created (or the unsupported case detected and permanently remembered, e.g. by disabling bar handling or still inserting into groupedBarSourceIds) before any recorder is attached.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +376 to +378
}
std::string label = module->getFullName();
barSetVisualization->recorders[label] = recorder;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Bars for same-named modules in one node overwrite each other

Each bar is labelled only with the short module name (module->getFullName() at src/inet/visualizer/base/StatisticVisualizerBase.cc:376), so two identically named modules inside the same node share one bar and only one of them is shown.
Impact: In nodes with several same-named submodules (for example multiple wireless interfaces), some sources silently disappear from the chart.

Label collision in the per-node bar map

In sources mode the bar set is keyed by the network node id, and each bar is keyed by module->getFullName(). For a source filter matching e.g. **.wlan[*].mac, both wlan[0].mac and wlan[1].mac have full name mac, so barSetVisualization->recorders[label] and values[label] (lines 377-378) overwrite the earlier entry; the first module's recorder is dropped from the map and its value never displayed, while the count of bars is lower than the number of matching sources.

A label that is unique within the node (e.g. the path relative to the network node) would avoid the collision.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@levy

levy commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The statistic visualizer should be capable of visualizing the value using any instrument figure. Each kind of instrument figure comes with its own set of parameters. We can't let all those parameters go into the statistic visualizer module.

For example, we can have bar chart/line chart/histogram chart/gauge/text instruments, each have their own set parameters.

Options:

  • add a @figure template to the derived statistic visualizer module which can have its figure type fixed and have its own parameters. Cons: doesn't really combine well with using a different figure.
  • add an object parameter to the statistic visualizer to control the appearance of figures

adamgeorge309 and others added 4 commits August 6, 2026 17:34
An indicator figure displayed either one value, or a fixed number of
series identified by index. A quantity that exists per peer, per source
or per flow fits neither: the set of series only becomes known while the
simulation is running, and the series have names rather than indices.

Add getSeriesIndex() to ~IIndicatorFigure, which resolves a series label
to an index and, in figures that support a changing set of series,
creates the series. The default implementation maps the empty label to
the first series, so single series figures are unaffected.

Add BarChartFigure, registered as the "barChart" figure type. It displays
one bar per series, the bar height representing the value over the
minValue..maxValue range, or over the autoscaled range of the current
values when maxValue is not given. barColor accepts a list of colors
interpolated over the same range, so the color of a bar carries the value
even where the chart is too small to read. Bars are laid out in sorted
label order, so the chart does not reshuffle as series appear.

Like the other instrument figures, everything about its appearance is a
figure attribute parsed from a property, so none of it has to appear as a
parameter of whatever displays the figure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… figure

~StatisticVisualizerBase displayed the last value of a statistic per
signal source. A quantity that exists per peer, per source or per flow
forces a choice between an aggregate that hides the distribution and one
visualizer instance per series with no visual relationship between them,
while the recording side already handles it with demux().

Add the seriesBy parameter, which determines how the values of the signal
are grouped into the series of a single visualization:

- "details": one series per distinct details object emitted with the
  value, the live counterpart of the demux() result filter
- "sources": one series per matching signal source, the visualizations
  being per network node
- "flow": one series per packet flow of a single source, demultiplexing
  its signal by the flow tag (statisticExpression contains demuxFlow())

In the latter two the values come from the result recorders built from
statisticExpression, so a series can display a count or a throughput
rather than the raw value of the signal.

How the series are displayed is not for the visualizer to decide: that is
what the figure is for, and each kind of figure comes with its own set of
parameters. So ~StatisticCanvasVisualizer takes the attributes of the
figure the same way an @figure property gives them, either from a figure
template property along its module path (the propertyName parameter,
which already existed), or from the new figure object parameter:

  *.visualizer.statisticVisualizer.figure = {type: "gauge", size: [60, 60], maxValue: 100}

The parameter, unlike the template, can be set from an ini file and can
refer to module parameters; the template, unlike the parameter, can
replace a figure a derived visualizer already defaults to, and therefore
takes precedence. A visualizer displaying series without either defaults
to a bar chart.

Along the way, the figure type is now also looked up among the types
registered with Register_Figure(), not only as an inet::<Type>Figure
class; an indicator figure is given the value in the display unit (what
the text label would display) rather than the raw value; and the size
reserved for the figure among the annotations of the network node is
updated when it changes, as it does in a chart gaining series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three UDP streams from the server to the receiver, with a bar chart above
the receiver showing a per stream quantity: one series per sink app
(seriesBy = "sources", with count or throughput), or one series per named
flow demultiplexed from a single signal (seriesBy = "flow"). A further
config displays the throughput of one stream on a gauge instead, to show
that the figure is a configuration choice rather than a display mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shows the data rate an access point is using towards each of its
associated stations as a bar chart above the node, so that rate diversity
across stations is visible while the simulation runs rather than only in
the results afterwards.

It is a configuration of the generic ~StatisticCanvasVisualizer rather
than new visualizer code: it subscribes to the rate control's
datarateChanged signal, which now tags each value with the receiving
station, displays one series per receiver, and configures a bar chart
figure with the scale, colors and label format that suit a data rate.
Pointing signalName at the coordination function's datarateSelected
instead makes it cover fixed and per-receiver configured rates too; the
NED documentation gives that configuration.

Wired into ~IntegratedCanvasVisualizer and ~IntegratedMultiCanvasVisualizer,
and inert by default: displayRates is false, so nothing is drawn unless it
is switched on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adamgeorge309
adamgeorge309 force-pushed the topic/gy/statistic-visualizer-bar-charts branch from 6f77762 to 1e5fc68 Compare August 6, 2026 15:35
@adamgeorge309 adamgeorge309 changed the title visualizer: add statistic bar charts and the IEEE 802.11 per-station rate visualizer visualizer: display several series of a statistic with any indicator figure, and the IEEE 802.11 per-station rate visualizer Aug 6, 2026
@adamgeorge309

Copy link
Copy Markdown
Contributor Author

Reworked along these lines — thanks, the objection was right, and it turned out the mechanism was already in the codebase and this PR was simply bypassing it.

StatisticCanvasVisualizer already had propertyName/propertyIndex: it looks up a figure template property along the module path, instantiates the class named by its type= key, and calls figure->parse(property), so every figure-specific parameter lives in the property. @statistic(record=figure; targetFigure=...) + @figure does the same thing for the canvas of a module. The 17 bar-specific parameters on the statistic visualizer were exactly what that mechanism exists to prevent, and displayMode was redundant with it — the figure type is the display mode.

So the two options are not alternatives: both are front ends for the same thing, and both now funnel into cCanvas::createFigure() + cFigure::parse(), i.e. one attribute vocabulary, one construction path, no second dialect to document.

  • The @figure template (option 1). The con named above is weaker than it sounds: the type is in the template's type=, propertyName/propertyIndex are ordinary string parameters, and the lookup walks the module path — so a derived visualizer only sets defaults and the user can put their own template on the network and select it from the ini. The real limit is different: NED property values are static strings, so a single attribute cannot be overridden from an ini file and cannot refer to a module parameter.
  • An object parameter (option 2) has neither limit, which matters immediately here: Ieee80211RateVisualizer forwards its maxRate parameter into the figure as {... maxValue: this.maxRate / 1Mbps ...}, which a static template cannot do.

Since the template is the only one of the two that can replace a figure a derived visualizer already defaults to, it takes precedence over the parameter.

What was actually missing was on the figure side, not the parameter side: IIndicatorFigure had a fixed series count and integer-indexed series, so a chart fed by a live demux had nowhere to go — which is why the bar chart ended up inside the visualizer. It now has getSeriesIndex(label, createIfMissing) (default implementation maps the empty label to series 0, so existing figures are unaffected), and BarChartFigure is a normal registered figure owning its own parameters. The visualizer keeps only the data-side question — seriesBy = "none"|"details"|"sources"|"flow" — which is a demultiplexing policy, not appearance.

The new examples/visualizer/statisticbars shows the same visualizer and the same series driving a bar chart or a gauge, by one ini line.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants