From 846d5242bef36e25739822ddf2867ad8ceee451a Mon Sep 17 00:00:00 2001 From: zandi Date: Thu, 22 Jan 2026 23:35:19 -0500 Subject: [PATCH 1/4] Add basic unit tests Just to get started, add sanity-check tests of our `calc_dist` euclidean distance calculating function in lib/common.py Use the `unittest` python module because it's built-in. --- README.md | 4 ++++ tests/__init__.py | 0 tests/test_common.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_common.py diff --git a/README.md b/README.md index 37636cc2..55d14297 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ Furthermore, since the simulator has an 'oracle view' of the network, it allows ![](/img/route_plot.png) +# Tests + +Unit tests can be executed by running `python3 -m unittest` from the root of the repo. Don't forget to activate your virtual env before running tests. + ## License Part of the source code is based on the work in [1], which eventually stems from [2]. The LoRaSim library from [2] can be found [here](https://www.lancaster.ac.uk/scc/sites/lora/lorasim.html). diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 00000000..e350951b --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,42 @@ +import unittest + +import lib.common + +class TestCommonFunctions(unittest.TestCase): + + def test_calc_dist(self): + message = "sanity-checking our euclidean distance calculation" + # test some pythagorean triple triangles https://en.wikipedia.org/wiki/Pythagorean_triple + # (3, 4, 5) + # x diff: 3 + # y diff: 4 + p1 = (-1, -1) + p2 = (2, 3) + self.assertEqual(lib.common.calc_dist(p1[0], p2[0], p1[1], p2[1]), 5.0, message) + + # (5, 12, 13) + # x diff: 5 + # y diff: 12 + p1 = (-1, -1) + p2 = (4, 11) + self.assertEqual(lib.common.calc_dist(p1[0], p2[0], p1[1], p2[1]), 13.0, message) + + # test some pythagorean quadruple cuboids https://en.wikipedia.org/wiki/Pythagorean_quadruple + # (1, 2, 2, 3) + # x diff: 1 + # y diff: 2 + # z diff: 2 + p1 = (-1, -1, -1) + p2 = (0, 1, 1) + self.assertEqual(lib.common.calc_dist(p1[0], p2[0], p1[1], p2[1], p1[2], p2[2]), 3.0, message) + + # (2, 3, 6, 7) + # x diff: 2 + # y diff: 3 + # z diff: 6 + p1 = (-1, -1, -1) + p2 = (1, 2, 5) + self.assertEqual(lib.common.calc_dist(p1[0], p2[0], p1[1], p2[1], p1[2], p2[2]), 7.0, message) + +if __name__ == '__main__': + unittest.main() From 71a31d8d6fcb367eb47c14f82ce4531577055a16 Mon Sep 17 00:00:00 2001 From: zandi Date: Fri, 23 Jan 2026 13:31:48 -0500 Subject: [PATCH 2/4] Add sanity-check testcase for newton-raphson root finder --- tests/test_phy.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_phy.py diff --git a/tests/test_phy.py b/tests/test_phy.py new file mode 100644 index 00000000..198ded47 --- /dev/null +++ b/tests/test_phy.py @@ -0,0 +1,33 @@ +import unittest + +import lib.phy + +class TestPhy(unittest.TestCase): + + def test_rootFinder(self): + # double-check we can find the roots of some polynomials + message = "sanity-check Newton-Raphson root-finding implementation" + tolerance = 0.0000001 + + def poly1(x): + ''' roots at x=-3, 0, 2.5 ''' + return (x+3)*(x-2.5)*x + + # should find -3 + res = lib.phy.rootFinder(poly1, -3.5, tol=tolerance) + diff = abs(res - -3) + self.assertLess(diff, tolerance, message) + + # should find 0 + res = lib.phy.rootFinder(poly1, -1, tol=tolerance) + diff = abs(res - 0) + self.assertLess(diff, tolerance, message) + + # should find 2.5 + res = lib.phy.rootFinder(poly1, 3, tol=tolerance) + diff = abs(res - 2.5) + self.assertLess(diff, tolerance, message) + + +if __name__ == '__main__': + unittest.main() From 8aa599945daa39d757b650f0c70ebbd8be9cb1cd Mon Sep 17 00:00:00 2001 From: zandi Date: Sat, 24 Jan 2026 11:01:22 -0500 Subject: [PATCH 3/4] Test random position finding Make sure for the first node case we: - return a node - it is within bounds For the second node case, additionally: - it is NOT within conf.MINDIST of origin node - it IS within radio range of origin node The radio range computation in the test is identical to the function so this is not ideal. Additionally, the found node is random each test run, so use f-strings in the failure messages to aid in troubleshooting any test failures which might happen. --- tests/test_common.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_common.py b/tests/test_common.py index e350951b..e844f679 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -38,5 +38,70 @@ def test_calc_dist(self): p2 = (1, 2, 5) self.assertEqual(lib.common.calc_dist(p1[0], p2[0], p1[1], p2[1], p1[2], p2[2]), 7.0, message) + def test_find_random_position(self): + # mock up the needed objects + # conf: config from lib.config.Config(). Must have + # - XSIZE, YSIZE, OX, OY, MINDIST, FREQ, PTX, GL, SENSMODEM, MODEM + # - MODEL, LPLD0, GAMMA, D0 + # (just use an actual config object) + # nodes: empty list OR list of nodes which must have: + # - x, y attributes + from lib.config import Config + from lib.phy import estimate_path_loss + + # TODO: iterate this test for each of our supported models, since they + # change the return value of estimate_path_loss. Also, each LoRa preset + # has its own sensitivity which changes radio range. + conf = Config() + + class MyNode: + def __init__(self, x, y): + self.x = x + self.y = y + + def __repr__(self): + return f"MyNode(x={self.x}, y={self.y})" + + lower_bound_x = conf.OX - conf.XSIZE/2 + upper_bound_x = conf.OX + conf.XSIZE/2 + lower_bound_y = conf.OY - conf.YSIZE/2 + upper_bound_y = conf.OY + conf.YSIZE/2 + + nodes = [] + # conditions that must be held: + # - found position can 'reach' at least one other node. + # - found position is not within conf.MINDIST of any other node. + # - found position is within defined scenario area. + # - a position is always returned + + # first node case + position = lib.common.find_random_position(conf, nodes) + self.assertIsNotNone(position, "always return position") + self.assertGreaterEqual(position[0], lower_bound_x, f"x within bounds {position=}") + self.assertLessEqual(position[0], upper_bound_x, f"x within bounds {position=}") + self.assertGreaterEqual(position[1], lower_bound_y, f"y within bounds {position=}") + self.assertLessEqual(position[1], upper_bound_y, f"y within bounds {position=}") + + # second node case + n = MyNode(0, 0) + nodes = [n] + position = lib.common.find_random_position(conf, nodes) + self.assertIsNotNone(position, "always return position") + self.assertGreaterEqual(position[0], lower_bound_x, f"x within bounds {position=}") + self.assertLessEqual(position[0], upper_bound_x, f"x within bounds {position=}") + self.assertGreaterEqual(position[1], lower_bound_y, f"y within bounds {position=}") + self.assertLessEqual(position[1], upper_bound_y, f"y within bounds {position=}") + + distance = lib.common.calc_dist(n.x, position[0], n.y, position[1]) + self.assertGreaterEqual(distance, conf.MINDIST, f"{position=} not within MINDIST of {n=}") + + # this directly replicates the logic from the function which I dislike. + # Find a better way to test "found node can reach one other node", + # perhaps by pre-computing a max distance based on the config params + # we're using. There are lots of those, but they shouldn't change often. + pathLoss = estimate_path_loss(conf, distance, conf.FREQ) + rssi = conf.PTX + 2*conf.GL - pathLoss + self.assertGreaterEqual(rssi, conf.SENSMODEM[conf.MODEM], f"found {position=} is within radio range of {n=}") + if __name__ == '__main__': unittest.main() From 8eb86294e2fd130b73514a37aece05100cf8fce2 Mon Sep 17 00:00:00 2001 From: zandi Date: Wed, 28 Jan 2026 20:53:13 -0500 Subject: [PATCH 4/4] Update find_random_position test for new modem preset data structure --- tests/test_common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_common.py b/tests/test_common.py index e844f679..5ed0cbab 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -41,7 +41,7 @@ def test_calc_dist(self): def test_find_random_position(self): # mock up the needed objects # conf: config from lib.config.Config(). Must have - # - XSIZE, YSIZE, OX, OY, MINDIST, FREQ, PTX, GL, SENSMODEM, MODEM + # - XSIZE, YSIZE, OX, OY, MINDIST, FREQ, PTX, GL, current_preset property # - MODEL, LPLD0, GAMMA, D0 # (just use an actual config object) # nodes: empty list OR list of nodes which must have: @@ -101,7 +101,7 @@ def __repr__(self): # we're using. There are lots of those, but they shouldn't change often. pathLoss = estimate_path_loss(conf, distance, conf.FREQ) rssi = conf.PTX + 2*conf.GL - pathLoss - self.assertGreaterEqual(rssi, conf.SENSMODEM[conf.MODEM], f"found {position=} is within radio range of {n=}") + self.assertGreaterEqual(rssi, conf.current_preset["sensitivity"], f"found {position=} is within radio range of {n=}") if __name__ == '__main__': unittest.main()