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"