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("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.

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, "__root__\n" \
40                                "  rxonly specified as False but tx_beam_order not given. Slice: 0 \(type=value_error\)"