diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index c1103e5d..82c27100 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -1,19 +1,27 @@ -# Discrete-event simulator -The discrete-event simulator mimics the radio section of the device software. It is currently based on Meshtastic 2.1. +# Discrete-event simulator +The discrete-event simulator mimics the radio section of the device software. It is currently based on Meshtastic 2.1. ## Usage -Please `git clone` or download this repository, navigate to the Meshtasticator folder (optionally create a virtual environment) and install the necessary requirements using: +Please `git clone` or download this repository, navigate to the Meshtasticator folder (optionally create a virtual environment) and install the necessary requirements using: ```pip install -r requirements.txt```. To start one simulation with the default configurations, run: -```python3 loraMesh.py [nr_nodes]``` +```python3 loraMesh.py [nr_nodes]``` If no argument is given, you first have to place the nodes on a plot. After you place a node, you can change its [role](https://meshtastic.org/docs/settings/config/device#role), hopLimit, height (elevation) and antenna gain. These settings will automatically save when you place a new node or when you start the simulation. ![](/img/configNode.png) -If the number of nodes is given, it will randomly place nodes in the area. It makes sure that each node can reach at least one other node. Furthermore, all nodes are placed at a configurable minimum distance (MINDIST) from each other. +If the number of nodes is given, it will randomly place nodes in the area. It makes sure that each node can reach at least one other node. Furthermore, all nodes are placed at a configurable minimum distance (MINDIST) from each other. + +For non-interactive smoke tests or CI runs, pass `--no-gui` together with either a node count or `--from-file`. This skips the Tk/Matplotlib placement graph and the final schedule plot while keeping the simulation logic unchanged: + +```python3 loraMesh.py 10 --no-gui``` + +Short deterministic smoke runs can also override the configured duration and message period from the command line: + +```python3 loraMesh.py 2 --no-gui --simtime-seconds 5 --period-seconds 0.5``` If you placed the nodes yourself, after a simulation the number of nodes, their coordinates and configuration are automatically saved and you can rerun the scenario with: @@ -21,20 +29,20 @@ If you placed the nodes yourself, after a simulation the number of nodes, their If you want to change any of the configurations, adapt the file *out/nodeConfig.yaml* before running it with the above command. -For running multiple repetitions of simulations for a set of parameters, e.g. the number of nodes, run: +For running multiple repetitions of simulations for a set of parameters, e.g. the number of nodes, run: -```python3 batchSim.py``` +```python3 batchSim.py``` -After the simulations are done, it plots relevant metrics obtained from the simulations. It saves these metrics in */out/report/* to analyze them later on. See *plotExample.py* for an example Python script to plot the results. +After the simulations are done, it plots relevant metrics obtained from the simulations. It saves these metrics in */out/report/* to analyze them later on. See *plotExample.py* for an example Python script to plot the results. -To simulate different parameters, you will have to change the *batchSim.py* script yourself. +To simulate different parameters, you will have to change the *batchSim.py* script yourself. ## Custom configurations Here we list some of the configurations, which you can change to model your scenario in */lib/config.py*. These apply to all nodes, except those that you configure per node when using the plot. ### Modem The LoRa modem ([see Meshtastic radio settings](https://meshtastic.org/docs/overview/radio-settings#predefined-channels)) that is used, as defined below: |Modem | Name | Bandwidth (kHz) | Coding rate | Spreading Factor | Data rate (kbps) -|--|--|--|--|--|--| +|--|--|--|--|--|--| | 0 |Short Fast|250|4/8|7|6.8 | 1 |Short Slow|250|4/8|8|3.9 | 2 |Mid Fast|250|4/8|9|2.2 @@ -45,20 +53,20 @@ The LoRa modem ([see Meshtastic radio settings](https://meshtastic.org/docs/over | 7 |Very Long Slow|62.5|4/8|12|0.09 ### Period -Mean period (in ms) with which the nodes generate a new message following an exponential distribution. E.g. if you set it to 300s, each node will generate a message on average once every five minutes. +Mean period (in ms) with which the nodes generate a new message following an exponential distribution. E.g. if you set it to 300s, each node will generate a message on average once every five minutes. -### Packet length -Payload size of each generated message in bytes. For a position packet, it will be around 40 bytes. +### Packet length +Payload size of each generated message in bytes. For a position packet, it will be around 40 bytes. ### Model This feature is referred to the path loss model, i.e. what the simulator uses to calculate how well a signal will propagate. Note that this is only a rough estimation of the physical environment and will not be 100% accurate, as it depends on a lot of factors. The implemented pathloss models are: -* ```0``` set the log-distance model -* ```1``` set the Okumura-Hata for small and medium-size cities model -* ```2``` set the Okumura-Hata for metropolitan areas +* ```0``` set the log-distance model +* ```1``` set the Okumura-Hata for small and medium-size cities model +* ```2``` set the Okumura-Hata for metropolitan areas * ```3``` set the Okumura-Hata for suburban environments -* ```4``` set the Okumura-Hata for rural areas -* ```5``` set the 3GPP for suburban macro-cell -* ```6``` set the 3GPP for metropolitan macro-cell +* ```4``` set the Okumura-Hata for rural areas +* ```5``` set the 3GPP for suburban macro-cell +* ```6``` set the 3GPP for metropolitan macro-cell ### Broadcasts or direct messages (DMs) By default, *DMs* is set to False, meaning it will send broadcast messages only. If you set it to True, each node will only send DMs to a random other node in the network. @@ -66,10 +74,10 @@ By default, *DMs* is set to False, meaning it will send broadcast messages only. ## Explanation A discrete-event simulator jumps from event to event over time, where an event is a change in the state of the system. It is therefore well-suited for simulating communication networks. -For every node in the simulation, an instance is created that mimics the [Meshtastic logic](https://meshtastic.org/docs/overview/mesh-algo). Each node runs three processes in parallel: *generateMessage*, *transmit* and *receive*. The first creates an event by constructing a new message with unique sequence number at a random time, taken from an exponential distribution. For now, each generated message is of the same payload size. The second and third processes model the actual transmitting and receiving behavior, respectively. +For every node in the simulation, an instance is created that mimics the [Meshtastic logic](https://meshtastic.org/docs/overview/mesh-algo). Each node runs three processes in parallel: *generateMessage*, *transmit* and *receive*. The first creates an event by constructing a new message with unique sequence number at a random time, taken from an exponential distribution. For now, each generated message is of the same payload size. The second and third processes model the actual transmitting and receiving behavior, respectively. -The model of the LoRa physical (PHY) layer is in */lib/phy.py*. Depending on the modem used, it is calculated what the airtime of a packet is. The PHY layer uses a configurable pathloss model to estimate whether nodes at a specific distance can sense each other's packets. Furthermore, it determines whether two packets collide, which depends on the frequency, spreading factor, received time and received power of the two packets. +The model of the LoRa physical (PHY) layer is in */lib/phy.py*. Depending on the modem used, it is calculated what the airtime of a packet is. The PHY layer uses a configurable pathloss model to estimate whether nodes at a specific distance can sense each other's packets. Furthermore, it determines whether two packets collide, which depends on the frequency, spreading factor, received time and received power of the two packets. The routing behavior is implemented in each of the processes of the node. Inside *generateMessage*, reliable retransmissions are handled if no implicit acknowledgement is received. A MeshPacket (defined in */lib/packet.py*) is created to transfer the message. Note that there may be multiple packets created containing the same message, due to retransmissions and rebroadcasting. In *receive*, it is decided what to do on reception of a packet. A packet is flooded if its hoplimit is not zero and no rebroadcast of this packet was heard before. In *transmit*, delays of the Medium Access Control (MAC) layer are called from */lib/mac.py*. The MAC uses a listen-before-talk mechanism, including introducing (random or SNR-based) delays before transmitting a packet. When a packet is ready to be transferred over the air, it is first checked whether in the meantime still no acknowledgement was received, otherwise the transmission is canceled. -The actual communication between processes of different nodes is handled by a BroadcastPipe of [Simpy](https://simpy.readthedocs.io/en/latest/examples/process_communication.html). This ensures that a transmitted packet by one node creates events (one at the start of a packet and one at the end) at the receiving nodes. \ No newline at end of file +The actual communication between processes of different nodes is handled by a BroadcastPipe of [Simpy](https://simpy.readthedocs.io/en/latest/examples/process_communication.html). This ensures that a transmitted packet by one node creates events (one at the start of a packet and one at the end) at the receiving nodes. diff --git a/lib/config.py b/lib/config.py index ccde495f..1db6c72c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -302,6 +302,7 @@ def __init__(self): self.REGION = self.regions["US"] # Select a different region here self.CHANNEL_NUM = 27 # Channel number + self.GUI_ENABLED = True # whether to update/save the Tk/Matplotlib node-placement graph during CLI simulation self.PLOT = True # whether to plot the time schedule of packets after the simulation ### End of discrete-event specific ### diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 3852ff38..4ad492e6 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -1,4 +1,5 @@ import logging +from typing import TYPE_CHECKING # probably not necessary, but "Environment" seemed too generic to me from simpy import Environment as SimpyEnvironment @@ -6,11 +7,11 @@ from lib.common import setup_asymmetric_links from lib.config import Config -from lib.discrete_event import BroadcastPipe from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking -from lib.gui import Graph, run_graph_updates -from lib.node import MeshNode, NodeConfig, default_generate_node_list -from lib.packet import MeshPacket +from lib.node import MeshNode, NodeConfig + +if TYPE_CHECKING: + from lib.gui import Graph logger = logging.getLogger(__name__) @@ -110,7 +111,7 @@ class DiscreteEventSim: simulation config, all necessary state, and sim plumbing. """ - def __init__(self, conf: Config, node_configs: [NodeConfig], graph: Graph | None = None): + def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | None" = None): """Constructor. Arguments: @@ -154,6 +155,8 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: Graph | None # TODO: revisit this design decision sometime. Do we want graphing/GUI to be handled in this object, # or by some external object the user wires in, like how batchSim.py adds in the simulation_progress process? # TODO: batchSim does this, but without the 4th parameter + from lib.gui import run_graph_updates + self.env.process(run_graph_updates(self.env, self.graph, self.mutated_state.nodes, self.conf.ONE_MIN_INTERVAL)) self.conf.update_router_dependencies() @@ -190,4 +193,3 @@ def get_results(self) -> SimulationResults: results.finalize(self.conf) return results - diff --git a/loraMesh.py b/loraMesh.py index bf30f11b..6d23afc5 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -1,26 +1,43 @@ #!/usr/bin/env python3 import argparse import logging +import math import os -import sys import random import yaml -import numpy as np from lib.config import CONFIG -from lib.discrete_event_sim import DiscreteEventSim from lib.node import NodeConfig, default_generate_node_list -from lib.gui import Graph, plot_schedule, gen_scenario conf = CONFIG -random.seed(conf.SEED) logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) # default log level +MIN_TIME_OVERRIDE_SECONDS = 0.01 +CLI_DEFAULT_ATTR = "_lora_mesh_cli_defaults" + + +def configure_logging(): + """Apply CLI logging defaults without changing logging during module import.""" + logging.basicConfig(level=logging.INFO) # default log level + + +def get_cli_defaults(conf): + """Remember the caller's initial CLI defaults across reusable parse calls.""" + if not hasattr(conf, CLI_DEFAULT_ATTR): + setattr( + conf, + CLI_DEFAULT_ATTR, + { + "SIMTIME": conf.SIMTIME, + "PERIOD": conf.PERIOD, + "GUI_ENABLED": conf.GUI_ENABLED, + "PLOT": conf.PLOT, + }, + ) + return getattr(conf, CLI_DEFAULT_ATTR) -log_level = logging.INFO -def parse_params(conf, args) -> [NodeConfig]: +def parse_params(conf, args=None) -> [NodeConfig]: """parses command-line arguments, alters global simulation config, and returns a list of node configurations, or a list of None. """ @@ -41,42 +58,95 @@ def parse_params(conf, args) -> [NodeConfig]: # replicate with argparse, especially since nesting groups was an unintended feature and deprecated. # Just implement as an optional argument, and manually treat it as incompatible with `--from-file` parser.add_argument('--router-type', type=conf.ROUTER_TYPE, choices=conf.ROUTER_TYPE, help='Router type to use, taken from ROUTER_TYPE enum. Omit the leading "ROUTER_TYPE". Incompatible with --from-file') + 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('-v', '--verbose', action='store_true', help='enable verbose/debug output') - parsed_arguments = parser.parse_args() + parsed_arguments = parser.parse_args(args) - if parsed_arguments.verbose: - # set this logger and the loggers for lib.* to DEBUG - # however, the default of INFO means we shouldn't see debug logs - # from matplotlib or PIL. If you want to see those and more, set - # the default log level for the root logger to DEBUG. - logger.setLevel(logging.DEBUG) - lib_logger = logging.getLogger('lib') - lib_logger.setLevel(logging.DEBUG) - print("verbose output enabled") + cli_defaults = get_cli_defaults(conf) + simtime = cli_defaults["SIMTIME"] + period = cli_defaults["PERIOD"] + gui_enabled = cli_defaults["GUI_ENABLED"] + plot_enabled = cli_defaults["PLOT"] + + if parsed_arguments.simtime_seconds is not None: + if not math.isfinite(parsed_arguments.simtime_seconds) or parsed_arguments.simtime_seconds < MIN_TIME_OVERRIDE_SECONDS: + parser.error(f"--simtime-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds") + simtime = int(parsed_arguments.simtime_seconds * conf.ONE_SECOND_INTERVAL) + + if parsed_arguments.period_seconds is not None: + if not math.isfinite(parsed_arguments.period_seconds) or parsed_arguments.period_seconds < MIN_TIME_OVERRIDE_SECONDS: + parser.error(f"--period-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds") + period = int(parsed_arguments.period_seconds * conf.ONE_SECOND_INTERVAL) + + if parsed_arguments.no_gui: + # Headless CI and smoke runs should not pay Tk startup, per-node + # plt.pause(), or the final interactive schedule plot. Keep this as an + # explicit flag so historical visual CLI behavior remains unchanged. + gui_enabled = False + plot_enabled = False 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") + seeded_for_scenario = False if parsed_arguments.from_file is not None: - with open(os.path.join("out", parsed_arguments.from_file), 'r') as file: - config = yaml.load(file, Loader=yaml.FullLoader) - conf.NR_NODES = len(config.keys()) + 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) + for node_id, node_config in raw_config.items() + ] + nr_nodes = len(config) elif parsed_arguments.nr_nodes is not None: - conf.NR_NODES = parsed_arguments.nr_nodes + if parsed_arguments.nr_nodes < 2: + parser.error(f"Need at least two nodes. You specified {parsed_arguments.nr_nodes}") + nr_nodes = parsed_arguments.nr_nodes if parsed_arguments.router_type is not None: routerType = parsed_arguments.router_type conf.SELECTED_ROUTER_TYPE = routerType conf.update_router_dependencies() - # put after router type selection, in case that affects placement, etc. + # Generated node positions come from the global RNG. Seed immediately + # before that generation, after every parser-only rejection path above. + conf.NR_NODES = nr_nodes + conf.PERIOD = period + random.seed(conf.SEED) + seeded_for_scenario = True config = default_generate_node_list(conf) else: + if not gui_enabled: + parser.error("--no-gui requires nr_nodes or --from-file") + from lib.gui import gen_scenario + config_dict = gen_scenario(conf) - config = [NodeConfig.from_gen_scenario_output(node_id, cfg, conf.PERIOD) for node_id, cfg in config_dict.items()] - conf.NR_NODES = len(config) + config = [NodeConfig.from_gen_scenario_output(node_id, cfg, period) for node_id, cfg in config_dict.items()] + nr_nodes = len(config) + + if nr_nodes < 2: + parser.error(f"Need at least two nodes. You specified {nr_nodes}") + if not seeded_for_scenario: + # Loaded and interactive scenarios do not need random state for node + # placement, but the later MAC/PHY simulation does. Seed only after + # successful scenario loading so rejected inputs leave caller RNG state + # alone. + random.seed(conf.SEED) + + conf.SIMTIME = simtime + conf.PERIOD = period + conf.GUI_ENABLED = gui_enabled + conf.PLOT = plot_enabled + conf.NR_NODES = nr_nodes - if conf.NR_NODES < 2: - parser.error(f"Need at least two nodes. You specified {conf.NR_NODES}") + if parsed_arguments.verbose: + # Set this logger and lib.* to DEBUG only after the command line has + # resolved into a usable scenario. Failed parser inputs should not leave + # imported callers with noisier logging. + logger.setLevel(logging.DEBUG) + lib_logger = logging.getLogger('lib') + lib_logger.setLevel(logging.DEBUG) + print("verbose output enabled") print("Number of nodes:", conf.NR_NODES) print("Modem:", conf.MODEM_PRESET) @@ -85,75 +155,95 @@ def parse_params(conf, args) -> [NodeConfig]: print("Interference level:", conf.INTERFERENCE_LEVEL) return config -nodeConfig = parse_params(conf, sys.argv) -conf.update_router_dependencies() -graph = Graph(conf) - -# set up sim -sim = DiscreteEventSim(conf, nodeConfig, graph) - -# run sim -print("\n====== START OF SIMULATION ======") -sim.run_simulation() - -# collect, process & display results -print("\n====== END OF SIMULATION ======") - -results = sim.get_results() - -packets = results["packets"] -packetsAtN = results["packetsAtN"] -messageSeq = results["messageSeq"] -messages = results["messages"] -delays = results["delays"] -totalPairs = results["totalPairs"] -symmetricLinks = results["symmetricLinks"] -asymmetricLinks = results["asymmetricLinks"] -noLinks = results["noLinks"] -nodes = results["nodes"] - -# collect second-order results from finalized results -sent = results['sent'] -potentialReceivers = results['potentialReceivers'] -nrCollisions = results['nrCollisions'] -nrSensed = results['nrSensed'] -nrReceived = results['nrReceived'] -meanDelay = results['meanDelay'] -txAirUtilizationRate = results['txAirUtilizationRate'] -collisionRate = results['collisionRate'] -nodeReach = results['nodeReach'] -usefulness = results['usefulness'] -delayDropped = results['delayDropped'] - -print("*******************************") -print(f"\nRouter Type: {conf.SELECTED_ROUTER_TYPE}") -print('Number of messages created:', messageSeq) -print('Number of packets sent:', sent, 'to', potentialReceivers, 'potential receivers') -print("Number of collisions:", nrCollisions) -print("Number of packets sensed:", nrSensed) -print("Number of packets received:", nrReceived) -print('Delay average (ms):', round(meanDelay, 2)) -print('Average Tx air utilization:', round(txAirUtilizationRate * 100, 2), '%') -print("Percentage of packets that collided:", round(collisionRate*100, 2)) -print("Average percentage of nodes reached:", round(nodeReach*100, 2)) -print("Percentage of received packets containing new message:", round(usefulness*100, 2)) -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: - movingNodes = results['movingNodes'] - gpsEnabled = results['gpsEnabled'] - print("Number of moving nodes:", movingNodes) - print("Number of moving nodes w/ GPS:", gpsEnabled) - -graph.save() - -if conf.PLOT: - plot_schedule(conf, packets, messages) + +def run_simulation(conf, node_config): + """Run one configured simulation and print the historical CLI summary.""" + # Keep the heavier simulation/GUI import out of module import. That makes + # CLI parsing unit-testable and lets CI/tools import loraMesh without + # starting Matplotlib/Tk plumbing as a side effect. + from lib.discrete_event_sim import DiscreteEventSim + + conf.update_router_dependencies() + if conf.GUI_ENABLED: + from lib.gui import Graph + + graph = Graph(conf) + else: + graph = None + + # set up sim + sim = DiscreteEventSim(conf, node_config, graph) + + # run sim + print("\n====== START OF SIMULATION ======") + sim.run_simulation() + + # collect, process & display results + print("\n====== END OF SIMULATION ======") + + results = sim.get_results() + + packets = results["packets"] + messageSeq = results["messageSeq"] + messages = results["messages"] + + # collect second-order results from finalized results + sent = results['sent'] + potentialReceivers = results['potentialReceivers'] + nrCollisions = results['nrCollisions'] + nrSensed = results['nrSensed'] + nrReceived = results['nrReceived'] + meanDelay = results['meanDelay'] + txAirUtilizationRate = results['txAirUtilizationRate'] + collisionRate = results['collisionRate'] + nodeReach = results['nodeReach'] + usefulness = results['usefulness'] + delayDropped = results['delayDropped'] + + print("*******************************") + print(f"\nRouter Type: {conf.SELECTED_ROUTER_TYPE}") + print('Number of messages created:', messageSeq) + print('Number of packets sent:', sent, 'to', potentialReceivers, 'potential receivers') + print("Number of collisions:", nrCollisions) + print("Number of packets sensed:", nrSensed) + print("Number of packets received:", nrReceived) + print('Delay average (ms):', round(meanDelay, 2)) + print('Average Tx air utilization:', round(txAirUtilizationRate * 100, 2), '%') + print("Percentage of packets that collided:", round(collisionRate*100, 2)) + print("Average percentage of nodes reached:", round(nodeReach*100, 2)) + print("Percentage of received packets containing new message:", round(usefulness*100, 2)) + 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: + movingNodes = results['movingNodes'] + gpsEnabled = results['gpsEnabled'] + print("Number of moving nodes:", movingNodes) + print("Number of moving nodes w/ GPS:", gpsEnabled) + + if graph is not None: + graph.save() + + if conf.PLOT: + from lib.gui import plot_schedule + + plot_schedule(conf, packets, messages) + + return results + + +def main(args=None): + configure_logging() + node_config = parse_params(conf, args) + return run_simulation(conf, node_config) + + +if __name__ == "__main__": + main() diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py new file mode 100644 index 00000000..d55508c0 --- /dev/null +++ b/tests/test_lora_mesh_cli.py @@ -0,0 +1,241 @@ +import contextlib +import io +import logging +import os +import random +import subprocess +import sys +import tempfile +import textwrap +import unittest + +from lib.config import Config + +import loraMesh + + +def generated_positions(node_configs): + return [ + (round(node.position.x, 6), round(node.position.y, 6), round(node.position.z, 6)) + for node in node_configs + ] + + +class TestLoraMeshCli(unittest.TestCase): + """Regression tests for the top-level CLI wrapper. + + loraMesh.py used to run a simulation while being imported and mutate global + process state while still rejecting arguments. These tests lock in the more + tool-friendly behavior: import is quiet, parser failures are side-effect + free, and accepted headless runs can be used by CI. + """ + + def parse_quietly(self, conf, args): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + nodes = loraMesh.parse_params(conf, args) + return nodes, stdout.getvalue() + + def assert_parser_rejects(self, conf, args): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + loraMesh.parse_params(conf, args) + self.assertEqual(raised.exception.code, 2) + return stderr.getvalue() + + def test_importing_lora_mesh_does_not_run_simulation(self): + completed = subprocess.run( + [sys.executable, "-c", "import loraMesh; print('import ok')"], + capture_output=True, + check=True, + text=True, + ) + + self.assertEqual(completed.stdout.strip(), "import ok") + self.assertEqual(completed.stderr, "") + + def test_parse_params_uses_supplied_argv(self): + conf = Config() + + nodes, output = self.parse_quietly( + conf, + ["2", "--no-gui", "--simtime-seconds", "1", "--period-seconds", "0.5"], + ) + + self.assertEqual(len(nodes), 2) + self.assertFalse(conf.GUI_ENABLED) + self.assertFalse(conf.PLOT) + self.assertEqual(conf.SIMTIME, 1000) + self.assertEqual(conf.PERIOD, 500) + self.assertIn("Number of nodes: 2", output) + + def test_parse_params_reuses_initial_defaults_after_override_run(self): + conf = Config() + default_simtime = conf.SIMTIME + default_period = conf.PERIOD + + self.parse_quietly( + conf, + ["2", "--no-gui", "--simtime-seconds", "1", "--period-seconds", "0.5"], + ) + nodes, _ = self.parse_quietly(conf, ["2"]) + + self.assertTrue(conf.GUI_ENABLED) + self.assertTrue(conf.PLOT) + self.assertEqual(conf.SIMTIME, default_simtime) + self.assertEqual(conf.PERIOD, default_period) + self.assertEqual([node.period for node in nodes], [default_period, default_period]) + + def test_parse_params_preserves_caller_initial_defaults(self): + conf = Config() + conf.SIMTIME = 1234 + conf.PERIOD = 2345 + conf.GUI_ENABLED = False + conf.PLOT = False + + self.parse_quietly(conf, ["2", "--simtime-seconds", "1", "--period-seconds", "0.5"]) + nodes, _ = self.parse_quietly(conf, ["2"]) + + self.assertFalse(conf.GUI_ENABLED) + self.assertFalse(conf.PLOT) + self.assertEqual(conf.SIMTIME, 1234) + self.assertEqual(conf.PERIOD, 2345) + self.assertEqual([node.period for node in nodes], [2345, 2345]) + + def test_parse_params_rejects_sub_centisecond_time_overrides(self): + conf = Config() + + simtime_error = self.assert_parser_rejects(conf, ["2", "--no-gui", "--simtime-seconds", "0.009"]) + period_error = self.assert_parser_rejects(conf, ["2", "--no-gui", "--period-seconds", "0.009"]) + + self.assertIn("--simtime-seconds must be at least 0.01 seconds", simtime_error) + self.assertIn("--period-seconds must be at least 0.01 seconds", period_error) + + def test_no_gui_run_does_not_import_gui_module(self): + script = textwrap.dedent( + """\ + import builtins + + real_import = builtins.__import__ + + def guarded_import(name, *args, **kwargs): + if name == "lib.gui": + raise AssertionError("headless run imported lib.gui") + return real_import(name, *args, **kwargs) + + builtins.__import__ = guarded_import + + import loraMesh + + loraMesh.main(["2", "--no-gui", "--simtime-seconds", "0.01", "--period-seconds", "0.01"]) + print("headless ok") + """ + ) + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + check=True, + text=True, + ) + + self.assertIn("headless ok", completed.stdout) + + def test_parse_params_loads_from_file_as_node_configs(self): + conf = Config() + scenario = textwrap.dedent( + """\ + 0: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 1: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + with tempfile.NamedTemporaryFile("w", dir="out", suffix=".yaml", delete=False, encoding="utf-8") as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + nodes, _ = self.parse_quietly( + conf, + ["--from-file", scenario_filename, "--no-gui", "--period-seconds", "2"], + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual([node.node_id for node in nodes], [0, 1]) + self.assertEqual([node.period for node in nodes], [2000, 2000]) + self.assertEqual(conf.NR_NODES, 2) + + def test_parse_params_rejects_before_applying_time_overrides(self): + conf = Config() + original_simtime = conf.SIMTIME + + self.assert_parser_rejects(conf, ["1", "--simtime-seconds", "1"]) + + self.assertEqual(conf.SIMTIME, original_simtime) + + def test_parse_params_rejects_before_applying_no_gui(self): + conf = Config() + + self.assert_parser_rejects(conf, ["1", "--no-gui"]) + + self.assertTrue(conf.GUI_ENABLED) + self.assertTrue(conf.PLOT) + + def test_parse_params_rejects_before_enabling_verbose_logging(self): + conf = Config() + lora_logger = logging.getLogger("loraMesh") + lib_logger = logging.getLogger("lib") + original_lora_level = lora_logger.level + original_lib_level = lib_logger.level + + try: + self.assert_parser_rejects(conf, ["1", "--verbose", "--no-gui"]) + + self.assertEqual(lora_logger.level, original_lora_level) + self.assertEqual(lib_logger.level, original_lib_level) + finally: + lora_logger.setLevel(original_lora_level) + lib_logger.setLevel(original_lib_level) + + def test_parse_params_rejects_one_node_before_seeding(self): + conf = Config() + random.seed(12345) + state_before = random.getstate() + + self.assert_parser_rejects(conf, ["1", "--no-gui"]) + + self.assertEqual(random.getstate(), state_before) + + def test_parse_params_seeds_generated_scenarios(self): + conf_a = Config() + conf_b = Config() + + nodes_a, _ = self.parse_quietly(conf_a, ["3", "--no-gui"]) + random.seed(999) + random.random() + nodes_b, _ = self.parse_quietly(conf_b, ["3", "--no-gui"]) + + self.assertEqual(generated_positions(nodes_a), generated_positions(nodes_b)) + + +if __name__ == "__main__": + unittest.main()