Experiment Unittests¶
experiment_unittests.py¶
1"""
2Test module for the experiment_handler/experiment_prototype code.
3
4This script can be run most simply via 'python3 experiment_unittests.py'. This will run through all
5experiments defined in the Borealis experiments top level directory, experiment exception tests
6defined in the testing_archive directory, as well as any other tests hard coded into this script.
7Any experiment that raises an exception when building will show up as a failed test here.
8
9This script can also be run to test individual experiments by using the --experiment flag. For
10example: `python3 experiment_unittests.py --experiment normalscan normalsound` will only test the
11normalscan and normalsound experiments. Any experiments specified must exist within the Borealis
12experiments top level directory (i.e. src/borealis_experiments).
13
14Other command line options include:
15
16- Specifying what radar site to run the tests as
17
18References:
19https://stackoverflow.com/questions/32899/how-do-you-generate-dynamic-parameterized-unit-tests-in-python
20https://docs.python.org/3/library/unittest.html
21https://www.bnmetrics.com/blog/dynamic-import-in-python3
22
23:copyright: 2023 SuperDARN Canada
24:author: Kevin Krieger, Theodore Kolkman
25"""
26
27import argparse
28import unittest
29import os
30import sys
31import inspect
32import pkgutil
33from importlib import import_module
34import importlib.util
35import json
36
37# Need the path append to import within this file
38BOREALISPATH = os.environ["BOREALISPATH"]
39sys.path.append(f"{BOREALISPATH}/src")
40
41
42def redirect_to_devnull(func, *args, **kwargs):
43 with open(os.devnull, "w") as devnull:
44 old_stdout = sys.stdout
45 old_stderr = sys.stderr
46 sys.stdout = devnull
47 sys.stderr = devnull
48 try:
49 result = func(*args, **kwargs)
50 finally:
51 sys.stdout = old_stdout
52 sys.stderr = old_stderr
53 return result
54
55
56def ehmain(experiment_name="normalscan", scheduling_mode="discretionary", **kwargs):
57 """
58 Calls the functions within experiment handler that verify an experiment
59
60 :param experiment_name: The module name of the experiment to be verified. Experiment name must
61 be in module format (i.e. testing_archive.test_example for unit tests)
62 to work properly
63 :type experiment_name: str
64 :param scheduling_mode: The scheduling mode to run. Defaults to 'discretionary'
65 :type scheduling_mode: str
66 :param kwargs: The keyword arguments for the experiment
67 :type kwargs: dict
68 """
69 from utils import log_config
70
71 log_config.log(
72 console=False, logfile=False, aggregator=False
73 ) # Prevent logging in experiment
74
75 import experiment_handler as eh
76
77 experiment = eh.retrieve_experiment(experiment_name)
78 exp = experiment(**kwargs)
79 exp._set_scheduling_mode(scheduling_mode)
80 exp.build_scans()
81
82
83class TestExperimentEnvSetup(unittest.TestCase):
84 """
85 A unittest class to test the environment setup for the experiment_handler module.
86 All test methods must begin with the word 'test' to be run by unittest.
87 """
88
89 def setUp(self):
90 """
91 This function is called before every test_* method within this class (every test case in
92 unittest lingo)
93 """
94 print("\nMethod: ", self._testMethodName)
95
96 def test_no_args(self):
97 """
98 Test calling the experiment handler without any command line arguments, which returns 2
99 """
100 import experiment_handler as eh
101
102 with self.assertRaisesRegex(SystemExit, "2"):
103 eh.main([])
104
105 @unittest.skip("Skip for TODO reason")
106 def test_borealispath(self):
107 """
108 Test failure to have BOREALISPATH in env
109 """
110 # Need to remove the environment variable, reset for other tests
111 os.environ.pop("BOREALISPATH")
112 sys.path.remove(BOREALISPATH)
113 del os.environ["BOREALISPATH"]
114 os.unsetenv("BOREALISPATH")
115 with self.assertRaisesRegex(KeyError, "BOREALISPATH"):
116 ehmain()
117 os.environ["BOREALISPATH"] = BOREALISPATH
118 sys.path.append(BOREALISPATH)
119
120 @unittest.skip("Cannot test this while hdw.dat files are in /usr/local/hdw")
121 def test_hdw_file(self):
122 """
123 Test the code that checks for the hdw.dat file
124 """
125 import borealis_experiments.superdarn_common_fields as scf
126
127 site_name = scf.options.site_id
128 hdw_path = scf.options.hdw_path
129 # Rename the hdw.dat file temporarily
130 os.rename(f"{hdw_path}/hdw.dat.{site_name}", f"{hdw_path}/_hdw.dat.{site_name}")
131
132 with self.assertRaisesRegex(ValueError, "Cannot open hdw.dat.[a-z]{3} file at"):
133 ehmain()
134
135 # Now rename the hdw.dat file and move on
136 os.rename(f"{hdw_path}/_hdw.dat.{site_name}", f"{hdw_path}/hdw.dat.{site_name}")
137
138
139class TestExperimentArchive(unittest.TestCase):
140 """
141 A unittest class to test various ways for an experiment to fail for the experiment_handler
142 module. Tests will check that exceptions are correctly thrown for each failure case. All test
143 methods must begin with the word 'test' to be run by unittest.
144 """
145
146 def setUp(self):
147 """
148 This function is called before every test_* method (every test case in unittest lingo)
149 """
150 print("\nException Test: ", self._testMethodName)
151
152
153class TestActiveExperiments(unittest.TestCase):
154 """
155 A unittest class to test all Borealis experiments and verify that none of them are built
156 incorrectly. Tests are verified using code within experiment handler. All test methods must
157 begin with the word 'test' to be run by unittest.
158 """
159
160 def setUp(self):
161 """
162 This function is called before every test_* method (every test case in unittest lingo)
163 """
164 print("\nExperiment Test: ", self._testMethodName)
165
166
167def build_unit_tests():
168 """
169 Create individual unit tests for all test cases specified in testing_archive directory of experiments path.
170 """
171 from experiment_prototype.experiment_prototype import ExperimentPrototype
172
173 experiment_package = "testing_archive"
174 experiment_path = f"{BOREALISPATH}/src/borealis_experiments/{experiment_package}/"
175 if not os.path.exists(experiment_path):
176 raise OSError(f"Error: experiment path {experiment_path} is invalid")
177
178 # Iterate through all modules in the borealis_experiments directory
179 for _, name, _ in pkgutil.iter_modules([experiment_path]):
180 imported_module = import_module(
181 "." + name, package=f"borealis_experiments.{experiment_package}"
182 )
183 # Loop through all attributes of each found module
184 for i in dir(imported_module):
185 attribute = getattr(imported_module, i)
186 # To verify that an attribute is a runnable experiment, check that the attribute is
187 # a class and inherits from ExperimentPrototype
188 if inspect.isclass(attribute) and issubclass(
189 attribute, ExperimentPrototype
190 ):
191 # Only create a test if the current attribute is the experiment itself
192 if "ExperimentPrototype" not in str(attribute):
193 if hasattr(attribute, "error_message"):
194 # If expected to fail, should have a classmethod called "error_message"
195 # that contains the error message raised
196 exp_exception, msg = getattr(attribute, "error_message")()
197 test = exception_test_generator(
198 "testing_archive." + name, exp_exception, msg
199 )
200 else: # No exception expected - this is a positive test
201 test = experiment_test_generator("testing_archive." + name)
202 # setattr makes a properly named test method within TestExperimentArchive which
203 # can be run by unittest.main()
204 setattr(TestExperimentArchive, name, test)
205 break
206
207
208def exception_test_generator(module_name, exception, exception_message):
209 """
210 Generate a single test for the given module name and exception message
211
212 :param module_name: Experiment module name, i.e. 'normalscan'
213 :type module_name: str
214 :param exception: Exception that is expected to be raised
215 :type exception: BaseException
216 :param exception_message: Message from the Exception raised.
217 :type exception_message: str
218 """
219
220 def test(self):
221 with self.assertRaisesRegex(exception, exception_message):
222 redirect_to_devnull(ehmain, experiment_name=module_name)
223
224 return test
225
226
227def build_experiment_tests(experiments=None, kwargs=None):
228 """
229 Create individual unit tests for all experiments within the base borealis_experiments/
230 directory. All experiments are run to ensure no exceptions are thrown when they are built
231 """
232 from experiment_prototype.experiment_prototype import ExperimentPrototype
233
234 experiment_package = "borealis_experiments"
235 experiment_path = f"{BOREALISPATH}/src/{experiment_package}/"
236 if not os.path.exists(experiment_path):
237 raise OSError(f"Error: experiment path {experiment_path} is invalid")
238
239 # parse kwargs and pass to experiment
240 kwargs_dict = {}
241 if kwargs:
242 for element in kwargs:
243 if element == "":
244 continue
245 kwarg = element.split("=")
246 if len(kwarg) == 2:
247 kwargs_dict[kwarg[0]] = kwarg[1]
248 else:
249 raise ValueError(f"Bad kwarg: {element}")
250
251 def add_experiment_test(exp_name: str):
252 """Add a unit test for a given experiment"""
253 imported_module = import_module("." + exp_name, package=experiment_package)
254 # Loop through all attributes of each found module
255 for i in dir(imported_module):
256 attribute = getattr(imported_module, i)
257 # To verify that an attribute is a runnable experiment, check that the attribute is
258 # a class and inherits from ExperimentPrototype
259 if inspect.isclass(attribute) and issubclass(
260 attribute, ExperimentPrototype
261 ):
262 # Only create a test if the current attribute is the experiment itself
263 if "ExperimentPrototype" not in str(attribute):
264 test = experiment_test_generator(exp_name, **kwargs_dict)
265 # setattr make the "test" function a method within TestActiveExperiments called
266 # "test_[exp_name]" which can be run via unittest.main()
267 setattr(TestActiveExperiments, f"test_{exp_name}", test)
268
269 # Grab the experiments specified
270 if experiments is not None:
271 for name in experiments:
272 spec = importlib.util.find_spec("." + name, package=experiment_package)
273 if spec is None:
274 # Add in a failing test for this experiment name
275 setattr(
276 TestActiveExperiments,
277 f"test_{name}",
278 lambda self: self.fail("Experiment not found"),
279 )
280 else:
281 add_experiment_test(name)
282
283 else:
284 # Iterate through all modules in the borealis_experiments directory
285 for _, name, _ in pkgutil.iter_modules([experiment_path]):
286 add_experiment_test(name)
287
288
289def experiment_test_generator(module_name, **kwargs):
290 """
291 Generate a single test for a given experiment name. The test will try to run the experiment,
292 and if any exceptions are thrown (i.e. the experiment is built incorrectly) the test will fail.
293
294 :param module_name: Experiment module name (i.e. 'normalscan')
295 :type module_name: str
296 """
297
298 def test(self):
299 try:
300 redirect_to_devnull(ehmain, experiment_name=module_name, **kwargs)
301 except Exception as err:
302 self.fail(err)
303
304 return test
305
306
307def run_tests(raw_args=None, buffer=True, print_results=True):
308 parser = argparse.ArgumentParser()
309 parser.add_argument(
310 "--site_id",
311 required=False,
312 default="sas",
313 choices=["sas", "pgr", "inv", "rkn", "cly", "lab"],
314 help="Site ID of site to test experiments as. Defaults to sas.",
315 )
316 parser.add_argument(
317 "--experiments",
318 required=False,
319 nargs="+",
320 default=None,
321 help="Only run the experiments specified after this option. Experiments \
322 specified must exist within the top-level Borealis experiments directory.",
323 )
324 parser.add_argument(
325 "--kwargs",
326 required=False,
327 nargs="+",
328 default=list(),
329 help="Keyword arguments to pass to the experiments. Note that kwargs are passed to all "
330 "experiments specified.",
331 )
332 parser.add_argument(
333 "--module",
334 required=False,
335 default="__main__",
336 help="If calling from another python file, this should be set to "
337 "'experiment_unittests' in order to properly work.",
338 )
339 parser.add_argument(
340 "--no-tests",
341 required=False,
342 action="store_true",
343 help="Only test the main experiments, not those in testing_archive/",
344 )
345 args = parser.parse_args(raw_args)
346
347 os.environ["RADAR_ID"] = args.site_id
348
349 # Read in config.ini file for current site to make necessary directories
350 path = (
351 f'{os.environ["BOREALISPATH"]}/config/'
352 f'{os.environ["RADAR_ID"]}/'
353 f'{os.environ["RADAR_ID"]}_config.ini'
354 )
355 try:
356 with open(path, "r") as data:
357 raw_config = json.load(data)
358 except OSError:
359 errmsg = f"Cannot open config file at {path}"
360 raise ValueError(errmsg)
361
362 # These directories are required for ExperimentHandler to run
363 data_directory = raw_config["data_directory"]
364 log_directory = raw_config["log_handlers"]["logfile"]["directory"]
365 hdw_path = raw_config["hdw_path"]
366 hdw_dat_file = f'{hdw_path}/hdw.dat.{os.environ["RADAR_ID"]}'
367 if not os.path.exists(data_directory):
368 os.makedirs(data_directory)
369 if not os.path.exists(log_directory):
370 os.makedirs(log_directory)
371 if not os.path.exists(hdw_path):
372 os.makedirs(hdw_path)
373 if not os.path.exists(hdw_dat_file):
374 open(hdw_dat_file, "w")
375
376 experiments = args.experiments
377 if experiments is None: # Run all unit tests and experiment tests
378 print("Running tests on all experiments")
379 if not args.no_tests:
380 build_unit_tests()
381 build_experiment_tests()
382 argv = [sys.argv[0]]
383 else: # Only test specified experiments
384 print(f"Running tests on experiments {experiments}")
385 build_experiment_tests(experiments, args.kwargs)
386 exp_tests = []
387 for exp in experiments:
388 # Check experiment exists
389 if hasattr(TestActiveExperiments, f"test_{exp}"):
390 # Create correct string to test the experiment with unittest
391 exp_tests.append(f"TestActiveExperiments.test_{exp}")
392 else:
393 print(f"Could not find experiment {exp}. Exiting...")
394 exit(1)
395 argv = [parser.prog] + exp_tests
396 if print_results:
397 result = unittest.main(module=args.module, argv=argv, exit=False, buffer=buffer)
398 else:
399 result = redirect_to_devnull(
400 unittest.main, module=args.module, argv=argv, exit=False, buffer=buffer
401 )
402
403 # Clean up the directories/files we created
404 try:
405 os.removedirs(data_directory)
406 except OSError: # If directories not empty, this will fail. That is fine.
407 pass
408 try:
409 os.removedirs(log_directory)
410 except OSError: # If directories not empty, this will fail. That is fine.
411 pass
412 if os.path.getsize(hdw_dat_file) == 0:
413 try:
414 os.remove(hdw_dat_file)
415 except OSError: # Path is a directory
416 os.removedirs(hdw_dat_file)
417 else: # File removed, now clean up directories
418 try:
419 os.removedirs(hdw_path)
420 except OSError: # If directories not empty, this will fail. That is fine.
421 pass
422
423 return result
424
425
426if __name__ == "__main__":
427 from utils import log_config
428
429 log = log_config.log(console=False, logfile=False, aggregator=False)
430
431 run_tests(sys.argv[1:])
This script tests both runnable experiments (those in borealis/src/borealis_experiments/) and a set of unit tests (those in borealis/src/borealis_experiments/testing_archive/). Some unit tests are meant to raise an exception; these tests have an extra method defined which returns the expected exception and a regex of the expected error message. An example unit test is shown below.
Example Unit Test file¶
1#!/usr/bin/python
2
3"""
4Experiment fault:
5 rxonly not specified alongside and no tx_beam_order specified either. rxonly must be manually set to True
6 for receive-only modes.
7"""
8
9import numpy as np
10
11import borealis_experiments.superdarn_common_fields as scf
12from experiment_prototype.experiment_prototype import ExperimentPrototype
13from experiment_prototype.experiment_utils.decimation_scheme import create_default_scheme
14from pydantic import ValidationError
15
16
17class TestExperiment(ExperimentPrototype):
18
19 def __init__(self):
20 cpid = 1
21 super().__init__(cpid)
22
23 slice_1 = { # slice_id = 0, there is only one slice.
24 "pulse_sequence": scf.SEQUENCE_7P,
25 "tau_spacing": scf.TAU_SPACING_7P,
26 "pulse_len": scf.PULSE_LEN_45KM,
27 "num_ranges": scf.STD_NUM_RANGES,
28 "first_range": scf.STD_FIRST_RANGE,
29 "intt": 3500, # duration of an integration, in ms
30 "beam_angle": scf.STD_16_BEAM_ANGLE,
31 "rx_beam_order": scf.STD_16_FORWARD_BEAM_ORDER,
32 "freq": scf.COMMON_MODE_FREQ_1, # kHz
33 "decimation_scheme": create_default_scheme(),
34 }
35 self.add_slice(slice_1)
36
37 @classmethod
38 def error_message(cls):
39 return ValidationError, "Value error, rxonly specified as False but tx_beam_order not given"