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/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/config.py b/lib/config.py index 1db6c72c..8765daa8 100644 --- a/lib/config.py +++ b/lib/config.py @@ -29,6 +29,9 @@ def __init__(self): self.ONE_HR_INTERVAL = self.ONE_MIN_INTERVAL * 60 ### Discrete-event specific ### + self.ENABLE_CONNECTIVITY_MAP = True # use the connectivity map optimization + self.CONNECTIVITY_MAP_RSSI_MARGIN = 8 + self.MODEM_PRESET = "LONG_FAST" # LoRa modem preset to use (default LONG_FAST matches firmware) self.PERIOD = 100 * self.ONE_SECOND_INTERVAL # mean period of generating a new message with exponential distribution in ms self.PACKETLENGTH = 40 # payload in bytes @@ -313,6 +316,7 @@ def __init__(self): # minimum sensitivity from https://www.rfwireless-world.com/calculators/LoRa-Sensitivity-Calculator.html, using a Noise Figure (NF) of 6dB # minimum received power for CAD: 3dB less than sensitivity # TODO: the 'bw' parameter is changed based on the region's 'wide_lora' setting. Implement this. + # Note: we store bandwidth here in Hz, but the firmware uses KHz. self.MODEM_PRESETS = { "SHORT_TURBO": { "bw": 500e3, @@ -419,10 +423,7 @@ def __init__(self): # Adds a random offset to the link quality of each link self.MODEL_ASYMMETRIC_LINKS = True self.MODEL_ASYMMETRIC_LINKS_MEAN = 0 - self.MODEL_ASYMMETRIC_LINKS_STDDEV = 3 - # Stores the offset for each link - # Populated when the simulator first starts - self.LINK_OFFSET = {} + self.MODEL_ASYMMETRIC_LINKS_STDDEV = 2 ################################################# ####### MOVING NODE SIMULATION VARIABLES ######## diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 4ad492e6..2eedc3a7 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 @@ -5,13 +6,14 @@ from simpy import Environment as SimpyEnvironment import numpy as np -from lib.common import setup_asymmetric_links from lib.config import Config from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking from lib.node import MeshNode, NodeConfig if TYPE_CHECKING: from lib.gui import Graph +from lib.packet import MeshPacket +from lib.phy import estimate_path_loss logger = logging.getLogger(__name__) @@ -94,12 +96,8 @@ def finalize(self, conf: Config): self.results["delayDropped"] = sum(n.droppedByDelay for n in nodes) - if conf.MODEL_ASYMMETRIC_LINKS and self.results["totalPairs"] != 0: - asymmetricLinkRate = self.results["asymmetricLinks"] / self.results["totalPairs"] - symmetricLinkRate = self.results["symmetricLinks"] / self.results["totalPairs"] + if self.results["totalPairs"] != 0: noLinkRate = self.results["noLinks"] / self.results["totalPairs"] - self.results["asymmetricLinkRate"] = asymmetricLinkRate - self.results["symmetricLinkRate"] = symmetricLinkRate self.results["noLinkRate"] = noLinkRate if conf.MOVEMENT_ENABLED: @@ -122,9 +120,13 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non # set constant state/initial state from parameters self.env = SimpyEnvironment() - self.conf = conf + self.conf = copy.deepcopy(conf) # have our own copy so our setup_asymmetric_links doesn't change whatever config we've been passed. self.node_configs = node_configs + # reset MeshPacket class variables + MeshPacket.seed_asym_rng(self.conf.SEED) + MeshPacket.reset_packet_counter() + # internal global state which changes self.mutated_state = SimulationState(self.conf, self.env) @@ -134,12 +136,19 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non # note: we allow user to specify if graphing will happen or not self.graph = graph + # use node configs to populate the connectivity matrix and compute + # initial condition links/no links. Because we always expect link/no + # link counts, and thus do an O(n^2) precomputation anyways, just + # always do this and reserve checking/not checking the map later based + # on config settings. + self.initialize_connectivity_map() + # node configs provided, create nodes with them for cfg in self.node_configs: n = MeshNode(self.conf, self.mutated_state, self.data_tracking, - cfg, + cfg ) self.mutated_state.nodes.append(n) @@ -147,8 +156,7 @@ 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"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 @@ -184,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, } @@ -193,3 +199,34 @@ 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. Also, initialize baseline path loss matrix. + ''' + for tx_node in self.node_configs: + # compute the set of all nodes our signal is detectable at + reachable_node_set = set() + for rx_node in self.node_configs: + if tx_node.node_id == rx_node.node_id: + continue # skip self + + self.data_tracking.totalPairs += 1 + + (rssi, pl) = tx_node.compute_rssi_and_pathloss_to(rx_node, self.conf) + + # compare with extra margin (set based on 10-node standard test) + if rssi + self.conf.CONNECTIVITY_MAP_RSSI_MARGIN > self.conf.current_preset['sensitivity']: + reachable_node_set.add(rx_node.node_id) + + # compute total/no links without margin + if rssi >= self.conf.current_preset['sensitivity']: + self.data_tracking.totalLinks += 1 + else: + self.data_tracking.noLinks += 1 + + # cache path loss (it is symmetric, and static until one of the nodes moves) + self.mutated_state.baseline_pathloss_matrix[tx_node.node_id][rx_node.node_id] = pl + self.mutated_state.baseline_pathloss_matrix[rx_node.node_id][tx_node.node_id] = pl + + self.mutated_state.connectivity_map[tx_node.node_id] = reachable_node_set diff --git a/lib/discrete_event_sim_components.py b/lib/discrete_event_sim_components.py index 59197760..65fa5755 100644 --- a/lib/discrete_event_sim_components.py +++ b/lib/discrete_event_sim_components.py @@ -47,6 +47,8 @@ def __init__(self, conf: Config, env: SimpyEnvironment): self.packetsAtN = [[] for _ in range(conf.NR_NODES)] self.messageSeq = Counter() self.nodes = [] + self.connectivity_map = {} # node_id -> set of nodes which can hear it. (list would be faster for lookup) + self.baseline_pathloss_matrix = [[None for _ in range(conf.NR_NODES)] for _ in range(conf.NR_NODES)] # cache for later lookup class SimulationDataTracking: """Class to hold data used to monitor a simulation which has no @@ -56,6 +58,5 @@ def __init__(self): self.messages = [] self.delays = [] self.totalPairs = 0 - self.symmetricLinks = 0 - self.asymmetricLinks = 0 + self.totalLinks = 0 self.noLinks = 0 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/node.py b/lib/node.py index ea47c426..459eab30 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__) @@ -41,35 +42,58 @@ 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 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']) @@ -94,15 +118,51 @@ 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 - self.nodeid = nodeConfig.node_id + + # 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 = self.node_conf.node_id # set up internal RNGs self.moveRng = random.Random(self.nodeid) @@ -110,15 +170,18 @@ 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.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) 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 @@ -232,6 +295,45 @@ 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 + 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 + + (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']: + 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"{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 + # 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 +360,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, 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)) @@ -321,7 +423,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, 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) @@ -338,41 +440,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. @@ -380,11 +484,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 @@ -393,11 +497,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 @@ -431,16 +536,18 @@ 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.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. 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}") - 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) + 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 self.packets.append(pNew) self.env.process(self.transmit(pNew)) @@ -486,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/lib/packet.py b/lib/packet.py index 1b033855..cdbf1c1e 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,10 +1,26 @@ +import logging +import random + +from lib.discrete_event_sim_components import Counter 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): + 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(): + 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 Arguments: @@ -20,7 +36,10 @@ 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 + 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 @@ -34,7 +53,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)] @@ -46,13 +65,47 @@ 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: continue - 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 + + # 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 self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): + 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 + # 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: + # 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) + + if conf.MODEL_ASYMMETRIC_LINKS: + offset = MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) + 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.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 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 diff --git a/lib/phy.py b/lib/phy.py index 98145841..cc2a8212 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -4,16 +4,29 @@ 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 +def get_current_slot_time(): # from RadioInterface::computeSlotTimeMsec + # all times in ms + sum_prop_turnaround_mac_time = 0.2 + 0.4 + 7 + 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 + # 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): @@ -27,7 +40,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 at node {rx_nodeId}!') c = power_collision(packet, other, rx_nodeId) # mark all the collided packets for p in c: @@ -107,33 +120,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,21 +175,24 @@ 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 +# 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"] @@ -177,10 +213,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) diff --git a/loraMesh.py b/loraMesh.py index 6d23afc5..ac54e536 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") @@ -96,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) @@ -215,11 +223,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 1f876105..9ca7787e 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') @@ -152,6 +141,66 @@ 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', + 'noLinkRate', + 'movingNodes', + 'gpsEnabled', + ] + + for f in facets: + 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): @@ -187,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"] @@ -201,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, 180, "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, 834, "expected number of packets sent") + self.assertEqual(potentialReceivers, 7506, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 332, "expected number of collisions") + self.assertEqual(nrCollisions, 323, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 3173, "expected number of packets sensed") + self.assertEqual(nrSensed, 2895, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2824, "expected number of packets received") + self.assertEqual(nrReceived, 2573, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 11174.95, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 6403.13, "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.83, "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.57, "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), 50.1, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1280, "expected number of packets dropped") + self.assertEqual(delayDropped, 1143, "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") @@ -241,5 +284,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() 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) 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" diff --git a/utils/time-discrete-sim.py b/utils/time-discrete-sim.py new file mode 100755 index 00000000..8ee7698e --- /dev/null +++ b/utils/time-discrete-sim.py @@ -0,0 +1,68 @@ +#!/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, 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. + 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=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('--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.times} times. connectivity map enabled: {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 5 slowest outliers: {results['modified_average']}") +print(f"{results['all_results']=}")