Source code for pybnf.pybnf

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


from . import __version__
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 fit_type {}. Options are: {}'.format(fit_type, ', '.join(FIT_TYPE_REGISTRY)))
    if entry.deprecated:
        msg = f'fit_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 _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, #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.
    """
    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
    if config.config['fit_type'] == method:
        logger.debug(f'Cannot refine further if the {name} algorithm was used for the original fit')
        print1(f"You specified refine=1 with refine_method={method}, but that is the algorithm you just ran."
               "\nSkipping refine.")
        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.
    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)


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:
        # 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)

        _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")
    print0('Bootstrapping complete')


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) # 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 if config.config['fit_type'] != 'check': # 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) else: # Run model checking 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`.')