From 6494114a05d9ddf35b092aceae17848935eeb7fe Mon Sep 17 00:00:00 2001 From: Marco Tazzari Date: Thu, 4 Nov 2021 13:03:52 +0000 Subject: [PATCH] Add GH Actions CI, more tests, more input checks, @timer, use setup.cfg This commit also has: * [cli] Better printout of numerical parameters * [utils] Add @timer to time functions * [cli] Major revamping: core functions are module-level; Simulator obj fully implemented * [cli] Allow running main() as a function (i.e. not through CLI) * [model] Switch off some debug logging and reduce numba logs * [tests] Implement test for consistency among simulators * [tests] Add test and checks (in cli) for input parameter values --- .github/workflows/tests.yml | 30 ++++ oasishurricane/cli.py | 122 +++++++++---- oasishurricane/model.py | 330 ++++++++++++++++++++++++++---------- oasishurricane/tests.py | 115 ++++++++++++- oasishurricane/utils.py | 40 +++++ requirements.txt | 1 - setup.cfg | 52 ++++++ setup.py | 30 +--- 8 files changed, 562 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 oasishurricane/utils.py delete mode 100644 requirements.txt create mode 100644 setup.cfg diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b8cf49d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests + +# Run this workflow every time a new commit pushed to your repository +on: push + +jobs: + test: + name: Run tests + runs-on: ubuntu-20.04 + + strategy: + matrix: + python-version: [ 3.6, 3.7, 3.8, 3.9 ] + + steps: + # Checks out a copy of your repository on the ubuntu-latest machine + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - name: Install test dependencies + run: | + pip install setuptools --upgrade + pip install .[test] + - name: Run unit tests + run: py.test oasishurricane/tests.py \ No newline at end of file diff --git a/oasishurricane/cli.py b/oasishurricane/cli.py index 4d63bd8..f9460e9 100644 --- a/oasishurricane/cli.py +++ b/oasishurricane/cli.py @@ -4,6 +4,7 @@ import sys import argparse import numpy as np +import copy import logging import logging.config @@ -12,7 +13,7 @@ logging.config.dictConfig(LOGGING) logger = logging.getLogger("cli") -from .model import Simulator +from .model import Simulator, SIMULATORS def parse_args(): @@ -20,93 +21,144 @@ def parse_args(): :return: """ - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + usage='use "%(prog)s --help" for more information', + formatter_class=argparse.RawTextHelpFormatter # for multi-line help text + ) # parser = parser.add_argument_group('parser arguments') parser.add_argument("florida_landfall_rate", action="store", - help="[float] florida_landfall_rate", + help="[float] annual rate of landfalling hurricanes in Florida.", type=float) parser.add_argument("florida_mean", action="store", - help="[float] florida_mean", + help="[float] mean of the economic loss of landfalling hurricane in Florida.", type=float) parser.add_argument("florida_stddev", action="store", - help="[float] florida_stddev", + help="[float] std deviation of the economic loss of landfalling hurricane in Florida.", type=float) parser.add_argument("gulf_landfall_rate", action="store", - help="[float] gulf_landfall_rate", + help="[float] annual rate of landfalling hurricanes in Gulf states.", type=float) parser.add_argument("gulf_mean", action="store", - help="[float] gulf_mean", + help="[float] mean of the economic loss of landfalling hurricane in Gulf states.", type=float) parser.add_argument("gulf_stddev", action="store", - help="[float] gulf_stddev", + help="[float] std deviation of the economic loss of landfalling hurricane in Gulf states.", type=float) parser.add_argument("-n", "--num_monte_carlo_samples", action="store", - help="[int] num_monte_carlo_samples (default=10)", + help="[int] number of monte carlo samples, i.e. years. (default=10)", type=int, dest="num_monte_carlo_samples", default=10) parser.add_argument("-s", "--simulator", action="store", - help="", + help="[int] simulator id. Implemented simulators: (id:name) \n" + \ + "\n".join([f"{k}: {v['desc']}" for k, v in SIMULATORS.items()]), type=int, dest="simulator_id", default=0) - args = parser.parse_args() + args = vars(parser.parse_args()) # convert to dict for ease of use return args def validate_args(args): """ + Validate parameters (args) passed in input through the CLI. + If necessary, perform transformations of parameter values to the simulation space. + + :param args: [dict] Parsed arguments. + + :return: [dict] Validated arguments. - :param args: - :return: """ - assert args.florida_mean > 0, \ - f"Expect florida_mean>0, got {args.florida_mean}" + # note: input data types are already checked by the parser object. + + # here we check input values + if args['florida_landfall_rate'] <= 0: + raise ValueError(f"Expect florida_landfall_rate>0, got {args['florida_landfall_rate']}") + + if args['florida_mean'] <= 0: + raise ValueError(f"Expect florida_mean>0, got {args['florida_mean']}") + + if args['florida_stddev'] <= 0: + raise ValueError(f"Expect florida_stddev>0, got {args['florida_stddev']}") + + if args['gulf_landfall_rate'] <= 0: + raise ValueError(f"Expect gulf_landfall_rate>0, got {args['gulf_landfall_rate']}") + + if args['gulf_mean'] < 0: + raise ValueError(f"Expect gulf_mean>0, got {args['gulf_mean']}") - assert args.gulf_mean > 0, \ - f"Expect gulf_mean>0, got {args.gulf_mean}" + if args['gulf_stddev'] < 0: + raise ValueError(f"Expect gulf_stddev>0, got {args['gulf_stddev']}") - florida_mean = np.log(args.florida_mean) - gulf_mean = np.log(args.gulf_mean) + if args['simulator_id'] < 0: + raise ValueError(f"Expect simulator_id>=0, got {args['simulator_id']}") - validated_args = { - "florida_landfall_rate": args.florida_landfall_rate, - "florida_mean": florida_mean, - "florida_stddev": args.florida_stddev, - "gulf_landfall_rate": args.gulf_landfall_rate, - "gulf_mean": gulf_mean, - "gulf_stddev": args.gulf_stddev, - "num_monte_carlo_samples": args.num_monte_carlo_samples, - } + # deepcopy ensures mutable items are copied too + validated_args = copy.deepcopy(args) + # validate parameters + # compute natural log of the LogNormal means + validated_args.update({ + "florida_mean": np.log(args['florida_mean']), + "gulf_mean": np.log(args['gulf_mean']), + }) + + # log validated parameter values logger.info("Validated parameters: ") - for arg_k, arg_v in validated_args.items(): - logger.info(f"{arg_k:>30s} = {arg_v:>10.5f}") + + numerical_args = [ + "florida_landfall_rate", + "florida_mean", + "florida_stddev", + "gulf_landfall_rate", + "gulf_mean", + "gulf_stddev", + ] + + for arg_k in numerical_args: + logger.info(f"{arg_k:>30s} = {validated_args[arg_k]:>10.5f}") return validated_args -def main(): - args = parse_args() +def main(args=None): + """ + Main function, called through the shell entrypoint. + # TODO: IMPROVE DOCS + + """ + as_CLI = False + + if not args: + # the code is used as a CLI, parse the arguments + as_CLI = True + args = parse_args() + # validate (and transform, if necessary) arguments validated_args = validate_args(args) - sim = Simulator(args.simulator_id) - sim.simulate(**validated_args) + # use the desired simulator + sim = Simulator(validated_args["simulator_id"]) + + # run the simulation + mean_loss = sim.simulate(**validated_args) - sys.exit(0) + if as_CLI: + sys.exit(0) + else: + return mean_loss if __name__ == "__main__": diff --git a/oasishurricane/model.py b/oasishurricane/model.py index e772bd4..071ccfd 100644 --- a/oasishurricane/model.py +++ b/oasishurricane/model.py @@ -5,10 +5,15 @@ import logging import time import datetime -from alive_progress import alive_bar +from numba import jit, njit, prange + +logging.getLogger('numba').setLevel(logging.WARNING) logger = logging.getLogger("model") +from .utils import timer + + def get_rng(seed=None): """ Get a new random number generator.s @@ -19,133 +24,282 @@ def get_rng(seed=None): return np.random.default_rng(seed) -def simulate_pbar(florida_landfall_rate, florida_mean, florida_stddev, - gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, - **kwargs): +@timer +def mean_loss_py(florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, + timeit_discard=False): """ - Simulate losses + Compute mean economic loss in Pure Python. - :param florida_landfall_rate: - :param florida_mean: - :param florida_stddev: - :param gulf_landfall_rate: - :param gulf_mean: - :param gulf_stddev: - :param num_monte_carlo_samples: + :param florida_landfall_rate: [float] annual rate of landfalling hurricanes in Florida. + :param florida_mean: [float] mean of the economic loss of landfalling hurricane in Florida. + :param florida_stddev: [float] std deviation of the economic loss of landfalling hurricane in Florida. + :param gulf_landfall_rate: [float] annual rate of landfalling hurricanes in Gulf states. + :param gulf_mean: [float] mean of the economic loss of landfalling hurricane in Gulf states. + :param gulf_stddev: [float] std deviation of the economic loss of landfalling hurricane in Gulf states. + :param num_monte_carlo_samples: [int] Number of monte carlo samples, i.e. years. + :param timeit_discard: [bool] (optional) If True, @timer does not record the timing. Only used by @timer. - :return: Mean annual losses + :return: [float] Mean annual losses. """ - rng_seed = kwargs.get('rng_seed', None) + tot_loss = 0 - # get a new random number generator - logger.info(f"Setting the random number generator with seed:{rng_seed}") - rng = get_rng(rng_seed) + for i in range(num_monte_carlo_samples): + fl_events = np.random.poisson(lam=florida_landfall_rate, size=1)[0] + fl_loss = 0 + for j in range(fl_events): + fl_loss += np.random.lognormal(florida_mean, florida_stddev) - logger.info(f"Starting main loop over desired {num_monte_carlo_samples} Monte Carlo samples ") - tot_loss = 0 + gulf_events = np.random.poisson(lam=gulf_landfall_rate, size=1)[0] - t0 = time.time() - with alive_bar(num_monte_carlo_samples) as bar: - for i in range(num_monte_carlo_samples): - log_prefix = f"year {i:0>10} " + gulf_loss = 0 + for k in range(gulf_events): + gulf_loss += np.random.lognormal(gulf_mean, gulf_stddev) - fl_events = rng.poisson(lam=florida_landfall_rate, size=1)[0] - logger.debug(log_prefix + f"Florida events: {fl_events:0>3}") - fl_loss = 0 - for j in range(fl_events): - fl_loss += rng.lognormal(florida_mean, florida_stddev) - logger.debug(log_prefix + f"Florida loss: {fl_loss:05.3f}") + year_loss = fl_loss + gulf_loss - gulf_events = rng.poisson(lam=gulf_landfall_rate, size=1)[0] - logger.debug(log_prefix + f"Gulf events: {gulf_events:5.3f}") + tot_loss += year_loss - gulf_loss = 0 - for k in range(gulf_events): - gulf_loss += rng.lognormal(gulf_mean, gulf_stddev) - logger.debug(log_prefix + f"Gulf loss: {gulf_loss:05.3f}") + return tot_loss / num_monte_carlo_samples - year_loss = fl_loss + gulf_loss - tot_loss += year_loss - logger.debug(log_prefix + f"TOTAL LOSS: {tot_loss:05.3f}") +@timer +@jit(nopython=True) +def mean_loss_jit(florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, + timeit_discard=False): + """ + Compute mean economic loss with explicit loops and jit-compilation with numba. - bar() + :param florida_landfall_rate: [float] annual rate of landfalling hurricanes in Florida. + :param florida_mean: [float] mean of the economic loss of landfalling hurricane in Florida. + :param florida_stddev: [float] std deviation of the economic loss of landfalling hurricane in Florida. + :param gulf_landfall_rate: [float] annual rate of landfalling hurricanes in Gulf states. + :param gulf_mean: [float] mean of the economic loss of landfalling hurricane in Gulf states. + :param gulf_stddev: [float] std deviation of the economic loss of landfalling hurricane in Gulf states. + :param num_monte_carlo_samples: [int] Number of monte carlo samples, i.e. years. + :param timeit_discard: [bool] (optional) If True, @timer does not record the timing. Only used by @timer. - t1 = time.time() - logger.info(f"End of main loop. Elapsed time: {datetime.timedelta(seconds=t1-t0)} (h:m:s)") - mean_loss = tot_loss / num_monte_carlo_samples + :return: [float] Mean annual losses. - logger.info(f"MEAN LOSS: {mean_loss}") + """ + fl_events = np.random.poisson(lam=florida_landfall_rate, size=num_monte_carlo_samples) + gulf_events = np.random.poisson(lam=gulf_landfall_rate, size=num_monte_carlo_samples) - return mean_loss + tot_loss = 0 + for i in range(num_monte_carlo_samples): -def simulate(florida_landfall_rate, florida_mean, florida_stddev, - gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, - **kwargs): - """ - Simulate losses - WITHOUT PROGRESSBAR + fl_loss = 0 + for j in range(fl_events[i]): + fl_loss += np.random.lognormal(florida_mean, florida_stddev) - :param florida_landfall_rate: - :param florida_mean: - :param florida_stddev: - :param gulf_landfall_rate: - :param gulf_mean: - :param gulf_stddev: - :param num_monte_carlo_samples: + gulf_loss = 0 + for k in range(gulf_events[i]): + gulf_loss += np.random.lognormal(gulf_mean, gulf_stddev) - :return: Mean annual losses + year_loss = fl_loss + gulf_loss + + tot_loss += year_loss + return tot_loss / num_monte_carlo_samples + + +@timer +@njit(parallel=True) +def mean_loss_jit_parallel(florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, + timeit_discard=False): """ - rng_seed = kwargs.get('rng_seed', None) + Compute mean economic loss with explicit loops, jit-compilation, and auto-parallelization with numba. - # get a new random number generator - logger.info(f"Setting the random number generator with seed:{rng_seed}") - rng = get_rng(rng_seed) + :param florida_landfall_rate: [float] annual rate of landfalling hurricanes in Florida. + :param florida_mean: [float] mean of the economic loss of landfalling hurricane in Florida. + :param florida_stddev: [float] std deviation of the economic loss of landfalling hurricane in Florida. + :param gulf_landfall_rate: [float] annual rate of landfalling hurricanes in Gulf states. + :param gulf_mean: [float] mean of the economic loss of landfalling hurricane in Gulf states. + :param gulf_stddev: [float] std deviation of the economic loss of landfalling hurricane in Gulf states. + :param num_monte_carlo_samples: [int] Number of monte carlo samples, i.e. years. + :param timeit_discard: [bool] (optional) If True, @timer does not record/print the timing. Only used by @timer. - logger.info(f"Starting main loop over desired {num_monte_carlo_samples} Monte Carlo samples ") - tot_loss = 0 + :return: [float] Mean annual losses. - t0 = time.time() - for i in range(num_monte_carlo_samples): - log_prefix = f"year {i:0>10} " + """ + fl_events = np.random.poisson(lam=florida_landfall_rate, size=num_monte_carlo_samples) + gulf_events = np.random.poisson(lam=gulf_landfall_rate, size=num_monte_carlo_samples) - fl_events = rng.poisson(lam=florida_landfall_rate, size=1)[0] - logger.debug(log_prefix + f"Florida events: {fl_events:0>3}") + tot_loss = 0 + for i in prange(num_monte_carlo_samples): fl_loss = 0 - for j in range(fl_events): - fl_loss += rng.lognormal(florida_mean, florida_stddev) - logger.debug(log_prefix + f"Florida loss: {fl_loss:05.3f}") - - gulf_events = rng.poisson(lam=gulf_landfall_rate, size=1)[0] - logger.debug(log_prefix + f"Gulf events: {gulf_events:5.3f}") + for j in range(fl_events[i]): + fl_loss += np.random.lognormal(florida_mean, florida_stddev) gulf_loss = 0 - for k in range(gulf_events): - gulf_loss += rng.lognormal(gulf_mean, gulf_stddev) - logger.debug(log_prefix + f"Gulf loss: {gulf_loss:05.3f}") + for k in range(gulf_events[i]): + gulf_loss += np.random.lognormal(gulf_mean, gulf_stddev) year_loss = fl_loss + gulf_loss tot_loss += year_loss - logger.debug(log_prefix + f"TOTAL LOSS: {tot_loss:05.3f}") + + return tot_loss / num_monte_carlo_samples - t1 = time.time() - logger.info(f"End of main loop. Elapsed time: {datetime.timedelta(seconds=t1-t0)} (h:m:s)") - mean_loss = tot_loss / num_monte_carlo_samples +@timer +@jit(nopython=True) +def mean_loss_noloops_jit(florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, + timeit_discard=False): + """ + Compute mean economic loss with numpy vectorization, no explicit loops, and jit-compilation with numba. - logger.info(f"MEAN LOSS: {mean_loss}") + :param florida_landfall_rate: [float] annual rate of landfalling hurricanes in Florida. + :param florida_mean: [float] mean of the economic loss of landfalling hurricane in Florida. + :param florida_stddev: [float] std deviation of the economic loss of landfalling hurricane in Florida. + :param gulf_landfall_rate: [float] annual rate of landfalling hurricanes in Gulf states. + :param gulf_mean: [float] mean of the economic loss of landfalling hurricane in Gulf states. + :param gulf_stddev: [float] std deviation of the economic loss of landfalling hurricane in Gulf states. + :param num_monte_carlo_samples: [int] Number of monte carlo samples, i.e. years. + :param timeit_discard: [bool] (optional) If True, @timer does not record the timing. Only used by @timer. - return mean_loss + :return: [float] Mean annual losses. + + """ + fl_events = np.random.poisson(lam=florida_landfall_rate, size=num_monte_carlo_samples) + gulf_events = np.random.poisson(lam=gulf_landfall_rate, size=num_monte_carlo_samples) + Nfl_events = np.sum(fl_events) + Ngulf_events = np.sum(gulf_events) + + fl_loss = np.random.lognormal(florida_mean, florida_stddev, size=(Nfl_events,)) + + gulf_loss = np.random.lognormal(gulf_mean, gulf_stddev, size=(Ngulf_events,)) + + tot_loss = np.sum(fl_loss) + np.sum(gulf_loss) + + return tot_loss / num_monte_carlo_samples + + +@timer +def mean_loss_noloops_py(florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, num_monte_carlo_samples, + timeit_discard=False): + """ + Compute mean economic loss in Pure Python, using numpy vectorization and no explicit loops. + + :param florida_landfall_rate: [float] annual rate of landfalling hurricanes in Florida. + :param florida_mean: [float] mean of the economic loss of landfalling hurricane in Florida. + :param florida_stddev: [float] std deviation of the economic loss of landfalling hurricane in Florida. + :param gulf_landfall_rate: [float] annual rate of landfalling hurricanes in Gulf states. + :param gulf_mean: [float] mean of the economic loss of landfalling hurricane in Gulf states. + :param gulf_stddev: [float] std deviation of the economic loss of landfalling hurricane in Gulf states. + :param num_monte_carlo_samples: [int] Number of monte carlo samples, i.e. years. + :param timeit_discard: [bool] (optional) If True, @timer does not record the timing. Only used by @timer. + + :return: [float] Mean annual losses. + + """ + + fl_events = np.random.poisson(lam=florida_landfall_rate, size=num_monte_carlo_samples) + gulf_events = np.random.poisson(lam=gulf_landfall_rate, size=num_monte_carlo_samples) + Nfl_events = np.sum(fl_events) + Ngulf_events = np.sum(gulf_events) + + fl_loss = np.random.lognormal(florida_mean, florida_stddev, size=(Nfl_events,)) + + gulf_loss = np.random.lognormal(gulf_mean, gulf_stddev, size=(Ngulf_events,)) + + tot_loss = np.sum(fl_loss) + np.sum(gulf_loss) + + return tot_loss / num_monte_carlo_samples + + +SIMULATORS = { + 0: { + 'func': mean_loss_py, + 'desc': "python" + }, + 1: { + 'func': mean_loss_jit, + 'desc': "jit" + }, + 2: { + 'func': mean_loss_jit_parallel, + 'desc': "jit-parallel" + }, + 3: { + 'func': mean_loss_noloops_jit, + 'desc': "jit-noloops" + }, + 4: { + 'func': mean_loss_noloops_py, + 'desc': "python-noloops" + }, +} class Simulator(object): def __init__(self, simulator_id): - if simulator_id == 0: - self.simulate = simulate_pbar - elif simulator_id == 1: - self.simulate = simulate + """Init the Simulator object by setting the simulator. """ + try: + self._simulate_core = SIMULATORS[simulator_id]['func'] + self._desc = SIMULATORS[simulator_id]['desc'] + logger.info(f"Using simulator: {self._desc}") + + except KeyError: + raise NotImplementedError(f"simulator_id={simulator_id} is not implemented") + + def __str__(self): + """Description of the simulator engine used.""" + return f"{self._desc:16s}" + + def simulate(self, florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, + num_monte_carlo_samples, **kwargs): + """ + Simulate losses due to hurricanes making landfall in Florida and in Gulf States. + + The simulation assumes a Poisson distribution for the rate of landfalling hurricanes, + and a LogNormal distribution for the economic loss. + + The `mean` provided in input for the economic loss is the mean of the normal distribution + underlying the LogNormal, namely: the expected value E[x] = mean (not exp^mean). + This makes it easier to interpret the results. + + :param florida_landfall_rate: [float] annual rate of landfalling hurricanes in Florida. + :param florida_mean: [float] mean of the economic loss of landfalling hurricane in Florida. + :param florida_stddev: [float] std deviation of the economic loss of landfalling hurricane in Florida. + :param gulf_landfall_rate: [float] annual rate of landfalling hurricanes in Gulf states. + :param gulf_mean: [float] mean of the economic loss of landfalling hurricane in Gulf states. + :param gulf_stddev: [float] std deviation of the economic loss of landfalling hurricane in Gulf states. + :param num_monte_carlo_samples: [int] number of monte carlo samples, i.e. years. + :param rng_seed: [int] (optional) Seed of the random number generator. + + :return: [float] Mean annual losses. + + """ + rng_seed = kwargs.get('rng_seed', None) + + # set the random number generator seed + logger.info(f"Setting the random number generator with seed:{rng_seed}") + np.random.seed(rng_seed) + + logger.info( + f"Starting main loop over desired {num_monte_carlo_samples} Monte Carlo samples ") + + # dummy call to jit-compile it + _ = self._simulate_core(1, 1e-10, 1e-10, 1, 1e-10, 1e-10, 1, timeit_discard=True) + + t0 = time.time() + mean_loss = self._simulate_core(florida_landfall_rate, florida_mean, florida_stddev, + gulf_landfall_rate, gulf_mean, gulf_stddev, + num_monte_carlo_samples, + ) + + t1 = time.time() + logger.info( + f"End of main loop. Elapsed time: {datetime.timedelta(seconds=t1 - t0)} (h:m:s)") + + logger.info(f"MEAN LOSS: {mean_loss}") + return mean_loss diff --git a/oasishurricane/tests.py b/oasishurricane/tests.py index 71b6ed3..a2e9adf 100644 --- a/oasishurricane/tests.py +++ b/oasishurricane/tests.py @@ -5,17 +5,118 @@ # gethurricaneloss 10 0.0001 0.001 20 0.0001 0.0001 -n 1000 # should be 0 +import copy import numpy as np +import pytest +from pytest import raises +from .cli import main +from .model import SIMULATORS + +# fix random number generator seed SEED = 123456789 +# mock CLI arguments +args = [ + { # reference test + "florida_landfall_rate": 10., + "florida_mean": 2, + "florida_stddev": 0.6, + "gulf_landfall_rate": 20., + "gulf_mean": 0.3, + "gulf_stddev": 0.1, + "num_monte_carlo_samples": 20000, + "simulator_id": 0, + "rng_seed": SEED, + }, + { # test larger rates + "florida_landfall_rate": 30., + "florida_mean": 2, + "florida_stddev": 0.6, + "gulf_landfall_rate": 34., + "gulf_mean": 0.3, + "gulf_stddev": 0.1, + "num_monte_carlo_samples": 20000, + "simulator_id": 0, + "rng_seed": SEED, + }, + { # test larger losses (requires deeper MC sampling) + "florida_landfall_rate": 8., + "florida_mean": 10.2333, + "florida_stddev": 1.8345, + "gulf_landfall_rate": 15., + "gulf_mean": 4.33232, + "gulf_stddev": 1.1344, + "num_monte_carlo_samples": 1000000, + "simulator_id": 0, + "rng_seed": SEED, + } +] + +@pytest.mark.parametrize("test_args", + [(args_) for args_ in args], + ids=["{}".format(i) for i in range(len(args))]) +def test_simulators_consistency(test_args, rtol=0.01, atol=0.001): + """ + Test if simulators return mean losses that agree within a relative tolerance `rtol` + and an absolute tolerance `atol` + :param test_args: [dict] test arguments, same format as in the CLI (i.e., before validation) + :param rtol: relative tolerance of the checks + :param atol: absolute tolerance of the checks + + """ + Nsimulators = len(SIMULATORS.keys()) + + # iterate through simulators and check for consistency + mean_loss = [] + for id_ in SIMULATORS.keys(): + test_args["simulator_id"] = id_ + mean_loss.append(main(test_args)) + + # compare results w.r.t. the python-only version, as a point-of-truth + np.testing.assert_allclose(mean_loss, np.repeat(mean_loss[0], Nsimulators), atol=atol, + rtol=rtol) + + +@pytest.mark.parametrize("test_args", + [(args_) for args_ in args], + ids=["{}".format(i) for i in range(len(args))]) +def test_simulator_selection(test_args): + """Test exceptions if simulator doesn't exist. """ + max_simulator_id = int(np.max(list(SIMULATORS.keys()))) + + # if simulator_id > max available should return NotImplementedError + test_args["simulator_id"] = max_simulator_id + 1 + with raises(NotImplementedError, + match=f"simulator_id={test_args['simulator_id']} is not implemented"): + main(test_args) + + # if simulator_id < 0 the validation should throw a ValueError + test_args["simulator_id"] = -1 + with raises(ValueError, match="Expect simulator_id>=0, got -1"): + main(test_args) + +@pytest.mark.parametrize("test_args", + [(args_) for args_ in args], + ids=["{}".format(i) for i in range(len(args))]) +def test_input_parameter_values(test_args): + """Test exceptions if input data has forbidden values. """ -def test_simulate_zero_losses(): - from .model import simulate + numerical_args = [ + "florida_landfall_rate", + "florida_mean", + "florida_stddev", + "gulf_landfall_rate", + "gulf_mean", + "gulf_stddev", + ] - res = simulate(florida_landfall_rate=10, florida_mean=-10, florida_stddev=1e-14, - gulf_landfall_rate=10, gulf_mean=-10, gulf_stddev=1e-14, - num_monte_carlo_samples=10000, - rng_seed=SEED) + for numerical_arg in numerical_args: + # turn negative each numerical argument; it should raise a Value Error + # take a deepcopy of test_args otherwise they are overwritten + test_args_ = copy.deepcopy(test_args) + test_args_[numerical_arg] *= -1 + with raises(ValueError, + match=f"Expect {numerical_arg}>0, got {test_args_[numerical_arg]}"): + main(test_args_) - np.testing.assert_allclose(res, 0., atol=1e-3, rtol=0) diff --git a/oasishurricane/utils.py b/oasishurricane/utils.py new file mode 100644 index 0000000..0cf8254 --- /dev/null +++ b/oasishurricane/utils.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# coding=utf-8 + +import functools +import time +import os + + +# TODO: pass named arguments to the core functions to improve formatting of the logfile +def timer(func): + """ + Decorator that times the decorated function. + If TIMEIT_LOGFILE is defined in the shell, it prints the timing to file, else to stdout. + + :param func: decorated function + :return: the evaluated function + """ + @functools.wraps(func) + def wrapper_timer(*args, **kwargs): + tic = time.perf_counter() + value = func(*args, **kwargs) + toc = time.perf_counter() + elapsed_time = toc - tic + + if kwargs.get("timeit_discard", False): + return value + + # timeit_msg = f"Elapsed time: {elapsed_time:0.4f} seconds" + timeit_msg = "\t".join([f"{arg:>10.6f}" for arg in args]) + timeit_msg += "\t" + f"{elapsed_time:5.4f}" + timeit_msg += " \n" + if 'TIMEIT_LOGFILE' in os.environ: + with open(os.environ['TIMEIT_LOGFILE'], "a") as f: + f.write(timeit_msg) + else: + print(timeit_msg) + + return value + + return wrapper_timer diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b22c7a4..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -numpy>=1.16.2 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..62b9ef6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,52 @@ +# to set version dynamically: https://github.com/pypa/setuptools/issues/1724#issuecomment-627241822 + +[metadata] +name = oasishurricane +version = attr: oasishurricane.__version__ +author = Marco Tazzari +author_email = marco.tazzari@gmail.com +description = A Python command-line utility for Linux that computes the economic loss for hurricanes in Florida and in the Gulf states. +long_description = file: README.md +long_description_content_type = text/markdown +license = BSD-3 +license_file = LICENSE +include_package_data = False +url = https://github.com/mtazzari/oasishurricane +project_urls = + Bug Tracker = https://github.com/mtazzari/oasishurricane/issues +classifiers = + Development Status :: 5 - Production/Stable + License :: OSI Approved :: BSD License + Intended Audience :: Developers + Intended Audience :: Science/Research + Operating System :: MacOS :: MacOS X + Operating System :: POSIX :: Linux + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 +keywords = + catastrophe + catastrophemodelling + insurtech + +[options] +packages = oasishurricane + +# python_requires docs: https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires +python_requires = >=3.6 + +# PEP 440 - pinning package versions: https://www.python.org/dev/peps/pep-0440/#compatible-release +install_requires = + numpy>=1.9 + numba + +[options.extras_require] +test = pytest + +# configuring entry_points in setup.cfg: +# https://stackoverflow.com/questions/48884796/how-to-setup-entry-points-in-setup-cfg/48891252 +[options.entry_points] +console_scripts = + gethurricaneloss = oasishurricane.cli:main + diff --git a/setup.py b/setup.py index c6ae2c4..2ca2946 100644 --- a/setup.py +++ b/setup.py @@ -1,30 +1,6 @@ #!/usr/bin/env python # coding=utf-8 -from setuptools import setup, find_packages +from setuptools import setup -# read version number -from oasishurricane import __version__ - -setup( - name="oasishurricane", - version=__version__, - packages=find_packages(), - author="Marco Tazzari", - author_email="marco.tazzari@gmail.com", - description="A command-line utility", - long_description=open('README.md').read(), - entry_points=''' - [console_scripts] - gethurricaneloss=oasishurricane.cli:main - ''', - install_requires=[line.rstrip() for line in open("requirements.txt", "r").readlines()], - license="BSD-3", - url="tbd", - classifiers=[ - 'Development Status :: 5 - Production/Stable', - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 3', - ] -) +# setup configuration is in `setup.cfg` +setup()