Source code for bumps.cli

# Note: the following text appears at the end of the bumps -h command line help.
"""
Basic command line usage::

    # Run a model from show its χ² value. This is useful for debugging the model file.
    bumps model.py --chisq

    # Run a simple batch fit on model.py, appending results to a store file.
    bumps --batch --session=T1.hdf model.py

    # Run a DREAM fit on model.py to explore parameter uncertainties
    bumps --batch --session=T1.hdf model.py --fit=dream

    # Load and resume the last fit in a session file. The model.py file is ignored.
    bumps --batch --session=T1.hdf [model.py] --resume

Basic interactive usage::

    # Open a web browser to the bumps application. Show the initial model if any.
    bumps [model.py]

    # Open a web browser and start a fit
    bumps model.py --start

    # Watch fit progress and exit when complete
    bumps model.py --run --session=T1.hdf

There are many more options available to control the fit, particularly for
batch mode fitting. To see them type::

    bumps --help
"""

__all__ = [
    "BumpsOptions",
    "interpret_fit_options",
    "reload_export",
    "main",
    # CRUFT: symbols from old cli interface still used by third parties
    "load_best",
    "load_model",
    "save_best",
    "config_matplotlib",
    "set_mplconfig",
    "install_plugin",
]

import argparse
import asyncio
from dataclasses import dataclass
from pathlib import Path
from textwrap import dedent
from typing import Callable, Dict, Optional, List, Any
import warnings
import signal
import sys
from dataclasses import field, fields
import hashlib
# from textwrap import dedent

from bumps import __version__ as bumps_version
from . import api
from . import options as fit_options
from . import persistent_settings
from .fitproblem import FitProblem, load_problem
from .logger import logger, setup_console_logging, LOGLEVEL
from .state import SERIALIZERS, FitResult


# CRUFT: mock old bumps.cli interfaces that may be used elsewhere
from .fitproblem import load_pars as load_best
from .fitproblem import load_problem as load_model
from .fitters import save_best  # used by scattertools
from .plotutil import config_matplotlib, set_mplconfig
from .plugin import install_plugin

# Ick! PREFERRED_PORT is here rather than in webserver so that it can
# be used in the command line help without a circular import.
PREFERRED_PORT = 5148  # "SLAB"

# TODO: try datargs to build a parser from the typed dataclass


[docs] @dataclass class BumpsOptions: """ Bumps options returned from the command line parser. This can be used as a stand-alone object if you are invoking bumps from a script without using the command line parser. """ # The list of command line argument destinations must match the list # of BumpsOptions attributes, including default values. This check # is performed in test_bumps_options() during CI. # # Although the default values from BumpsOptions are used when parsing, # the bumps --help output will show the default value from the command # line parser. The easiest way to ensure these are the same is to set # default=BumpsOptions.field when defining field in the parser. # Positional arguments. filename: Optional[str] = None args: Optional[List[str]] = None # Fitter controls. fit_options: Dict[str, Any] = field(default_factory=dict) resume: bool = False show_cov: bool = False show_err: bool = False show_entropy: Optional[str] = None # Session file controls. session: Optional[str] = None read_session: Optional[str] = None write_session: Optional[str] = None serializer: SERIALIZERS = "dataclass" checkpoint: int = 300 auto_history: bool = True path: Optional[str] = None use_persistent_path: bool = False reload_export: Optional[str] = None pars: Optional[str] = None # Simulation controls. simulate: bool = False simrandom: bool = False shake: bool = False noise: float = 5.0 seed: int = 0 # Program controls. chisq: bool = False export: Optional[str] = None trace: bool = False loglevel: str = "warn" parallel: int = 0 threads: bool = False mpi: Optional[bool] = None # Webserver controls. mode: str = "edit" """One of "batch", "edit", "start" or "run" depending on -b, -s and -r options""" headless: bool = False external: bool = False port: int = 0 hub: Optional[str] = None convergence_heartbeat: bool = False
class DictAction(argparse.Action): """ Gather argparse command line options into a dict entry. Given *dest="group.key"* in the parser argument definition, add *group* to the returned namespace and set *group["key"]=value*. There is special handling of bool types, converting them to True/False values. """ # Note: implicit __init__ inherited from argparse.Action def __call__(self, parser, namespace, values, option_string=None): if self.type is bool: values = not option_string.startswith("--no-") # Find target dict name and key. key, subkey = self.dest.split(".") # Grab the target dict, if present. store = getattr(namespace, key, None) if store is None: store = {} store[subkey] = values # Store the target back in the namespace in case it was missing or None. setattr(namespace, key, store) class HelpFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter): """ Help formatter customization: * Don't reflow text * Show non-trivial default values """ def _get_help_string(self, action): help_text = action.help if action.default is not argparse.SUPPRESS: # Only show default if it's not None or False if action.default is not None and action.default is not False: help_text += f" (default: {action.default})" return help_text def _branding(): """Return a string with version and system information.""" output = f"{'=' * 55}\n" output += f"{api.state.app_name}\t\t{api.state.app_version}\n" if api.state.app_name != "bumps": output += f"bumps\t\t{bumps_version}\n" output += "Python\t\t" + ".".join(map(str, sys.version_info[:3])) + "\n" output += f"Platform\t{sys.platform}\n" output += f"{'=' * 55}\n" return output def build_arg_parser() -> argparse.ArgumentParser: # TODO: allow --pars from session file # TODO: missing the following options from pre-1.0 """ # Wait for someone to ask for the following --overwrite [new version extends session file] if store already exists, replace it --resynth=0 run resynthesis error analysis for n generations --time_model run the model --steps times in order to estimate total run time. --profile run the python profiler on the model; use --steps to run multiple models for better statistics --stepmon show details for each step in .log file # Won't implement --plot=linear|log|residuals [plugin specific] type of plot to display --view=linear|log [plugin specific] one of the predefined problem views; reflectometry also has fresnel, logfresnel, q4 and residuals --staj [plugin specific. Can plugins extend argparse?] output staj file when done [Refl1D only] # Superseded -m/-c/-p command [we are shipping a python environment] run the python interpreter with bumps on the path: m: command is a module such as bumps.cli, run as __main__ c: command is a python one-line command p: command is the name of a python script -i [our python environment can install ipython with pip] start the interactive interpreter --noshow [use --export to produce plots] semi-batch; send output to console but don't show plots after fit --preview [use webview instead] display model but do not perform a fitting operation --edit [default] start the gui --batch [current version doesn't save .mon] batch mode; save output in .mon file and don't show plots after fit """ prog = api.state.app_name parser = argparse.ArgumentParser( prog=prog, formatter_class=HelpFormatter, epilog=__doc__.replace("::", ":").replace("bumps", prog), ) parser.add_argument( "filename", nargs="?", help="problem file to load, .py or .json (serialized) fitproblem", ) # TODO: Don't need --args specifier since this is just the extras after model.py parser.add_argument( "--args", nargs="*", type=str, help="arguments to send to the model loader on load", ) # parser.add_argument('-d', '--debug', action=argparse.BooleanOptionalAction, help='autoload modules on change') # TODO: Help is missing for pt options even when --fit=pt is on the command line # Fitter controls fitter = parser.add_argument_group("Fitting options") for name, option in fit_options.Setting.items(): stype = option.stype metavar = "" if stype is bool else name.replace("_", "-").upper() choices = stype if isinstance(stype, list) else None # For the trim option allow both --trim and --no-trim. The DictAction class # checks for leading "--no-" in the option string when assigning to bool. flagname = name.replace("_", "-") flags = (f"--{flagname}", f"--no-{flagname}") if stype is bool else (f"--{flagname}",) fitter.add_argument( *flags, action=DictAction, dest=f"fit_options.{name}", type=str if choices else stype, metavar=metavar, nargs=0 if stype is bool else None, choices=choices, help=option.help(), ) # Fit outputs output = parser.add_argument_group("Fitting outputs") output.add_argument( "--err", action="store_true", dest="show_err", help="Show uncertainty from covariance", ) output.add_argument( "--cov", action="store_true", dest="show_cov", help="Show covariance matrix on output", ) output.add_argument( "--entropy", dest="show_entropy", type=str, # TODO: type is str|None default=BumpsOptions.show_entropy, const="gmm", nargs="?", choices=["gmm", "mvn", "wnn", "llf"], help=dedent("""\ Compute entropy from uncertainty distribution [dream only]: mvn: Fit to a single multivariate normal gmm: Fit to a Gaussian mixture model wnn: Weighted Kozeachenko-Leonenko nearest neighbour (Berrettt 2016) llf: Use local density to estimate loglikelihood factor (Kramer 2010) """), ) # Session file controls. session = parser.add_argument_group("Session file management") session.add_argument( "--session", metavar="SESSION", default=None, type=str, help="set read/write session to same file", ) session.add_argument( "--read-session", metavar="SESSION", default=None, type=str, help="read initial session state from file (overrides --session)", ) session.add_argument( "--write-session", metavar="SESSION", default=None, type=str, help="output file for session state (overrides --session)", ) session.add_argument( "--resume", default=BumpsOptions.resume, action=argparse.BooleanOptionalAction, help=dedent("""\ Resume the most recent fit from the saved session file. [dream, de] Note that this loads the model from the session and ignores any model file specified on the command line. """), ) session.add_argument( "--checkpoint", default=300, type=int, help="session autosave interval in seconds. This is also the update interval for the DREAM parameter histograms.", ) session.add_argument( "--serializer", default=BumpsOptions.serializer, type=str, choices=["pickle", "cloudpickle", "dill", "dataclass"], help="strategy for serializing problem, will use value from session if it has already been defined", ) session.add_argument( "--auto-history", default=BumpsOptions.auto_history, action=argparse.BooleanOptionalAction, help="auto-append problem state to history on load and at the end of the fit", ) session.add_argument( "--path", default=None, type=str, help="set initial path for save and load dialogs (webview only)", ) session.add_argument( "--use-persistent-path", action=argparse.BooleanOptionalAction, help="save most recently used path to disk for persistence between sessions (webview only)", ) session.add_argument( "--reload-export", type=str, help="reload a bumps 0.x store directory as if it were a session file", ) # Simulation controls. sim = parser.add_argument_group("Simulation") sim.add_argument( "--simulate", action=argparse.BooleanOptionalAction, help="simulate a dataset using the initial problem parameters", ) sim.add_argument( "--simrandom", action=argparse.BooleanOptionalAction, help="simulate a dataset using the random problem parameters", ) sim.add_argument( "--shake", action=argparse.BooleanOptionalAction, help="set random parameters before fitting", ) sim.add_argument( "--noise", type=float, default=BumpsOptions.noise, help="percent noise to add to the simulated data", ) sim.add_argument( "--seed", type=int, default=0, help="random number seed, or 0 for none", ) # Program controls. misc = parser.add_argument_group("Miscellaneous") misc.add_argument( "--chisq", action="store_true", help="print χ² and exit", ) misc.add_argument( "--export", type=str, help="directory path for data and figure export.", ) misc.add_argument("--pars", default=None, type=str, help="start a fit from an exported result.") misc.add_argument( "--parallel", default=BumpsOptions.parallel, type=int, help="run fit using multiprocessing for parallelism; use --parallel=0 for all cpus", ) misc.add_argument( "--threads", action="store_true", help=argparse.SUPPRESS, # help="run fit using multithreading for parallelism", ) misc.add_argument( "--mpi", action=argparse.BooleanOptionalAction, help="Use MPI for parallelization (only needed if we fail to detect MPI correctly)", ) misc.add_argument( "--trace", action=argparse.BooleanOptionalAction, help="enable memory tracing (prints after every uncertainty update in dream)", ) misc.add_argument( "--loglevel", type=str, choices=list(LOGLEVEL.keys()), help="display logging to console", default=BumpsOptions.loglevel, ) # Show version numbers for both bumps and child program misc.add_argument( "-V", "--version", action="version", version=_branding(), ) # TODO: restructure so that -b -s -r --webview override each other # Maybe a runmode enum: 0=batch 1=edit 2=start 3=run # Webserver controls server = parser.add_argument_group("Webview server controls") server.add_argument( "--edit", "--webview", action="store_const", const="edit", dest="mode", help="run bumps fit with the webview server (this is the default)", ) server.add_argument( "-b", "--batch", action="store_const", const="batch", dest="mode", help="run bumps fit in batch mode without the webview server", ) server.add_argument( "-s", "--start", action="store_const", const="start", dest="mode", help="start fit immediately, leaving it open when done", ) server.add_argument( "-r", "--run", action="store_const", const="run", dest="mode", help="run the fit to completion", ) server.add_argument( "-x", "--headless", action=argparse.BooleanOptionalAction, help="do not automatically load client in browser", ) server.add_argument( "--external", action=argparse.BooleanOptionalAction, help="listen on all interfaces, including external (local connections only if not set)", ) server.add_argument( "-p", "--port", default=BumpsOptions.port, type=int, help=f"web server port; use --port=0 to first try {PREFERRED_PORT} then fall back to a random port", ) server.add_argument( "--hub", default=BumpsOptions.hub, type=str, # Don't show jupyter-only parameters to the user. # help="api address of parent hub (only used when called as subprocess)", help=argparse.SUPPRESS, ) server.add_argument( "--convergence-heartbeat", action=argparse.BooleanOptionalAction, # Don't show jupyter-only parameters to the user. # help="enable convergence heartbeat for jupyter kernel (keeps kernel alive during fit)", help=argparse.SUPPRESS, ) return parser def get_commandline_options(arg_defaults: Optional[Dict] = None): """Parse bumps command line options.""" parser = build_arg_parser() # parser.add_argument('-c', '--config-file', type=str, help='path to JSON configuration to load') namespace = BumpsOptions() if arg_defaults is not None: logger.debug(f"arg_defaults: {arg_defaults}") for k, v in arg_defaults.items(): setattr(namespace, k, v) parser.parse_args(namespace=namespace) return namespace def test_bumps_options(): """ Check that BumpsOptions includes fields for all options, and that defaults are correct. """ parser = build_arg_parser() args = parser.parse_args(args=[]) # Note that fit_options.* will appear directly as keys in the namespace. We aren't # checking them here. valid = {f.name for f in fields(BumpsOptions)} parsed = {k.split(".")[0] for k in args.__dict__.keys()} if parsed - valid: raise TypeError(f"The following fields are missing from BumpsOptions: {parsed-valid}") if valid - parsed: raise TypeError(f"The following fields don't have command options: {valid-parsed}") # Check defaults. # Using `defaults = BumpsOptions()` so that fit_options dict field gets assigned {} # Using `namespace = BumpsOptions()` because that is how we are calling the parser # during regular processing. The parse_args() call updates namespace with values from # the parser. Since we are using no command line arguments this should populate it # with the defaults. defaults = BumpsOptions() namespace = BumpsOptions() parser.parse_args(args=[], namespace=namespace) for f in fields(BumpsOptions): target, actual = getattr(defaults, f.name), getattr(namespace, f.name) assert target == actual, f"BumpsOptions.{f.name} default={target} does not match parser default={actual}"
[docs] def interpret_fit_options(options: BumpsOptions): # ordered list of actions to do on startup on_startup: List[Callable] = [] on_complete: List[Callable] = [] if options.use_persistent_path: api.state.base_path = persistent_settings.get_value( "base_path", str(Path().absolute()), application=api.state.app_name ) elif options.path is not None and Path(options.path).exists(): api.state.base_path = options.path else: api.state.base_path = str(Path.cwd().absolute()) if options.read_session is not None and options.session is not None: warnings.warn("read_session and session are both set; read_session will be used to initialize state") if options.write_session is not None and options.session is not None: warnings.warn("write_session and session are both set; write_session will be used to save state") read_session = options.read_session if options.read_session is not None else options.session write_session = options.write_session if options.write_session is not None else options.session # The logic for setting the current fit problem is complex. # # (1) bumps [PATH/MODEL.py] --reload-export=PATH[/MODEL.par] [--session=SESSION.h5] [--resume] # Load the results of a pre-1.0 bumps fit. Use PATH/MODEL.py if specified, otherwise # use ./MODEL.py in the current directory, or ./GLOB.par with PATH/*.par expands only # to PATH/GLOB.par. This is useful for exploring the results of an old DREAM run, and # possibly resuming the fit. This loads the model from its original location # because the model script probably refers to data files using relative paths, and these # aren't included in the export directory. # (2) bumps PATH/MODEL.py --session=SESSION.h5 # Load from PATH/MODEL.py even if SESSION.h5 is available. This is useful for a workflow # where you tweak the model script then rerun the fit, collecting all the results in # SESSION.h5. # (3) bumps PATH/MODEL.py --session=SESSION.h5 --resume # Load from SESSION.h5 even if PATH/MODEL.py is available. This is useful for resuming # the fit where it left off, even if the model constraints were adjusted in webview # before fitting. # (4) bumps --session=SESSION.h5 # Load from SESSION.h5. This is useful for a workflow primarily driven by webview, but # done over multiple sessions. # # More succinctly: # # Use --reload-export if specified. # Otherwise use MODEL.py if specified except on resume. # Otherwise use SESSION.h5. # TODO: why is session file read immediately but model.py delayed? # If a session file exists load the problem. if read_session is not None: read_session_path = Path(read_session).absolute() if read_session_path.exists(): api.state.read_session_file(str(read_session_path)) if write_session is None: api.state.shared.session_output_file = dict( pathlist=list(read_session_path.parent.parts), filename=read_session_path.name, ) if write_session is not None: write_session_path = Path(write_session).absolute() # TODO: Why are we splitting path into parts? api.state.shared.session_output_file = dict( pathlist=list(write_session_path.parent.parts), filename=write_session_path.name ) api.state.shared.autosave_session = True api.state.shared.autosave_session_interval = options.checkpoint if api.state.problem.serializer is None or api.state.problem.serializer == "": api.state.problem.serializer = options.serializer if not options.auto_history: api.state.shared.autosave_history = False if options.trace: global TRACE_MEMORY TRACE_MEMORY = True api.TRACE_MEMORY = True fitopts, errors = fit_options.check_options(options.fit_options) if errors: warnings.warn("\n".join(errors)) # TODO: leave fitter_id in fitopts # TODO: use dict rather than list of pairs for fitopts fitter_id = fitopts.pop("fit") # Add secret fitter to the GUI (pt and scipy.leastsq as of this writing) # TODO: FitOptions.vue needs to update fit_names on changeActiveFitter if fitter_id not in api.state.shared.fitter_settings: fitter = fit_options.lookup_fitter(fitter_id) api.state.shared.fitter_settings[fitter_id] = {"name": fitter.name, "settings": dict(fitter.settings)} # Show selected fit options in the GUI api.state.shared.selected_fitter = fitter_id api.state.shared.fitter_settings[fitter_id]["settings"].update(fitopts) api.state.parallel = options.parallel # TODO: How do we resume if the model is saved as a pickle and can't be restored? # on_startup.append(lambda App: publish('', 'local_file_path', Path().absolute().parts)) # resume only works when you have an identical problem, so we don't load from file # if we are resuming the fit. if options.reload_export: async def load_export_to_state(App=None): problem, fit = reload_export(options.reload_export, modelfile=options.filename, args=options.args) path = Path(problem.path) await api.set_problem(problem, path.parent, path.name, fit=fit) on_startup.append(load_export_to_state) # on_startup.append(lambda App: publish('', 'local_file_path', Path().absolute().parts)) # resume only works when you have an identical problem, so we don't load from file # if you are resuming the fit. elif options.filename is not None and not options.resume: async def load_problem_to_state(App=None): path = Path(options.filename).absolute() problem = load_problem(path, args=options.args) await api.set_problem(problem, path.parent, path.name) on_startup.append(load_problem_to_state) # TODO: allow pars to be loaded from a session file. # TODO: verify that --pars doesn't interfere with --resume if options.pars is not None: filepath = Path(options.pars).absolute() pars_pathlist = list(filepath.parent.parts) pars_filename = filepath.name on_startup.append(lambda App: api.apply_parameters(pars_pathlist, pars_filename)) # TODO: make sure --seed on command line produces the same result each time # TODO: store the seed with the fit results and print on console monitor # TODO: need separate seed for simulate and for fit # TODO: make sure seed works with async and threading and mpi # Reproducibility requires a known seed and a guaranteed order of execution. # So long as the startup items loop over awaits rather than using asyncio gather # this should be guaranteed. if options.seed: # raise NotImplementedError("--seed not yet supported") import numpy as np np.random.seed(options.seed) if options.simulate or options.simrandom: noise = None if options.noise <= 0.0 else options.noise async def simulate(App=None): if api.state.problem is None or api.state.problem.fitProblem is None: return problem = api.state.problem.fitProblem if options.simrandom: problem.randomize() problem.simulate_data(noise=noise) # TODO: How do these make it to the GUI? print("simulation parameters") print(problem.summarize()) print("chisq", problem.chisq_str()) on_startup.append(simulate) if options.shake: on_startup.append(lambda App=None: api.shake_parameters()) need_mapper = not options.chisq if need_mapper: from bumps.mapper import MPIMapper, MPMapper, SerialMapper, ThreadPoolMapper, using_mpi async def start_mapper(App=None): # print(f"{api.state.rank}start mapper") # TODO: When running Only load problem from root on MPI? # MPI is assumed # You can still run non-picklable problems on MPI using batch mode # since each work loads its own copy of the problem. if api.state.problem is not None: problem = api.state.problem.fitProblem else: problem = None if using_mpi() or options.mpi: # print("Starting with MPI mapper") mapper = MPIMapper elif options.parallel == 1: mapper = SerialMapper elif options.threads: mapper = ThreadPoolMapper else: mapper = MPMapper mapper.start_worker(problem) # if mapper == MPIMapper: print(f"{MPIMapper.rank}: got beyond start worker.") api.state.mapper = mapper on_startup.append(start_mapper) webview = options.mode != "batch" autostart = not webview or options.mode in ("start", "run") or options.resume autostop = not webview or options.mode == "run" # print(f"{options.mode=} {webview=} {autostart=} {autostop=}") if options.chisq: async def show_chisq(App=None): if api.state.problem is not None: # show_cov won't work yet because I don't have driver # defined and because I don't have a --cov option. # if opts.cov: fitdriver.show_cov() problem = api.state.problem.fitProblem # print(problem.summarize()) print("chisq", problem.chisq_str()) on_startup.append(show_chisq) elif autostart: # if batch mode then start the fit async def start_fit(App=None): # print(f"{api.state.rank}start fit") # print(f"{fitter_settings=}") if api.state.problem is not None: await api.start_fit_thread(fitter_id, fitopts, resume=options.resume) # TODO: This is duplicating code from bumps/fitters.py. Move it and share it. def _show_results(show_err: bool = True, show_cov: bool = False, show_entropy: str | None = None): import numpy as np from bumps.lsqerror import stderr from bumps.dream.entropy import cov_entropy from bumps.util import format_uncertainty from bumps.dream.stats import var_stats, format_vars if not (show_err or show_cov or show_entropy): return problem, state = api.state.problem.fitProblem, api.state.fitting.fit_state x = problem.getp() # TODO: Should we show the estimates from derivatives even when running dream? if state is None: cov = problem.cov(x) vstats = None dx = stderr(cov) S, dS = cov_entropy(cov), 0 else: draw = state.draw() # TODO: if there are derived variables then problem.show() doesn't work # TODO: sample from whole population, not just the end, to reduce autocorrelation if show_cov: cov = np.cov(draw.points[-1000:].T) vstats = var_stats(draw) # dx = np.array([(v.p68[1] - v.p68[0]) / 2 for v in vstats]) if show_entropy: entropy_method = show_entropy S, dS = state.entropy(method=entropy_method) if show_cov: problem.show_cov(x, cov) if show_err: if state is None: problem.show_err(x, dx) else: print(format_vars(vstats)) if show_entropy: print(f"Entropy: {format_uncertainty(S, dS)} bits") async def show_results(App=None): _show_results(options.show_err, options.show_cov, options.show_entropy) on_startup.append(start_fit) api.state.console_update_interval = 0 if webview else 1 if not options.export and write_session is None and autostop: # TODO: can we specify problem.path in the model file? # TODO: can we default the session file name to model.hdf? raise RuntimeError("Include '--session=output.h5' on the command line to save the fit.") # TODO: if not autostop maybe --export after fit only or after every fit if options.export and autostop: # print("adding completion lambda") on_complete.append(lambda App=None: api.export_results(options.export)) if autostop: on_complete.append(show_results) # TODO: cleaner handling of autostop if webview and autostop: # trigger shutdown on fit complete [webview only] # print("setting shutdown True") api.state.shutdown_on_fit_complete = True else: # signal that no fit is running at startup, even if a fit was # interrupted and the state was saved: on_startup.append(lambda App=None: api.state.shared.set("active_fit", {})) # TODO: maybe warn when --export option is ignored # if options.export and not autostop: # ... return on_startup, on_complete
[docs] def reload_export( path: Path | str, modelfile: Path | str | None = None, args: list[str] | None = None, ) -> tuple[FitProblem, FitResult]: """ Reload a bumps export directory. *path* is the path to the directory, or to a <model>.par file within that directory. Use the <model>.par file if you have multiple models exported to the same path. If *modelfile* is provided then use it, otherwise use <model>.py in the current directory. That means you can change to the directory containing your model then run bumps with --reload-export=path without having to list <model>.py on the command line. This is handy if you have several variations saved to different filenames stored along with your data. sys.argv is set to *args* before loading the model. """ from .fitproblem import load_problem, load_pars from .state import SERIALIZER_EXTENSIONS # Find the .par file in the export directory path = Path(path) if path.is_file(): parfile = path if parfile.suffix != ".par": raise ValueError(f"Reload export needs path or path/model.par, not {path}") path = path.parent # set path to the directory containing *.par else: # path is already a directory; look for a par file within it pars_glob = list(path.glob("*.par")) if len(pars_glob) == 0: raise ValueError(f"Reload export {path}/*.par does not exist") if len(pars_glob) > 1: raise ValueError(f"More than one .par file. Use {path}/model.par in reload export.") parfile = pars_glob[0] # Look for a pickle in the parfile directory and try loading that problem = None for serializer, suffix in SERIALIZER_EXTENSIONS.items(): picklefile = parfile.with_suffix(f".{suffix}") if picklefile.exists(): try: problem = load_problem(picklefile) # Note: no need to load pars because serializer contains the parameters except Exception as exc: logger.warn(f"Failed to deserialize {picklefile}; look for model file") break # Pickle failed. Try loading modelfile if problem is None: # If modelfile is not given look for one of the serializers in the current directory if modelfile is None: # Look for model.py file in the parent directory of the export directory modelfile = path.parent / parfile.with_suffix(".py").name else: modelfile = Path(modelfile) saved_model = path / modelfile.name if not saved_model.exists(): raise ValueError(f"Model '{modelfile.name}' does not exist in '{path}'") if not modelfile.exists(): raise ValueError(f"Model '{modelfile}' does not exist.") if filehash(saved_model) != filehash(modelfile): raise ValueError(f"Model file has been modified. Copy {saved_model} into {modelfile.parent}") # Load the model script and the fitted values. problem = load_problem(modelfile, model_options=args) load_pars(problem, parfile) # Load the MCMC files fit_result = load_fit_result(parfile) return problem, fit_result
def load_fit_result(parfile: Path | str) -> FitResult: from bumps.dream.state import load_state # Reload DREAM state if it exists. Note that labels come from the parfile. try: fit_state = load_state(str(parfile.parent / parfile.stem)) fit_state.mark_outliers() fit_state.portion = fit_state.trim_portion() except Exception as exc: # no fit state, but that's okay logger.warning(f"Could not load DREAM state: {exc}") fit_state = None # TODO: might be able to get method and options from the start of the .mon file if fit_state is not None: # Reconstruct dream options for pop and steps are set correctly. # Use negative pop for fixed number of chains independent of the number of parameters Nchains, Nvar, Nsteps = fit_state.Npop, fit_state.Nvar, fit_state.Nthin pop = Nchains / Nvar if Nchains % Nvar == 0 else -Nchains samples = Nchains * Nsteps method = "dream" options = dict(pop=pop, steps=0, samples=samples) else: method = "amoeba" # arbitrary method options = {} # TODO: reload convergence data when it is available if fit_state is not None: convergence = build_convergence_from_fit_state(fit_state) else: convergence = [] return FitResult(method=method, options=options, convergence=convergence, fit_state=fit_state) def filehash(filename): with open(filename, "rb") as fd: # TODO: simplify to this when python min version is 3.11 # return hashlib.file_digest(fd, "md5").hexdigest() chunk_size = 2**16 hash = hashlib.md5() while chunk := fd.read(chunk_size): hash.update(chunk) return hash.hexdigest() def build_convergence_from_fit_state(fit_state): """ Build a pseudo-convergence array from a dream state object. "pseudo" because it doesn't include burn or thinning. Also, the best value seen during burn may be lower than the best value seen at the start of the buffer, our estimate of the best so far is an over estimate. It will look like the best is improving even though it is not. This is better than assuming the best occurred before the buffer started. """ import numpy as np if fit_state is None: return [] draws, point, logp = fit_state.chains() p = np.sort(abs(logp), axis=1) best = np.minimum.accumulate(p[:, 0]) QI, Qmid = int(0.2 * logp.shape[1]), int(0.5 * logp.shape[1]) quantiles = np.vstack((best, p[:, 0], p[:, QI], p[:, Qmid], p[:, -(QI + 1)], p[:, -1])) return quantiles.T async def _run_operations(on_startup, on_complete): for step in on_startup: await step(None) # print("waiting for fit complete") await api.wait_for_fit_complete() # print("running complete actions") for step in on_complete: await step(None) # if api.state.mapper is not None: # api.mapper.stop_mapper() # api.mapper = None # print("shutting down") await api.shutdown() # print("exiting") def sigint_handler(sig, frame): """ Support user interrupt of the fit in batch mode. The first Ctrl-C triggers a graceful shutdown. The second Ctrl-C should abort immediately. """ # The first Ctrl-C will set fit_abort_event, and the second Ctrl-C will see that # the event is set and abort immediately. # Scenarios: # 1) Ctrl-C before the fit starts. The abort flag starts clear, so we set it. # If another Ctrl-C is received before the start_fit_thread is called then it # will still be set and we will abort. Otherwise, start_fit_thread will clear # the flag before starting the fit and the first Ctrl-C is ignored. # 2) Ctrl-C during fit. start_fit_thread clears the abort flag before starting # so we set it. If another Ctrl-C is received, either during the fit or after # the fit during save and cleanup, the flag will be set and we will abort. # 3) Ctrl-C after fit. The abort flag will still be clear so we set it. The # subsequent Ctrl-C will abort. # Note: the same behaviour should work if we attach this signal handler # when starting webview. If stop fit is triggered by the GUI, then like the # first Ctrl-S it will set the abort event. Futher stop requests do nothing, # but Ctrl-C in the terminal will exit the program. if api.state.fit_abort_event.is_set(): print("Program is not stopping. Aborting.") sys.exit(0) # print("\nCaught KeyboardInterrupt, stopping fit") api.state.fit_abort_event.set() def run_batch_fit(options: BumpsOptions): # TODO: use GUI notifications instead of console monitor for console output # TODO: provide info such as steps k of n and chisq with emit notifications # async def emit_to_console(*args, **kw): # ... # print("console", args, kw) # api.EMITTERS["console"] = emit_to_console # Monkeypatch shutdown so it doesn't raise system exit signal.signal(signal.SIGINT, sigint_handler) on_startup, on_complete = interpret_fit_options(options) asyncio.run(_run_operations(on_startup, on_complete)) # print("completed run") def plugin_main(name: str, client: Path, version: str = ""): api.state.app_name = name api.state.app_version = version api.state.client_path = client main()
[docs] def main(options: Optional[BumpsOptions] = None): # Redirect to old interface if --wx is added to the command line # You can also invoke it as python -m bumps.gui if options is None and "--wx" in sys.argv: from bumps.gui.old_cli import main as old_main sys.argv = [s for s in sys.argv if s != "--wx"] return old_main() # TODO: where do we configure matplotlib? # Need to set matplotlib to a non-interactive backend because it is being used in the # the export thread. The next_color method calls gca() which needs to produce a blank # graph even when there is none (we ask for next color before making the plot). from bumps.mapper import using_mpi if options is None: options = get_commandline_options() logger.setLevel(LOGLEVEL[options.loglevel]) setup_console_logging(options.loglevel) # from .logger import capture_warnings # capture_warnings(monkeypatch=True) logger.info(options) info_only = options.chisq webview = options.mode != "batch" and not info_only # TODO: cleaner way to isolate MPI? # TODO: cleaner handling of worker exit. # TODO: allow mpi fits from a jupyter slurm allocation spanning multiple nodes. # Only want one aiohttp server running so we need to know up front whether # the process we are running is the controller or one of the workers. # In order to support unpickleable problems in MPI we need to load the model # independently for each worker. Unfortunately the problem loader is in an # asynchronous function so we need the full async processing loop to load # the problem before we start the worker loop. The current hack is to call # sys.exit() after the worker loop finishes so that the remaining on startup # and on complete actions are skipped. We could instead pass an is_worker flag # into the options processor so that there are no tasks to skip. # Don't use MPI autodetect when --mpi/--no-mpi is given on the command line. if (options.mpi or (options.mpi is None and using_mpi())) and not info_only: # ** Warning **: importing MPI from mpi4py calls MPI_Init() which triggers # network traffic. Only import it when you know you are using MPI calls. from mpi4py import MPI is_controller = MPI.COMM_WORLD.rank == 0 # api.state.rank = f"{MPI.COMM_WORLD.rank:3d}: " else: is_controller = True # api.state.rank = "" if webview and is_controller: # gui mode # TODO: circular import since webserver imports from cli from .webview.webserver import start_from_cli print(_branding()) start_from_cli(options) else: # console mode run_batch_fit(options)
if __name__ == "__main__": main()