Source code for pybnf.pybnf

"""The entry point for the PyBNF application containing the main function and version"""


from . import __version__
from . import method_chain
from .budget import FitBudget, format_duration, spend_reserve
from .parse import load_config
from .config import init_logging
from .printing import print0, print1, print2, PybnfError
from .cluster import Cluster
from .pset import Trajectory, set_sim_registry, reap_active_sims, clear_sim_registry
from .registry import FIT_TYPE_REGISTRY
import pybnf.algorithms as algs
import pybnf.printing as printing

from subprocess import run
from numpy import inf
import numpy as np

import logging
import argparse
import os
import shutil
import time
import traceback
import pickle
from pathlib import Path


def _initialize_random_seed(config):
    """
    Resolve and log the run's random seed.

    If the config does not specify a seed, one is chosen from system entropy so it
    can be reused later for reproducibility. The resolved seed is written back into
    the config; each algorithm builds its own ``np.random.Generator`` (default_rng)
    from it in ``Algorithm.__init__``. NumPy's legacy global RNG is no longer seeded
    or used.
    """
    seed = config.config['random_seed']
    if seed is None:
        # Draw a fresh seed from OS entropy (kept within the config's allowed
        # [0, 2**32) range) without touching the legacy global RNG.
        seed = int(np.random.SeedSequence().generate_state(1)[0])
    config.config['random_seed'] = seed
    logger = logging.getLogger(__name__)
    logger.info('Random seed: %d', seed)
    return seed


def _finalize(success, alg, start_time):
    """Run best-effort post-fit cleanup, report the total run time, then exit.

    Called from ``main()``'s outer ``finally``. A failure inside
    ``alg.cleanup()`` is logged and swallowed so it can't hide the real run
    outcome -- but only if it is an ordinary ``Exception``. A
    ``KeyboardInterrupt``/``SystemExit`` raised during cleanup is allowed to
    propagate instead of being masked: ``exit()`` lives here as a plain
    statement, not inside a ``finally``, so an in-flight exception is no longer
    swallowed by it.
    """
    logger = logging.getLogger(__name__)
    try:
        if not success:
            logger.info('Fitting unsuccessful.  Attempting cleanup')
            if alg:
                alg.cleanup()
                logger.info('Completed cleanup after exception')
    except Exception:
        logger.exception('During cleanup, another exception occurred')

    # Remove this run's (now-empty) live-sim registry directory.
    clear_sim_registry()

    secs = time.time() - start_time
    mins, secs = divmod(secs, 60)
    hrs, mins = divmod(mins, 60)
    print2('Total fitting time: %d:%02d:%02d' % (hrs, mins, secs))
    logger.info('Total fitting time: %d:%02d:%02d' % (hrs, mins, secs))
    exit(0 if success else 1)


def _build_arg_parser():
    """Construct the command-line argument parser for the fitting application."""
    parser = argparse.ArgumentParser(description='Performs parameter fitting on systems biology models defined in '
                                                 'BNGL or SBML. For documentation, examples, and source code, go to '
                                                 'https://github.com/lanl/PyBNF')

    parser.add_argument('-c', action='store', dest='conf_file',
                        help='Path to the BioNetFit configuration file', metavar='config.conf')
    parser.add_argument('-o', '--overwrite', action='store_true',
                        help='automatically overwrites existing folders if necessary')
    parser.add_argument('-t', '--cluster_type', action='store',
                        help='optional string denoting the type of cluster')
    parser.add_argument('-s', '--scheduler_file', action='store',
                        help='optional file on shared filesystem to get scheduler location, should be same as passed to dask-scheduler and dask-worker.')
    parser.add_argument('-r', '--resume', action='store', nargs='?', const=0, default=None, type=int,
                        metavar='iterations',
                        help='automatically resume the previously stopped fitting run; '
                             'if a number is passed with this flag, add that many iterations to the fitting run.')
    parser.add_argument('-d', '--debug_logging', action='store_true',
                        help='outputs a separate debugging log (file could be very large)')
    parser.add_argument('-l', '--log_prefix', action='store',
                        help='specifies a custom prefix for the log files (will overwrite if files already exist')
    parser.add_argument('-L', '--log_level', type=str.lower, default='i',
                        choices=['debug', 'info', 'warning', 'error', 'critical', 'none', 'd', 'i', 'w', 'e', 'c', 'n'],
                        help='set the level of output to the log file. Options in decreasing order of verbosity are: '
                             'debug, info, warning, error, critical, none.')
    return parser


def _setup_logging(cmdline_args):
    """Resolve the log prefix, clear any stale log files, and initialize logging.

    Returns the resolved ``(log_prefix, debug)`` pair.
    """
    if cmdline_args.log_prefix:
        log_prefix = cmdline_args.log_prefix
    else:
        log_prefix = 'bnf_{}'.format(time.strftime('%Y%m%d-%H%M%S'))
    debug = cmdline_args.debug_logging

    # Overwrite log file if it exists
    if os.path.isfile(f'{log_prefix}_debug.log'):
        os.remove(f'{log_prefix}_debug.log')
    if os.path.isfile(f'{log_prefix}.log'):
        os.remove(f'{log_prefix}.log')

    init_logging(log_prefix, debug, cmdline_args.log_level)
    return log_prefix, debug


def _resolve_continue_file(config, cmdline_args):
    """Determine which saved-algorithm file (if any) to resume from.

    Returns the path to the backup/finished file to reload, or ``None`` for a
    fresh run. When the user opts (at the prompt) to continue an in-progress run
    found in ``output_dir``, ``cmdline_args.resume`` is set to 0 as a side effect.
    """
    logger = logging.getLogger(__name__)
    output_dir = Path(config.config['output_dir'])
    backup_file = output_dir / 'alg_backup.bp'
    finished_file = output_dir / 'alg_finished.bp'
    continue_file = None
    if cmdline_args.resume is not None:
        if backup_file.exists():
            continue_file = backup_file
        elif finished_file.exists():
            if cmdline_args.resume <= 0:
                raise PybnfError(f'The fitting run saved in {output_dir} already finished. If you want to continue '
                                 'the fitting with more iterations, pass a number of iterations with the '
                                 '--resume flag.')
            continue_file = finished_file
        else:
            raise PybnfError(f'No algorithm found to resume in {output_dir}')
    elif backup_file.exists() and not cmdline_args.overwrite:
        ans = 'x'
        while ans.lower() not in ['y', 'yes', 'n', 'no', '']:
            ans = input('Your output_dir contains an in-progress run.\nContinue that run? [y/n] (y) ')
        if ans.lower() in ('y', 'yes', ''):
            logger.info('Resuming a previous run')
            continue_file = backup_file
            cmdline_args.resume = 0
    return continue_file


def _load_resumed_algorithm(continue_file, cmdline_args):
    """Reload a pickled algorithm (and its pending jobs) to resume a run.

    Returns ``(alg, pending, config)``; ``config`` is taken from the reloaded
    algorithm.
    """
    logger = logging.getLogger(__name__)
    logger.info('Reloading algorithm')
    with open(continue_file, 'rb') as f:
        alg, pending = pickle.load(f)
    logger.debug('Loaded algorithm is the %s algorithm' % ('refinement' if alg.refine else 'configured'))
    config = alg.config

    logger.debug('Checking for Simulations directory')
    if not os.path.exists(alg.sim_dir):
        os.makedirs(alg.sim_dir)

    if alg.bootstrap_number is not None:
        print0('Resuming a bootstrapping run')
        logger.info('Resuming a bootstrapping run')
        if cmdline_args.resume > 0 and cmdline_args.resume is not None:
            raise PybnfError("Cannot increase the number of iterations in a boostrapping run")
    else:
        print0('Resuming a fitting run')

    alg.add_iterations(cmdline_args.resume)
    if isinstance(alg, algs.SimplexAlgorithm):
        # The continuing alg is already on the Simplex stage, so don't restart simplex after completion
        alg.config.config['refine'] = 0
    return alg, pending, config


def _prepare_run_directories(config, cmdline_args):
    """Create the output/results/simulation directories for a fresh run.

    Prompts for (or auto-applies, with ``--overwrite``) deletion of any leftover
    files from a previous run, then creates the fresh directory tree and copies
    the config file into Results. May ``exit(0)`` if the user declines to overwrite.
    """
    logger = logging.getLogger(__name__)
    # Create output folders, checking for overwrites.
    output_dir = Path(config.config['output_dir'])
    sim_dir_cfg = config.config['simulation_dir']
    subdirs = ('Simulations', 'Results', 'Initialize', 'FailedSimLogs')
    subfiles = ('alg_backup.bp', 'alg_finished.bp', 'alg_refine_finished.bp')
    will_overwrite = [subdir for subdir in subdirs + subfiles
                      if (output_dir / subdir).exists()]
    if sim_dir_cfg:
        simdir = Path(sim_dir_cfg) / 'Simulations'
        if simdir.exists():
            will_overwrite.append(str(simdir))
    if len(will_overwrite) > 0:
        if not cmdline_args.overwrite:
            logger.info("Output directory already exists... querying user for overwrite permission")
            ans = 'x'
            while ans.lower() not in ['y', 'yes', 'n', 'no', '']:
                print0('Your output directory contains files from a previous run: {}.'.format(', '.join(will_overwrite)))
                ans = input(
                    'Overwrite them with the current run? [y/n] (n) ')
            if not(ans.lower() == 'y' or ans.lower() == 'yes'):
                logger.info("Overwrite rejected... exiting")
                print0('Quitting')
                exit(0)
        # If we get here, safe to overwrite files
        for subdir in subdirs:
            target = output_dir / subdir
            try:
                shutil.rmtree(target)
                logger.info('Deleted old directory %s' % target)
            except OSError:
                logger.debug('Directory %s does not already exist' % target)
        for subfile in subfiles:
            target = output_dir / subfile
            try:
                target.unlink()
                logger.info('Deleted old file %s' % target)
            except OSError:
                logger.debug('File %s does not already exist' % target)
        if sim_dir_cfg:
            sim_simulations = Path(sim_dir_cfg) / 'Simulations'
            try:
                shutil.rmtree(sim_simulations)
                logger.info('Deleted old simulation directory %s' % sim_simulations)
            except OSError:
                logger.debug('Simulation directory %s does not already exist' % sim_simulations)

    # Create new directories for the current run.
    (output_dir / 'Results').mkdir(parents=True)
    if sim_dir_cfg:
        (Path(sim_dir_cfg) / 'Simulations').mkdir(parents=True)
    else:
        (output_dir / 'Simulations').mkdir()
    shutil.copy(cmdline_args.conf_file, output_dir / 'Results')


def _create_algorithm(config):
    """Instantiate the fitting algorithm selected by ``config['fit_type']``.

    Dispatch goes through the self-registering FIT_TYPE_REGISTRY, populated when
    ``pybnf.algorithms`` is imported above (each leaf's ``@register_fit_type``
    fires on import); see ADR-0005. Deprecated fit types warn but still run.
    The mh-vs-pt difference is handled in Config by setting or not setting the
    exchange_every key; sa runs the same class in simulated-annealing mode.
    """
    logger = logging.getLogger(__name__)
    fit_type = config.config['fit_type']
    entry = FIT_TYPE_REGISTRY.get(fit_type)
    if entry is None:
        raise PybnfError('Invalid job_type {}. Options are: {}'.format(fit_type, ', '.join(FIT_TYPE_REGISTRY)))
    if entry.deprecated:
        msg = f'job_type {fit_type} is deprecated and may be removed in a future release.'
        logger.warning(msg)
        print1(f'Warning: {msg}')
    return entry.cls(config, **entry.kwargs)


def _record_phase(alg, phase, method, status, reason=None, simulations=None,
                  best_objective=None, extra=None):
    """Append one phase to the run's executed-method record, if it is keeping one.

    A no-op when ``alg`` carries no :class:`~pybnf.method_chain.MethodChain` -- an
    algorithm driven directly rather than through ``main()``.
    """
    chain = getattr(alg, 'method_chain', None)
    if chain is None:
        return
    chain.record(phase, method, status, reason=reason, simulations=simulations,
                 best_objective=best_objective,
                 bootstrap_replicate=getattr(alg, 'bootstrap_number', None), extra=extra)


def _phase_status(alg):
    """Whether a finished phase ended on its own terms or on the wall-time budget.

    ``Algorithm.stop_reason`` is set by exactly one thing -- the budget (#529); every
    method's own stop criteria live on its runner objects -- so its presence is the
    distinction, and its text is the explanation.
    """
    if alg.stop_reason:
        return method_chain.WALL_TIME_EXPIRED, alg.stop_reason
    return method_chain.COMPLETED, None


def _refine_best_fit(config, alg, cluster, debug):
    """Refine the best-fit parameter set with the chosen local optimizer, if
    requested.

    A no-op unless ``refine == 1``; the refiner is the start-point optimizer named
    by ``refine_method`` (Simplex / Powell / CMA-ES / a gradient optimizer,
    #403/ADR-0015), dispatched through the registry's ``refiner`` flag. Skipped (with
    a message) when the original fit already used that same algorithm. Reuses the
    algorithm's generated networks and trajectory so refinement continues from the
    existing best fit.

    ``refine = 1`` is a request for a *method* -- search globally, then polish
    locally -- so under a wall-clock budget the refine runs on the slice of it the
    search was forbidden to spend (``wall_time_refine_frac``, #564/ADR-0107), released
    here by :func:`~pybnf.budget.spend_reserve`. Whether it ran, and on what terms, is
    recorded in ``Results/method_chain.json`` either way.
    """
    logger = logging.getLogger(__name__)
    if config.config['refine'] != 1:
        return
    logger.debug('Refinement requested for best fit parameter set')
    method = config.config['refine_method']
    entry = FIT_TYPE_REGISTRY[method]   # validated by Configuration._check_refine_method
    refiner_cls = entry.cls
    name = entry.display_name
    # Checked before the budget: this is not a refine the clock cost anyone, and it is
    # the more useful thing to be told (no reserve was taken for it either).
    if config.config['fit_type'] == method:
        skipped = (f"You specified refine=1 with refine_method={method}, but that is the algorithm you just ran."
                   "\nSkipping refine.")
        logger.debug(f'Cannot refine further if the {name} algorithm was used for the original fit')
        print1(skipped)
        _record_phase(alg, 'refine', method, method_chain.SKIPPED,
                      reason='refine_method %s is the algorithm the fit itself ran' % method)
        return
    reserve = alg.budget.reserve if alg.budget is not None else 0.0
    # Everything from here runs with the reserve released: this is the phase it was
    # held back FOR, so "is the budget spent?" must be asked of the refine's own share
    # of it, not of the search's -- otherwise the reserve buys the refine nothing and
    # #564 stands unfixed. Inside the block the budget is the run's whole remaining
    # time: the reserve, plus whatever a search that converged early left behind.
    with spend_reserve(alg.budget):
        if alg.budget is not None and alg.budget.expired():
            # Reachable only when the run reserved nothing for this phase
            # (wall_time_refine_frac = 0) or the search overran the reserve anyway --
            # one in-flight simulation may outlive the deadline by up to wall_time_sim
            # (ADR-0093). Either way the requested method chain did NOT run, so say so
            # at every verbosity: a silent downgrade to a different method is the defect
            # the reserve exists to prevent.
            reason = ('the wall-time budget was spent before the refine could start'
                      if reserve <= 0 else
                      'the search overran the %d s reserved for the refine'
                      % int(round(reserve)))
            logger.info('Wall-time budget spent; skipping the refine of the best fit')
            print0('Warning: %s, so the best fit was NOT refined by the %s algorithm -- this run '
                   'executed %s alone.\nRaise wall_time_refine_frac (the share of wall_time_fit held '
                   'back for the refine) to make room for it.'
                   % (reason, name, config.config['fit_type']))
            _record_phase(alg, 'refine', method, method_chain.SKIPPED, reason=reason)
            return
        logger.debug(f'Refining further using the {name} algorithm')
        print1(f"Refining the best fit by the {name} algorithm")
        config.config[refiner_cls.START_POINT_KEY] = alg.trajectory.best_fit()
        refiner = refiner_cls(config, refine=True)
        refiner.model_list = alg.model_list  # Reuse already-generated networks
        refiner.trajectory = alg.trajectory  # Reuse existing trajectory; don't start a new one.
        refiner.budget = alg.budget  # One deadline for the whole run, not one per phase
        # The refine's outputs belong beside the fit's, in the directory that fit wrote to.
        # For the main fit that is the default the refiner already builds; for a bootstrap
        # replicate it is Results-boot{N}, and taking the default there would overwrite the
        # MAIN run's sorted_params_refine_final.txt (and stop_reason.txt) once per replicate.
        refiner.res_dir = alg.res_dir
        if alg.bootstrap_number is not None:
            # During bootstrapping the replicate's fit wrote to per-replicate working dirs
            # (Simulations-boot{N} etc.) that alg.reset(bootstrap=N) (re)creates for every
            # replicate and retry, so point the refiner at those same, existing dirs.
            # Otherwise it defaults to the shared output_dir/Simulations, which the *main*
            # fit's own refinement already deleted at teardown (it is a leaf Simplex run).
            # Every replicate refine then tries os.mkdir under that missing parent; the
            # FileNotFoundError reads as OSError, which the job's mkdir-retry loop treats as
            # a name collision and walks to _rerun999 before giving up -> FailedSimulation.
            # The whole refine then scores inf and never polishes the replicate's fit.
            refiner.sim_dir = alg.sim_dir
            refiner.failed_logs_dir = alg.failed_logs_dir
        refiner.run(cluster.client, debug=debug)
    status, reason = _phase_status(refiner)
    _record_phase(alg, 'refine', method, status, reason=reason,
                  simulations=refiner.completed_simulations,
                  best_objective=refiner.trajectory.best_score() if len(refiner.trajectory) else None)


def _run_bootstrapping(config, alg, cluster, debug):
    """Run the configured number of bootstrap replicate fits.

    Resamples the experimental-data weights for each replicate, re-runs the fit
    (optionally refining), and records accepted best-fit parameter sets. Replicates
    whose objective exceeds ``bootstrap_max_obj`` are retried.
    """
    logger = logging.getLogger(__name__)
    # Path the accepted-replicate parameter sets are accumulated into (str at the
    # boundary: Trajectory.load_trajectory / Trajectory.add take a filename).
    bootstrap_file = str(Path(config.config['output_dir']) / 'Results' / 'bootstrapped_parameter_sets.txt')
    # Bootstrapping setup
    if config.config['bootstrap_max_obj']:
        bootstrap_max_obj = config.config['bootstrap_max_obj']
    else:
        bootstrap_max_obj = inf
        logger.info('No bootstrap_max_obj specified; set to infinity')
        print1('No bootstrap_max_obj specified. All bootstrap replicates will be accepted regardless of '
               'objective value.')

    num_to_bootstrap = config.config['bootstrap']
    completed_bootstrap_runs = 0
    if alg.bootstrap_number is None:
        bootstrapped_psets = Trajectory(num_to_bootstrap)
    else:  # Check if finished a resumed bootstrap fitting run
        completed_bootstrap_runs += alg.bootstrap_number
        if completed_bootstrap_runs == 0:
            bootstrapped_psets = Trajectory(num_to_bootstrap)
        else:
            if completed_bootstrap_runs > 0:
                bootstrapped_psets = Trajectory.load_trajectory(bootstrap_file,
                                                                config.variables,
                                                                config.config['num_to_output'])

        # Gate on the trajectory's best score, exactly as the main loop below. main()
        # ran _refine_best_fit on this resumed replicate before calling us, and the
        # refiner shares alg.trajectory -- but alg.best_fit_obj is the *pre*-refine value
        # the loaded algorithm stopped at (a replicate resumed mid-fit finishes its fit
        # and is then polished), so gating on it would reject a replicate the polish
        # already brought under the threshold and pair the refined pset with a stale score.
        resumed_best_obj = alg.trajectory.best_score()
        if resumed_best_obj <= bootstrap_max_obj:
            logger.info(f'Bootstrap run {completed_bootstrap_runs} complete')
            bootstrapped_psets.add(alg.trajectory.best_fit(), resumed_best_obj,
                                   f'bootstrap_run_{completed_bootstrap_runs}',
                                   bootstrap_file,
                                   completed_bootstrap_runs == 0)
            logger.info(f'Succesfully completed resumed bootstrapping run {completed_bootstrap_runs}')
            completed_bootstrap_runs += 1
        else:
            shutil.rmtree(alg.res_dir)
            if os.path.exists(alg.sim_dir):
                shutil.rmtree(alg.sim_dir)
            print0("Bootstrap run did not achieve maximum allowable objective function value.  Retrying")
            logger.info(f'Resumed bootstrapping run {completed_bootstrap_runs} did not achieve maximum allowable objective function '
                        'value.  Retrying')

    # Run bootstrapping
    consec_failed_bootstrap_runs = 0
    while completed_bootstrap_runs < num_to_bootstrap:
        if alg.budget is not None and alg.budget.expired():
            # A replicate is a whole fit's worth of new work; the budget bounds the run,
            # not each replicate (#529). Stop with the replicates already accepted --
            # bootstrapped_parameter_sets.txt was appended to as each one finished, so
            # what is on disk is a complete record of them.
            logger.info('Wall-time budget spent after %d of %d bootstrap replicates',
                        completed_bootstrap_runs, num_to_bootstrap)
            print0('Wall-time budget spent; stopping after %d of %d bootstrap replicates.'
                   % (completed_bootstrap_runs, num_to_bootstrap))
            _record_bootstrap_total(config, alg, completed_bootstrap_runs, num_to_bootstrap,
                                    method_chain.WALL_TIME_EXPIRED)
            return
        # A rejected replicate is retried with a *fresh* resample: bumping the retry
        # count advances the RNG sub-stream reset() derives. Without it, reset() keys
        # the seed only on the (unchanged) replicate number, so every retry regenerates
        # the identical resample and identical fit -- the 20 "retries" are byte-for-byte
        # the same failing run, and the loop can never make progress. The first attempt
        # (the common case) is byte-identical to the historical seeding.
        alg.bootstrap_attempt = consec_failed_bootstrap_runs
        alg.reset(bootstrap=completed_bootstrap_runs)

        for model in alg.exp_data:
            for name, data in alg.exp_data[model].items():
                # alg.reset(bootstrap=...) just reseeded alg.rng to this replicate's
                # deterministic sub-stream, so the resampled weights are reproducible.
                data.gen_bootstrap_weights(alg.rng)
                data.weights_to_file(f'{alg.res_dir}/{name}_weights_{completed_bootstrap_runs}.txt')

        logger.info(f'Beginning bootstrap run {completed_bootstrap_runs}')
        print0(f"Beginning bootstrap run {completed_bootstrap_runs}")
        alg.run(cluster.client, debug=debug)
        status, reason = _phase_status(alg)
        _record_phase(alg, 'fit', config.config['fit_type'], status, reason=reason,
                      simulations=alg.completed_simulations,
                      best_objective=alg.trajectory.best_score() if len(alg.trajectory) else None)

        _refine_best_fit(config, alg, cluster, debug)

        best_fit_pset = alg.trajectory.best_fit()
        # Gate (and record) on the trajectory's best score, which reflects the refine
        # polish above (the refiner shares alg.trajectory). alg.best_fit_obj is the
        # *pre*-refine value the fit algorithm stopped at, so using it here would reject
        # a replicate the polish already brought under the threshold -- and pair the
        # recorded (refined) pset with a stale, larger objective.
        best_fit_obj = alg.trajectory.best_score()

        if best_fit_obj <= bootstrap_max_obj:
            logger.info(f'Bootstrap run {completed_bootstrap_runs} complete')
            bootstrapped_psets.add(best_fit_pset, best_fit_obj, f'bootstrap_run_{completed_bootstrap_runs}',
                                   bootstrap_file,
                                   completed_bootstrap_runs == 0)
            completed_bootstrap_runs += 1
            consec_failed_bootstrap_runs = 0
        else:
            consec_failed_bootstrap_runs += 1
            print0("Bootstrap run did not achieve maximum allowable objective function value.  Retrying")
            logger.warning("Bootstrap run did not achieve maximum allowable objective function value.")
            if consec_failed_bootstrap_runs > 20:  # Arbitrary...  should we make this configurable or smaller?
                raise PybnfError("20 consecutive bootstrap runs failed to achieve maximum allowable objective "
                                 "function values.  Check 'bootstrap_max_obj' configuration key")

    # bootstrapped_psets.write_to_file(config.config['output_dir'] + "/Results/bootstrapped_parameter_sets.txt")
    _record_bootstrap_total(config, alg, completed_bootstrap_runs, num_to_bootstrap,
                            method_chain.COMPLETED)
    print0('Bootstrapping complete')


def _record_bootstrap_total(config, alg, completed, requested, status):
    """Record how many bootstrap replicates the run actually produced.

    The replicates themselves are recorded one by one as they run; this is the single
    field a consumer can assert on -- ``bootstrap = 30`` in the conf is worth nothing
    if the budget stopped the run at 11 (#564).
    """
    chain = getattr(alg, 'method_chain', None)
    if chain is None:
        return
    chain.record('bootstrap', config.config['fit_type'], status,
                 reason=None if completed >= requested else
                 'the wall-time budget was spent after %d of %d replicates' % (completed, requested),
                 extra={'replicates_requested': requested, 'replicates_completed': completed})


def _reap_running_sims():
    """SIGKILL any simulation subprocesses still running on this node.

    Called from ``main()``'s finally on every exit. On a clean finish it is a
    no-op (finished sims have deregistered); on a Ctrl-C or crash it kills the
    in-flight, detached sim process groups that would otherwise orphan (the
    "kill -9 needed" problem). Local node only: sims on remote dask-ssh worker
    nodes are cleaned by SLURM's cgroup tracking at step/job teardown.
    """
    try:
        reaped = reap_active_sims()
        if reaped:
            logging.info('Killed %d orphaned simulation process group(s) on abort', len(reaped))
    except Exception:
        logging.exception('Failed while reaping simulation subprocesses')


def _teardown_cluster(cluster):
    """Tear down the dask cluster after a run, logging (not raising on) any failure."""
    # Stop dask-ssh regardless of success
    if cluster:
        try:
            cluster.teardown()
            if not cluster.local:
                time.sleep(10)  # wait for teardown before continuing
        except Exception:
            logging.exception('Failed to tear down cluster')
    else:
        logging.info('No cluster to tear down')


def _cleanup_dask_workspace():
    """Remove any leftover ``dask-worker-space`` directories (cwd and home)."""
    # Attempt to remove dask-worker-space directory if necessary
    # (exists in directory where workers were instantiated)
    # Tries current and home directories
    if os.path.isdir('dask-worker-space'):
        if os.name == 'nt':  # Windows
            shutil.rmtree('dask-worker-space', ignore_errors=True)
        else:
            run(['rm', '-rf', 'dask-worker-space'])  # More likely to succeed than rmtree()
    home_dask_dir = os.path.expanduser(os.path.join('~', 'dask-worker-space'))
    if os.path.isdir(home_dask_dir):
        if os.name == 'nt':  # Windows
            shutil.rmtree(home_dask_dir, ignore_errors=True)
        else:
            run(['rm', '-rf', home_dask_dir])


[docs] def main(): """The main function for running a fitting job""" start_time = time.time() success = False alg = None cluster = None cmdline_args = _build_arg_parser().parse_args() log_prefix, debug = _setup_logging(cmdline_args) logger = logging.getLogger(__name__) # Enable the node-local live-sim registry so a Ctrl-C or crash can reap any # in-flight simulation subprocesses. Set before the cluster is created so # locally-spawned dask workers inherit PYBNF_SIM_REGISTRY (see pset). set_sim_registry(f'{log_prefix}_{os.getpid()}') print0(f"PyBNF v{__version__}") logger.info(f'Running PyBNF v{__version__}') try: # Load the conf file and create the algorithm if cmdline_args.conf_file is None: print0('No configuration file given, so I won''t do anything.\nFor more information, try pybnf --help') exit(0) logger.info(f'Loading configuration file: {cmdline_args.conf_file}') config = load_config(cmdline_args.conf_file) if 'verbosity' in config.config: printing.verbosity = config.config['verbosity'] _initialize_random_seed(config) if cmdline_args.resume is not None and cmdline_args.overwrite: raise PybnfError("Options --overwrite and --resume are contradictory. Use --resume to continue a previous " "run, or --overwrite to overwrite the previous run with a new one.") continue_file = _resolve_continue_file(config, cmdline_args) if continue_file: # Restart the loaded algorithm alg, pending, config = _load_resumed_algorithm(continue_file, cmdline_args) else: # Fresh run: prepare the output directory tree and build the algorithm. _prepare_run_directories(config, cmdline_args) pending = None alg = _create_algorithm(config) # The fit's total wall-clock budget (wall_time_fit, #529/ADR-0093), or None when # unbounded (the default). Built from the process start time so configuration # loading and network generation are charged to it -- the budget bounds the run # the way an external `timeout` around this process would have -- and attached to # the algorithm, which stops its run loop and finalizes when the budget is spent. # The same object is handed to the refiner and reused across bootstrap replicates, # so one deadline bounds the whole run rather than each phase separately. alg.budget = FitBudget.from_config(config, started_at=start_time) if alg.budget is not None: logger.info('Total wall-time budget for this fit: %d s (%s remaining)', alg.budget.limit, format_duration(alg.budget.remaining())) print1('Wall-time budget for this fit: %s (wall_time_fit = %d s).' % (format_duration(alg.budget.limit), alg.budget.limit)) if alg.budget.reserve > 0: # State the phase split up front. It is the difference between the # method the conf requests and the method a budgeted run executes, so # it belongs on the console before the search starts, not in a # post-mortem (#564). logger.info('Reserving %d s of the budget for the refine', alg.budget.reserve) print1(' of which %s is reserved for the refine (wall_time_refine_frac = %g), ' 'leaving %s to search.' % (format_duration(alg.budget.reserve), config.config['wall_time_refine_frac'], format_duration(alg.budget.limit - alg.budget.reserve))) # Override configuration values if provided on command line if cmdline_args.cluster_type: config.config['cluster_type'] = cmdline_args.cluster_type if cmdline_args.scheduler_file: config.config['scheduler_file'] = cmdline_args.scheduler_file # Everything inside this branch is Algorithm surface. ``ModelCheck`` # (``job_type = check``) deliberately does not subclass Algorithm -- it has no # res_dir, no trajectory, no completed_simulations and no stop_reason -- so a # line that reads any of those belongs here and not above the branch, where it # would run on both paths and take the checker down before it ever evaluated # the model (#569). if config.config['fit_type'] != 'check': # The record of which methods this run actually executes (#564/ADR-0107). Kept # on the algorithm beside the budget, and written after every phase, so it is # complete on disk even if a later phase raises. alg.method_chain = method_chain.chain_for_run(alg.res_dir, config.config, budget=alg.budget, version=__version__) # Set up cluster cluster = Cluster(config, log_prefix, debug, cmdline_args.log_level) # Run the algorithm! logger.debug('Algorithm initialization') alg.run(cluster.client, resume=pending, debug=debug) fit_status, fit_reason = _phase_status(alg) _record_phase(alg, 'fit', config.config['fit_type'], fit_status, reason=fit_reason, simulations=alg.completed_simulations, best_objective=alg.trajectory.best_score() if len(alg.trajectory) else None) else: # Run model checking. No method chain: a check is one evaluation of the # parameters as given, not a chain of search phases, and ModelCheck holds no # res_dir to write the record into. logger.debug('Model checking initialization') alg.run_check(debug=debug) _refine_best_fit(config, alg, cluster, debug) if alg.bootstrap_number is None: print0('Fitting complete') # Bootstrapping (optional) if config.config['bootstrap'] > 0: _run_bootstrapping(config, alg, cluster, debug) success = True except PybnfError as e: # Exceptions generated by problems such as bad user input should be caught here and print a useful message # before quitting logger.error('Terminating due to a PybnfError:') logger.error(e.log_message) print0(f'Error: {e.message}') except KeyboardInterrupt: print0('Fitting aborted.') logger.info('Terminating due to keyboard interrupt') logger.exception('Keyboard interrupt') except Exception: # Sends any unhandled errors to log instead of to user output logger.exception('Internal error') exceptiondata = traceback.format_exc().splitlines() print0(f'Sorry, an unknown error occurred: {exceptiondata[-1]}\n' f'Logs have been saved to {log_prefix}.log.\n' 'Please report this bug to help us improve PyBNF.') finally: # Kill any in-flight sims first (while their PGIDs are still live), then # tear down the cluster and report timing. _reap_running_sims() _teardown_cluster(cluster) _cleanup_dask_workspace() # After any error, try to clean up; then report timing and exit. _finalize(success, alg, start_time)
# PyBNF is launched via the ``pybnf`` console script (``pybnf -c fit.conf``) or # ``python -m pybnf`` (see pybnf/__main__.py) -- both call main() behind a proper # __main__ guard, which is what keeps spawned dask workers from re-running the fit. # Executing this *submodule* directly (``python -m pybnf.pybnf``) would instead # import-and-exit silently, reading as a confusing no-op. Fail loudly and point at # the supported entry points rather than adding a second, unguarded run path. if __name__ == '__main__': raise SystemExit( 'Run PyBNF via the `pybnf` console script (e.g. `pybnf -c fit.conf`) or ' '`python -m pybnf`, not `python -m pybnf.pybnf`.')