The Simulation Lab runs a complete adaptive transfer between two virtual phones, a sender A and a receiver B, entirely in software. Three simulated channels (optical, acoustic and vibration) connect them through a packet-level medium model that drops, corrupts and delays packets according to a per-channel profile. The same ReliableTransport, TransferStateMachine and AdaptiveDecisionEngine classes used by the protocol path of the app decide which channel to use and when to switch. The companion Performance Comparison screen reuses this machinery to compare an “adaptive” run with two “fixed-channel” runs. This document covers the medium model, every scenario, the transfer loop, the switching logic with a worked example, the screens, how to add a scenario, and the quirks that were confirmed in the code. Every constant here is copied from the code. Observed outcomes come from probe runs of the unmodified code on 2026-09-27. The medium’s random generator is unseeded, so your runs will differ in detail.
Back to the documentation index.
The real Light, Sound and Vibration channels need two phones, a camera, a speaker and a quiet room, and they use the rateless fountain modems described elsewhere. The Simulation Lab exercises a different layer: the adaptive protocol stack of discovery, channel testing, scoring, a sliding-window reliable transport, degradation detection and a channel-switch handshake. It exercises that stack without any hardware, so the decision logic can be demonstrated and debugged on a laptop.
Use it to:
The lab is not a physical-layer model. There is no signal-to-noise ratio, no modulation and no fountain coding. Each channel is reduced to a throughput, a loss probability, a corruption probability, a latency with jitter, and two quality numbers (confidence and stability). The simulation core in lib/core/simulation/ is pure Dart and imports nothing from Flutter. For the scoring formula in isolation see ADAPTIVE_ENGINE.md. For every formula in one place see CALCULATIONS.md.
Neither screen is on the home screen; test/widget_test.dart checks that the text “Simulation Lab” is not found there. To open them:
DevMenuScreen, titled Developer tools.SimulationScreen, or Performance Comparison (“Compare fixed vs adaptive strategies”) to open PerformanceScreen.Both screens read and drive the shared AppController. Its single running flag is shared by every long operation in the app, so the buttons below are disabled while any simulation, comparison or hardware operation is in progress.
| Control | Where | What it does |
|---|---|---|
| Scenario drop-down | “Scenario” card at the top | Lists the name of every entry in scenarios (section 5), in list order. The initial selection is optical-degrades (“Optical Starts Good, Becomes Poor”), from AppController._scenarioId. Changing it calls setScenario(id). Disabled while running |
| Run Simulation | Scenario card | Calls AppController.runSimulation(). It clears the live log and both panels, builds the scenario’s pair, subscribes to both endpoints’ loggers, sets onUpdate so the panels refresh on every snapshot, and runs SimulationOrchestrator.runTransfer(generateTestData(dataSize)). While running, the label reads “Running…” with a spinner |
| Clear Logs | Scenario card | Calls clearLogs(), which empties the live log list and the app’s own StructuredLogger. Disabled while running |
| Run All Scenarios | App bar; labelled “Run All” on narrow layouts | Calls runAllScenarios(). It runs every scenario in order with its own dataSize, counts result.success, and sets the status message to N/9 scenarios passed. See quirk 12 for what the screen does not show |
| Sender (A) panel | Body | An EndpointPanel showing endpoint A’s latest DashboardSnapshot (section 7.2) |
| Receiver (B) panel | Body | The same for endpoint B |
| LIVE LOGS panel | Body | A LogPanel listing every log entry from both endpoints as it happens (section 7.1) |
| SUCCESS / FAILED chip | Below the panels | Appears after a single run, from lastSimResult.success |
On wide layouts the two endpoint panels and the log panel sit side by side in a row. On narrow layouts they are stacked in a column with flex ratios 2 : 2 : 3. Before the first run the endpoint panels show “Waiting for transfer” and the log panel shows “Logs will appear here during transfer”.
| Control | What it does |
|---|---|
| Run Comparison (in the “Baseline Comparison” card) | Calls runPerformanceComparison(), which clears the previous results and awaits PerformanceComparator().runComparison(). The label reads “Running…” with a spinner while it works |
| Result cards | One card per strategy (section 8). Wide layouts use a grid, with 3 columns on desktop and 2 otherwise. Narrow layouts use a list. Before the first run the area shows “Run comparison to see results” |
AppController starts in OperationMode.simulation. In that mode, sending a chat message from Legacy Messages (also in Developer tools) calls runSimulation(data: envelope) with the currently selected scenario instead of the scenario’s generated test data. The message is marked delivered when dataMatch is true and failed otherwise.
| Class or function | File | Role |
|---|---|---|
ChannelSimulationProfile |
lib/core/simulation/simulated_medium.dart |
Seven numbers that describe a simulated channel (section 4.1) |
DegradationSchedule |
same | afterPacket plus a replacement profile |
SimulatedChannelConfig |
same | A base profile plus optional degradation and recovery schedules |
SimulatedMedium |
same | Applies the active profile to every packet sent; queues packets for the peer; runs discovery and channel tests |
VirtualLink |
same | Cross-connects two media so that each one’s output is the other’s input |
profileToMetrics, profileToTestResult |
same | Convert a profile into the ChannelMetrics and ChannelTestResult that the engine scores |
SimulatedCommChannel, createOpticalChannel, createAcousticChannel, createVibrationChannel |
lib/core/channels/comm_channel.dart |
The CommChannel implementation backed by a SimulatedMedium |
ChannelManager |
lib/core/manager/channel_manager.dart |
Holds the registered channels and the active channel; discoverAll, testAll, testChannel, transmit, receiveActive |
VirtualEndpoint |
lib/core/simulation/simulation_orchestrator.dart |
One virtual phone: id, role, logger, channel manager, state machine, decision engine, transport, scores, switch events |
SimulationPair, createSimulationPair |
same | Build both endpoints, six media, six channels and three links |
SimulationOrchestrator |
same | runTransfer(data) drives one full transfer and returns a SimulationResult |
generateTestData(size) |
same | Test payload whose byte i is i % 256 |
ScenarioDefinition, scenarios, getScenario |
lib/core/simulation/scenarios.dart |
The nine named scenarios |
ReliableTransport, TransportConfig |
lib/core/transport/reliable_transport.dart |
Packetisation, sliding window, ACK/NACK handling, timeouts, switch packets |
AdaptiveDecisionEngine, ChannelScorer, MetricNormalizer |
lib/core/engine/adaptive_decision_engine.dart |
Scoring, selection, degradation test, switch test |
TransferStateMachine |
lib/core/manager/transfer_state_machine.dart |
Validated state transitions |
StructuredLogger |
lib/core/logging/structured_logger.dart |
Categorised log with listeners |
PerformanceComparator, StrategyResult, TransferStrategy, strategyLabel |
lib/core/performance/performance_comparator.dart |
The comparison runs (section 8) |
AppController |
lib/application/app_controller.dart |
runSimulation, runAllScenarios, runPerformanceComparison, setScenario, clearLogs |
TransferManager is not used by the lab. createChannelManagerForMode(OperationMode.simulation) in transfer_manager.dart can register the simulated channels of a pair’s endpoint A for use with TransferManager, but the app only calls that function in hardware mode. Class signatures are listed in API_REFERENCE.md, and the screens and widgets are described in UI_GUIDE.md.
createSimulationPair(...) builds the whole two-phone world:
SimulatedChannelConfig whose profile is defaultXProfile.copyWith(...) with every field of the optional override profile. Degradation and recovery schedules are attached to endpoint A’s configs only.StructuredLoggers and two ChannelManagers.A-optical, B-optical, A-acoustic, B-acoustic, A-vibration and B-vibration.SimulatedCommChannels with each manager, in the order optical, acoustic, vibration. That order is also the order used for discovery, testing and trying switch alternatives.VirtualLink(A-optical, B-optical), VirtualLink(A-acoustic, B-acoustic) and VirtualLink(A-vibration, B-vibration).A with role sender and endpoint B with role receiver. SimulationOrchestrator.runTransfer(data)
|
+--------------------------+---------------------------+
v v
VirtualEndpoint A (sender) VirtualEndpoint B (receiver)
TransferStateMachine TransferStateMachine
AdaptiveDecisionEngine (scores, decides) AdaptiveDecisionEngine (unused)
ReliableTransport (sends data) ReliableTransport (ACKs, NACKs)
StructuredLogger StructuredLogger
ChannelManager A ChannelManager B
|- SimulatedCommChannel OPTICAL |- SimulatedCommChannel OPTICAL
| SimulatedMedium A-optical <==VirtualLink==> | SimulatedMedium B-optical
|- SimulatedCommChannel ACOUSTIC |- SimulatedCommChannel ACOUSTIC
| SimulatedMedium A-acoustic <==VirtualLink==> | SimulatedMedium B-acoustic
|- SimulatedCommChannel VIBRATION |- SimulatedCommChannel VIBRATION
SimulatedMedium A-vibration <==VirtualLink==> SimulatedMedium B-vibration
(degradation / recovery schedules live here) (profiles never change)
A packet sent by A on optical is shaped by A-optical’s profile. An ACK or NACK sent back by B on optical is shaped by B-optical’s profile. The two directions of one channel can therefore behave differently.
SimulationOrchestrator(pair).runTransfer(data) goes through these phases. Section 6 describes the loop and the switching in detail.
| Phase | What happens | Sender state | Receiver state |
|---|---|---|---|
| Setup | initializeAll() and startAll() on both managers; a new session ID and transfer ID are created and copied to B; both state machines are reset |
idle |
idle |
| Discovery | discoverAll(300) on A’s manager, then on B’s. If either list is empty the run ends at once with success: false |
discovering |
discovering |
| Testing | testAll(20) on A’s manager tests every registered channel; each result is scored with scoreTestResult and written to both endpoints’ channelScores |
testingChannels |
testingChannels |
| Selection | selectBestChannel(testResults) picks the highest score; both managers set it active |
negotiating |
negotiating |
| Transfer | A ReliableTransport is created on each side; A splits the data into packets; the loop runs for up to 1500 iterations |
transferring, and degraded → switchingChannel → recovering → transferring around a switch |
transferring |
| Verification | The receiver’s reassembled data is compared with the original | completed, or forced to failed |
completed, or forced to failed |
The orchestrator calls onUpdate(senderSnapshot, receiverSnapshot) at each phase change, every 5 loop iterations and once at the end. Only the sender’s state machine ever enters degraded, switchingChannel or recovering.
A ChannelSimulationProfile has seven fields. The constructor defaults matter because any profile written in a scenario starts from them, not from the channel’s own default (see quirk 2).
| Field | Constructor default | Meaning |
|---|---|---|
baseThroughput |
18000 | Bits per second, used for the per-packet air time |
packetLossRate |
0.01 | Probability that a sent packet is dropped |
baseLatencyMs |
80 | Fixed latency in ms, before jitter |
confidence |
0.92 | Quality number from 0 to 1, fed to the scorer |
stability |
0.88 | Quality number from 0 to 1, fed to the scorer |
corruptionRate |
0.005 | Probability that one byte of a delivered packet is flipped |
discoverable |
true |
Whether the discovery probe can succeed |
These constants are used when a scenario does not override a channel.
| Channel | Constant | Throughput | Loss | Latency | Confidence | Stability | Corruption | Discoverable |
|---|---|---|---|---|---|---|---|---|
| Optical | defaultOpticalProfile |
18 000 bps | 0.01 | 2 ms | 0.92 | 0.88 | 0.005 | yes |
| Acoustic | defaultAcousticProfile |
9 000 bps | 0.04 | 3 ms | 0.78 | 0.72 | 0.01 | yes |
| Vibration | defaultVibrationProfile |
800 bps | 0.06 | 120 ms | 0.72 | 0.68 | 0.015 | yes |
defaultOpticalProfile only sets baseLatencyMs: 2; its other fields are the constructor defaults. The simulated channels advertise ChannelCapabilities named Optical (Simulated), Acoustic (Simulated) and Vibration (Simulated), with maxThroughput equal to the configured baseThroughput, minLatency equal to the configured baseLatencyMs, supportsBinary: true and hardwareImplemented: false.
SimulatedCommChannel.transmit(packet) throws a StateError if the channel has not been started, and otherwise awaits SimulatedMedium.send(raw). send applies the medium’s active profile in this order:
_packetsProcessed is incremented, then _checkDegradation() and _checkRecovery() run (section 4.6).random.nextDouble() < packetLossRate, the method returns at once. The packet is gone and costs no time.random.nextDouble() < corruptionRate and the packet is longer than the 24-byte header, one byte is XORed with 0xFF. The index is headerSize + random.nextInt(max(1, length − headerSize − crcSize)) with headerSize = 24 and crcSize = 4. For a data packet this lands in the payload. For a 28-byte ACK or NACK it lands on the first CRC byte. Either way the receiver’s CRC-32 check fails, packetCodec.decode returns null, and the packet is silently discarded, so corruption behaves like extra loss.Delay. The sender’s send call waits for the whole flight time:
latency = baseLatencyMs + (random.nextDouble() × 2 − 1) × 15 (uniform jitter of ±15 ms)
throughputDelay = (raw.length × 8) / baseThroughput × 1000 (ms)
delayMs = max(1, round(latency + throughputDelay))
receiveFromPeer, which queues it with deliverAt = now. The peer’s next pollReceived() returns everything queued, in arrival order, and SimulatedCommChannel.receive() decodes it.Because the orchestrator awaits every send, only one packet is ever in flight, packets are never reordered, and a window of 8 packets blocks the sender for the sum of the delays of the packets that were not lost. The medium has no separate noise model: corruption stands in for noise, and the ±15 ms jitter is the only timing randomness. Each medium uses its own unseeded Random().
A full data packet is 256 payload bytes plus 28 bytes of overhead (24-byte header and 4-byte CRC-32), which is 284 bytes. ACK and NACK packets have no payload and are 28 bytes. The table shows the mean delay per delivered packet. Every value varies by ±15 ms, and the result is never below 1 ms.
| Profile | Data packet (284 B) | ACK / NACK (28 B) |
|---|---|---|
| Default optical (18 000 bps, 2 ms) | 126.2 + 2 ≈ 128 ms | 12.4 + 2 ≈ 14 ms |
| Default acoustic (9 000 bps, 3 ms) | 252.4 + 3 ≈ 255 ms | 24.9 + 3 ≈ 28 ms |
| Default vibration (800 bps, 120 ms) | 2 840 + 120 ≈ 2 960 ms | 280 + 120 = 400 ms |
Degraded optical in optical-degrades (2 000 bps, 500 ms) |
1 136 + 500 ≈ 1 636 ms | 112 + 500 ≈ 612 ms |
Discovery and channel tests do not send packets through the link. They sample the sender’s active profile directly.
| Method | Behaviour |
|---|---|
runDiscoveryTest(timeoutMs) |
Returns false at once if discoverable is false. Otherwise it waits min(timeoutMs, round(50 + random × 100)) ms and returns random > packetLossRate × 2. With a loss rate of 0.01, discovery succeeds 98% of the time |
runChannelTest(n) |
For each of n virtual packets it counts one as received if random >= packetLossRate, then waits round(baseLatencyMs / n) ms. It returns (sent: n, received: k) |
SimulatedCommChannel.test(n) turns the counts into a ChannelTestResult with profileToTestResult:
measuredLoss = (sent − received) / sent (packetLossRate if sent == 0)
throughput = baseThroughput × (1 − measuredLoss)
latency = baseLatencyMs
confidence = confidence × (1 − measuredLoss × 0.5)
stability = stability
With 20 test packets the measured loss moves in steps of 5%.
SimulatedCommChannel.getMetrics() returns the configured values of the active profile through profileToMetrics. Nothing is measured from the traffic.
throughput = baseThroughput
packetLoss = packetLossRate
latency = baseLatencyMs
reliability = max(0, 1 − packetLossRate − corruptionRate)
errorRate = corruptionRate
confidence = confidence
stability = stability
These values drive the mid-transfer re-evaluation (section 6.3), the Throughput, Packet Loss and Latency rows of the endpoint panels, and the Throughput and Packet Loss fields of the Performance Comparison.
A DegradationSchedule has an afterPacket count and a replacement profile. On every send:
_checkDegradation(): if _packetsProcessed >= degradation.afterPacket, the active profile becomes activeProfile.merge(degradation.profile). merge copies every field from the schedule’s profile._checkRecovery(): if _packetsProcessed >= recovery.afterPacket, the active profile becomes recovery.profile.The following consequences come straight from the code:
afterPacket: 15 means “from the 15th send onward”.send counts: first transmissions, window re-sends, timeout and NACK retransmissions, switch requests, and packets that are then lost. Discovery probes and channel tests do not count.lib/core/simulation/scenarios.dart defines nine ScenarioDefinitions. Each has an id, a name, a description, a dataSize in bytes and a createPair function that calls createSimulationPair(...). The payload is generateTestData(dataSize), and the packet count is ceil(dataSize / 256).
id |
name |
dataSize |
Packets | Overrides and schedules as written in the code |
|---|---|---|---|---|
optical-always-good |
Optical Always Good | 4096 | 16 | Optical A and B: loss 0.005, 20 000 bps. Acoustic A and B: loss 0.05 |
acoustic-always-good |
Acoustic Always Good | 4096 | 16 | Optical A and B: discoverable: false. Acoustic A and B: loss 0.02, 10 000 bps |
optical-degrades |
Optical Starts Good, Becomes Poor | 8192 | 32 | Acoustic A: loss 0.03, 9 000 bps, confidence 0.8. Acoustic B: loss 0.03, 9 000 bps. Optical degradation after 15 sends: 2 000 bps, loss 0.30, 500 ms, confidence 0.3, stability 0.2 |
random-loss |
Random Packet Loss | 4096 | 16 | Optical A: loss 0.08, corruption 0.02. Optical B: loss 0.08. Acoustic A and B: loss 0.06 |
burst-loss |
Burst Packet Loss | 6144 | 24 | Optical degradation after 8 sends: loss 0.45, 4 000 bps, 300 ms |
both-degrade |
Both Channels Degrade | 2048 | 8 | Optical degradation after 10 sends: loss 0.25, 3 000 bps. Acoustic degradation after 10 sends: loss 0.20, 4 000 bps |
acoustic-recovers |
Acoustic Starts Poor, Recovers | 4096 | 16 | Optical A and B: discoverable: false. Acoustic A: loss 0.15, 3 000 bps, confidence 0.4. Acoustic B: loss 0.15, 3 000 bps. Acoustic recovery after 30 sends: loss 0.02, 10 000 bps, confidence 0.85 |
repeated-degradation |
Repeated Degradation and Recovery | 10240 | 40 | Acoustic A and B: loss 0.05, 8 000 bps. Optical degradation after 12 sends: loss 0.35, 2 500 bps, confidence 0.25. Optical recovery after 35 sends: loss 0.01, 18 000 bps, confidence 0.9 |
vibration-coupled |
Vibration Coupled | 2048 | 8 | Optical and acoustic A and B: discoverable: false. Vibration A: loss 0.03, 900 bps, confidence 0.82. Vibration B: loss 0.03, 900 bps |
Because an override starts from the constructor defaults (quirk 2), fields that a scenario never mentions still change. The table lists the profiles that createSimulationPair actually builds, as throughput / loss / latency / confidence / stability / corruption. “Default” means the channel’s own default from section 4.2.
id |
Optical | Acoustic | Vibration | Schedules (endpoint A only) |
|---|---|---|---|---|
optical-always-good |
20 000 / 0.005 / 80 / 0.92 / 0.88 / 0.005 | 18 000 / 0.05 / 80 / 0.92 / 0.88 / 0.005 | default | none |
acoustic-always-good |
18 000 / 0.01 / 80 / 0.92 / 0.88 / 0.005, not discoverable | 10 000 / 0.02 / 80 / 0.92 / 0.88 / 0.005 | default | none |
optical-degrades |
default (18 000 / 0.01 / 2 / 0.92 / 0.88 / 0.005) | A: 9 000 / 0.03 / 80 / 0.8 / 0.88 / 0.005; B: 9 000 / 0.03 / 80 / 0.92 / 0.88 / 0.005 | default | Optical from send 15: 2 000 / 0.30 / 500 / 0.3 / 0.2 / 0.005 |
random-loss |
A: 18 000 / 0.08 / 80 / 0.92 / 0.88 / 0.02; B: 18 000 / 0.08 / 80 / 0.92 / 0.88 / 0.005 | 18 000 / 0.06 / 80 / 0.92 / 0.88 / 0.005 | default | none |
burst-loss |
default | default | default | Optical from send 8: 4 000 / 0.45 / 300 / 0.92 / 0.88 / 0.005 |
both-degrade |
default | default | default | Optical from send 10: 3 000 / 0.25 / 80 / 0.92 / 0.88 / 0.005; acoustic from send 10: 4 000 / 0.20 / 80 / 0.92 / 0.88 / 0.005 |
acoustic-recovers |
18 000 / 0.01 / 80 / 0.92 / 0.88 / 0.005, not discoverable | A: 3 000 / 0.15 / 80 / 0.4 / 0.88 / 0.005; B: 3 000 / 0.15 / 80 / 0.92 / 0.88 / 0.005 | default | Acoustic from send 30: 10 000 / 0.02 / 80 / 0.85 / 0.88 / 0.005 |
repeated-degradation |
default | 8 000 / 0.05 / 80 / 0.92 / 0.88 / 0.005 | default | Optical from send 12: 2 500 / 0.35 / 80 / 0.25 / 0.88 / 0.005; optical from send 35: 18 000 / 0.01 / 80 / 0.9 / 0.88 / 0.005 |
vibration-coupled |
18 000 / 0.01 / 80 / 0.92 / 0.88 / 0.005, not discoverable | 18 000 / 0.01 / 80 / 0.92 / 0.88 / 0.005, not discoverable | A: 900 / 0.03 / 80 / 0.82 / 0.88 / 0.005; B: 900 / 0.03 / 80 / 0.92 / 0.88 / 0.005 | none |
“Intended” paraphrases the scenario’s name and description. “Observed” comes from two runs of each scenario on 2026-09-27, and four runs of optical-degrades and burst-loss. The scores quoted are the initial 20-packet test scores.
id |
Intended | Observed | Why |
|---|---|---|---|
optical-always-good |
Optical chosen, stays good | Success every time on optical (0.882), no switch, about 13.7 s | As intended. Acoustic scored 0.740 to 0.854, because its override gives it 18 000 bps |
acoustic-always-good |
Optical unavailable, acoustic chosen | Success every time, but on optical (0.854 against acoustic 0.742), about 14 s | Undiscoverable channels are still tested and ranked (quirk 1), and the override gives optical 18 000 bps |
optical-degrades |
Optical first, degrades, switch to acoustic | Failed in all 4 runs, 135 to 146 s. One switch OPTICAL to ACOUSTIC per run, always with “Last confirmed packet = 32” | The switch only happens after the transfer has already stalled because of cumulative ACK handling (quirk 4). Section 6.4 walks through it |
random-loss |
Optical carries on through 8% loss | Success every time. Acoustic was chosen once (0.854 against 0.825) and optical once. 14.7 to 16.6 s | Both overrides give 18 000 bps with 6–8% loss, so the random 20-packet test decides |
burst-loss |
Optical degrades after send 8 and the engine reacts | Failed in all 4 runs, 62 to 70 s. One switch OPTICAL to ACOUSTIC per run at “Last confirmed packet = 24” | Same mechanism as optical-degrades. In one run the sender had all 24 packets acknowledged while the receiver was still missing packet 18. Degraded optical scores 0.478 and acoustic about 0.70 |
both-degrade |
Both degrade, no clearly better alternative | Success every time on optical, no switch, 5.8 to 6.8 s | Only 8 packets, so the transfer ends before the first re-evaluation. If re-evaluated, degraded optical would score 0.580 against about 0.69–0.71 for untouched acoustic, a gap below 0.15 |
acoustic-recovers |
Acoustic chosen while poor, then recovers | Success every time on optical (0.854 against acoustic 0.50 to 0.52), about 14 s | Quirk 1. Acoustic is never used, so its send counter never reaches 30 and the recovery never fires |
repeated-degradation |
Optical degrades at send 12, recovers at send 35 | Success every time on optical, no switch, 37.6 to 40.4 s | No re-evaluation is reached while optical is degraded, and the recovery arrives before the 40 packets finish |
vibration-coupled |
Only vibration usable, vibration chosen | Success every time on optical (0.854; acoustic 0.825 to 0.854; vibration 0.600), about 6 s | Quirk 1. Only vibration is discoverable, and it passes discovery with probability 0.94 on each endpoint, so about 12% of runs (1 − 0.94²) end with “Discovery failed” |
A full Run All therefore takes around five minutes and, on the evidence above, usually reports 7 of 9 passed, because optical-degrades and burst-loss fail. The automated tests in test/simulation_integration_test.dart cover only optical-always-good and random-loss with 1024 bytes and acoustic-always-good with its own 4096 bytes. They assert only that the data matches. See TESTING.md.
| Setting | Value | Source |
|---|---|---|
| Discovery timeout | 300 ms | runTransfer |
| Test packets, initial and per alternative | 20 | runTransfer, _evaluateAndSwitch |
| Packet payload size | 256 bytes | TransmissionConfig.packetSize |
| ACK timeout | 500 ms | TransportConfig.ackTimeoutMs |
| Max retries per packet | 5 | TransportConfig.maxRetries |
| Window | 8 packets | TransportConfig.windowSize |
| Loop delay | 5 ms per iteration | runTransfer |
| Max loop iterations | 1500 | runTransfer |
| Re-evaluation | when i − lastMonitor >= 20, so first at iteration 20, then 40, 60 and so on |
runTransfer |
| Degradation threshold | score below 0.65 | degradationThreshold |
| Switch hysteresis | alternative − current ≥ 0.15 | switchHysteresisThreshold |
| Snapshot emission | every 5 iterations | runTransfer |
After selection, the sender calls createDataPackets(data), which numbers the packets 1 to N and encodes each one once. The receiver calls setExpectedTotalPackets(N). Each iteration then does the following:
sender.lastAckedSequence < N, call sendNextWindow, which transmits packets lastAcked + 1 to lastAcked + 8. Packets that are already pending are sent again on every iteration without counting as retries.receiveActive(). The receiver stores each new data packet and replies with an ACK for it, plus a NACK for every gap below the highest sequence it holds. The sender handles ACK, NACK and retransmission-request packets and transmits any retransmissions._handleSwitchNegotiation, which polls both channels again and acts only on channelSwitchRequest and channelSwitchAck packets. Any other packet read here is dropped (quirk 5).checkTimeouts(), which retransmits every pending packet older than 500 ms that has fewer than 5 retries.i − lastMonitor >= 20, set lastMonitor = i and run _evaluateAndSwitch.i % 5 == 0, emit a snapshot.When the loop ends, dataMatch is true if the reassembled data is at least data.length long and its first data.length bytes equal the original. On a match both state machines go to completed. Otherwise both are forced to failed. Reaching 1500 iterations without completing is a failure.
_evaluateAndSwitch runs on the sender only:
getMetrics() with scoreMetrics. These are the configured profile values from section 4.5, not measurements. The score is stored in channelScores.<CHANNEL> quality degraded (score=x.xx) and moves the sender from transferring to degraded.Testing <CHANNEL> channel, runs a fresh 20-packet test and calls evaluateSwitch(current, currentMetrics, altResult). That scores the alternative with scoreTestResult and switches if current < 0.65 and alternative − current >= 0.15. The first alternative that qualifies wins, not necessarily the best one.degraded to transferring.The two inputs to the formula differ:
| Used for | Function | Throughput | Reliability | Latency | Confidence |
|---|---|---|---|---|---|
| Initial selection and alternatives | scoreTestResult |
baseThroughput × (1 − measuredLoss) |
received / sent |
baseLatencyMs |
confidence × (1 − measuredLoss / 2) |
| Re-evaluating the active channel | scoreMetrics |
baseThroughput |
1 − packetLossRate − corruptionRate |
baseLatencyMs |
confidence |
In both cases score = 0.35·T + 0.25·R + 0.15·L + 0.15·C + 0.10·S, with T = clamp(throughput / 25000, 0, 1), L = clamp(1 − latency / 500, 0, 1) and R, C and S clamped to 0–1.
_performSwitch(from, to) then does the following:
switchingChannel, and lastConfirmed is set to sender.lastAckedSequence.Switching FROM → TO (DECISION) and Last confirmed packet = N (SWITCH).setActiveChannel(to), transport.setChannelId(to) and transport.resumeFromSequence(lastConfirmed), which sets lastAckedSequence and drops pending packets up to lastConfirmed.channelSwitchRequest on the new channel. Its payload is two little-endian u32 values, the new channel ID and lastConfirmed, and its header sequence number is lastConfirmed._handleSwitchNegotiation. The receiver sees the request, sets the channel again, replies with a channelSwitchAck, and logs Switch acknowledged, resuming from packet N+1.SwitchEvent(from, to, lastConfirmedPacket, timestamp, reason), logs Resuming packet N+1 (TRANSFER), and moves the sender through recovering back to transferring.optical-degradesInitial test and selection. A tests all three channels with 20 packets each. With every test packet arriving, the scores are:
| Term | Optical (default) | Acoustic A (9 000 / 0.03 / 80 / 0.8 / 0.88) | Vibration (default) |
|---|---|---|---|
| T | 18 000 / 25 000 = 0.720, × 0.35 = 0.2520 | 9 000 / 25 000 = 0.360, × 0.35 = 0.1260 | 800 / 25 000 = 0.032, × 0.35 = 0.0112 |
| R | 20 / 20 = 1.000, × 0.25 = 0.2500 | 1.000, × 0.25 = 0.2500 | 1.000, × 0.25 = 0.2500 |
| L | 1 − 2 / 500 = 0.996, × 0.15 = 0.1494 | 1 − 80 / 500 = 0.840, × 0.15 = 0.1260 | 1 − 120 / 500 = 0.760, × 0.15 = 0.1140 |
| C | 0.92, × 0.15 = 0.1380 | 0.80, × 0.15 = 0.1200 | 0.72, × 0.15 = 0.1080 |
| S | 0.88, × 0.10 = 0.0880 | 0.88, × 0.10 = 0.0880 | 0.68, × 0.10 = 0.0680 |
| Score | 0.8774 | 0.7100 | 0.5512 |
If one optical test packet is lost (19 of 20), the optical score becomes 0.849. Either way optical wins, and the log shows [DECISION] Selected OPTICAL with score 0.8774 and reason Higher throughput and reliability, because optical’s measured throughput is higher than the runner-up’s.
Degradation. The 32 packets go out in windows of 8. A-optical’s send counter counts the first sends, the re-sends of the window and the retransmissions, so the 15th send happens by the second loop iteration at the latest. From then on, A-optical uses 2 000 bps, loss 0.30, 500 ms, confidence 0.3 and stability 0.2. Each delivered data packet now costs about 1.64 s. A full window of 8 costs about 9 s, because the roughly 30% of packets that are lost cost nothing.
What the engine would decide. Re-evaluating the degraded optical channel from its profile gives:
| Term | Value | Weighted |
|---|---|---|
| T = 2 000 / 25 000 | 0.080 | 0.0280 |
| R = 1 − 0.30 − 0.005 | 0.695 | 0.1738 |
| L = 1 − 500 / 500 | 0.000 | 0.0000 |
| C | 0.300 | 0.0450 |
| S | 0.200 | 0.0200 |
| Current score | 0.2668, below 0.65, so degraded |
The first alternative tried is acoustic. A fresh 20-packet test on acoustic A scores 0.710 with all 20 received, 0.688 with 19 and 0.666 with 18. The gap is at least 0.666 − 0.267 = 0.40, well over 0.15, so evaluateSwitch returns shouldSwitch: true with the reason Switching OPTICAL → ACOUSTIC: 0.71 vs 0.27. Vibration is never tested, because acoustic qualifies first.
What actually happens. The first re-evaluation is at loop iteration 20, and on the degraded link every iteration that sends a window takes several seconds. The loop therefore never reaches iteration 20 while data is still flowing. In one traced run:
| Time since start | Event |
|---|---|
| 1.06 s | [DECISION] Selected OPTICAL (score 0.8774) |
| 111.4 s | [TRANSFER] Packet 32 acknowledged. The sender’s cumulative ACK is now 32/32, but the receiver is still missing packets 10 and 14 |
| 111.5 s | With nothing left to send, iterations take milliseconds, iteration 20 arrives, and the log shows [WARNING] OPTICAL quality degraded (score=0.27) and [ADAPT] Testing ACOUSTIC channel |
| 111.9 s | [DECISION] Switching OPTICAL → ACOUSTIC, [SWITCH] Last confirmed packet = 32, and on the receiver Switch acknowledged, resuming from packet 33 |
| 135.2 s | The loop reaches 1500 iterations with packets 10 and 14 still missing, and the log shows [ERROR] Transfer failed |
The switch itself works as designed, but by then there is nothing left to send on acoustic, because the sender has already discarded packets 10 and 14 from its pending list (quirk 4). All four probe runs of this scenario ended this way. The re-evaluated scores of the other scenarios’ degraded profiles are:
| Degraded profile | Re-evaluated score | Degraded? | Typical alternative | Switch if evaluated? |
|---|---|---|---|---|
burst-loss optical (4 000 / 0.45 / 300 / 0.92 / 0.88) |
0.478 | yes | default acoustic, about 0.69–0.71 | yes (gap about 0.22) |
both-degrade optical (3 000 / 0.25 / 80 / 0.92 / 0.88) |
0.580 | yes | default acoustic, about 0.69–0.71 | no (gap below 0.15) |
repeated-degradation optical (2 500 / 0.35 / 80 / 0.25 / 0.88) |
0.448 | yes | acoustic 8 000 / 0.05 / 80, about 0.71 | yes (gap about 0.26) |
| Default optical, healthy (R = 0.985) | 0.874 | no | none | no |
Each endpoint has its own StructuredLogger. Entries carry a category, a message, a timestamp and optional data, and each logger keeps at most 1000 entries. During a single run AppController subscribes to both loggers with onLog, so the LIVE LOGS panel shows sender and receiver entries interleaved in the order they happen. The panel draws each entry as [CATEGORY] message in a monospace font, with a count chip in its header.
| Category | Colour | Messages produced in a simulated run |
|---|---|---|
DISCOVERY |
blue | Device discovered on <CHANNEL> channel |
TEST |
blue | <CHANNEL> throughput = x.x kbps, <CHANNEL> packet loss = y.y% (initial tests and alternative tests) |
DECISION |
green | Selected <CHANNEL> (with score and reason in its data), Switching FROM → TO |
TRANSFER |
light blue | Packet n received, Missing packet n detected, requesting retransmission (receiver); Packet n acknowledged, Retransmitting packet n (retry k), Resuming packet n, Transfer complete (sender) |
WARNING |
amber | <CHANNEL> quality degraded (score=x.xx) |
ADAPT |
cyan | Testing <CHANNEL> channel |
SWITCH |
purple | Last confirmed packet = n (sender), Switch acknowledged, resuming from packet n (receiver) |
ERROR |
red | Max retries exceeded for packet n, Timeout: max retries for packet n, Transfer failed |
Packet n acknowledged is logged only when an ACK advances the sender’s lastAckedSequence. A discovery failure is not written to either logger. It appears only as the single string Discovery failed in SimulationResult.logs.
EndpointPanel renders a DashboardSnapshot built by VirtualEndpoint.getSnapshot().
| Row | Source |
|---|---|
| Title and state chip | transferStateLabel(state). The chip is green for COMPLETED, red for FAILED, amber for DEGRADED, SWITCHING_CHANNEL and RECOVERING, and blue otherwise |
| Channel | The endpoint’s active channel, or N/A |
| Score, Confidence | currentDecision.score and .confidence from the initial selection. They are not updated after a switch |
| Throughput, Packet Loss, Latency | The active channel’s getMetrics(), shown in kbps, % and ms. These are the configured profile values (section 4.5). Packet loss is drawn in red above 10% |
| Progress bar, Progress, Packets, Retries | transport.getProgress(allPackets.length, dataToSend.length). Packets shows acknowledged / total, and Retries shows the transport’s retryCount (NACK, retransmission-request and timeout resends; window re-sends are not counted). See quirk 10 for why the bar stays at 0% |
| CHANNELS | One bar per entry in channelScores. On the sender these start as the initial test scores and are overwritten by re-evaluation and alternative-test scores. On the receiver they stay at the initial test scores |
| SWITCH EVENTS | FROM → TO @ pkt N for each SwitchEvent. Only the sender records switch events |
In the probe runs a successful 16-packet transfer typically ended with the sender showing “Packets 8 / 16” and “Retries 16”. The receiver completed even though many ACKs never reached the sender’s transport (quirk 5).
SimulationResult| Field | Meaning |
|---|---|
success, dataMatch |
Both equal the verification result |
originalSize, receivedSize |
Bytes sent and bytes reassembled (0 if nothing was reassembled) |
switchEvents |
Number of switches the sender performed |
senderSnapshot, receiverSnapshot |
Final DashboardSnapshots |
durationMs |
Wall-clock time of the whole run, including discovery and tests |
logs |
Sender entries followed by receiver entries, formatted [CATEGORY] message |
PerformanceComparator.runComparison({int dataSize = 4096}) runs three strategies one after another and returns one StrategyResult each. The app calls it with the default of 4096 bytes (16 packets).
| Strategy | Label | How it is built | Channel actually used in the probe runs |
|---|---|---|---|
TransferStrategy.adaptive |
Adaptive | getScenario('optical-degrades')!.createPair(), run with 4096 bytes instead of the scenario’s 8192 |
Optical throughout, no switch |
TransferStrategy.fixedOptical |
Fixed Optical | createSimulationPair(opticalProfileA: ChannelSimulationProfile(packetLossRate: 0.08)), so optical A is 18 000 / 0.08 / 80 / 0.92 / 0.88 / 0.005 and everything else is default |
Optical |
TransferStrategy.fixedAcoustic |
Fixed Acoustic | createSimulationPair with optical A and B set to ChannelSimulationProfile(discoverable: false) |
Optical, because the undiscoverable optical channel (18 000 bps, 80 ms, score about 0.85) outscores default acoustic (about 0.71) |
TransferStrategy.staticSwitch |
Static Switch | Present in the enum and in strategyLabel, but never run |
none |
All three call the full SimulationOrchestrator.runTransfer, including discovery, score-based selection and periodic re-evaluation (quirk 3).
| Field | Computed as | Shown on the card as |
|---|---|---|
success |
result.success |
Green tick or red error icon next to the label |
durationMs |
result.durationMs |
Duration in ms |
throughput |
senderSnapshot.metrics.throughput, the configured baseThroughput of the sender’s active channel at the end |
Throughput, value ÷ 1000 with one decimal, labelled kbps |
goodput |
dataSize / (durationMs / 1000) in bytes per second if successful, otherwise 0 |
Goodput, value ÷ 1024 with one decimal, labelled KB/s |
packetLoss |
senderSnapshot.metrics.packetLoss, the configured loss rate of the active channel |
Packet Loss, value × 100 % |
retransmissions |
senderSnapshot.progress.retryCount |
Retransmissions |
switchCount |
result.switchEvents for Adaptive; hard-coded 0 for both fixed strategies |
Channel Switches |
Two comparison runs on 2026-09-27 produced:
| Strategy | Success | Duration | Throughput field | Packet Loss field | Retransmissions | Switches | Goodput |
|---|---|---|---|---|---|---|---|
| Adaptive | 2 of 2 | 53.9 s, 48.5 s | 2 000 bps (degraded optical profile) | 0.30 | 17, 19 | 0, 0 | 76.0, 84.5 B/s |
| Fixed Optical | 2 of 2 | 11.9 s, 11.9 s | 18 000 bps | 0.08 | 15, 16 | 0 | 344.1, 345.2 B/s |
| Fixed Acoustic | 2 of 2 | 13.7 s, 13.6 s | 18 000 bps | 0.01 | 16, 16 | 0 | 298.9, 302.0 B/s |
One comparison takes about 75 to 80 seconds. Read the results with care. “Adaptive” is the slowest because it is the only strategy whose channel degrades, and with 16 packets it never reaches a re-evaluation. “Fixed Acoustic” actually ran on optical, which is why its throughput field shows 18 000 bps and its loss field 0.01. The comparison therefore does not currently show an adaptive advantage. See KNOWN_ISSUES.md.
lib/core/simulation/scenarios.dart and add a ScenarioDefinition to the scenarios list. Give it a unique kebab-case id, a human-readable name, a one-sentence description, a dataSize in bytes, and a createPair closure that calls createSimulationPair(...).DegradationSchedule.afterPacket counts. It counts endpoint A’s send() calls on that channel, including re-sends, retransmissions and lost packets. A schedule on a channel that never becomes active never fires, and schedules cannot be attached to endpoint B.discoverable: false to keep a channel out of selection (quirk 1). To make a channel lose, give it a genuinely bad profile, such as low throughput, high loss and high latency.AppController.availableScenarios, which returns scenarios, and Run All iterates the same list and uses scenarios.length as its denominator.test/simulation_integration_test.dart. Assert dataMatch and give it a generous timeout, because the medium really waits for every packet’s latency and air time.This example makes optical nearly unusable, so that acoustic wins on merit, and gives acoustic its real default characteristics with 10% loss. The two constants go at the top of scenarios.dart, and the ScenarioDefinition goes inside the scenarios list:
const noisyAcousticProfile = ChannelSimulationProfile(
baseThroughput: 9000,
packetLossRate: 0.10,
baseLatencyMs: 3,
confidence: 0.78,
stability: 0.72,
corruptionRate: 0.01,
);
const unusableOpticalProfile = ChannelSimulationProfile(
baseThroughput: 1000,
packetLossRate: 0.40,
baseLatencyMs: 400,
confidence: 0.3,
stability: 0.3,
);
ScenarioDefinition(
id: 'acoustic-noisy',
name: 'Acoustic Only, Noisy',
description: 'Optical is nearly unusable; acoustic carries the transfer through 10% loss.',
dataSize: 2048,
createPair: () => createSimulationPair(
opticalProfileA: unusableOpticalProfile,
opticalProfileB: unusableOpticalProfile,
acousticProfileA: noisyAcousticProfile,
acousticProfileB: noisyAcousticProfile,
),
),
With these profiles, a typical optical test (12 of 20 received) scores about 0.25, acoustic with 18 of 20 received scores about 0.67, and default vibration about 0.55, so acoustic is selected. Re-evaluated from its profile, acoustic scores 0.687, which is above the 0.65 threshold, so no switch is attempted. The matching test:
test('acoustic noisy scenario completes', () async {
final scenario = getScenario('acoustic-noisy')!;
final orchestrator = SimulationOrchestrator(scenario.createPair());
final result = await orchestrator.runTransfer(generateTestData(scenario.dataSize));
expect(result.dataMatch, isTrue);
}, timeout: const Timeout(Duration(minutes: 2)));
Because loss is random and unseeded, run a new scenario test several times before relying on it. A 10% loss rate will sometimes leave the permanent gap described in quirk 4, and the test will then fail.
const ChannelSimulationProfile(...) with all fields written out, like noisyAcousticProfile above. Put it at the top of scenarios.dart, or next to the defaults in simulated_medium.dart, which scenarios.dart already imports. Pass it as the opticalProfileA, acousticProfileB or other override arguments.defaultOpticalProfile, defaultAcousticProfile or defaultVibrationProfile in simulated_medium.dart) affects every scenario that does not override that channel, and the Performance Comparison strategies. Scenarios that do override the channel are unaffected, because their override replaces every field.createSimulationPair has fixed parameters for three channels, and CommChannelId has only optical, acoustic and vibration. Adding one would need a new CommChannelId value, a new factory in comm_channel.dart, new parameters and registrations in createSimulationPair, and a new case in ChannelManager.startAll.Each item below was confirmed in the code, and most were also seen in the probe runs. Items 1 to 4 have been reported before; the rest were found while writing this document. Wider limitations are collected in KNOWN_ISSUES.md.
runTransfer only checks that the discovery lists are not empty. testAll(20) then tests every registered channel, runChannelTest ignores discoverable, and the switch loop iterates availableChannelIds, which is every registered channel. In the probe runs acoustic-always-good, acoustic-recovers, vibration-coupled and “Fixed Acoustic” all transferred over optical.createSimulationPair calls defaultXProfile.copyWith(baseThroughput: override?.baseThroughput, ...). A const ChannelSimulationProfile(packetLossRate: 0.05) carries a non-null value for every field (the constructor defaults), so every default is overwritten, including optical’s 2 ms latency and all of acoustic’s and vibration’s characteristics. Degradation schedules behave the same way, because merge(other) takes every field from other, and recovery assigns its profile directly._runFixedOptical and _runFixedAcoustic call SimulationOrchestrator.runTransfer, with its discovery, 20-packet tests, score-based selection and re-evaluation every 20 iterations. Nothing forces a channel, and their switchCount is hard-coded to 0.ReliableTransport treats per-packet ACKs as cumulative. The receiver ACKs each packet individually and NACKs gaps. The sender’s _handleAck sets lastAckedSequence to the ACKed sequence and removes all pending packets up to it. If packet k is lost and k+1 is ACKed, packet k leaves the pending list. The NACK for k then finds nothing to retransmit, and sendNextWindow starts after k. Once lastAckedSequence reaches N the loop sends nothing more, the receiver never completes, and the run fails after 1500 iterations. This is why optical-degrades and burst-loss failed in every probe run._handleSwitchNegotiation polls both active channels with receiveActive(), which drains the channel’s buffer, and ignores everything that is not a switch request or switch ACK. It runs right after the receiver has sent its ACKs and NACKs for the window, so many of those are discarded. Progress then relies on timeout retransmissions and on duplicate ACKs from repeated window sends. This is why retransmission counts roughly equal the packet count, and why successful runs often end with the sender showing only part of the data acknowledged.lastMonitor starts at 0, so the first check is at i == 20. On a slow link one iteration takes seconds, so most transfers end, or stall, before any re-evaluation. The only switches seen in the probe runs happened after the loop had stalled because of quirk 4.SimulatedMedium.send returns before the delay when a packet is lost, so a lossy channel finishes a window faster than a clean one with the same throughput.getMetrics() returns the active profile. The panels’ Throughput, Packet Loss and Latency rows, the mid-transfer re-evaluation, and the comparison’s Throughput and Packet Loss fields all report the profile, not what happened on the link.getProgress counts its own reassembly buffer, which stays empty, so it reads 0%. The receiver passes allPackets.length, which is 0 on the receiver, as the total, so it reads 0% with a total of 0 packets. Its acknowledged count only changes when a switch calls resumeFromSequence on it. In practice only the sender’s Packets and Retries rows move during a transfer.currentDecision, which is set only at the initial selection.runAllScenarios sets neither onUpdate nor log listeners and does not touch lastSimResult, so the panels, the live log and the result chip do not change. Its N/9 scenarios passed summary goes to AppController.statusMessage, which this screen does not display.SimulatedMedium creates its own Random(), so selection when scores are close, loss patterns, failures and durations all vary between runs. vibration-coupled fails discovery about 12% of the time.createDataPackets encodes every packet once, with the channel chosen at the start, and sendNextWindow sends those stored bytes. The receiver checks only the transfer ID, so this has no effect on delivery.TransferStrategy.staticSwitch is never run.See also ADAPTIVE_ENGINE.md for the decision engine in isolation, CALCULATIONS.md for the formulas, TESTING.md for the test suite, API_REFERENCE.md for class signatures, and UI_GUIDE.md for the screens and widgets.