Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7c961a5
Ensure that running a DiscreteEventSim does not change provided config
zandi Apr 24, 2026
801073d
Add basic script to time DiscreteEventSim, to help measure optimizations
zandi Apr 24, 2026
088e248
Add connectivity map optimization
zandi Apr 24, 2026
2ace3b0
Cache baseline path loss from computing connectivity matrix, reuse
zandi Apr 27, 2026
76a0144
Add ENABLE_CONNECTIVITY_MAP config option to enable/disable connectiv…
zandi Apr 25, 2026
35523a1
Add parameter to loraMesh.py to enable/disable connectivity map optim…
zandi Apr 25, 2026
17eac58
Clean up 'times'/'n_times' argument, make sure we have minimum number…
zandi Apr 27, 2026
bdc17cb
Add test to verify connectivity_map optimization is consistent
zandi Apr 27, 2026
95868be
Add model parameter to estimate_path_loss, enforce valid choices, han…
zandi May 1, 2026
bbb90a6
add packetsHeard, packetsRebroadcast fields to MeshNodeStats, track i…
zandi May 1, 2026
b4c2d11
Add unique sequence number to Packets, enrich log statements with it
zandi May 5, 2026
b4364c0
Add ability to reset MeshPacket unique packet counter, improve logging
zandi May 5, 2026
861dd7c
Make asymmetric link simulation dynamic; move into MeshPacket init
zandi May 5, 2026
b1e72b2
Move pathloss/rssi computation into NodeConfig method, add member var…
zandi May 7, 2026
05fb4b9
More explicitly use node_conf binding in MeshNode init
zandi May 7, 2026
e2a7800
Update CW-related computations to match firmware tag v2.7.15.567b8ea
zandi May 13, 2026
7f95883
add missing sim time to debug log message in node.py
zandi May 13, 2026
f392179
Create utils directory, move time-testing script into it
zandi May 14, 2026
0d93c3f
Gate synchronizing asym_rng call behind MODEL_ASYMMETRIC_LINKS config…
zandi May 14, 2026
7f87c4b
Improve logging in packet.
zandi May 14, 2026
15cd8a6
Fix units for bandwidth in get_current_slot_time
zandi May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions batchSim.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,6 @@ def simulation_progress(env, currentRep, repetitions, endTime):
usefulnessStds_dict = {}

# If you have link asymmetry metrics
asymmetricLinkRate_dict = {}
symmetricLinkRate_dict = {}
noLinkRate_dict = {}

# Initialize dictionaries for each router type
Expand All @@ -138,8 +136,6 @@ def simulation_progress(env, currentRep, repetitions, endTime):
usefulness_dict[rt] = []
usefulnessStds_dict[rt] = []

asymmetricLinkRate_dict[rt] = []
symmetricLinkRate_dict[rt] = []
noLinkRate_dict[rt] = []


Expand Down Expand Up @@ -201,8 +197,6 @@ def __init__(self, conf, x, y):
reachabilityStds = []
usefulness = []
usefulnessStds = []
asymmetricLinkRateAll = []
symmetricLinkRateAll = []
noLinkRateAll = []

# Inner loop for each nrNodes
Expand All @@ -213,8 +207,6 @@ def __init__(self, conf, x, y):
collisionRate = [0 for _ in range(repetitions)]
meanDelay = [0 for _ in range(repetitions)]
meanTxAirUtilization = [0 for _ in range(repetitions)]
asymmetricLinkRate = [0 for _ in range(repetitions)]
symmetricLinkRate = [0 for _ in range(repetitions)]
noLinkRate = [0 for _ in range(repetitions)]

print(f"\n[Router: {routerTypeLabel}] Start of {p+1} out of {len(numberOfNodes)} - {nrNodes} nodes")
Expand Down Expand Up @@ -262,8 +254,6 @@ def __init__(self, conf, x, y):
messageSeq = results["messageSeq"]
delays = results["delays"]
totalPairs = results["totalPairs"]
symmetricLinks = results["symmetricLinks"]
asymmetricLinks = results["asymmetricLinks"]
noLinks = results["noLinks"]
nodes = results["nodes"]

Expand All @@ -283,8 +273,6 @@ def __init__(self, conf, x, y):

if routerTypeConf.MODEL_ASYMMETRIC_LINKS:
# actually percentages, not rates
asymmetricLinkRate[rep] = round(results['asymmetricLinkRate'] * 100, 2)
symmetricLinkRate[rep] = round(results['symmetricLinkRate'] * 100, 2)
noLinkRate[rep] = round(results['noLinkRate'] * 100, 2)

# After finishing all repetitions for this nrNodes, compute means/stdevs
Expand All @@ -298,8 +286,6 @@ def __init__(self, conf, x, y):
delayStds.append(np.nanstd(meanDelay))
meanTxAirUtils.append(np.nanmean(meanTxAirUtilization))
txAirUtilsStds.append(np.nanstd(meanTxAirUtilization))
asymmetricLinkRateAll.append(np.nanmean(asymmetricLinkRate))
symmetricLinkRateAll.append(np.nanmean(symmetricLinkRate))
noLinkRateAll.append(np.nanmean(noLinkRate))

# Saving to file if needed
Expand Down Expand Up @@ -339,8 +325,6 @@ def __init__(self, conf, x, y):
print('Delay average:', round(np.nanmean(meanDelay), 2))
print('Tx air utilization average:', round(np.nanmean(meanTxAirUtilization), 2))
if routerTypeConf.MODEL_ASYMMETRIC_LINKS:
print('Asymmetric Links:', round(np.nanmean(asymmetricLinkRate), 2))
print('Symmetric Links:', round(np.nanmean(symmetricLinkRate), 2))
print('No Links:', round(np.nanmean(noLinkRate), 2))

# After finishing all nrNodes for the *current* router type,
Expand All @@ -355,8 +339,6 @@ def __init__(self, conf, x, y):
delayStds_dict[routerType] = delayStds
meanTxAirUtils_dict[routerType] = meanTxAirUtils
txAirUtilsStds_dict[routerType] = txAirUtilsStds
asymmetricLinkRate_dict[routerType] = asymmetricLinkRateAll
symmetricLinkRate_dict[routerType] = symmetricLinkRateAll
noLinkRate_dict[routerType] = noLinkRateAll


Expand Down
2 changes: 2 additions & 0 deletions lib/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ def calc_dist(x0, x1, y0, y1, z0=0, z1=0):
return np.sqrt(((abs(x0-x1))**2)+((abs(y0-y1))**2)+((abs(z0-z1)**2)))

def setup_asymmetric_links(conf, nodes):
"""updates conf to populate LINK_OFFSET member to simulate asymmetric links
"""
asymLinkRng = random.Random(conf.SEED)
totalPairs = 0
symmetricLinks = 0
Expand Down
9 changes: 5 additions & 4 deletions lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def __init__(self):
self.ONE_HR_INTERVAL = self.ONE_MIN_INTERVAL * 60

### Discrete-event specific ###
self.ENABLE_CONNECTIVITY_MAP = True # use the connectivity map optimization
self.CONNECTIVITY_MAP_RSSI_MARGIN = 8

self.MODEM_PRESET = "LONG_FAST" # LoRa modem preset to use (default LONG_FAST matches firmware)
self.PERIOD = 100 * self.ONE_SECOND_INTERVAL # mean period of generating a new message with exponential distribution in ms
self.PACKETLENGTH = 40 # payload in bytes
Expand Down Expand Up @@ -313,6 +316,7 @@ def __init__(self):
# minimum sensitivity from https://www.rfwireless-world.com/calculators/LoRa-Sensitivity-Calculator.html, using a Noise Figure (NF) of 6dB
# minimum received power for CAD: 3dB less than sensitivity
# TODO: the 'bw' parameter is changed based on the region's 'wide_lora' setting. Implement this.
# Note: we store bandwidth here in Hz, but the firmware uses KHz.
self.MODEM_PRESETS = {
"SHORT_TURBO": {
"bw": 500e3,
Expand Down Expand Up @@ -419,10 +423,7 @@ def __init__(self):
# Adds a random offset to the link quality of each link
self.MODEL_ASYMMETRIC_LINKS = True
self.MODEL_ASYMMETRIC_LINKS_MEAN = 0
self.MODEL_ASYMMETRIC_LINKS_STDDEV = 3
# Stores the offset for each link
# Populated when the simulator first starts
self.LINK_OFFSET = {}
self.MODEL_ASYMMETRIC_LINKS_STDDEV = 2

#################################################
####### MOVING NODE SIMULATION VARIABLES ########
Expand Down
61 changes: 49 additions & 12 deletions lib/discrete_event_sim.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import copy
import logging
from typing import TYPE_CHECKING

# probably not necessary, but "Environment" seemed too generic to me
from simpy import Environment as SimpyEnvironment
import numpy as np

from lib.common import setup_asymmetric_links
from lib.config import Config
from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking
from lib.node import MeshNode, NodeConfig

if TYPE_CHECKING:
from lib.gui import Graph
from lib.packet import MeshPacket
from lib.phy import estimate_path_loss

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -94,12 +96,8 @@ def finalize(self, conf: Config):

self.results["delayDropped"] = sum(n.droppedByDelay for n in nodes)

if conf.MODEL_ASYMMETRIC_LINKS and self.results["totalPairs"] != 0:
asymmetricLinkRate = self.results["asymmetricLinks"] / self.results["totalPairs"]
symmetricLinkRate = self.results["symmetricLinks"] / self.results["totalPairs"]
if self.results["totalPairs"] != 0:
noLinkRate = self.results["noLinks"] / self.results["totalPairs"]
self.results["asymmetricLinkRate"] = asymmetricLinkRate
self.results["symmetricLinkRate"] = symmetricLinkRate
self.results["noLinkRate"] = noLinkRate

if conf.MOVEMENT_ENABLED:
Expand All @@ -122,9 +120,13 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non

# set constant state/initial state from parameters
self.env = SimpyEnvironment()
self.conf = conf
self.conf = copy.deepcopy(conf) # have our own copy so our setup_asymmetric_links doesn't change whatever config we've been passed.
self.node_configs = node_configs

# reset MeshPacket class variables
MeshPacket.seed_asym_rng(self.conf.SEED)
MeshPacket.reset_packet_counter()

# internal global state which changes
self.mutated_state = SimulationState(self.conf, self.env)

Expand All @@ -134,21 +136,27 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non
# note: we allow user to specify if graphing will happen or not
self.graph = graph

# use node configs to populate the connectivity matrix and compute
# initial condition links/no links. Because we always expect link/no
# link counts, and thus do an O(n^2) precomputation anyways, just
# always do this and reserve checking/not checking the map later based
# on config settings.
self.initialize_connectivity_map()

# node configs provided, create nodes with them
for cfg in self.node_configs:
n = MeshNode(self.conf,
self.mutated_state,
self.data_tracking,
cfg,
cfg
)
self.mutated_state.nodes.append(n)

if self.graph is not None:
for n in self.mutated_state.nodes:
self.graph.add_node(n)

# setup that requires having nodes
self.data_tracking.totalPairs, self.data_tracking.symmetricLinks, self.data_tracking.asymmetricLinks, self.data_tracking.noLinks = setup_asymmetric_links(self.conf, self.mutated_state.nodes)
logger.debug(f"connectivity map: {self.mutated_state.connectivity_map}")

if self.graph is not None and self.conf.MOVEMENT_ENABLED:
# NOTE: this does not run under test, since we skip creating a GUI
Expand Down Expand Up @@ -184,12 +192,41 @@ def get_results(self) -> SimulationResults:
"messages": self.data_tracking.messages,
"delays": self.data_tracking.delays,
"totalPairs": self.data_tracking.totalPairs,
"symmetricLinks": self.data_tracking.symmetricLinks,
"asymmetricLinks": self.data_tracking.asymmetricLinks,
"noLinks": self.data_tracking.noLinks,
"nodes": self.mutated_state.nodes,
}
results = SimulationResults(first_order_results)
results.finalize(self.conf)

return results

def initialize_connectivity_map(self):
'''use node configs to compute the initial connectivity map for later
lookups. Also, initialize baseline path loss matrix.
'''
for tx_node in self.node_configs:
# compute the set of all nodes our signal is detectable at
reachable_node_set = set()
for rx_node in self.node_configs:
if tx_node.node_id == rx_node.node_id:
continue # skip self

self.data_tracking.totalPairs += 1

(rssi, pl) = tx_node.compute_rssi_and_pathloss_to(rx_node, self.conf)

# compare with extra margin (set based on 10-node standard test)
if rssi + self.conf.CONNECTIVITY_MAP_RSSI_MARGIN > self.conf.current_preset['sensitivity']:
reachable_node_set.add(rx_node.node_id)

# compute total/no links without margin
if rssi >= self.conf.current_preset['sensitivity']:
self.data_tracking.totalLinks += 1
else:
self.data_tracking.noLinks += 1

# cache path loss (it is symmetric, and static until one of the nodes moves)
self.mutated_state.baseline_pathloss_matrix[tx_node.node_id][rx_node.node_id] = pl
self.mutated_state.baseline_pathloss_matrix[rx_node.node_id][tx_node.node_id] = pl

self.mutated_state.connectivity_map[tx_node.node_id] = reachable_node_set
5 changes: 3 additions & 2 deletions lib/discrete_event_sim_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def __init__(self, conf: Config, env: SimpyEnvironment):
self.packetsAtN = [[] for _ in range(conf.NR_NODES)]
self.messageSeq = Counter()
self.nodes = []
self.connectivity_map = {} # node_id -> set of nodes which can hear it. (list would be faster for lookup)
self.baseline_pathloss_matrix = [[None for _ in range(conf.NR_NODES)] for _ in range(conf.NR_NODES)] # cache for later lookup

class SimulationDataTracking:
"""Class to hold data used to monitor a simulation which has no
Expand All @@ -56,6 +58,5 @@ def __init__(self):
self.messages = []
self.delays = []
self.totalPairs = 0
self.symmetricLinks = 0
self.asymmetricLinks = 0
self.totalLinks = 0
self.noLinks = 0
31 changes: 20 additions & 11 deletions lib/mac.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

logger = logging.getLogger(__name__)

CWmin = 2
# checked as of tag v2.7.15.567b8ea in meshtastic-firmware repo
CWmin = 3
CWmax = 8
PROCESSING_TIME_MSEC = 4500

Expand All @@ -20,28 +21,34 @@ def set_transmit_delay(node, packet): # from RadioLibInterface::setTransmitDela
def get_tx_delay_msec_weighted(node, rssi): # from RadioInterface::getTxDelayMsecWeighted
snr = rssi - node.conf.NOISE_LEVEL
SNR_MIN = -20
SNR_MAX = 15
SNR_MAX = 10
slot_time_msec = get_current_slot_time()
if snr < SNR_MIN:
logger.debug(f'Minimum SNR at RSSI of {rssi} dBm')
logger.debug(f'{node.env.now:.3f} Node {node.nodeid} clamping to Minimum SNR at RSSI of {rssi} dBm')
snr = SNR_MIN
if snr > SNR_MAX:
logger.debug(f'Maximum SNR at RSSI of {rssi} dBm')
logger.debug(f'{node.env.now:.3f} Node {node.nodeid} clamping to Maximum SNR at RSSI of {rssi} dBm')
snr = SNR_MAX

CWsize = int((snr - SNR_MIN) * (CWmax - CWmin) / (SNR_MAX - SNR_MIN) + CWmin)

if node.is_router:
CW = random.randint(0, 2 * CWsize - 1)
delay = random.randint(0, 2 * CWsize) * slot_time_msec
logger.debug(f'{node.env.now:.3f} Node {node.nodeid} is router, has CW size {CWsize} and picked {delay=}')
else:
CW = random.randint(0, 2 ** CWsize - 1)
logger.debug(f'Node {node.nodeid} has CW size {CWsize} and picked CW {CW}')
return CW * get_current_slot_time()
max_router_delay = 2 * CWmax * slot_time_msec
delay = max_router_delay + random.randint(0, 2 ** CWsize) * slot_time_msec
logger.debug(f'{node.env.now:.3f} Node {node.nodeid} is not router, has CW size {CWsize} and picked {delay=}')
return delay


def get_tx_delay_msec(node): # from RadioInterface::getTxDelayMsec
# channelUtilizationPercent is actually computed based on the last CHANNEL_UTILIZATION_PERIODS, summing
# the utilization of those periods. In v2.7.15.567b8ea this macro is 6, with SECONDS_PER_PERIOD 3600
channelUtil = node.airUtilization / node.env.now * 100
CWsize = int(channelUtil * (CWmax - CWmin) / 100 + CWmin)
CW = random.randint(0, 2 ** CWsize - 1)
logger.debug(f'Current channel utilization is {channelUtil}, so picked CW {CW}')
CW = random.randint(0, 2 ** CWsize)
logger.debug(f'{node.env.now:.3f} Current channel utilization is {channelUtil}, so picked {CWsize=} and {CW=}')
return CW * get_current_slot_time()


Expand All @@ -50,4 +57,6 @@ def get_retransmission_msec(node, packet): # from RadioInterface::getRetransmis
packetAirtime = int(airtime(node.conf, preset["sf"], preset["cr"], packet.packetLen, preset["bw"]))
channelUtil = node.airUtilization / node.env.now * 100
CWsize = int(channelUtil * (CWmax - CWmin) / 100 + CWmin)
return 2 * packetAirtime + (2 ** CWsize + 2 ** (int((CWmax + CWmin) / 2))) * get_current_slot_time() + PROCESSING_TIME_MSEC
return 2 * packetAirtime + (2 ** CWsize + 2 * CWmax + 2 ** (int((CWmax + CWmin) / 2))) * get_current_slot_time() + PROCESSING_TIME_MSEC

# NOTE: there is a getTxDelayMsecWeightedWorst function that we haven't implemented yet.
Loading
Loading