Skip to content

ieee80211: make rate control adapt per receiver, add per-receiver configured rates - #1124

Open
adamgeorge309 wants to merge 6 commits into
masterfrom
topic/gy/ieee80211-per-station-rate-control
Open

ieee80211: make rate control adapt per receiver, add per-receiver configured rates#1124
adamgeorge309 wants to merge 6 commits into
masterfrom
topic/gy/ieee80211-per-station-rate-control

Conversation

@adamgeorge309

@adamgeorge309 adamgeorge309 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Makes 802.11 rate control adapt per receiver, and adds a way to configure a fixed rate per peer.

Per-receiver rate control. AarfRateControl and OnoeRateControl kept one set of adaptive state per module: a single current mode, timer, probe flag, threshold and success counter. A station transmitting to several peers therefore blended their feedback into one rate — a retry to a distant station pushed the rate down for a nearby one, and vice versa. This is the normal case for an access point serving clients at different distances.

IRateControl::getRate() now takes the receiver address, and both algorithms key their state on it: every variable that adapts moves into a per-receiver State, while configured thresholds and intervals stay shared. State is created on first use, seeded from initialRate (or the fastest mandatory mode), and dropped when the mode set changes.

Per-station statistics. datarateChanged is now emitted with the receiver's station label (the peer's node name, resolved and cached by the new StationLabelCache) as a named details object. The aggregate datarateChanged statistic ignores details and is unchanged; a new dataratePerStation statistic uses demux(datarateChanged) to record one vector per station. datarateSelected is tagged the same way, which additionally covers fixed and per-receiver configured rates.

Per-receiver configured rates. dataFrameBitrate fixes one rate for the whole interface. RateSelection and QosRateSelection now accept dataFrameBitratePerReceiver, a map from peer interface module path to bitrate. Resolution to MAC addresses is deferred to the first transmitted data frame, because peer MACs are assigned during INITSTAGE_LINK_LAYER with undefined intra-stage ordering. Empty by default, so the capability is inert unless configured.

Also included: a one-line fix to TxopProcedure::getRemaining(), which returned the elapsed time rather than the remaining time. Its only caller tests the result against zero and reaches it only after a frame of the TXOP has gone out, so this is a correctness fix with no behavioural effect. Happy to split it out if preferred.

Test

Builds at every commit. Run in examples/wireless/hiddennode with AarfRateControl; the recorded results contain per-station vectors keyed by peer node name (B:dataratePerStation:vector on both senders), confirming the demux path end to end.

⚠️ Fingerprints not yet re-recorded. Rate control now adapts per peer instead of blending peers, so wireless simulations using an adaptive rate control with more than one peer are expected to move. Tell me which suite/filter defines green here and I will re-record with per-ingredient justifications.

🤖 Generated with Claude Code


Open in Devin Review

@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 2 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +44 to +51
if (it == stations.end()) {
State state;
state.address = receiverAddress;
state.mode = getInitialMode();
state.increaseThreshold = par("increaseThreshold");
it = stations.insert({receiverAddress, state}).first;
emitDatarateChangedSignal(state.address, state.mode);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 A newly seen peer's rate immediately jumps one step above the configured starting rate

The per-peer rate state is created with an unset timer (State state; at src/inet/linklayer/ieee80211/mac/ratecontrol/AarfRateControl.cc:45), so the periodic "increase" deadline is already in the past the moment the peer appears, and the very first transmission to it is sent one rate step above the configured starting rate.
Impact: The configured starting rate is effectively ignored for every peer first used after the increase interval, and an extra spurious rate-change value is recorded for that peer.

Timer initialised to zero makes increaseRateIfTimerIsExpired fire on first use

State::timer defaults to SIMTIME_ZERO (src/inet/linklayer/ieee80211/mac/ratecontrol/AarfRateControl.h:26). getRate() calls stateFor() and then increaseRateIfTimerIsExpired(state) (src/inet/linklayer/ieee80211/mac/ratecontrol/AarfRateControl.cc:124-125), whose condition is simTime() - state.timer >= interval (default 50 ms). For any station whose state is created after t = 50 ms this is immediately true, so state.mode is bumped to the next faster mode and a second datarateChanged is emitted before the first frame is sent. Previously there was a single module-wide state created at t≈0, so this typically did not happen; with lazily created per-peer state it happens for essentially every peer. frameTransmitted() has the same path. Initialising state.timer = simTime() at creation preserves the intended "seeded from initialRate" semantics.

Suggested change
if (it == stations.end()) {
State state;
state.address = receiverAddress;
state.mode = getInitialMode();
state.increaseThreshold = par("increaseThreshold");
it = stations.insert({receiverAddress, state}).first;
emitDatarateChangedSignal(state.address, state.mode);
}
if (it == stations.end()) {
State state;
state.address = receiverAddress;
state.mode = getInitialMode();
state.increaseThreshold = par("increaseThreshold");
state.timer = simTime();
it = stations.insert({receiverAddress, state}).first;
emitDatarateChangedSignal(state.address, state.mode);
}
Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a follow-up commit — confirmed, thanks.

Reproduced in examples/wireless/hiddennode with initialRate = 2Mbps and traffic starting at t = 1s: the per-station vector opened with two values at t = 1, 2 Mbps then 5.5 Mbps, and the first frame went out at 5.5 Mbps. With the timer seeded at creation it opens with a single 2 Mbps value.

OnoeRateControl has the same defect and is fixed in the same commit. It is milder there — computeMode() returns early while the station has no successful transmission yet — but it still divides by zero on the way in, leaving avgRetriesPerFrame at NaN.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 65474d9 (current head): the state is created with state.timer = simTime(), so the increase interval starts when the station is first seen rather than at t=0.

Comment on lines +25 to +42
for (cModule::SubmoduleIterator it(getSimulation()->getSystemModule()); !it.end(); ++it) {
cModule *node = *it;
if (!isNetworkNode(node))
continue;
auto interfaceTable = resolver.findInterfaceTableOf(node);
if (interfaceTable == nullptr)
continue;
bool found = false;
for (int i = 0; i < interfaceTable->getNumInterfaces(); i++) {
if (interfaceTable->getInterface(i)->getMacAddress() == receiver) {
label = node->getFullName();
found = true;
break;
}
}
if (found)
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Per-station statistics are labelled with raw MAC addresses when hosts are nested inside sub-networks

The peer name lookup only inspects the modules directly under the network (cModule::SubmoduleIterator over the system module at src/inet/linklayer/ieee80211/mac/common/StationLabelCache.cc:25), so peers placed inside any grouping module are never found and get labelled with the raw MAC address instead.
Impact: Per-station data-rate series are keyed by unreadable hardware addresses in any topology where hosts are not direct children of the network.

Non-recursive sweep vs. the existing recursive helper

INET already provides L3AddressResolver::findHostWithMacAddress() / findInterfaceWithMacAddress() (src/inet/networklayer/common/L3AddressResolver.cc:551-573), which collect network nodes recursively via doCollectNetworkNodes() (src/inet/networklayer/common/L3AddressResolver.cc:525-534) and therefore also find nodes nested inside compound sub-network modules. The new cache duplicates that logic but iterates only the immediate submodules of the system module and skips any module that is not itself a network node, so nested hosts are missed. Because the fallback result is stored in the cache, the MAC-string label is then permanent for that receiver.

Suggested change
for (cModule::SubmoduleIterator it(getSimulation()->getSystemModule()); !it.end(); ++it) {
cModule *node = *it;
if (!isNetworkNode(node))
continue;
auto interfaceTable = resolver.findInterfaceTableOf(node);
if (interfaceTable == nullptr)
continue;
bool found = false;
for (int i = 0; i < interfaceTable->getNumInterfaces(); i++) {
if (interfaceTable->getInterface(i)->getMacAddress() == receiver) {
label = node->getFullName();
found = true;
break;
}
}
if (found)
break;
}
if (cModule *node = resolver.findHostWithMacAddress(receiver))
label = node->getFullName();
Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0248366ffe — switched to L3AddressResolver::findHostWithMacAddress(), which collects network nodes recursively.

One addition: the label is now the node's path relative to the network, not getFullName(). Once nested nodes resolve, two nodes named host in different subnetworks would share a label and their vectors would merge into one demux() series. For a node directly under the network the two strings are identical, so flat topologies keep the labels they had.

Verified with the hiddennode nodes wrapped in a plain compound module: 20-00-00-00-00-00:dataratePerStation before, subnet1.B:dataratePerStation after, and the unmodified example still records B:dataratePerStation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in bf8ddc1 by going one step further: StationLabelCache is removed entirely, and the label is resolved on demand with L3AddressResolver::findHostWithMacAddress() (see IRateSelection::getStationLabel()), which collects network nodes recursively, so nested hosts are found too.

@adamgeorge309
adamgeorge309 force-pushed the topic/gy/ieee80211-per-station-rate-control branch 2 times, most recently from 05bb4f7 to 1be82e6 Compare August 10, 2026 09:44
@adamgeorge309

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit for the first Devin finding.

Confirmed. Per-receiver state was created with its interval timer at zero, so for a peer first seen after interval (50 ms by default) the periodic rate increase fired immediately: the station was bumped a step above initialRate before its first frame, and an extra datarateChanged value landed in its vector. Seeding the timer at creation fixes it.

Reproduced in examples/wireless/hiddennode with AarfRateControl, initialRate = 2Mbps and traffic starting at t = 1s:

first values of B:dataratePerStation:vector
before t=1 → 2Mbps, t=1 → 5.5Mbps
after t=1 → 2Mbps

The first frame really did go out at 5.5 Mbps before the fix, so the configured starting rate was effectively ignored for every peer.

OnoeRateControl has the same defect and is fixed in the same commit. It is milder there — computeMode() returns early while the station has no successful transmission yet — but it still divides by zero on the way in, leaving avgRetriesPerFrame at NaN.

The second finding (StationLabelCache missing hosts nested inside sub-networks) is not addressed yet. It looks right: the sweep iterates only the immediate submodules of the network, while L3AddressResolver::findHostWithMacAddress() recurses. One open question before switching to it — with nested hosts resolvable, two peers named host in different sub-networks would collapse into one demux() series under getFullName(), so it may need a path-based label instead.

@adamgeorge309

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit for the second Devin finding.

Confirmed. StationLabelCache swept only the immediate submodules of the network, so a peer inside a grouping module was never found and kept the MAC address fallback. The fallback is cached, so that label was then permanent for the run. 0248366ffe switches to L3AddressResolver::findHostWithMacAddress(), which collects network nodes recursively.

One addition beyond the suggestion: the label is now the node's path relative to the network rather than getFullName(). Once nested nodes resolve, two nodes named host in different subnetworks would share a label and have their values merged into a single demux() series. For a node directly under the network the two strings are identical.

Verified with the hiddennode nodes wrapped in a plain compound module:

per-station vector
before 20-00-00-00-00-00:dataratePerStation
after subnet1.B:dataratePerStation
unmodified example B:dataratePerStation, unchanged

The last row is the point: no existing example or showcase sees a renamed statistic.

@adamgeorge309
adamgeorge309 force-pushed the topic/gy/ieee80211-per-station-rate-control branch from 0248366 to a91a713 Compare August 12, 2026 14:14
@adamgeorge309

Copy link
Copy Markdown
Contributor Author

Pushed bf8ddc1: dropped the StationLabelCache class. Station labels are now resolved on demand through L3AddressResolver::findHostWithMacAddress() in a static IRateSelection::getStationLabel() helper; Dcf, Hcf and RateControlBase no longer carry cache members, and emitDatarateSelected() lost its extra parameter. Since datarateSelected fires per transmitted frame, the label is only computed when the signal has listeners, so runs with result recording off pay nothing for it. Verified with the wireless/throughput and wireless/ratecontrol examples — the per-station demux vectors are still keyed by node name (e.g. sinkClient:dataratePerStation:vector).

getRemaining() computed now - start, which is the time already spent in
the TXOP rather than the time left in it. The guard above it already
handles the expired case, so the remaining time is start + limit - now.

This is a correctness fix with no behavioural effect. The only caller,
~HcfFs, tests the result against zero, and reaches it only once a frame
of the TXOP has already been transmitted -- so now is strictly greater
than start, and elapsed and remaining are either both positive, while the
TXOP is live, or both zero once it has expired. Verified by a TXOP-limit
sweep whose recorded scalars are identical before and after.
@adamgeorge309
adamgeorge309 force-pushed the topic/gy/ieee80211-per-station-rate-control branch from bf8ddc1 to 87e64f2 Compare August 12, 2026 15:59
~AarfRateControl and ~OnoeRateControl kept one set of adaptive state per
module: a single current mode, timer, probe flag, threshold and success
counter. A station transmitting to several peers therefore blended their
feedback into one rate -- a retry to a distant station pushed the rate
down for a nearby one, and vice versa. This is visible on an access point
serving clients at different distances, which is the normal case.

IRateControl::getRate() now takes the receiver address, and both
algorithms key their state on it: every variable that adapts moves into a
per-receiver State, while the configured thresholds and intervals stay
shared. State for a station is created on first use, seeded from
initialRate (or the fastest mandatory mode), and dropped when the mode
set changes. The interval timer of a new station is seeded with the
current time, so the periodic rate increase measures its interval from
when the station was first seen -- not from t=0, which for a station
first used later than one interval would put the deadline already in the
past at creation.

RateControlBase no longer holds a currentMode, so the watched expression
that fed the display string moves to the subclasses and reports the
number of stations being tracked instead.
@adamgeorge309
adamgeorge309 force-pushed the topic/gy/ieee80211-per-station-rate-control branch from 87e64f2 to 353ab79 Compare August 14, 2026 14:38
Naming a peer by the address a model knows it by -- to label the
per-station series of a demultiplexed statistic, for example -- takes
both a lookup and a naming convention, and neither belongs to the model
that happens to need it first.

Add L3AddressResolver::getHostNameWithMacAddress(), next to the
findHostWithMacAddress() it builds on. It names the host by its path
relative to the network, so that hosts of the same name in different
subnetworks are distinguishable, and falls back to the MAC address string
when no host owns the address.
@adamgeorge309
adamgeorge309 force-pushed the topic/gy/ieee80211-per-station-rate-control branch from 353ab79 to fa2bc31 Compare August 14, 2026 14:57
The datarateChanged statistic recorded one interleaved vector per rate
control module, in which the rates chosen for different peers are
indistinguishable. Now that rate control adapts per receiver, the rate of
an individual station is what the statistic should be able to show.

datarateChanged is emitted with the receiver's station name as a named
details object, and a new dataratePerStation statistic uses
demux(datarateChanged) to record one data rate vector per station. The
aggregate datarateChanged statistic ignores details and is unchanged.
Group-addressed frames carry no per-station rate and are emitted without
details, so they land only in the aggregate.

This is also the observable that makes the per-receiver adaptation of the
previous commit testable, so the module test for it comes with it. One
source saturates a near and a far ad-hoc peer at once; the rate towards
the near peer stays at 54 Mbps while the far peer's retries pull its own
rate down to 24.7 Mbps. With the single set of adaptive state the two
peers previously shared, both are served at a blended 29.6 Mbps instead,
and the far peer receives 380 packets rather than 1350.
dataFrameBitrate fixes one rate for the whole interface, so a scenario
that needs different peers reached at different rates -- the usual setup
for studying rate diversity -- had to fall back on an adaptive rate
control and let the channel produce the spread indirectly, which is slow
to set up and hard to hold steady.

~RateSelection and ~QosRateSelection now accept dataFrameBitratePerReceiver,
a map from peer interface module path to bitrate. A unicast data frame
whose receiver matches an entry is sent at that rate; everything else
falls back to the existing rules. The parameter is empty by default, so
the capability is inert unless configured.

Resolution of the paths to MAC addresses is deferred to the first
transmitted data frame rather than done in initialize(): peer MAC
addresses are assigned during INITSTAGE_LINK_LAYER and the order of
modules within a stage is undefined, so reading them from initialize()
would be a race. By the time any data frame is sent, all init stages have
completed. Unresolvable paths and rates the mode set does not support
fail with an error naming the offending entry.
datarateSelected reports the rate chosen for every transmitted frame, so
unlike datarateChanged it also covers interface-wide fixed rates and
per-receiver configured rates, not just adaptive rate control. It was
emitted with the packet as details, which identifies the frame but not
the peer, so the values of several stations arrived interleaved with no
way to separate them.

~IRateSelection::emitDatarateSelected() now emits unicast data frames
with the receiver's station label as a named details object, so a
demux(datarateSelected) result filter can key a per-station series on
it. The condition matches the one under which ~RateSelection applies a
per-receiver configured rate, so the label always names the station
whose rate is being reported. Control, management and group-addressed
frames have no per-station rate and are emitted without details: they
still reach the aggregate statistic.

Both coordination functions emit through the shared helper rather than
each formatting the signal themselves. Unlike datarateChanged, this
signal fires for every transmitted frame, and resolving a station label
sweeps the network's interface tables -- so the label is only computed
when the signal has listeners: with result recording off, transmitting
costs nothing extra.
@adamgeorge309
adamgeorge309 force-pushed the topic/gy/ieee80211-per-station-rate-control branch from fa2bc31 to 42d0174 Compare August 14, 2026 15:45
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.

1 participant