Experiment Unittests¶
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("Skip because it is annoying")
121 def test_config_file(self):
122 """
123 Test the code that checks for the config file
124 """
125 # Rename the config file temporarily
126 site_id = scf.options.site_id
127 os.rename(
128 f"{BOREALISPATH}/config/{site_id}/{site_id}_config.ini",
129 f"{BOREALISPATH}/_config.ini",
130 )
131 with self.assertRaisesRegex(ValueError, "Cannot open config file at "):
132 ehmain()
133
134 # Now rename the config file and move on
135 os.rename(
136 f"{BOREALISPATH}/_config.ini",
137 f"{BOREALISPATH}/config/{site_id}/{site_id}_config.ini",
138 )
139
140 @unittest.skip("Cannot test this while hdw.dat files are in /usr/local/hdw")
141 def test_hdw_file(self):
142 """
143 Test the code that checks for the hdw.dat file
144 """
145 import borealis_experiments.superdarn_common_fields as scf
146
147 site_name = scf.options.site_id
148 hdw_path = scf.options.hdw_path
149 # Rename the hdw.dat file temporarily
150 os.rename(f"{hdw_path}/hdw.dat.{site_name}", f"{hdw_path}/_hdw.dat.{site_name}")
151
152 with self.assertRaisesRegex(ValueError, "Cannot open hdw.dat.[a-z]{3} file at"):
153 ehmain()
154
155 # Now rename the hdw.dat file and move on
156 os.rename(f"{hdw_path}/_hdw.dat.{site_name}", f"{hdw_path}/hdw.dat.{site_name}")
157
158
159class TestExperimentArchive(unittest.TestCase):
160 """
161 A unittest class to test various ways for an experiment to fail for the experiment_handler
162 module. Tests will check that exceptions are correctly thrown for each failure case. All test
163 methods must begin with the word 'test' to be run by unittest.
164 """
165
166 def setUp(self):
167 """
168 This function is called before every test_* method (every test case in unittest lingo)
169 """
170 print("\nException Test: ", self._testMethodName)
171
172
173class TestActiveExperiments(unittest.TestCase):
174 """
175 A unittest class to test all Borealis experiments and verify that none of them are built
176 incorrectly. Tests are verified using code within experiment handler. All test methods must
177 begin with the word 'test' to be run by unittest.
178 """
179
180 def setUp(self):
181 """
182 This function is called before every test_* method (every test case in unittest lingo)
183 """
184 print("\nExperiment Test: ", self._testMethodName)
185
186
187def build_unit_tests():
188 """
189 Create individual unit tests for all test cases specified in testing_archive directory of experiments path.
190 """
191 from experiment_prototype.experiment_prototype import ExperimentPrototype
192
193 experiment_package = "testing_archive"
194 experiment_path = f"{BOREALISPATH}/src/borealis_experiments/{experiment_package}/"
195 if not os.path.exists(experiment_path):
196 raise OSError(f"Error: experiment path {experiment_path} is invalid")
197
198 # Iterate through all modules in the borealis_experiments directory
199 for _, name, _ in pkgutil.iter_modules([experiment_path]):
200 imported_module = import_module(
201 "." + name, package=f"borealis_experiments.{experiment_package}"
202 )
203 # Loop through all attributes of each found module
204 for i in dir(imported_module):
205 attribute = getattr(imported_module, i)
206 # To verify that an attribute is a runnable experiment, check that the attribute is
207 # a class and inherits from ExperimentPrototype
208 if inspect.isclass(attribute) and issubclass(
209 attribute, ExperimentPrototype
210 ):
211 # Only create a test if the current attribute is the experiment itself
212 if "ExperimentPrototype" not in str(attribute):
213 if hasattr(attribute, "error_message"):
214 # If expected to fail, should have a classmethod called "error_message"
215 # that contains the error message raised
216 exp_exception, msg = getattr(attribute, "error_message")()
217 test = exception_test_generator(
218 "testing_archive." + name, exp_exception, msg
219 )
220 else: # No exception expected - this is a positive test
221 test = experiment_test_generator("testing_archive." + name)
222 # setattr makes a properly named test method within TestExperimentArchive which
223 # can be run by unittest.main()
224 setattr(TestExperimentArchive, name, test)
225 break
226
227
228def exception_test_generator(module_name, exception, exception_message):
229 """
230 Generate a single test for the given module name and exception message
231
232 :param module_name: Experiment module name, i.e. 'normalscan'
233 :type module_name: str
234 :param exception: Exception that is expected to be raised
235 :type exception: BaseException
236 :param exception_message: Message from the Exception raised.
237 :type exception_message: str
238 """
239
240 def test(self):
241 with self.assertRaisesRegex(exception, exception_message):
242 redirect_to_devnull(ehmain, experiment_name=module_name)
243
244 return test
245
246
247def build_experiment_tests(experiments=None, kwargs=None):
248 """
249 Create individual unit tests for all experiments within the base borealis_experiments/
250 directory. All experiments are run to ensure no exceptions are thrown when they are built
251 """
252 from experiment_prototype.experiment_prototype import ExperimentPrototype
253
254 experiment_package = "borealis_experiments"
255 experiment_path = f"{BOREALISPATH}/src/{experiment_package}/"
256 if not os.path.exists(experiment_path):
257 raise OSError(f"Error: experiment path {experiment_path} is invalid")
258
259 # parse kwargs and pass to experiment
260 kwargs_dict = {}
261 if kwargs:
262 for element in kwargs:
263 if element == "":
264 continue
265 kwarg = element.split("=")
266 if len(kwarg) == 2:
267 kwargs_dict[kwarg[0]] = kwarg[1]
268 else:
269 raise ValueError(f"Bad kwarg: {element}")
270
271 def add_experiment_test(exp_name: str):
272 """Add a unit test for a given experiment"""
273 imported_module = import_module("." + exp_name, package=experiment_package)
274 # Loop through all attributes of each found module
275 for i in dir(imported_module):
276 attribute = getattr(imported_module, i)
277 # To verify that an attribute is a runnable experiment, check that the attribute is
278 # a class and inherits from ExperimentPrototype
279 if inspect.isclass(attribute) and issubclass(
280 attribute, ExperimentPrototype
281 ):
282 # Only create a test if the current attribute is the experiment itself
283 if "ExperimentPrototype" not in str(attribute):
284 test = experiment_test_generator(exp_name, **kwargs_dict)
285 # setattr make the "test" function a method within TestActiveExperiments called
286 # "test_[exp_name]" which can be run via unittest.main()
287 setattr(TestActiveExperiments, f"test_{exp_name}", test)
288
289 # Grab the experiments specified
290 if experiments is not None:
291 for name in experiments:
292 spec = importlib.util.find_spec("." + name, package=experiment_package)
293 if spec is None:
294 # Add in a failing test for this experiment name
295 setattr(
296 TestActiveExperiments,
297 f"test_{name}",
298 lambda self: self.fail("Experiment not found"),
299 )
300 else:
301 add_experiment_test(name)
302
303 else:
304 # Iterate through all modules in the borealis_experiments directory
305 for _, name, _ in pkgutil.iter_modules([experiment_path]):
306 add_experiment_test(name)
307
308
309def experiment_test_generator(module_name, **kwargs):
310 """
311 Generate a single test for a given experiment name. The test will try to run the experiment,
312 and if any exceptions are thrown (i.e. the experiment is built incorrectly) the test will fail.
313
314 :param module_name: Experiment module name (i.e. 'normalscan')
315 :type module_name: str
316 """
317
318 def test(self):
319 try:
320 redirect_to_devnull(ehmain, experiment_name=module_name, **kwargs)
321 except Exception as err:
322 self.fail(err)
323
324 return test
325
326
327def run_tests(raw_args=None, buffer=True, print_results=True):
328 parser = argparse.ArgumentParser()
329 parser.add_argument(
330 "--site_id",
331 required=False,
332 default="sas",
333 choices=["sas", "pgr", "inv", "rkn", "cly", "lab"],
334 help="Site ID of site to test experiments as. Defaults to sas.",
335 )
336 parser.add_argument(
337 "--experiments",
338 required=False,
339 nargs="+",
340 default=None,
341 help="Only run the experiments specified after this option. Experiments \
342 specified must exist within the top-level Borealis experiments directory.",
343 )
344 parser.add_argument(
345 "--kwargs",
346 required=False,
347 nargs="+",
348 default=list(),
349 help="Keyword arguments to pass to the experiments. Note that kwargs are passed to all "
350 "experiments specified.",
351 )
352 parser.add_argument(
353 "--module",
354 required=False,
355 default="__main__",
356 help="If calling from another python file, this should be set to "
357 "'experiment_unittests' in order to properly work.",
358 )
359 args = parser.parse_args(raw_args)
360
361 os.environ["RADAR_ID"] = args.site_id
362
363 # Read in config.ini file for current site to make necessary directories
364 path = (
365 f'{os.environ["BOREALISPATH"]}/config/'
366 f'{os.environ["RADAR_ID"]}/'
367 f'{os.environ["RADAR_ID"]}_config.ini'
368 )
369 try:
370 with open(path, "r") as data:
371 raw_config = json.load(data)
372 except OSError:
373 errmsg = f"Cannot open config file at {path}"
374 raise ValueError(errmsg)
375
376 # These directories are required for ExperimentHandler to run
377 data_directory = raw_config["data_directory"]
378 log_directory = raw_config["log_handlers"]["logfile"]["directory"]
379 hdw_path = raw_config["hdw_path"]
380 hdw_dat_file = f'{hdw_path}/hdw.dat.{os.environ["RADAR_ID"]}'
381 if not os.path.exists(data_directory):
382 os.makedirs(data_directory)
383 if not os.path.exists(log_directory):
384 os.makedirs(log_directory)
385 if not os.path.exists(hdw_path):
386 os.makedirs(hdw_path)
387 if not os.path.exists(hdw_dat_file):
388 open(hdw_dat_file, "w")
389
390 experiments = args.experiments
391 if experiments is None: # Run all unit tests and experiment tests
392 print("Running tests on all experiments")
393 build_unit_tests()
394 build_experiment_tests()
395 argv = [sys.argv[0]]
396 else: # Only test specified experiments
397 print(f"Running tests on experiments {experiments}")
398 build_experiment_tests(experiments, args.kwargs)
399 exp_tests = []
400 for exp in experiments:
401 # Check experiment exists
402 if hasattr(TestActiveExperiments, f"test_{exp}"):
403 # Create correct string to test the experiment with unittest
404 exp_tests.append(f"TestActiveExperiments.test_{exp}")
405 else:
406 print(f"Could not find experiment {exp}. Exiting...")
407 exit(1)
408 argv = [parser.prog] + exp_tests
409 if print_results:
410 result = unittest.main(module=args.module, argv=argv, exit=False, buffer=buffer)
411 else:
412 result = redirect_to_devnull(
413 unittest.main, module=args.module, argv=argv, exit=False, buffer=buffer
414 )
415
416 # Clean up the directories/files we created
417 try:
418 os.removedirs(data_directory)
419 except OSError: # If directories not empty, this will fail. That is fine.
420 pass
421 try:
422 os.removedirs(log_directory)
423 except OSError: # If directories not empty, this will fail. That is fine.
424 pass
425 if os.path.getsize(hdw_dat_file) == 0:
426 try:
427 os.remove(hdw_dat_file)
428 except OSError: # Path is a directory
429 os.removedirs(hdw_dat_file)
430 else: # File removed, now clean up directories
431 try:
432 os.removedirs(hdw_path)
433 except OSError: # If directories not empty, this will fail. That is fine.
434 pass
435
436 return result
437
438
439if __name__ == "__main__":
440 from utils import log_config
441
442 log = log_config.log(console=False, logfile=False, aggregator=False)
443
444 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.
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, "__root__\n" \
40 " rxonly specified as False but tx_beam_order not given. Slice: 0 \(type=value_error\)"