From 7c961a52c27f8e41aaa43e7f06863cb06775f287 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 23 Apr 2026 21:26:53 -0400 Subject: [PATCH 01/21] Ensure that running a DiscreteEventSim does not change provided config We just copy.deepcopy the config object so we operate on our own config. setup_asymmetric_links sets up asymmetric links by adding a LINK_OFFSET member to the config. Add a docstring for the function mentioning this but otherwise leave it as-is. Also add a test to make sure that running a DiscreteEventSim doesn't change the config object we provide to create the sim. --- lib/common.py | 2 ++ lib/discrete_event_sim.py | 3 ++- tests/test_discrete_event_sim.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/common.py b/lib/common.py index 13d0a98c..b8a639bf 100644 --- a/lib/common.py +++ b/lib/common.py @@ -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 diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 4ad492e6..9037374a 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -1,3 +1,4 @@ +import copy import logging from typing import TYPE_CHECKING @@ -122,7 +123,7 @@ 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 # internal global state which changes diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 1f876105..ec77db5f 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -241,5 +241,36 @@ def test_discrete_sim_ten_nodes(self): gpsEnabled = results['gpsEnabled'] self.assertEqual(gpsEnabled, 1, "expected number of nodes with GPS") + def test_sim_does_not_change_config(self): + import copy + + from lib.node import default_generate_node_list + + # get default config, set node number + from lib.config import CONFIG + conf = CONFIG + + # copied from the 10-node test just because, but not necessary + random.seed(conf.SEED) + + conf.NR_NODES = 3 # smaller number for speed. + conf.update_router_dependencies() + nodeConfig = default_generate_node_list(conf) + # skipping GUI graphing to speed things up + + # get copy of the config pre-run + old_conf = copy.deepcopy(conf) + + # set up and run sim + sim = lib.discrete_event_sim.DiscreteEventSim(conf, nodeConfig) + sim.run_simulation() + + # go through the full sim lifecycle, to cover everywhere that may touch config + results = sim.get_results() + + # set difference trick to compare configs + conf_diff = conf.__dict__.items() ^ old_conf.__dict__.items() + self.assertEqual(len(conf_diff), 0, "config has not been changed by running a simulation") + if __name__ == '__main__': unittest.main() From 801073d2eddec0527c5a3d71f36c31b3553e2f30 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 23 Apr 2026 22:07:47 -0400 Subject: [PATCH 02/21] Add basic script to time DiscreteEventSim, to help measure optimizations The timing can vary quite a bit between runs depending on what else is happening on the system, but this at least helps get some numbers when working on optimizations. Hopefully we can make improvements on the big-O runtime that will be easy to see even in rough numbers. --- time-discrete-sim.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100755 time-discrete-sim.py diff --git a/time-discrete-sim.py b/time-discrete-sim.py new file mode 100755 index 00000000..bbd9c8a1 --- /dev/null +++ b/time-discrete-sim.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import argparse +import copy +import random +import timeit + +from lib.config import CONFIG +from lib.discrete_event_sim import DiscreteEventSim +from lib.node import NodeConfig, default_generate_node_list + +conf = CONFIG + +def run_discrete_sim_timeit(nr_nodes: int, n_times: int): + def init_and_run_discrete_sim(): + random.seed(conf.SEED) # arbitrary seed rng for consistency between runs + sim = DiscreteEventSim(conf, node_configs) + sim.run_simulation() + # include getting results, since a sim isn't useful unless we have results. + results = sim.get_results() + + return results + + random.seed(conf.SEED) # get consistent default node list + + conf.NR_NODES = nr_nodes + conf.update_router_dependencies() + node_configs = default_generate_node_list(conf) + + # number set to 1, since this is heavy weight enough that a single run will take + # an easily visible time + results = timeit.repeat('init_and_run_discrete_sim()', repeat=parsed_arguments.n_times, number=1, globals=locals()) + + # take average ignoring 5 slowest runs, assuming some other very unlucky things were happening + # on the system. + results.sort() + average_without_slowest_outliers = sum(results[:-5]) / len(results[:-5]) + + report = { + 'fastest': results[0], + 'modified_average': average_without_slowest_outliers, + 'all_results': results + } + + return report + +parser = argparse.ArgumentParser( + description='basic benchmark of discrete simulation. Uses timeit, which disables garbage collection by default.' + ) +parser.add_argument('nr_nodes', type=int, help='number of nodes to generate') +parser.add_argument('n_times', nargs='?', type=int, default=25, help='number of times to run a discrete sim to gather timing data') + +parsed_arguments = parser.parse_args() + +# do timing run using timeit. More involved profiling should use cProfile +print(f"running default configuration discrete sim of {parsed_arguments.nr_nodes} nodes, {parsed_arguments.n_times} times") + +results = run_discrete_sim_timeit(parsed_arguments.nr_nodes, parsed_arguments.n_times) + +print(f"fastest time (in seconds): {results['fastest']}") +print(f"average time (in seconds), ignoring slowest outliers: {results['modified_average']}") +print(f"{results['all_results']=}") From 088e24897fc2bc7aa72d0e6bc271bdc3a3fbae55 Mon Sep 17 00:00:00 2001 From: zandi Date: Fri, 24 Apr 2026 18:38:39 -0400 Subject: [PATCH 03/21] Add connectivity map optimization This uses a connectivity map to track what nodes could feasibly 'hear' each other. It is symmetric and with ample margin to accomodate asymmetric link behavior. A margin of 8 is chosen based on the 10-node 'gold standard' test run, where one link has an offset of +7.5. Later reimplementation of asymmetric link simulation can remove this link offset data structure and narrow this margin further, which should improve performance slightly, but will necessarily change our 'gold standard' reference test run. The connectivity map is initialized before simulation start by a O(n^2) loop that computes the set of reachable nodes from any arbitrary transmitting node. This runtime could probably be reduced by 1/2 since links are symmetric, but worry about that later (and it would still be O(n^2)). This connectivity map must be updated every time a node moves, but this is computed only from the perspective of the moving node, with some set operations used to update the connectivity maps of other nodes as necessary. So this is O(n), multiplied by every time a mobile node moves. This connectivity map is used in MeshPacket to skip path loss/rssi/sensed computations for nodes that aren't in the transmitting node's connectivity map. This should skip computations for receiving nodes which can't even sense the packet. This step is still O(n), but can skip potentially many unnecessary computations. It all depends on node configuration. Before this optimization, for path loss computations the O(n^2) precomputation obviously doesn't happen, and neither would the O(n) computation for each node move. However each packet creation has a O(n) computation of path loss that can include potentially many unnecessary calculations, and there can be many more packets in a simulation than nodes. Consider this all back-of-the-envelope calculations rather than a rigorous analysis. This gets the most benefit in situations with: - dispersed nodes that can't reach many other nodes - none or fewer moving nodes - very many packets (long-running sim/more active network) - and perhaps many nodes? But may be worse for situations with: - many moving nodes - a densely connected network - perhaps very many nodes (precomputation gets bad) It is probably best to gate this behind an option w/ a config setting, so that it can be enabled/disabled as appropriate for the scenario being simulated. This can be further improved (perhaps greatly?) by caching path loss calculations in the connectivity map, since we've already computed those and can just re-use that value. Do this once asymmetric link simulation is moved into the packet, rather than baked into the config using link offsets. --- lib/discrete_event_sim.py | 32 +++++++++++++++++++- lib/discrete_event_sim_components.py | 1 + lib/node.py | 45 +++++++++++++++++++++++++--- lib/packet.py | 16 +++++++++- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 9037374a..c44bf4c0 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from lib.gui import Graph +from lib.packet import MeshPacket +from lib.phy import estimate_path_loss logger = logging.getLogger(__name__) @@ -135,12 +137,15 @@ 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 + 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) @@ -151,6 +156,9 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non # 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"link offsets: {self.conf.LINK_OFFSET}") + 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 # TODO: revisit this design decision sometime. Do we want graphing/GUI to be handled in this object, @@ -194,3 +202,25 @@ def get_results(self) -> SimulationResults: results.finalize(self.conf) return results + + def initialize_connectivity_map(self): + '''use node configs to compute the initial connectivity map for later + lookups + ''' + 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 + + # compute path loss + tx_power = self.conf.PTX # can move this into NodeConfig w/ default + dist = tx_node.position.euclidean_distance(rx_node.position) + pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, tx_node.position.z, rx_node.position.z) + rssi = tx_power + tx_node.antenna_gain + rx_node.antenna_gain - pl + rssi += 8 # some extra margin (tested against 10-node standard) + if rssi > self.conf.current_preset['sensitivity']: + reachable_node_set.add(rx_node.node_id) + + self.mutated_state.connectivity_map[tx_node.node_id] = reachable_node_set diff --git a/lib/discrete_event_sim_components.py b/lib/discrete_event_sim_components.py index 59197760..e0123828 100644 --- a/lib/discrete_event_sim_components.py +++ b/lib/discrete_event_sim_components.py @@ -47,6 +47,7 @@ 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) class SimulationDataTracking: """Class to hold data used to monitor a simulation which has no diff --git a/lib/node.py b/lib/node.py index ea47c426..4c5478df 100644 --- a/lib/node.py +++ b/lib/node.py @@ -12,6 +12,7 @@ from lib.mac import set_transmit_delay, get_retransmission_msec from lib.phy import check_collision, is_channel_active, airtime from lib.packet import NODENUM_BROADCAST, MeshPacket, MeshMessage +from lib.phy import estimate_path_loss from lib.point import Point logger = logging.getLogger(__name__) @@ -119,6 +120,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.my_stats = MeshNodeStats(self.nodeid) self.messageSeq = sim_state.messageSeq + self.connectivity_map = sim_state.connectivity_map self.env = sim_state.env self.bc_pipe = sim_state.bc_pipe self.nodes = sim_state.nodes @@ -232,6 +234,41 @@ def move_node(self): # Update node’s position self.position.update_xy(new_x, new_y) + # update connectivity map: + # - update for this node: we may have gained and lost reachable nodes + # - new reachable nodes: add ourselves to their connectivity map entry + # - lost reachable nodes: remove ourselves from their connectivity map entry + # may need to deepcopy if we put more complex things in here + old_reachable_set = self.connectivity_map[self.nodeid].copy() + new_reachable_set = set() + for rx_node in self.nodes: + if rx_node.nodeid == self.nodeid: + continue # skip self + tx_power = self.conf.PTX # can move this into NodeConfig w/ default + dist = self.position.euclidean_distance(rx_node.position) + pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, self.position.z, rx_node.position.z) + rssi = tx_power + self.antennaGain + rx_node.antennaGain - pl + rssi += 8 # some extra margin (tested against 10-node standard) + if rssi > self.conf.current_preset['sensitivity']: + new_reachable_set.add(rx_node.nodeid) + + # calculate set differences to detect added and removed nodes + lost_nodes = old_reachable_set.difference(new_reachable_set) + gained_nodes = new_reachable_set.difference(old_reachable_set) + + logger.debug(f"node {self.nodeid} moved. Connectivity change: -{len(lost_nodes)}, +{len(gained_nodes)}.") + # TODO: -0, +0 case is very common. Skip what we can in this case. + # update this node's connectivity map + self.connectivity_map[self.nodeid] = new_reachable_set + # add ourself to the connectivity map of every node we gained + for node_id in gained_nodes: + self.connectivity_map[node_id].add(self.nodeid) + # remove ourself from the connectivity map of every node we lost + for node_id in lost_nodes: + self.connectivity_map[node_id].discard(self.nodeid) + + # connectivity map updated! + if self.gpsEnabled: distanceTraveled = self.position.euclidean_distance(self.lastBroadcastPosition) logger.debug(f"{self.env.now:.3f} node {self.nodeid} checks last broadcast position distance: {distanceTraveled} from {self.lastBroadcastPosition} to {self.position}") @@ -258,7 +295,7 @@ def send_packet(self, destId, type=""): # increment the shared counter messageSeq = self.messageSeq.get() self.messages.append(MeshMessage(self.nodeid, destId, self.env.now, messageSeq)) - p = MeshPacket(self.conf, self.nodes, self.nodeid, destId, self.nodeid, self.conf.PACKETLENGTH, messageSeq, self.env.now, True, False, None, self.env.now) + p = MeshPacket(self.conf, self.nodes, self.nodeid, destId, self.nodeid, self.conf.PACKETLENGTH, messageSeq, self.env.now, True, False, None, self.env.now, self.connectivity_map) logger.debug(f"{self.env.now:.3f} Node {self.nodeid} generated {type} message {p.seq} to {destId}") self.packets.append(p) self.env.process(self.transmit(p)) @@ -321,7 +358,7 @@ def generate_message(self): break else: if minRetransmissions > 0: # generate new packet with same sequence number - pNew = MeshPacket(self.conf, self.nodes, self.nodeid, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now) + pNew = MeshPacket(self.conf, self.nodes, self.nodeid, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map) pNew.retransmissions = minRetransmissions - 1 logger.debug(f"{self.env.now:.3f} Node {self.nodeid} wants to retransmit its generated packet to {destId} with seq.nr. {p.seq} minRetransmissions {minRetransmissions}") self.packets.append(pNew) @@ -431,7 +468,7 @@ def receive(self, in_pipe): logger.debug(f"{self.env.now:.3f} Node {self.nodeid} sends a flooding ACK.") messageSeq = self.messageSeq.get() self.messages.append(MeshMessage(self.nodeid, p.origTxNodeId, self.env.now, messageSeq)) - pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now) + pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map) self.packets.append(pAck) self.env.process(self.transmit(pAck)) # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. @@ -440,7 +477,7 @@ def receive(self, in_pipe): if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: if not self.is_client_mute: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} rebroadcasts received packet {p.seq}") - pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now) + pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map) pNew.hopLimit = p.hopLimit - 1 self.packets.append(pNew) self.env.process(self.transmit(pNew)) diff --git a/lib/packet.py b/lib/packet.py index 1b033855..7cf96db7 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,10 +1,13 @@ +import logging + from lib.phy import airtime, estimate_path_loss NODENUM_BROADCAST = 0xFFFFFFFF +logger = logging.getLogger(__name__) class MeshPacket: - def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTime, wantAck, isAck, requestId, now): + def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTime, wantAck, isAck, requestId, now, connectivity_map): """Create a new packet and calculate which nodes sense and receive it Arguments: @@ -20,6 +23,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi isAck -- this packet is an ACK packet requestId -- ID of packet requesting ACK (only valid for ACK packets) now -- current sim time when called, always `env.now` + connectivity_map -- map of nodeid -> set of reachable nodeids """ self.conf = conf self.origTxNodeId = origTxNodeId @@ -50,6 +54,16 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi for rx_node in nodes: if rx_node.nodeid == self.txNodeId: continue + + # reduce calculations just to plausibly reachable nodes. This is initialized + # before sim start, and updated whenever a moving node's position is updated, + # so is always an accurate map of what nodes could (with extra margin) even + # sense each other. + if not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): + logger.debug(f"skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") + continue + #pass # swap the comments on the pass/continue to skip the optimization, accepting a bit of overhead. + dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) offset = self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)] self.LplAtN[rx_node.nodeid] = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) + offset From 2ace3b0ddf0477d954c2bb63a8127bacc53c41e8 Mon Sep 17 00:00:00 2001 From: zandi Date: Mon, 27 Apr 2026 16:56:03 -0400 Subject: [PATCH 04/21] Cache baseline path loss from computing connectivity matrix, reuse Since we're already computing the baseline path loss between nodes for our connectivity matrix, just cache those values and reuse them later when constructing packets. This at least saves us distance and path loss calculations for each packet that gets created. --- lib/discrete_event_sim.py | 11 ++++++++--- lib/discrete_event_sim_components.py | 1 + lib/node.py | 18 ++++++++++++------ lib/packet.py | 9 ++++++--- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index c44bf4c0..f00020ab 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -205,7 +205,7 @@ def get_results(self) -> SimulationResults: def initialize_connectivity_map(self): '''use node configs to compute the initial connectivity map for later - lookups + 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 @@ -219,8 +219,13 @@ def initialize_connectivity_map(self): dist = tx_node.position.euclidean_distance(rx_node.position) pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, tx_node.position.z, rx_node.position.z) rssi = tx_power + tx_node.antenna_gain + rx_node.antenna_gain - pl - rssi += 8 # some extra margin (tested against 10-node standard) - if rssi > self.conf.current_preset['sensitivity']: + + # compare with extra margin (set based on 10-node standard test) + if rssi + 8 > self.conf.current_preset['sensitivity']: reachable_node_set.add(rx_node.node_id) + # 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 diff --git a/lib/discrete_event_sim_components.py b/lib/discrete_event_sim_components.py index e0123828..6354d074 100644 --- a/lib/discrete_event_sim_components.py +++ b/lib/discrete_event_sim_components.py @@ -48,6 +48,7 @@ def __init__(self, conf: Config, env: SimpyEnvironment): 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 diff --git a/lib/node.py b/lib/node.py index 4c5478df..a0756690 100644 --- a/lib/node.py +++ b/lib/node.py @@ -121,6 +121,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.messageSeq = sim_state.messageSeq self.connectivity_map = sim_state.connectivity_map + self.baseline_pathloss_matrix = sim_state.baseline_pathloss_matrix self.env = sim_state.env self.bc_pipe = sim_state.bc_pipe self.nodes = sim_state.nodes @@ -248,10 +249,15 @@ def move_node(self): dist = self.position.euclidean_distance(rx_node.position) pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, self.position.z, rx_node.position.z) rssi = tx_power + self.antennaGain + rx_node.antennaGain - pl - rssi += 8 # some extra margin (tested against 10-node standard) - if rssi > self.conf.current_preset['sensitivity']: + + # compare with extra margin (set based on 10-node standard test) + if rssi + 8 > self.conf.current_preset['sensitivity']: new_reachable_set.add(rx_node.nodeid) + # cache path loss (it is symmetric, and static until one of the nodes moves) + self.baseline_pathloss_matrix[self.nodeid][rx_node.nodeid] = pl + self.baseline_pathloss_matrix[rx_node.nodeid][self.nodeid] = pl + # calculate set differences to detect added and removed nodes lost_nodes = old_reachable_set.difference(new_reachable_set) gained_nodes = new_reachable_set.difference(old_reachable_set) @@ -295,7 +301,7 @@ def send_packet(self, destId, type=""): # increment the shared counter messageSeq = self.messageSeq.get() self.messages.append(MeshMessage(self.nodeid, destId, self.env.now, messageSeq)) - p = MeshPacket(self.conf, self.nodes, self.nodeid, destId, self.nodeid, self.conf.PACKETLENGTH, messageSeq, self.env.now, True, False, None, self.env.now, self.connectivity_map) + p = MeshPacket(self.conf, self.nodes, self.nodeid, destId, self.nodeid, self.conf.PACKETLENGTH, messageSeq, self.env.now, True, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) logger.debug(f"{self.env.now:.3f} Node {self.nodeid} generated {type} message {p.seq} to {destId}") self.packets.append(p) self.env.process(self.transmit(p)) @@ -358,7 +364,7 @@ def generate_message(self): break else: if minRetransmissions > 0: # generate new packet with same sequence number - pNew = MeshPacket(self.conf, self.nodes, self.nodeid, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map) + pNew = MeshPacket(self.conf, self.nodes, self.nodeid, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) pNew.retransmissions = minRetransmissions - 1 logger.debug(f"{self.env.now:.3f} Node {self.nodeid} wants to retransmit its generated packet to {destId} with seq.nr. {p.seq} minRetransmissions {minRetransmissions}") self.packets.append(pNew) @@ -468,7 +474,7 @@ def receive(self, in_pipe): logger.debug(f"{self.env.now:.3f} Node {self.nodeid} sends a flooding ACK.") messageSeq = self.messageSeq.get() self.messages.append(MeshMessage(self.nodeid, p.origTxNodeId, self.env.now, messageSeq)) - pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map) + pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) self.packets.append(pAck) self.env.process(self.transmit(pAck)) # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. @@ -477,7 +483,7 @@ def receive(self, in_pipe): if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: if not self.is_client_mute: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} rebroadcasts received packet {p.seq}") - pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map) + pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) pNew.hopLimit = p.hopLimit - 1 self.packets.append(pNew) self.env.process(self.transmit(pNew)) diff --git a/lib/packet.py b/lib/packet.py index 7cf96db7..94678bdd 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -7,7 +7,7 @@ logger = logging.getLogger(__name__) class MeshPacket: - def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTime, wantAck, isAck, requestId, now, connectivity_map): + def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTime, wantAck, isAck, requestId, now, connectivity_map, baseline_pathloss_matrix): """Create a new packet and calculate which nodes sense and receive it Arguments: @@ -24,6 +24,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi requestId -- ID of packet requesting ACK (only valid for ACK packets) now -- current sim time when called, always `env.now` connectivity_map -- map of nodeid -> set of reachable nodeids + baseline_pathloss_matrix -- pre-computed matrix of pathloss between nodes """ self.conf = conf self.origTxNodeId = origTxNodeId @@ -64,9 +65,11 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi continue #pass # swap the comments on the pass/continue to skip the optimization, accepting a bit of overhead. - dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) + # look up baseline path loss from matrix, since we've already computed it. + baseline_pathloss = baseline_pathloss_matrix[self.txNodeId][rx_node.nodeid] + offset = self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)] - self.LplAtN[rx_node.nodeid] = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) + offset + self.LplAtN[rx_node.nodeid] = baseline_pathloss + offset self.rssiAtN[rx_node.nodeid] = self.txpow + self.tx_node.antennaGain + rx_node.antennaGain - self.LplAtN[rx_node.nodeid] if self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["sensitivity"]: self.sensedByN[rx_node.nodeid] = True From 76a0144f06d6514c3f971a281c882290d625789a Mon Sep 17 00:00:00 2001 From: zandi Date: Sat, 25 Apr 2026 10:32:35 -0400 Subject: [PATCH 05/21] Add ENABLE_CONNECTIVITY_MAP config option to enable/disable connectivity map optimization. Default True. This way we can easily enable/disable this optimization, which may be beneficial or detrimental in different situations. This will make it easy to add CLI arguments later to toggle this, and make it easy to evaluate the optimization across a variety of scenarios. This could potentially let us encode some heuristics for when to enable or disable the optimization based on the scenario given. --- lib/config.py | 1 + lib/discrete_event_sim.py | 3 +- lib/node.py | 71 ++++++++++++++++++++------------------- lib/packet.py | 11 +++--- 4 files changed, 46 insertions(+), 40 deletions(-) diff --git a/lib/config.py b/lib/config.py index 1db6c72c..c56b9528 100644 --- a/lib/config.py +++ b/lib/config.py @@ -29,6 +29,7 @@ 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.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 diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index f00020ab..868244cb 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -138,7 +138,8 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non self.graph = graph # use node configs to populate the connectivity matrix - self.initialize_connectivity_map() + if self.conf.ENABLE_CONNECTIVITY_MAP: + self.initialize_connectivity_map() # node configs provided, create nodes with them for cfg in self.node_configs: diff --git a/lib/node.py b/lib/node.py index a0756690..0722b7d9 100644 --- a/lib/node.py +++ b/lib/node.py @@ -239,41 +239,42 @@ def move_node(self): # - update for this node: we may have gained and lost reachable nodes # - new reachable nodes: add ourselves to their connectivity map entry # - lost reachable nodes: remove ourselves from their connectivity map entry - # may need to deepcopy if we put more complex things in here - old_reachable_set = self.connectivity_map[self.nodeid].copy() - new_reachable_set = set() - for rx_node in self.nodes: - if rx_node.nodeid == self.nodeid: - continue # skip self - tx_power = self.conf.PTX # can move this into NodeConfig w/ default - dist = self.position.euclidean_distance(rx_node.position) - pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, self.position.z, rx_node.position.z) - rssi = tx_power + self.antennaGain + rx_node.antennaGain - pl - - # compare with extra margin (set based on 10-node standard test) - if rssi + 8 > self.conf.current_preset['sensitivity']: - new_reachable_set.add(rx_node.nodeid) - - # cache path loss (it is symmetric, and static until one of the nodes moves) - self.baseline_pathloss_matrix[self.nodeid][rx_node.nodeid] = pl - self.baseline_pathloss_matrix[rx_node.nodeid][self.nodeid] = pl - - # calculate set differences to detect added and removed nodes - lost_nodes = old_reachable_set.difference(new_reachable_set) - gained_nodes = new_reachable_set.difference(old_reachable_set) - - logger.debug(f"node {self.nodeid} moved. Connectivity change: -{len(lost_nodes)}, +{len(gained_nodes)}.") - # TODO: -0, +0 case is very common. Skip what we can in this case. - # update this node's connectivity map - self.connectivity_map[self.nodeid] = new_reachable_set - # add ourself to the connectivity map of every node we gained - for node_id in gained_nodes: - self.connectivity_map[node_id].add(self.nodeid) - # remove ourself from the connectivity map of every node we lost - for node_id in lost_nodes: - self.connectivity_map[node_id].discard(self.nodeid) - - # connectivity map updated! + if self.conf.ENABLE_CONNECTIVITY_MAP: + # may need to deepcopy if we put more complex things in here + old_reachable_set = self.connectivity_map[self.nodeid].copy() + new_reachable_set = set() + for rx_node in self.nodes: + if rx_node.nodeid == self.nodeid: + continue # skip self + tx_power = self.conf.PTX # can move this into NodeConfig w/ default + dist = self.position.euclidean_distance(rx_node.position) + pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, self.position.z, rx_node.position.z) + rssi = tx_power + self.antennaGain + rx_node.antennaGain - pl + + # compare with extra margin (set based on 10-node standard test) + if rssi + 8 > self.conf.current_preset['sensitivity']: + new_reachable_set.add(rx_node.nodeid) + + # cache path loss (it is symmetric, and static until one of the nodes moves) + self.baseline_pathloss_matrix[self.nodeid][rx_node.nodeid] = pl + self.baseline_pathloss_matrix[rx_node.nodeid][self.nodeid] = pl + + # calculate set differences to detect added and removed nodes + lost_nodes = old_reachable_set.difference(new_reachable_set) + gained_nodes = new_reachable_set.difference(old_reachable_set) + + logger.debug(f"node {self.nodeid} moved. Connectivity change: -{len(lost_nodes)}, +{len(gained_nodes)}.") + # TODO: -0, +0 case is very common. Skip what we can in this case. + # update this node's connectivity map + self.connectivity_map[self.nodeid] = new_reachable_set + # add ourself to the connectivity map of every node we gained + for node_id in gained_nodes: + self.connectivity_map[node_id].add(self.nodeid) + # remove ourself from the connectivity map of every node we lost + for node_id in lost_nodes: + self.connectivity_map[node_id].discard(self.nodeid) + + # connectivity map updated! if self.gpsEnabled: distanceTraveled = self.position.euclidean_distance(self.lastBroadcastPosition) diff --git a/lib/packet.py b/lib/packet.py index 94678bdd..5008285e 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -60,13 +60,16 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi # before sim start, and updated whenever a moving node's position is updated, # so is always an accurate map of what nodes could (with extra margin) even # sense each other. - if not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): + if self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): logger.debug(f"skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") continue - #pass # swap the comments on the pass/continue to skip the optimization, accepting a bit of overhead. - # look up baseline path loss from matrix, since we've already computed it. - baseline_pathloss = baseline_pathloss_matrix[self.txNodeId][rx_node.nodeid] + if self.conf.ENABLE_CONNECTIVITY_MAP: + # look up baseline path loss from matrix, since we've already computed it. + baseline_pathloss = baseline_pathloss_matrix[self.txNodeId][rx_node.nodeid] + else: + dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) + baseline_pathloss = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) offset = self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)] self.LplAtN[rx_node.nodeid] = baseline_pathloss + offset From 35523a1df802877492ba983f68a424de62ce3981 Mon Sep 17 00:00:00 2001 From: zandi Date: Sat, 25 Apr 2026 10:41:43 -0400 Subject: [PATCH 06/21] Add parameter to loraMesh.py to enable/disable connectivity map optimization --- loraMesh.py | 7 +++++++ time-discrete-sim.py | 8 +++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/loraMesh.py b/loraMesh.py index 6d23afc5..07c2f38f 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -61,6 +61,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.add_argument('--simtime-seconds', type=float, help='Override simulation duration in seconds') parser.add_argument('--period-seconds', type=float, help='Override mean message-generation period in seconds') parser.add_argument('--no-gui', action='store_true', help='Run without Tk/Matplotlib graphing or schedule plotting') + parser.add_argument('--disable-connectivity-map', action='store_true', help='disable the connectivity map optimization. May be faster for some scenarios with many moving nodes and/or a densely connected network.') parser.add_argument('-v', '--verbose', action='store_true', help='enable verbose/debug output') parsed_arguments = parser.parse_args(args) @@ -88,6 +89,12 @@ def parse_params(conf, args=None) -> [NodeConfig]: gui_enabled = False plot_enabled = False + # enforce defaulting to True + if parsed_arguments.disable_connectivity_map: + conf.ENABLE_CONNECTIVITY_MAP = False + else: + conf.ENABLE_CONNECTIVITY_MAP = True + if parsed_arguments.from_file is not None and parsed_arguments.router_type is not None: parser.error("Incompatible argument selection. --from-file and --router-type can not be used together") diff --git a/time-discrete-sim.py b/time-discrete-sim.py index bbd9c8a1..0a4b8571 100755 --- a/time-discrete-sim.py +++ b/time-discrete-sim.py @@ -11,9 +11,10 @@ conf = CONFIG -def run_discrete_sim_timeit(nr_nodes: int, n_times: int): +def run_discrete_sim_timeit(nr_nodes: int, n_times: int, enable_connectivity_map: bool): def init_and_run_discrete_sim(): random.seed(conf.SEED) # arbitrary seed rng for consistency between runs + conf.ENABLE_CONNECTIVITY_MAP = enable_connectivity_map sim = DiscreteEventSim(conf, node_configs) sim.run_simulation() # include getting results, since a sim isn't useful unless we have results. @@ -49,13 +50,14 @@ def init_and_run_discrete_sim(): ) parser.add_argument('nr_nodes', type=int, help='number of nodes to generate') parser.add_argument('n_times', nargs='?', type=int, default=25, help='number of times to run a discrete sim to gather timing data') +parser.add_argument('--enable-connectivity-map', action='store_true', help='enable the connectivity map optimization. Default false') parsed_arguments = parser.parse_args() # do timing run using timeit. More involved profiling should use cProfile -print(f"running default configuration discrete sim of {parsed_arguments.nr_nodes} nodes, {parsed_arguments.n_times} times") +print(f"running default configuration discrete sim of {parsed_arguments.nr_nodes} nodes, {parsed_arguments.n_times} times. connectivity map enabled: {parsed_arguments.enable_connectivity_map}") -results = run_discrete_sim_timeit(parsed_arguments.nr_nodes, parsed_arguments.n_times) +results = run_discrete_sim_timeit(parsed_arguments.nr_nodes, parsed_arguments.n_times, parsed_arguments.enable_connectivity_map) print(f"fastest time (in seconds): {results['fastest']}") print(f"average time (in seconds), ignoring slowest outliers: {results['modified_average']}") From 17eac58f4b4ad97e9dd2fecbfb016e9cfa348b7d Mon Sep 17 00:00:00 2001 From: zandi Date: Mon, 27 Apr 2026 16:08:00 -0400 Subject: [PATCH 07/21] Clean up 'times'/'n_times' argument, make sure we have minimum number of test runs We (hardcoded) throw away the 5 slowest test runs, so must have 6 or more to not crash. Just gate this argument with a manual check/exception. --- time-discrete-sim.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/time-discrete-sim.py b/time-discrete-sim.py index 0a4b8571..8ee7698e 100755 --- a/time-discrete-sim.py +++ b/time-discrete-sim.py @@ -11,7 +11,7 @@ conf = CONFIG -def run_discrete_sim_timeit(nr_nodes: int, n_times: int, enable_connectivity_map: bool): +def run_discrete_sim_timeit(nr_nodes: int, times: int, enable_connectivity_map: bool): def init_and_run_discrete_sim(): random.seed(conf.SEED) # arbitrary seed rng for consistency between runs conf.ENABLE_CONNECTIVITY_MAP = enable_connectivity_map @@ -30,7 +30,7 @@ def init_and_run_discrete_sim(): # number set to 1, since this is heavy weight enough that a single run will take # an easily visible time - results = timeit.repeat('init_and_run_discrete_sim()', repeat=parsed_arguments.n_times, number=1, globals=locals()) + results = timeit.repeat('init_and_run_discrete_sim()', repeat=times, number=1, globals=locals()) # take average ignoring 5 slowest runs, assuming some other very unlucky things were happening # on the system. @@ -49,16 +49,20 @@ def init_and_run_discrete_sim(): description='basic benchmark of discrete simulation. Uses timeit, which disables garbage collection by default.' ) parser.add_argument('nr_nodes', type=int, help='number of nodes to generate') -parser.add_argument('n_times', nargs='?', type=int, default=25, help='number of times to run a discrete sim to gather timing data') -parser.add_argument('--enable-connectivity-map', action='store_true', help='enable the connectivity map optimization. Default false') +parser.add_argument('--times', type=int, default=25, help='number of times to run a discrete sim to gather timing data. Default: 25') +parser.add_argument('--enable-connectivity-map', action='store_true', help='enable the connectivity map optimization. Default: False') parsed_arguments = parser.parse_args() +if parsed_arguments.times <= 5: + # hardcoded remove of 5 slowest runs -> must have 6 or more runs + raise ValueError(f"Times must be 6 or more. You gave: {parsed_arguments.times}") + # do timing run using timeit. More involved profiling should use cProfile -print(f"running default configuration discrete sim of {parsed_arguments.nr_nodes} nodes, {parsed_arguments.n_times} times. connectivity map enabled: {parsed_arguments.enable_connectivity_map}") +print(f"running default configuration discrete sim of {parsed_arguments.nr_nodes} nodes, {parsed_arguments.times} times. connectivity map enabled: {parsed_arguments.enable_connectivity_map}") -results = run_discrete_sim_timeit(parsed_arguments.nr_nodes, parsed_arguments.n_times, parsed_arguments.enable_connectivity_map) +results = run_discrete_sim_timeit(parsed_arguments.nr_nodes, parsed_arguments.times, parsed_arguments.enable_connectivity_map) print(f"fastest time (in seconds): {results['fastest']}") -print(f"average time (in seconds), ignoring slowest outliers: {results['modified_average']}") +print(f"average time (in seconds), ignoring 5 slowest outliers: {results['modified_average']}") print(f"{results['all_results']=}") From bdc17cb3e38cf40ad1a69a2ce2613dc4fc4d65ff Mon Sep 17 00:00:00 2001 From: zandi Date: Mon, 27 Apr 2026 17:21:44 -0400 Subject: [PATCH 08/21] Add test to verify connectivity_map optimization is consistent Just make sure our optimization isn't unintentionally creating different simulations/results. Previous in-dev testing made sure of this, but now it's encoded in a test case. Currently only checking second-order results since comparing first-order results like the list of packets & nodes requires adding more comparison functions which would be handy, but is more work and can be its own thing. --- tests/test_discrete_event_sim.py | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index ec77db5f..de943464 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -152,6 +152,68 @@ def __init__(self, num_nodes: int): self.assertEqual(sim_results['movingNodes'], 1, 'expected number of moving nodes') self.assertEqual(sim_results['gpsEnabled'], 1, 'expected number of gps enabled nodes') + def test_connectivity_map_optimization_is_consistent(self): + from lib.node import default_generate_node_list + + from lib.config import CONFIG + conf = CONFIG + + all_results = [] + + # somewhat lazily test with connectivity map optimization on and off, + # to make sure the optimization doesn't change any results/the simulation + # is consistent regardless of this optimization. Further simulation changes + # that warrant this kind of testing should be very carefully considered, + # since that leads to exponential growth in configurations to test. + for enable_optimization in [True, False]: + # test against optimization being enabled/disabled + conf.ENABLE_CONNECTIVITY_MAP = enable_optimization + + # crucial!! and perhaps a tad fragile + random.seed(conf.SEED) + + self.assertEqual(conf.SEED, 44, "expected default seed for rng") + + # imitate parse_params + conf.NR_NODES = 10 + conf.update_router_dependencies() + nodeConfig = default_generate_node_list(conf) + # skipping GUI graphing to speed things up + + # set up sim + sim = lib.discrete_event_sim.DiscreteEventSim(conf, nodeConfig) + sim.run_simulation() + + # collect & unpack results for easy copy/paste of asserts + results = sim.get_results() + all_results.append(results) + + # look at just specific simulation results for now. May go as deep as + # comparing MeshPacket objects later if that seems useful and we feel + # like adding comparison functions to those objects. + facets = [ + 'potentialReceivers', + 'sent', + 'nrCollisions', + 'nrSensed', + 'nrReceived', + 'nrUseful', + 'meanDelay', + 'txAirUtilizationRate', + 'collisionRate', + 'nodeReach', + 'nrReceived', + 'usefulness', + 'delayDropped', + 'asymmetricLinkRate', + 'symmetricLinkRate', + 'noLinkRate', + 'movingNodes', + 'gpsEnabled', + ] + + for f in facets: + self.assertEqual(all_results[0][f], all_results[1][f], 'connectivity map optimization is consistent') # TODO: add default-skip GUI test? def test_discrete_sim_ten_nodes(self): From 95868bec47995085c0b5898d4460ab76f9f33d3b Mon Sep 17 00:00:00 2001 From: zandi Date: Fri, 1 May 2026 15:11:00 -0400 Subject: [PATCH 09/21] Add model parameter to estimate_path_loss, enforce valid choices, handle defaults for txZ, rxZ accepting the model as a parameter gives us some nice flexibility, and the new default handling makes it clearer we are getting defaults from the config passed as a parameter and not the module-level config. --- lib/phy.py | 42 ++++++++++++++++++++++++++++++++---------- tests/test_phy.py | 18 ++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/lib/phy.py b/lib/phy.py index 98145841..8ead2152 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -107,33 +107,53 @@ def airtime(conf, sf, cr, pl, bw): return (Tpream + Tpayload) * 1000 -def estimate_path_loss(conf, dist, freq, txZ=conf.HM, rxZ=conf.HM): +def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): + '''Calculate path loss between transmitter and receiver using a specific model + + Arguments: + conf -- config object + dist -- distance between nodes in meters + freq -- frequency in MHz + txZ -- height of transmitter. Default: conf.HM + rxZ -- height of receiver. Default: conf.HM + model -- choice of model (currently integer in [0,6], default: conf.MODEL) + + Returns: + path loss as float + ''' + if txZ is None: + txZ = conf.HM + if rxZ is None: + rxZ = conf.HM + if model is None: + model = conf.MODEL + # With randomized movements we may end up on top of another node which is problematic for log(dist) dist = max(dist, .001) # Log-Distance model - if conf.MODEL == 0: + if model == 0: Lpl = conf.LPLD0 + 10 * conf.GAMMA * math.log10(dist / conf.D0) # Okumura-Hata model - elif 1 <= conf.MODEL <= 4: + elif 1 <= model <= 4: # small and medium-size cities - if conf.MODEL == 1: + if model == 1: ahm = (1.1 * (math.log10(freq) - 6.0) - 0.7) * rxZ - (1.56 * (math.log10(freq) - 6.0) - 0.8) C = 0 # metropolitan areas - elif conf.MODEL == 2: + elif model == 2: if freq <= 200000000: ahm = 8.29 * ((math.log10(1.54 * rxZ)) ** 2) - 1.1 elif freq >= 400000000: ahm = 3.2 * ((math.log10(11.75 * rxZ)) ** 2) - 4.97 C = 0 # suburban environments - elif conf.MODEL == 3: + elif model == 3: ahm = (1.1 * (math.log10(freq) - 6.0) - 0.7) * rxZ - (1.56 * (math.log10(freq) - 6.0) - 0.8) C = -2 * ((math.log10(freq) - math.log10(28000000)) ** 2) - 5.4 # rural area - elif conf.MODEL == 4: + elif model == 4: ahm = (1.1 * (math.log10(freq) - 6.0) - 0.7) * rxZ - (1.56 * (math.log10(freq) - 6.0) - 0.8) C = -4.78 * ((math.log10(freq) - 6.0) ** 2) + 18.33 * (math.log10(freq) - 6.0) - 40.98 @@ -142,17 +162,19 @@ def estimate_path_loss(conf, dist, freq, txZ=conf.HM, rxZ=conf.HM): Lpl = A + B * (math.log10(dist) - 3.0) + C # 3GPP model - elif 5 <= conf.MODEL < 7: + elif 5 <= model < 7: # Suburban Macro - if conf.MODEL == 5: + if model == 5: C = 0 # dB # Urban Macro - elif conf.MODEL == 6: + elif model == 6: C = 3 # dB Lpl = (44.9 - 6.55 * math.log10(txZ)) * (math.log10(dist) - 3.0) \ + 45.5 + (35.46 - 1.1 * rxZ) * (math.log10(freq) - 6.0) \ - 13.82 * math.log10(rxZ) + 0.7 * rxZ + C + else: + raise ValueError(f"Unsupported path loss model: {model}") return Lpl diff --git a/tests/test_phy.py b/tests/test_phy.py index 198ded47..7e123853 100644 --- a/tests/test_phy.py +++ b/tests/test_phy.py @@ -4,6 +4,24 @@ class TestPhy(unittest.TestCase): + def test_path_loss_estimator(self): + # make sure we reject invalid model selection integers + from lib.config import CONFIG + conf = CONFIG + + model = -1 # invalid model + self.assertRaises(ValueError, lib.phy.estimate_path_loss, conf, 50, 915, 3, 3, model) + model = 7 # invalid model + self.assertRaises(ValueError, lib.phy.estimate_path_loss, conf, 50, 915, 3, 3, model) + model = 10 # invalid model + self.assertRaises(ValueError, lib.phy.estimate_path_loss, conf, 50, 915, 3, 3, model) + model = -10 # invalid model + self.assertRaises(ValueError, lib.phy.estimate_path_loss, conf, 50, 915, 3, 3, model) + + # TODO: hardcode some expected values for the calculations across different + # models, to detect unintended changes. This also requires verifying + # the calculations are correct. + def test_rootFinder(self): # double-check we can find the roots of some polynomials message = "sanity-check Newton-Raphson root-finding implementation" From bbb90a63dbce1570f24c677c0109b6681bc95b89 Mon Sep 17 00:00:00 2001 From: zandi Date: Fri, 1 May 2026 15:46:50 -0400 Subject: [PATCH 10/21] add packetsHeard, packetsRebroadcast fields to MeshNodeStats, track in MeshNode Just more data we track for later analysis. Not actually used yet, just updated. Mostly because it's in abandoned PR #33 --- lib/node.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/node.py b/lib/node.py index 0722b7d9..2e9c36ef 100644 --- a/lib/node.py +++ b/lib/node.py @@ -42,12 +42,17 @@ class MeshNodeStats: def __init__(self, nodeid: int): self.nodeid = nodeid + self.packetsHeard = 0 + self.packetsRebroadcast = 0 + def get_stats_dictionary(self) -> dict: """Return dictionary holding all internal data (may not need this) """ data = { "nodeid": self.nodeid, + "packetsHeard": self.packetsHeard, + "packetsRebroadcast": self.packetsRebroadcast, } return data @@ -117,6 +122,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.antennaGain = nodeConfig.antenna_gain self.period = nodeConfig.period + # using this more like a struct than a proper object. self.my_stats = MeshNodeStats(self.nodeid) self.messageSeq = sim_state.messageSeq @@ -480,10 +486,12 @@ def receive(self, in_pipe): self.env.process(self.transmit(pAck)) # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. elif not p.destId == self.nodeid and not ackReceived and not realAckReceived and p.hopLimit > 0: + self.my_stats.packetsHeard += 1 # packets which could potentially be rebroadcast # FloodingRouter: rebroadcast received packet if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: if not self.is_client_mute: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} rebroadcasts received packet {p.seq}") + self.my_stats.packetsRebroadcast += 1 pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) pNew.hopLimit = p.hopLimit - 1 self.packets.append(pNew) From b4c2d119251cd6af754ca1d24899627fe5c0ec0c Mon Sep 17 00:00:00 2001 From: zandi Date: Tue, 5 May 2026 11:41:11 -0400 Subject: [PATCH 11/21] Add unique sequence number to Packets, enrich log statements with it Additionally add some explanatory comments and some TODOs. This all comes from debugging the double-count issue fixed in #65, and I felt it was helpful to keep around for detailed debugging of packet flows --- lib/node.py | 25 ++++++++++++++----------- lib/packet.py | 9 ++++++++- lib/phy.py | 5 ++++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/node.py b/lib/node.py index 2e9c36ef..e41d45dd 100644 --- a/lib/node.py +++ b/lib/node.py @@ -388,41 +388,43 @@ def transmit(self, packet): # listen-before-talk from src/mesh/RadioLibInterface.cpp txTime = set_transmit_delay(self, packet) - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} picked wait time {txTime}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} schedules tx. Picked wait time {txTime}") yield self.env.timeout(txTime) # wait when currently receiving or transmitting, or channel is active while any(self.isReceiving) or self.isTransmitting or is_channel_active(self, self.env): - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} is busy Tx-ing {self.isTransmitting} or Rx-ing {any(self.isReceiving)} else channel busy!") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} delaying tx: busy Tx-ing {self.isTransmitting=} or Rx-ing {any(self.isReceiving)=}, else channel busy!") txTime = set_transmit_delay(self, packet) yield self.env.timeout(txTime) - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} ends waiting") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} ends waiting for scheduled tx") # check if you received an ACK for this message in the meantime self.was_seen_recently(packet, ownTransmit=True) if not self.perhaps_cancel_dupe(packet): # if you did not receive an ACK for this message in the meantime - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started low level send {packet.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started low level send {packet.unique_packet_seq} for msg {packet.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 for rx_node in self.nodes: if packet.sensedByN[rx_node.nodeid]: if check_collision(self.conf, self.env, packet, rx_node.nodeid, self.packetsAtN) == 0: self.packetsAtN[rx_node.nodeid].append(packet) + # packet's collidedAtN field is now computed/valid packet.startTime = self.env.now packet.endTime = self.env.now + packet.timeOnAir self.txAirUtilization += packet.timeOnAir self.airUtilization += packet.timeOnAir - self.bc_pipe.put(packet) + self.bc_pipe.put(packet) # queue for nodes to receive packet self.isTransmitting = True yield self.env.timeout(packet.timeOnAir) self.isTransmitting = False else: # received ACK: abort transmit, remove from packets generated - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} in the meantime received ACK, abort packet with seq. nr {packet.seq}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} in the meantime received ACK, abort packet with seq. nr {packet.unique_packet_seq} for msg {packet.seq}") self.packets.remove(packet) def receive(self, in_pipe): while True: p = yield in_pipe.get() + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} fetches packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId} from bc_pipe: sensed: {p.sensedByN[self.nodeid]} collided: {p.collidedAtN[self.nodeid]} on air: {p.onAirToN[self.nodeid]}") if p.sensedByN[self.nodeid] and p.onAirToN[self.nodeid]: # start of reception if p.collidedAtN[self.nodeid]: # this packet collided, so we can sense it but not decode it. @@ -430,11 +432,11 @@ def receive(self, in_pipe): # the 'end of transmission' branch p.onAirToN[self.nodeid] = False elif not self.isTransmitting: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.seq} from {p.txNodeId}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId}") p.onAirToN[self.nodeid] = False self.isReceiving.append(True) else: # if you were currently transmitting, you could not have sensed it - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} was transmitting, so could not receive packet {p.seq}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} was transmitting, so could not receive packet {p.unique_packet_seq} for msg {p.seq}") p.sensedByN[self.nodeid] = False p.onAirToN[self.nodeid] = False elif p.sensedByN[self.nodeid]: # end of reception @@ -443,11 +445,12 @@ def receive(self, in_pipe): except Exception: pass self.airUtilization += p.timeOnAir + # begin receiving packet fine, but a collision begins before we finish receiving. if p.collidedAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {p.unique_packet_seq}.") continue p.receivedAtN[self.nodeid] = True - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.unique_packet_seq} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log self.delays.append(self.env.now - p.genTime) # Update history of received packets @@ -490,7 +493,7 @@ def receive(self, in_pipe): # FloodingRouter: rebroadcast received packet if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: if not self.is_client_mute: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} rebroadcasts received packet {p.seq}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} schedules rebroadcast for received packet {p.unique_packet_seq} for msg {p.seq}") self.my_stats.packetsRebroadcast += 1 pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) pNew.hopLimit = p.hopLimit - 1 diff --git a/lib/packet.py b/lib/packet.py index 5008285e..cd36311e 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,5 +1,6 @@ import logging +from lib.discrete_event_sim_components import Counter from lib.phy import airtime, estimate_path_loss NODENUM_BROADCAST = 0xFFFFFFFF @@ -7,6 +8,8 @@ logger = logging.getLogger(__name__) class MeshPacket: + unique_packet_counter = Counter() + def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTime, wantAck, isAck, requestId, now, connectivity_map, baseline_pathloss_matrix): """Create a new packet and calculate which nodes sense and receive it @@ -26,6 +29,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi connectivity_map -- map of nodeid -> set of reachable nodeids baseline_pathloss_matrix -- pre-computed matrix of pathloss between nodes """ + self.unique_packet_seq = MeshPacket.unique_packet_counter.get() self.conf = conf self.origTxNodeId = origTxNodeId self.destId = destId @@ -39,7 +43,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.txpow = self.conf.PTX self.LplAtN = [0 for _ in range(self.conf.NR_NODES)] self.rssiAtN = [0 for _ in range(self.conf.NR_NODES)] - self.sensedByN = [False for _ in range(self.conf.NR_NODES)] + self.sensedByN = [False for _ in range(self.conf.NR_NODES)] # nodes which may possibly sense this packet self.detectedByN = [False for _ in range(self.conf.NR_NODES)] self.collidedAtN = [False for _ in range(self.conf.NR_NODES)] self.receivedAtN = [False for _ in range(self.conf.NR_NODES)] @@ -51,6 +55,9 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.bw = self.conf.current_preset["bw"] self.freq = self.conf.FREQ self.tx_node = next(n for n in nodes if n.nodeid == self.txNodeId) + + logger.debug(f"{self.now:.3f} Packet {self.unique_packet_seq} for msg {self.seq} generated by node {self.txNodeId}") + # calculate reception at all other nodes for rx_node in nodes: if rx_node.nodeid == self.txNodeId: diff --git a/lib/phy.py b/lib/phy.py index 8ead2152..1e9bd83e 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -27,7 +27,7 @@ def check_collision(conf, env, packet, rx_nodeId, packetsAtN): for other in packetsAtN[rx_nodeId]: if frequency_collision(packet, other) and sf_collision(packet, other): if timing_collision(conf, env, packet, other): - logger.debug(f'Packet nr. {packet.seq} from {packet.txNodeId} and packet nr. {other.seq} from {other.txNodeId} will collide!') + logger.debug(f'Packet nr. {packet.unique_packet_seq} from {packet.txNodeId} and packet nr. {other.unique_packet_seq} from {other.txNodeId} will collide!') c = power_collision(packet, other, rx_nodeId) # mark all the collided packets for p in c: @@ -179,6 +179,7 @@ def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): return Lpl +# TODO: take conf as parameter so we don't use this module's default conf def zero_link_budget(dist): return conf.PTX + 2 * conf.GL - estimate_path_loss(conf, dist, conf.FREQ) - conf.current_preset["sensitivity"] @@ -199,10 +200,12 @@ def rootFinder(func, x0, args=(), tol=1, maxiter=100): print("Warning: could not estimate max. range") return x +# TODO: take conf as parameter so we don't use this module's default conf def zero_link_budget_with_gain(dist, gain): return conf.PTX + gain - estimate_path_loss(conf, dist, conf.FREQ) - conf.current_preset["sensitivity"] def estimate_max_range(gain): return rootFinder(zero_link_budget_with_gain, 1500, args=(gain,)) +# TODO: take conf as parameter so we don't use this module's default conf MAXRANGE = rootFinder(zero_link_budget, 1500) From b4364c01e8bd36fc9c7da0f9965f830d579cd8c1 Mon Sep 17 00:00:00 2001 From: zandi Date: Tue, 5 May 2026 15:34:50 -0400 Subject: [PATCH 12/21] Add ability to reset MeshPacket unique packet counter, improve logging Ideally the unique packet counter would be part of a sim's state. For now, add a static method so we can reset the counter, which should let us have consistent start-from-0 unique packet sequence numbers between simulation runs. Perhaps later refactor this counter to be part of sim state so we don't have to manually remember to reset it. --- lib/discrete_event_sim.py | 3 +++ lib/packet.py | 4 ++++ lib/phy.py | 2 +- tests/test_discrete_event_sim.py | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 868244cb..854c5f9f 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -128,6 +128,9 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non 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.reset_packet_counter() + # internal global state which changes self.mutated_state = SimulationState(self.conf, self.env) diff --git a/lib/packet.py b/lib/packet.py index cd36311e..5061a6f6 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -10,6 +10,10 @@ class MeshPacket: unique_packet_counter = Counter() + @staticmethod + def reset_packet_counter(): + MeshPacket.unique_packet_counter = Counter() + def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTime, wantAck, isAck, requestId, now, connectivity_map, baseline_pathloss_matrix): """Create a new packet and calculate which nodes sense and receive it diff --git a/lib/phy.py b/lib/phy.py index 1e9bd83e..06958f7e 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -27,7 +27,7 @@ def check_collision(conf, env, packet, rx_nodeId, packetsAtN): for other in packetsAtN[rx_nodeId]: if frequency_collision(packet, other) and sf_collision(packet, other): if timing_collision(conf, env, packet, other): - logger.debug(f'Packet nr. {packet.unique_packet_seq} from {packet.txNodeId} and packet nr. {other.unique_packet_seq} from {other.txNodeId} will collide!') + logger.debug(f'Packet nr. {packet.unique_packet_seq} from {packet.txNodeId} and packet nr. {other.unique_packet_seq} from {other.txNodeId} will collide at node {rx_nodeId}!') c = power_collision(packet, other, rx_nodeId) # mark all the collided packets for p in c: diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index de943464..7118dd3f 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -213,7 +213,7 @@ def test_connectivity_map_optimization_is_consistent(self): ] for f in facets: - self.assertEqual(all_results[0][f], all_results[1][f], 'connectivity map optimization is consistent') + self.assertEqual(all_results[0][f], all_results[1][f], f'connectivity map optimization is inconsistent for facet {f}') # TODO: add default-skip GUI test? def test_discrete_sim_ten_nodes(self): From 861dd7c50b4e1faa79c53a453b7436af79cb32af Mon Sep 17 00:00:00 2001 From: zandi Date: Tue, 5 May 2026 15:56:21 -0400 Subject: [PATCH 13/21] Make asymmetric link simulation dynamic; move into MeshPacket init Previously asymmetric links were simulated with static, pre-computed offsets to each potential link. Instead, add a random offset to the path loss at each potential receiver on MeshPacket initialization. This makes the asymmetric link simulation dynamic to better simulate transient conditions like moving clutter or other uncontrollable factors. To keep the simulation consistent between the connectivity map optimization being enabled/disabled, do 'useless' asym_rng calls so runs can be deterministic and consistent across that optimization being enabled/disabled. We tune the asymmetric standard deviation to seemingly be quite low so that all RSSIs are still within the connectivity_map margin. Exceeding that margin can lead to inconsistent simulations depending on the connectivity map optimization being enabled/disabled. Log if this happens. Always compute the connectivity map on simulation init and remove the call to setup_asymmetric_links. The asymmetric link generation was already doing an O(n^2) iteration of nodes so we don't save on complexity, and the functionality we still want from setup_asymmetric_links (totalPairs/noLink) can be rolled into initialize_connectivity_map. Rip out everywhere we can find the now-obsolete symmetricLinks/asymmetricLinks variables and whatever depends on them. Finally, update our 'gold standard' test, since this necessarily changes the results. --- batchSim.py | 18 ------------ lib/config.py | 7 ++--- lib/discrete_event_sim.py | 33 ++++++++++----------- lib/discrete_event_sim_components.py | 3 +- lib/node.py | 2 +- lib/packet.py | 23 ++++++++++++++- loraMesh.py | 4 --- tests/test_discrete_event_sim.py | 43 ++++++++-------------------- 8 files changed, 56 insertions(+), 77 deletions(-) diff --git a/batchSim.py b/batchSim.py index f61978be..d61e3ece 100644 --- a/batchSim.py +++ b/batchSim.py @@ -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 @@ -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] = [] @@ -201,8 +197,6 @@ def __init__(self, conf, x, y): reachabilityStds = [] usefulness = [] usefulnessStds = [] - asymmetricLinkRateAll = [] - symmetricLinkRateAll = [] noLinkRateAll = [] # Inner loop for each nrNodes @@ -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") @@ -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"] @@ -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 @@ -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 @@ -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, @@ -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 diff --git a/lib/config.py b/lib/config.py index c56b9528..73baeced 100644 --- a/lib/config.py +++ b/lib/config.py @@ -30,6 +30,8 @@ def __init__(self): ### 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 @@ -420,10 +422,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 ######## diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 854c5f9f..c29df182 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -6,7 +6,6 @@ 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 @@ -97,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: @@ -129,6 +124,7 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non 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 @@ -140,9 +136,12 @@ 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 - if self.conf.ENABLE_CONNECTIVITY_MAP: - self.initialize_connectivity_map() + # 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: @@ -157,10 +156,6 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non 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"link offsets: {self.conf.LINK_OFFSET}") logger.debug(f"connectivity map: {self.mutated_state.connectivity_map}") if self.graph is not None and self.conf.MOVEMENT_ENABLED: @@ -197,8 +192,6 @@ 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, } @@ -218,6 +211,8 @@ def initialize_connectivity_map(self): if tx_node.node_id == rx_node.node_id: continue # skip self + self.data_tracking.totalPairs += 1 + # compute path loss tx_power = self.conf.PTX # can move this into NodeConfig w/ default dist = tx_node.position.euclidean_distance(rx_node.position) @@ -225,9 +220,15 @@ def initialize_connectivity_map(self): rssi = tx_power + tx_node.antenna_gain + rx_node.antenna_gain - pl # compare with extra margin (set based on 10-node standard test) - if rssi + 8 > self.conf.current_preset['sensitivity']: + 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 diff --git a/lib/discrete_event_sim_components.py b/lib/discrete_event_sim_components.py index 6354d074..65fa5755 100644 --- a/lib/discrete_event_sim_components.py +++ b/lib/discrete_event_sim_components.py @@ -58,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 diff --git a/lib/node.py b/lib/node.py index e41d45dd..591bd720 100644 --- a/lib/node.py +++ b/lib/node.py @@ -258,7 +258,7 @@ def move_node(self): rssi = tx_power + self.antennaGain + rx_node.antennaGain - pl # compare with extra margin (set based on 10-node standard test) - if rssi + 8 > self.conf.current_preset['sensitivity']: + if rssi + self.conf.CONNECTIVITY_MAP_RSSI_MARGIN > self.conf.current_preset['sensitivity']: new_reachable_set.add(rx_node.nodeid) # cache path loss (it is symmetric, and static until one of the nodes moves) diff --git a/lib/packet.py b/lib/packet.py index 5061a6f6..03f15dc9 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,4 +1,5 @@ import logging +import random from lib.discrete_event_sim_components import Counter from lib.phy import airtime, estimate_path_loss @@ -9,6 +10,11 @@ class MeshPacket: unique_packet_counter = Counter() + asym_rng = random.Random(44) # same default seed as in lib/config.py + + @staticmethod + def seed_asym_rng(seed): + MeshPacket.asym_rng.seed(seed) @staticmethod def reset_packet_counter(): @@ -73,6 +79,15 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi # sense each other. if self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): logger.debug(f"skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") + # Each tx -> rx computation we skip gets the asrm_rng one call out + # of sync between simulations with and without the connectivity + # map optimization. Thus particular tx -> rx calculations + # change between the optimization, which can lead to changes + # in sim behavior between the optimization being on/off, leading + # to inconsistencies beteen the optimization being on/off. + # + # Keep things balanced by 'unnecessarily' calling the rng here. + MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) continue if self.conf.ENABLE_CONNECTIVITY_MAP: @@ -82,7 +97,13 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) baseline_pathloss = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) - offset = self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)] + if conf.MODEL_ASYMMETRIC_LINKS: + offset = MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) + logger.debug(f"packet {self.unique_packet_seq} for msg {self.seq} has asym offset {offset} dB") + if abs(offset) > conf.CONNECTIVITY_MAP_RSSI_MARGIN: + logger.debug(f"packet {self.unique_packet_counter} has asymmetric RSSI offset {offset} which is outside margin. This will lead to inconsistent results with the connectivity map optimization.") + else: + offset = 0 self.LplAtN[rx_node.nodeid] = baseline_pathloss + offset self.rssiAtN[rx_node.nodeid] = self.txpow + self.tx_node.antennaGain + rx_node.antennaGain - self.LplAtN[rx_node.nodeid] if self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["sensitivity"]: diff --git a/loraMesh.py b/loraMesh.py index 07c2f38f..3207d370 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -222,11 +222,7 @@ def run_simulation(conf, node_config): print("Number of packets dropped by delay/hop limit:", delayDropped) if conf.MODEL_ASYMMETRIC_LINKS: - asymmetricLinkRate = results['asymmetricLinkRate'] - symmetricLinkRate = results['symmetricLinkRate'] noLinkRate = results['noLinkRate'] - print("Asymmetric links:", round(asymmetricLinkRate * 100, 2), '%') - print("Symmetric links:", round(symmetricLinkRate * 100, 2), '%') print("No links:", round(noLinkRate * 100, 2), '%') if conf.MOVEMENT_ENABLED: diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 7118dd3f..0f52113a 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -45,8 +45,6 @@ def test_simulation_results_finalization(self): # - delays (list of ...floats?) # - messageSeq - total # of messages # - totalPairs (int) - # - asymmetricLinks (int) - # - symmetricLinks (int) # - noLinks (int) # Things which are computed (keys in results): @@ -64,8 +62,6 @@ def test_simulation_results_finalization(self): # - nodereach *+ # - usefulness + # - delayDropped - # - symmetricLinkRate *+ - # - asymmetricLinkRate *+ # - noLinkRate *+ # - movingNodes * # - gpsEnabled * @@ -112,10 +108,7 @@ def __init__(self, num_nodes: int): r['delays'] = [1.0 for _ in range(10)] r['messageSeq'] = 10 # total # of messages (not packets) - # as set up, totalPairs = symmetricLinks + asymmetricLinks + noLinks r['totalPairs'] = 3 - r['asymmetricLinks'] = 0 - r['symmetricLinks'] = 0 r['noLinks'] = 0 sim_results = lib.discrete_event_sim.SimulationResults(r) @@ -141,10 +134,6 @@ def __init__(self, num_nodes: int): #self.assertIsNotNone(sim_results['x'], 'x is created') # check rate calculations in [0, 1] (assuming we mocked sane values) - self.assertLessEqual(0.0, sim_results['asymmetricLinkRate'], 'calculated asymmetricLinkRate is above or equal to 0') - self.assertLessEqual(sim_results['asymmetricLinkRate'], 1.0, 'calculated asymmetricLinkRate is below or equal to 1') - self.assertLessEqual(0.0, sim_results['symmetricLinkRate'], 'calculated symmetricLinkRate is above or equal to 0') - self.assertLessEqual(sim_results['symmetricLinkRate'], 1.0, 'calculated symmetricLinkRate is below or equal to 1') self.assertLessEqual(0.0, sim_results['noLinkRate'], 'calculated noLinkRate is above or equal to 0') self.assertLessEqual(sim_results['noLinkRate'], 1.0, 'calculated noLinkRate is below or equal to 1') @@ -205,8 +194,6 @@ def test_connectivity_map_optimization_is_consistent(self): 'nrReceived', 'usefulness', 'delayDropped', - 'asymmetricLinkRate', - 'symmetricLinkRate', 'noLinkRate', 'movingNodes', 'gpsEnabled', @@ -249,8 +236,6 @@ def test_discrete_sim_ten_nodes(self): messages = results["messages"] delays = results["delays"] totalPairs = results["totalPairs"] - symmetricLinks = results["symmetricLinks"] - asymmetricLinks = results["asymmetricLinks"] noLinks = results["noLinks"] nodes = results["nodes"] @@ -263,39 +248,35 @@ def test_discrete_sim_ten_nodes(self): # and modify your changes, or to update the hardcoded "known good" # simulation results is up to your judgement for which is # appropriate. Be cautious! - self.assertEqual(messageSeq, 183, "expected number of messages created") + self.assertEqual(messageSeq, 174, "expected number of messages created") sent = results['sent'] potentialReceivers = results['potentialReceivers'] - self.assertEqual(sent, 895, "expected number of packets sent") - self.assertEqual(potentialReceivers, 8055, "expected number of potential receivers") + self.assertEqual(sent, 853, "expected number of packets sent") + self.assertEqual(potentialReceivers, 7677, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 332, "expected number of collisions") + self.assertEqual(nrCollisions, 301, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 3173, "expected number of packets sensed") + self.assertEqual(nrSensed, 2870, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2824, "expected number of packets received") + self.assertEqual(nrReceived, 2567, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 11174.95, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 13030.81, "expected rounded delay average") txAirUtilizationRate = results['txAirUtilizationRate'] - self.assertEqual(round(txAirUtilizationRate * 100, 2), 5.15, "expected rounded average tx air utilization") + self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.93, "expected rounded average tx air utilization") nodeReach = results['nodeReach'] - self.assertEqual(round(nodeReach*100, 2), 85.55, "expected rounded percentage of nodes reached") + self.assertEqual(round(nodeReach*100, 2), 79.76, "expected rounded percentage of nodes reached") usefulness = results['usefulness'] - self.assertEqual(round(usefulness*100, 2), 49.89, "expected rounded 'usefulness' percentage") + self.assertEqual(round(usefulness*100, 2), 48.66, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1280, "expected number of packets dropped") + self.assertEqual(delayDropped, 1173, "expected number of packets dropped") # default config has both asymmetric links and movement enabled - asymmetricLinkRate = results['asymmetricLinkRate'] - self.assertEqual(round(asymmetricLinkRate * 100, 2), 8.89, "expected rounded percentage of asymmetric links") - symmetricLinkRate = results['symmetricLinkRate'] - self.assertEqual(round(symmetricLinkRate * 100, 2), 42.22, "expected rounded percentage of symmetric links") noLinkRate = results['noLinkRate'] - self.assertEqual(round(noLinkRate * 100, 2), 48.89, "expected rounded percentage of 'no' links") + self.assertEqual(round(noLinkRate * 100, 2), 55.56, "expected rounded percentage of 'no' links") movingNodes = results['movingNodes'] self.assertEqual(movingNodes, 4, "expected number of moving nodes") From b1e72b2fdd5dc4cea495918fbffc542b83f178e1 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 7 May 2026 14:20:52 -0400 Subject: [PATCH 14/21] Move pathloss/rssi computation into NodeConfig method, add member variables to NodeConfig This deduplicates crucial code for computing rssi & pathloss between nodes. We put this in NodeConfig so we can use it in discrete sim setup before nodes are created. This requires moving some extra variables into NodeConfig. Also add the NodeConfig provided to create a MeshNode as a member variable so we can access the computation method, and supply the rx node's config to it. Many other member variables are already convenience bindings into this object, so may as well bind to the object itself. Add some docstrings along the way. --- lib/discrete_event_sim.py | 6 +--- lib/node.py | 70 ++++++++++++++++++++++++++++++++++----- loraMesh.py | 3 +- tests/test_node.py | 16 +++++++++ 4 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 tests/test_node.py diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index c29df182..2eedc3a7 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -213,11 +213,7 @@ def initialize_connectivity_map(self): self.data_tracking.totalPairs += 1 - # compute path loss - tx_power = self.conf.PTX # can move this into NodeConfig w/ default - dist = tx_node.position.euclidean_distance(rx_node.position) - pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, tx_node.position.z, rx_node.position.z) - rssi = tx_power + tx_node.antenna_gain + rx_node.antenna_gain - pl + (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']: diff --git a/lib/node.py b/lib/node.py index 591bd720..25f863a0 100644 --- a/lib/node.py +++ b/lib/node.py @@ -59,23 +59,41 @@ def get_stats_dictionary(self) -> dict: class NodeConfig: """Specific configuration for a node """ - def __init__(self, node_id: int, position: Point, period: int, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False): + def __init__(self, node_id: int, position: Point, period: int, tx_power: int, freq: float, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False): + """Initial configuration of a node + + Arguments: + node_id -- unique integer id of node (used as list index) + position -- beginning Point(x, y, z) location of node + period -- how often to generate messages. Average of an exponential distribution. + tx_power -- transmit power in dB + freq -- frequency in Hz + role -- Meshtastic firmware role. Default: CLIENT + antenna_gain -- antenna gain in dBi. Default 0 + hop_limit -- hop limit. Default 3 + neighbor_info -- if neighbor info is enabled. Default False + """ self.node_id = node_id self.position = position.copy() # make sure we keep our own point self.period = period + self.tx_power = tx_power + self.freq = freq self.role = role self.antenna_gain = antenna_gain self.hop_limit = hop_limit self.neighbor_info = neighbor_info @classmethod - def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int): + def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int, tx_power: int, freq: float): """create NodeConfig from a node dict as returned from gen_scenario. You probably want to iterate over the keys that function gives you and pass individual values indexed by them to this method. Arguments: node_dict -- dictionary defining a single node. From gen_scenario. + period -- how often to generate messages. Average of an exponential distribution. + tx_power -- transmit power in dB + freq -- frequency in Hz """ nd = node_dict position = Point(nd['x'], nd['y'], nd['z']) @@ -100,14 +118,50 @@ def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int): else: role = MESHTASTIC_ROLE.CLIENT - return NodeConfig(node_id, position, period, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo']) + return NodeConfig(node_id, position, period, tx_power, freq, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo']) + + def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, float): + """Compute RSSI and pathloss from this node config as the transmitting node + to a receiving node, using a given config for various physical parameters. + + Arguments: + rx_nodeconf -- NodeConfig of node we are transmitting to + conf -- Config object specifying various physical parameters + + Returns: + (rssi, pathloss) -- rssi at rx_nodeconf, and pathloss along the path + """ + if self.node_id == rx_nodeconf.node_id: + raise ValueError(f"Calculating rssi/pathloss between identical nodes is invalid. Node ID {self.node_id}") + + # compute path loss + dist = self.position.euclidean_distance(rx_nodeconf.position) + pl = estimate_path_loss(conf, dist, self.freq, self.position.z, rx_nodeconf.position.z) + rssi = self.tx_power + self.antenna_gain + rx_nodeconf.antenna_gain - pl + + return (rssi, pl) class MeshNode: """Class containing all the particular state of a MeshNode, references to necessary external resources like the simpy env, and process functions for simulation """ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDataTracking, nodeConfig: NodeConfig): + """Create a MeshNode. Houses all node-specific state, sim processes, and + connections to broader sim environment and data collection. + + Arguments: + conf -- Config object of various sim parameters + sim_state -- object holding all mutating state of the simulation + data_tracking -- object holding data collected from sim, doesn't influence state. + nodeConfig -- initial configuration of node + """ self.conf = conf + + # initially to move repeated rssi/pathloss computation into NodeConfig class. + # maybe move other state/config (role, period, etc.) into here explicitly + # rather than binding to a member variable + self.node_conf = nodeConfig + self.nodeid = nodeConfig.node_id # set up internal RNGs @@ -116,7 +170,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.rebroadcastRng = random.Random() # require the user to specify a node configuration now, including position - self.position = nodeConfig.position.copy() # make sure we have our own point + self.position = nodeConfig.position # explicitly use position in node_conf self.role = nodeConfig.role self.hopLimit = nodeConfig.hop_limit self.antennaGain = nodeConfig.antenna_gain @@ -252,10 +306,8 @@ def move_node(self): for rx_node in self.nodes: if rx_node.nodeid == self.nodeid: continue # skip self - tx_power = self.conf.PTX # can move this into NodeConfig w/ default - dist = self.position.euclidean_distance(rx_node.position) - pl = estimate_path_loss(self.conf, dist, self.conf.FREQ, self.position.z, rx_node.position.z) - rssi = tx_power + self.antennaGain + rx_node.antennaGain - pl + + (rssi, pl) = self.node_conf.compute_rssi_and_pathloss_to(rx_node.node_conf, 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']: @@ -541,6 +593,6 @@ def default_generate_node_list(conf: Config) -> [NodeConfig]: role = MESHTASTIC_ROLE.CLIENT # make NodeConfig object to pass to MeshNode constructor - node_configs.append(NodeConfig(i, position, conf.PERIOD, role)) + node_configs.append(NodeConfig(i, position, conf.PERIOD, conf.PTX, conf.FREQ, role)) return node_configs diff --git a/loraMesh.py b/loraMesh.py index 3207d370..ac54e536 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -103,7 +103,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: with open(os.path.join("out", parsed_arguments.from_file), 'r', encoding="utf-8") as file: raw_config = yaml.load(file, Loader=yaml.FullLoader) config = [ - NodeConfig.from_gen_scenario_output(node_id, node_config, period) + # transmit power and frequency not previously saved. Use defaults from Config. + NodeConfig.from_gen_scenario_output(node_id, node_config, period, conf.PTX, conf.FREQ) for node_id, node_config in raw_config.items() ] nr_nodes = len(config) diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 00000000..75f4c2e4 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,16 @@ +import unittest + +import lib.node + +class TestNodeConf(unittest.TestCase): + def test_reject_rssi_and_pathloss_between_identical_nodes(self): + + from lib.config import CONFIG + from lib.point import Point + conf = CONFIG + + # reasonable values + nodeconf = lib.node.NodeConfig(0, Point(0, 0, 0), 1, 30, 902e6) + + with self.assertRaises(ValueError, msg="cannot compute rssi/pathloss between the same nodes (by id)"): + nodeconf.compute_rssi_and_pathloss_to(nodeconf, conf) From 05fb4b9eadfd93655853172255a3f71d4a726056 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 7 May 2026 14:36:29 -0400 Subject: [PATCH 15/21] More explicitly use node_conf binding in MeshNode init These convenience bindings are useful for not having `self.node_conf` repeated all over, but more intentionally show we consider the node_conf a mutable member variable by referencing `self.node_conf` rather than the `nodeConfig` parameter. Later we may add a copy() so we can give the MeshNode its own node config and not have it risk mutating some external one provided to the constructor. --- lib/node.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/node.py b/lib/node.py index 25f863a0..ea966f07 100644 --- a/lib/node.py +++ b/lib/node.py @@ -162,7 +162,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa # rather than binding to a member variable self.node_conf = nodeConfig - self.nodeid = nodeConfig.node_id + self.nodeid = self.node_conf.node_id # set up internal RNGs self.moveRng = random.Random(self.nodeid) @@ -170,11 +170,11 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.rebroadcastRng = random.Random() # require the user to specify a node configuration now, including position - self.position = nodeConfig.position # explicitly use position in node_conf - self.role = nodeConfig.role - self.hopLimit = nodeConfig.hop_limit - self.antennaGain = nodeConfig.antenna_gain - self.period = nodeConfig.period + self.position = self.node_conf.position # explicitly use position in node_conf + self.role = self.node_conf.role + self.hopLimit = self.node_conf.hop_limit + self.antennaGain = self.node_conf.antenna_gain + self.period = self.node_conf.period # using this more like a struct than a proper object. self.my_stats = MeshNodeStats(self.nodeid) From e2a7800ee1522c5d6f99cc12eed4f99d4c40e2eb Mon Sep 17 00:00:00 2001 From: zandi Date: Tue, 12 May 2026 20:03:29 -0400 Subject: [PATCH 16/21] Update CW-related computations to match firmware tag v2.7.15.567b8ea The way CW windows and delays are calculated must have changed, the simulation doesn't seem to match current stable firmware. Update the constants and computations necessary so we match current stable firmware. At time of writing, tag v2.7.15.567b8ea is the most recent 'stable' release on the meshtastic website. This necessarily changes the simulation and thus the results, so update the 'gold standard' 10-node test to match. Additionally, add and improve logging around CW selection. --- lib/mac.py | 31 ++++++++++++++++++++----------- lib/phy.py | 18 +++++++++++++++--- tests/test_discrete_event_sim.py | 22 +++++++++++----------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/lib/mac.py b/lib/mac.py index 390b0145..46befce2 100644 --- a/lib/mac.py +++ b/lib/mac.py @@ -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 @@ -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() @@ -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. diff --git a/lib/phy.py b/lib/phy.py index 06958f7e..4c901c52 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -4,16 +4,28 @@ from lib.config import CONFIG +# TODO: if our config deviates from the default, we WILL get incorrect results. +# refactor things to take a config object conf = CONFIG logger = logging.getLogger(__name__) - - +# checked as of tag v2.7.15.567b8ea in meshtastic-firmware repo +NUM_SYM_CAD = 2 +NUM_SYM_CAD_24GHZ = 4 # CAD duration + airPropagationTime+TxRxTurnaround+MACprocessing def get_current_slot_time(): - return 8.5 * (2.0 ** conf.current_preset["sf"]) / conf.current_preset["bw"] * 1000 + 0.2 + 0.4 + 7 + # all times in ms + sum_prop_turnaround_mac_time = 0.2 + 0.4 + 7 + symbol_time = (2.0 ** conf.current_preset["sf"]) / conf.current_preset["bw"] + + if conf.REGION['wide_lora']: + # TODO: currently wide_lora isn't fully implemented + # currently only 2.4GHz LoRa + return (NUM_SYM_CAD_24GHZ + (2 * conf.current_preset['sf'] + 3) / 32) * symbol_time + sum_prop_turnaround_mac_time + else: + return max(2.25, NUM_SYM_CAD + 0.5) * symbol_time + sum_prop_turnaround_mac_time def check_collision(conf, env, packet, rx_nodeId, packetsAtN): diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 0f52113a..928426a2 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -248,32 +248,32 @@ def test_discrete_sim_ten_nodes(self): # and modify your changes, or to update the hardcoded "known good" # simulation results is up to your judgement for which is # appropriate. Be cautious! - self.assertEqual(messageSeq, 174, "expected number of messages created") + self.assertEqual(messageSeq, 179, "expected number of messages created") sent = results['sent'] potentialReceivers = results['potentialReceivers'] - self.assertEqual(sent, 853, "expected number of packets sent") - self.assertEqual(potentialReceivers, 7677, "expected number of potential receivers") + self.assertEqual(sent, 789, "expected number of packets sent") + self.assertEqual(potentialReceivers, 7101, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 301, "expected number of collisions") + self.assertEqual(nrCollisions, 336, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 2870, "expected number of packets sensed") + self.assertEqual(nrSensed, 2722, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2567, "expected number of packets received") + self.assertEqual(nrReceived, 2388, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 13030.81, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 3750.26, "expected rounded delay average") txAirUtilizationRate = results['txAirUtilizationRate'] - self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.93, "expected rounded average tx air utilization") + self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.57, "expected rounded average tx air utilization") nodeReach = results['nodeReach'] - self.assertEqual(round(nodeReach*100, 2), 79.76, "expected rounded percentage of nodes reached") + self.assertEqual(round(nodeReach*100, 2), 75.85, "expected rounded percentage of nodes reached") usefulness = results['usefulness'] - self.assertEqual(round(usefulness*100, 2), 48.66, "expected rounded 'usefulness' percentage") + self.assertEqual(round(usefulness*100, 2), 51.17, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1173, "expected number of packets dropped") + self.assertEqual(delayDropped, 1004, "expected number of packets dropped") # default config has both asymmetric links and movement enabled noLinkRate = results['noLinkRate'] self.assertEqual(round(noLinkRate * 100, 2), 55.56, "expected rounded percentage of 'no' links") From 7f958833a915bb81225a0112e076eb8b247c2fc2 Mon Sep 17 00:00:00 2001 From: zandi Date: Tue, 12 May 2026 20:12:15 -0400 Subject: [PATCH 17/21] add missing sim time to debug log message in node.py --- lib/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/node.py b/lib/node.py index ea966f07..459eab30 100644 --- a/lib/node.py +++ b/lib/node.py @@ -321,7 +321,7 @@ def move_node(self): lost_nodes = old_reachable_set.difference(new_reachable_set) gained_nodes = new_reachable_set.difference(old_reachable_set) - logger.debug(f"node {self.nodeid} moved. Connectivity change: -{len(lost_nodes)}, +{len(gained_nodes)}.") + logger.debug(f"{self.env.now:.3f} node {self.nodeid} moved. Connectivity change: -{len(lost_nodes)}, +{len(gained_nodes)}.") # TODO: -0, +0 case is very common. Skip what we can in this case. # update this node's connectivity map self.connectivity_map[self.nodeid] = new_reachable_set From f392179d45981657994dd145f1c868610fd4e1f1 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 14 May 2026 14:08:31 -0400 Subject: [PATCH 18/21] Create utils directory, move time-testing script into it This directory can be for miscellaneous utilities that are worth keeping around, but are meant to be used by developers or testers rather than meshtasticator proper, or general users. --- time-discrete-sim.py => utils/time-discrete-sim.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename time-discrete-sim.py => utils/time-discrete-sim.py (100%) diff --git a/time-discrete-sim.py b/utils/time-discrete-sim.py similarity index 100% rename from time-discrete-sim.py rename to utils/time-discrete-sim.py From 0d93c3f5cabce29252f29551b9e301820f381f15 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 14 May 2026 14:21:59 -0400 Subject: [PATCH 19/21] Gate synchronizing asym_rng call behind MODEL_ASYMMETRIC_LINKS config knob. Just cleaner. This call is to keep simulations using asymmetric links consistent between having the connectivity map optimization on/off. It's unnecessary to do this when not simulating asymmetric links, although doing so doesn't change simulation behavior. --- lib/packet.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/packet.py b/lib/packet.py index 03f15dc9..556523a1 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -79,15 +79,16 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi # sense each other. if self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): logger.debug(f"skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") - # Each tx -> rx computation we skip gets the asrm_rng one call out - # of sync between simulations with and without the connectivity - # map optimization. Thus particular tx -> rx calculations - # change between the optimization, which can lead to changes - # in sim behavior between the optimization being on/off, leading - # to inconsistencies beteen the optimization being on/off. - # - # Keep things balanced by 'unnecessarily' calling the rng here. - MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) + if conf.MODEL_ASYMMETRIC_LINKS: + # Each tx -> rx computation we skip gets the asrm_rng one call out + # of sync between simulations with and without the connectivity + # map optimization. Thus particular tx -> rx calculations + # change between the optimization, which can lead to changes + # in sim behavior between the optimization being on/off, leading + # to inconsistencies beteen the optimization being on/off. + # + # Keep things balanced by 'unnecessarily' calling the rng here. + MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) continue if self.conf.ENABLE_CONNECTIVITY_MAP: From 7f87c4beec96a224263d1644b90f890d06211779 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 14 May 2026 14:23:45 -0400 Subject: [PATCH 20/21] Improve logging in packet. Add `now` to log lines, and change a debug log to a warning log, because it's warning about a condition which will lead to inconsistent or unexpected simulation results. --- lib/packet.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/packet.py b/lib/packet.py index 556523a1..cdbf1c1e 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -78,7 +78,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi # so is always an accurate map of what nodes could (with extra margin) even # sense each other. if self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): - logger.debug(f"skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") + logger.debug(f"{self.now:.3f} skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") if conf.MODEL_ASYMMETRIC_LINKS: # Each tx -> rx computation we skip gets the asrm_rng one call out # of sync between simulations with and without the connectivity @@ -100,9 +100,9 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi if conf.MODEL_ASYMMETRIC_LINKS: offset = MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) - logger.debug(f"packet {self.unique_packet_seq} for msg {self.seq} has asym offset {offset} dB") + logger.debug(f"{self.now:.3f} packet {self.unique_packet_seq} for msg {self.seq} has asym offset {offset} dB") if abs(offset) > conf.CONNECTIVITY_MAP_RSSI_MARGIN: - logger.debug(f"packet {self.unique_packet_counter} has asymmetric RSSI offset {offset} which is outside margin. This will lead to inconsistent results with the connectivity map optimization.") + logger.warning(f"{self.now:.3f} packet {self.unique_packet_counter} has asymmetric RSSI offset {offset} which is outside margin. This will lead to inconsistent results with the connectivity map optimization.") else: offset = 0 self.LplAtN[rx_node.nodeid] = baseline_pathloss + offset From 15cd8a6a35aed9ef0c78d2d384dfa4dfafbea7f3 Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 14 May 2026 15:35:30 -0400 Subject: [PATCH 21/21] Fix units for bandwidth in get_current_slot_time The firwmare stores bandwidth in KHz, but our config stores bandwidth in Hz. So, scale it to convert from Hz to KHz so our calculation can match the firmware. This slightly changes the 'gold standard' 10-node unit test, so also update that. --- lib/config.py | 1 + lib/phy.py | 5 +++-- tests/test_discrete_event_sim.py | 22 +++++++++++----------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/config.py b/lib/config.py index 73baeced..8765daa8 100644 --- a/lib/config.py +++ b/lib/config.py @@ -316,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, diff --git a/lib/phy.py b/lib/phy.py index 4c901c52..cc2a8212 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -15,10 +15,11 @@ NUM_SYM_CAD_24GHZ = 4 # CAD duration + airPropagationTime+TxRxTurnaround+MACprocessing -def get_current_slot_time(): +def get_current_slot_time(): # from RadioInterface::computeSlotTimeMsec # all times in ms sum_prop_turnaround_mac_time = 0.2 + 0.4 + 7 - symbol_time = (2.0 ** conf.current_preset["sf"]) / conf.current_preset["bw"] + firmware_bw = conf.current_preset["bw"] / 1000 # convert Hz to KHz to match firmware + symbol_time = (2.0 ** conf.current_preset["sf"]) / firmware_bw if conf.REGION['wide_lora']: # TODO: currently wide_lora isn't fully implemented diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 928426a2..9ca7787e 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -248,32 +248,32 @@ def test_discrete_sim_ten_nodes(self): # and modify your changes, or to update the hardcoded "known good" # simulation results is up to your judgement for which is # appropriate. Be cautious! - self.assertEqual(messageSeq, 179, "expected number of messages created") + self.assertEqual(messageSeq, 180, "expected number of messages created") sent = results['sent'] potentialReceivers = results['potentialReceivers'] - self.assertEqual(sent, 789, "expected number of packets sent") - self.assertEqual(potentialReceivers, 7101, "expected number of potential receivers") + self.assertEqual(sent, 834, "expected number of packets sent") + self.assertEqual(potentialReceivers, 7506, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 336, "expected number of collisions") + self.assertEqual(nrCollisions, 323, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 2722, "expected number of packets sensed") + self.assertEqual(nrSensed, 2895, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2388, "expected number of packets received") + self.assertEqual(nrReceived, 2573, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 3750.26, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 6403.13, "expected rounded delay average") txAirUtilizationRate = results['txAirUtilizationRate'] - self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.57, "expected rounded average tx air utilization") + self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.83, "expected rounded average tx air utilization") nodeReach = results['nodeReach'] - self.assertEqual(round(nodeReach*100, 2), 75.85, "expected rounded percentage of nodes reached") + self.assertEqual(round(nodeReach*100, 2), 79.57, "expected rounded percentage of nodes reached") usefulness = results['usefulness'] - self.assertEqual(round(usefulness*100, 2), 51.17, "expected rounded 'usefulness' percentage") + self.assertEqual(round(usefulness*100, 2), 50.1, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1004, "expected number of packets dropped") + self.assertEqual(delayDropped, 1143, "expected number of packets dropped") # default config has both asymmetric links and movement enabled noLinkRate = results['noLinkRate'] self.assertEqual(round(noLinkRate * 100, 2), 55.56, "expected rounded percentage of 'no' links")