diff --git a/.autorelease/test-testpypi.sh b/.autorelease/test-testpypi.sh new file mode 100644 index 00000000..d4509bb2 --- /dev/null +++ b/.autorelease/test-testpypi.sh @@ -0,0 +1,2 @@ +python -m pip install sqlalchemy dill pytest +py.test --pyargs paths_cli diff --git a/.github/workflows/autorelease-default-env.sh b/.github/workflows/autorelease-default-env.sh index 74dd4615..f1da159b 100644 --- a/.github/workflows/autorelease-default-env.sh +++ b/.github/workflows/autorelease-default-env.sh @@ -1,4 +1,4 @@ -INSTALL_AUTORELEASE="python -m pip install autorelease==0.2.3" +INSTALL_AUTORELEASE="python -m pip install autorelease==0.2.6" if [ -f autorelease-env.sh ]; then source autorelease-env.sh fi diff --git a/.github/workflows/autorelease-deploy.yml b/.github/workflows/autorelease-deploy.yml index 89aaeac3..9a8f03bc 100644 --- a/.github/workflows/autorelease-deploy.yml +++ b/.github/workflows/autorelease-deploy.yml @@ -14,7 +14,9 @@ jobs: python-version: "3.x" - run: | # TODO: move this to an action source ./.github/workflows/autorelease-default-env.sh - cat autorelease-env.sh >> $GITHUB_ENV + if [ -f "autorelease-env.sh" ]; then + cat autorelease-env.sh >> $GITHUB_ENV + fi eval $INSTALL_AUTORELEASE name: "Install autorelease" - run: | @@ -27,5 +29,5 @@ jobs: - uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.pypi_password }} - name: "Deploy to testpypi" + name: "Deploy to pypi" diff --git a/.github/workflows/autorelease-gh-rel.yml b/.github/workflows/autorelease-gh-rel.yml index f9e294eb..bb5cd276 100644 --- a/.github/workflows/autorelease-gh-rel.yml +++ b/.github/workflows/autorelease-gh-rel.yml @@ -15,7 +15,9 @@ jobs: python-version: "3.7" - run: | # TODO: move this to an action source ./.github/workflows/autorelease-default-env.sh - cat autorelease-env.sh >> $GITHUB_ENV + if [ -f "autorelease-env.sh" ]; then + cat autorelease-env.sh >> $GITHUB_ENV + fi eval $INSTALL_AUTORELEASE name: "Install autorelease" - run: | diff --git a/.github/workflows/autorelease-prep.yml b/.github/workflows/autorelease-prep.yml index 48c82ba8..49674133 100644 --- a/.github/workflows/autorelease-prep.yml +++ b/.github/workflows/autorelease-prep.yml @@ -19,7 +19,9 @@ jobs: python-version: "3.x" - run: | # TODO: move this to an action source ./.github/workflows/autorelease-default-env.sh - cat autorelease-env.sh >> $GITHUB_ENV + if [ -f "autorelease-env.sh" ]; then + cat autorelease-env.sh >> $GITHUB_ENV + fi eval $INSTALL_AUTORELEASE name: "Install autorelease" - run: | @@ -49,7 +51,9 @@ jobs: python-version: "3.x" - run: | # TODO: move this to an action source ./.github/workflows/autorelease-default-env.sh - cat autorelease-env.sh >> $GITHUB_ENV + if [ -f "autorelease-env.sh" ]; then + cat autorelease-env.sh >> $GITHUB_ENV + fi eval $INSTALL_AUTORELEASE name: "Install autorelease" - run: test-testpypi diff --git a/README.md b/README.md index 8fead643..d4c5887b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ miscellaneous operations on OPS output files. **Simulation Commands:** * `visit-all`: Run MD to generate initial trajectories +* `md`: Run MD for fixed time or until a given ensemble is satisfied * `equilibrate`: Run equilibration for path sampling * `pathsampling`: Run any path sampling simulation, including TIS variants diff --git a/autorelease-env.sh b/autorelease-env.sh index 3b488eb7..9074cb38 100644 --- a/autorelease-env.sh +++ b/autorelease-env.sh @@ -1,2 +1,2 @@ -INSTALL_AUTORELEASE="python -m pip install autorelease==0.2.3 nose" +INSTALL_AUTORELEASE="python -m pip install autorelease==0.2.3 nose sqlalchemy dill" PACKAGE_IMPORT_NAME=paths_cli diff --git a/devtools/tests_require.txt b/devtools/tests_require.txt index 75b415c2..e5f44cc0 100644 --- a/devtools/tests_require.txt +++ b/devtools/tests_require.txt @@ -3,3 +3,6 @@ nose pytest pytest-cov coveralls +# following are for SimStore integration +dill +sqlalchemy diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index c1a31597..cf316f5a 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -1,12 +1,32 @@ import click from paths_cli.parameters import INPUT_FILE +UNNAMED_SECTIONS = ['steps', 'movechanges', 'samplesets', 'trajectories', + 'snapshots'] + +NAME_TO_ATTR = { + 'CVs': 'cvs', + 'Volumes': 'volumes', + 'Engines': 'engines', + 'Networks': 'networks', + 'Move Schemes': 'schemes', + 'Simulations': 'pathsimulators', + 'Tags': 'tags', + 'Steps': 'steps', + 'Move Changes': 'movechanges', + 'SampleSets': 'samplesets', + 'Trajectories': 'trajectories', + 'Snapshots': 'snapshots' +} + @click.command( 'contents', short_help="list named objects from an OPS .nc file", ) @INPUT_FILE.clicked(required=True) -def contents(input_file): +@click.option('--table', type=str, required=False, + help="table to show results from") +def contents(input_file, table): """List the names of named objects in an OPS .nc file. This is particularly useful when getting ready to use one of simulation @@ -14,6 +34,31 @@ def contents(input_file): """ storage = INPUT_FILE.get(input_file) print(storage) + if table is None: + report_all_tables(storage) + else: + table_attr = table.lower() + try: + store = getattr(storage, table_attr) + except AttributeError: + raise click.UsageError("Unknown table: '" + table_attr + "'") + else: + print(get_section_string(table_attr, store)) + + +def get_section_string(label, store): + attr = NAME_TO_ATTR.get(label, label.lower()) + if attr in UNNAMED_SECTIONS: + string = get_unnamed_section_string(label, store) + elif attr in ['tag', 'tags']: + string = get_section_string_nameable(label, store, _get_named_tags) + else: + string = get_section_string_nameable(label, store, + _get_named_namedobj) + return string + + +def report_all_tables(storage): store_section_mapping = { 'CVs': storage.cvs, 'Volumes': storage.volumes, 'Engines': storage.engines, 'Networks': storage.networks, @@ -26,12 +71,16 @@ def contents(input_file): print(get_section_string_nameable('Tags', storage.tags, _get_named_tags)) print("\nData Objects:") - unnamed_sections = { - 'Steps': storage.steps, 'Move Changes': storage.movechanges, - 'SampleSets': storage.samplesets, - 'Trajectories': storage.trajectories, 'Snapshots': storage.snapshots + data_object_mapping = { + 'Steps': lambda storage: storage.steps, + 'Move Changes': lambda storage: storage.movechanges, + 'SampleSets': lambda storage: storage.samplesets, + 'Trajectories': lambda storage: storage.trajectories, + 'Snapshots': lambda storage: storage.snapshots } - for section, store in unnamed_sections.items(): + + for section, store_func in data_object_mapping.items(): + store = store_func(storage) print(get_unnamed_section_string(section, store)) def _item_or_items(count): diff --git a/paths_cli/commands/equilibrate.py b/paths_cli/commands/equilibrate.py index 357635e4..41b74676 100644 --- a/paths_cli/commands/equilibrate.py +++ b/paths_cli/commands/equilibrate.py @@ -43,6 +43,7 @@ def equilibrate_main(output_storage, scheme, init_conds, multiplier, extra_steps): import openpathsampling as paths init_conds = scheme.initial_conditions_from_trajectories(init_conds) + scheme.assert_initial_conditions(init_conds) simulation = paths.PathSampling( storage=output_storage, move_scheme=scheme, diff --git a/paths_cli/commands/md.py b/paths_cli/commands/md.py new file mode 100644 index 00000000..22f36240 --- /dev/null +++ b/paths_cli/commands/md.py @@ -0,0 +1,203 @@ +import click + +import paths_cli.utils +from paths_cli.parameters import (INPUT_FILE, OUTPUT_FILE, ENGINE, + MULTI_ENSEMBLE, INIT_SNAP) + +import logging +logger = logging.getLogger(__name__) + +@click.command( + "md", + short_help=("Run MD for fixed time or until a given ensemble is " + "satisfied"), +) +@INPUT_FILE.clicked(required=True) +@OUTPUT_FILE.clicked(required=True) +@ENGINE.clicked(required=False) +@MULTI_ENSEMBLE.clicked(required=False) +@click.option('-n', '--nsteps', type=int, + help="number of MD steps to run") +@INIT_SNAP.clicked(required=False) +def md(input_file, output_file, engine, ensemble, nsteps, init_frame): + """Run MD for for time of steps or until ensembles are satisfied. + + This can either take a --nsteps or --ensemble, but not both. If the + --ensemble option is specfied more than once, then this will attempt to + run until all ensembles are satisfied by a subtrajectory. + + This still respects the maximum number of frames as set in the engine, + and will terminate if the trajectory gets longer than that. + """ + storage = INPUT_FILE.get(input_file) + md_main( + output_storage=OUTPUT_FILE.get(output_file), + engine=ENGINE.get(storage, engine), + ensembles=MULTI_ENSEMBLE.get(storage, ensemble), + nsteps=nsteps, + initial_frame=INIT_SNAP.get(storage, init_frame) + ) + +class ProgressReporter(object): + """Generic class for a callable that reports progress. + + Base class for ends-with-ensemble and fixed-length tricks. + + Parameters + ---------- + timestep : Any + timestep, optionally with units + update_freq : int + how often to report updates + """ + def __init__(self, timestep, update_freq): + self.timestep = timestep + self.update_freq = update_freq + + def steps_progress_string(self, n_steps): + """Return string for number of frames run and time elapsed + + Not newline-terminated. + """ + report_str = "Ran {n_steps} frames" + if self.timestep is not None: + report_str += " [{}]".format(str(n_steps * self.timestep)) + report_str += '.' + return report_str.format(n_steps=n_steps) + + def progress_string(self, n_steps): + """Return the progress string. Subclasses may override. + """ + report_str = self.steps_progress_string(n_steps) + "\n" + return report_str.format(n_steps=n_steps) + + def report_progress(self, n_steps, force=False): + """Report the progress to the terminal. + """ + import openpathsampling as paths + if (n_steps % self.update_freq == 0) or force: + string = self.progress_string(n_steps) + paths.tools.refresh_output(string) + + def __call__(self, trajectory, trusted=False): + raise NotImplementedError() + + +class EnsembleSatisfiedContinueConditions(ProgressReporter): + """Continuation condition for including subtrajs for each ensemble. + + This object creates a continuation condition (a callable) analogous with + the ensemble ``can_append`` method. This will tell the trajectory to + keep running until, for each of the given ensembles, a subtrajectory has + been found that will satisfy the ensemble. + + Parameters + ---------- + ensembles: List[:class:`openpathsampling.Ensemble`] + the ensembles to satisfy + timestep : Any + timestep, optionally with units + update_freq : int + how often to report updates + """ + def __init__(self, ensembles, timestep=None, update_freq=10): + super().__init__(timestep, update_freq) + self.satisfied = {ens: False for ens in ensembles} + + def progress_string(self, n_steps): + report_str = self.steps_progress_string(n_steps) + report_str += (" Found ensembles [{found}]. " + "Looking for [{missing}].\n") + found = [ens.name for ens, done in self.satisfied.items() if done] + missing = [ens.name for ens, done in self.satisfied.items() + if not done] + found_str = ",".join(found) + missing_str = ",".join(missing) + return report_str.format(n_steps=n_steps, + found=found_str, + missing=missing_str) + + + def _check_previous_frame(self, trajectory, start, unsatisfied): + if -start > len(trajectory): + # we've done the whole traj; don't keep going + return False + subtraj = trajectory[start:] + logger.debug(str(subtraj) + "/" + str(trajectory)) + for ens in unsatisfied: + if not ens.strict_can_prepend(subtraj, trusted=True): + # test if we can't prepend because we satsify + self.satisfied[ens] = ens(subtraj) or ens(subtraj[1:]) + unsatisfied.remove(ens) + return bool(unsatisfied) + + def _call_untrusted(self, trajectory): + self.satisfied = {ens: False for ens in self.satisfied} + for i in range(1, len(trajectory)): + keep_going = self(trajectory[:i], trusted=True) + if not keep_going: + return False + return self(trajectory, trusted=True) + + def __call__(self, trajectory, trusted=False): + if not trusted: + return self._call_untrusted(trajectory) + + # below here, trusted is True + self.report_progress(len(trajectory) - 1) + + unsatisfied = [ens for ens, done in self.satisfied.items() + if not done] + # TODO: update on how many ensembles left, what frame number we are + + start = -1 + while self._check_previous_frame(trajectory, start, unsatisfied): + start -= 1 + + return not all(self.satisfied.values()) + + +class FixedLengthContinueCondition(ProgressReporter): + """Continuation condition for fixed-length runs. + + Parameters + ---------- + length : int + final length of the trajectory in frames + timestep : Any + timestep, optionally with units + update_freq : int + how often to report updates + """ + def __init__(self, length, timestep=None, update_freq=10): + super().__init__(timestep, update_freq) + self.length = length + + def __call__(self, trajectory, trusted=False): + len_traj = len(trajectory) + self.report_progress(len_traj - 1) + return len_traj < self.length + + + +def md_main(output_storage, engine, ensembles, nsteps, initial_frame): + import openpathsampling as paths + if nsteps is not None and ensembles: + raise RuntimeError("Options --ensemble and --nsteps cannot both be" + " used at once.") + + if ensembles: + continue_cond = EnsembleSatisfiedContinueConditions(ensembles) + else: + continue_cond = FixedLengthContinueCondition(nsteps) + + trajectory = engine.generate(initial_frame, running=continue_cond) + continue_cond.report_progress(len(trajectory) - 1, force=True) + paths_cli.utils.tag_final_result(trajectory, output_storage, + 'final_conditions') + return trajectory, None + +CLI = md +SECTION = "Simulation" +REQUIRES_OPS = (1, 0) + diff --git a/paths_cli/commands/visit_all.py b/paths_cli/commands/visit_all.py index 2cb61c1a..4b686b56 100644 --- a/paths_cli/commands/visit_all.py +++ b/paths_cli/commands/visit_all.py @@ -1,5 +1,6 @@ import click +import paths_cli.utils from paths_cli.parameters import (INPUT_FILE, OUTPUT_FILE, ENGINE, STATES, INIT_SNAP) @@ -34,9 +35,8 @@ def visit_all_main(output_storage, states, engine, initial_frame): timestep = getattr(engine, 'timestep', None) visit_all_ens = paths.VisitAllStatesEnsemble(states, timestep=timestep) trajectory = engine.generate(initial_frame, [visit_all_ens.can_append]) - if output_storage is not None: - output_storage.save(trajectory) - output_storage.tags['final_conditions'] = trajectory + paths_cli.utils.tag_final_result(trajectory, output_storage, + 'final_conditions') return trajectory, None # no simulation object to return here diff --git a/paths_cli/param_core.py b/paths_cli/param_core.py index 3d379b58..1ec2540c 100644 --- a/paths_cli/param_core.py +++ b/paths_cli/param_core.py @@ -72,22 +72,48 @@ class StorageLoader(AbstractLoader): mode : 'r', 'w', or 'a' the mode for the file """ + has_simstore_patch = False def __init__(self, param, mode): super(StorageLoader, self).__init__(param) self.mode = mode + @staticmethod + def _is_simstore(name): + return name.endswith(".db") or name.endswith(".sql") + def _workaround(self, name): # this is messed up... for some reason, storage doesn't create a new # file in append mode. That may be a bug import openpathsampling as paths - if self.mode == 'a' and not os.path.exists(name): + needs_workaround = ( + self.mode == 'a' + and not os.path.exists(name) + and not self._is_simstore(name) + ) + if needs_workaround: st = paths.Storage(name, mode='w') st.close() def get(self, name): - import openpathsampling as paths - self._workaround(name) - return paths.Storage(name, mode=self.mode) + if self._is_simstore(name): + import openpathsampling as paths + from openpathsampling.experimental.storage import \ + Storage, monkey_patch_all + + if not self.has_simstore_patch: + paths = monkey_patch_all(paths) + paths.InterfaceSet.simstore = True + StorageLoader.has_simstore_patch = True + + from openpathsampling.experimental.simstore import \ + SQLStorageBackend + backend = SQLStorageBackend(name, mode=self.mode) + storage = Storage.from_backend(backend) + else: + from openpathsampling import Storage + self._workaround(name) + storage = Storage(name, self.mode) + return storage class OPSStorageLoadNames(AbstractLoader): @@ -200,10 +226,20 @@ class GetOnlySnapshot(Getter): def __init__(self, store_name="snapshots"): super().__init__(store_name) + def _min_num_snapshots(self, storage): + # For netcdfplus, we see 2 snapshots when there is only one + # (reversed copy gets saved). For SimStore, we see only one. + import openpathsampling as paths + if isinstance(storage, paths.netcdfplus.NetCDFPlus): + min_snaps = 2 + else: + min_snaps = 1 + return min_snaps + def __call__(self, storage): store = getattr(storage, self.store_name) - if len(store) == 2: - # this is really only 1 snapshot; reversed copy gets saved + min_snaps = self._min_num_snapshots(storage) + if len(store) == min_snaps: return store[0] @@ -270,6 +306,61 @@ def get(self, storage, name): result = _try_strategies(self.none_strategies, storage) if result is None: - raise RuntimeError("Couldn't find %s", name) + if name is None: + msg = "Couldn't guess which item to use from " + self.store + else: + msg = "Couldn't find {name} is {store}".format( + name=name, + store=self.store + ) + raise RuntimeError(msg) return result + + +class OPSStorageLoadMultiple(OPSStorageLoadSingle): + """Objects that can guess a single object or have multiple specified. + + Parameters + ---------- + param : :class:`.AbstractParameter` + the Option or Argument wrapping a click decorator + store : Str + the name of the store to search + value_strategies : List[Callable[(:class:`.Storage`, Str), Any]] + The strategies to be used when the CLI provides a value for this + parameter. Each should be a callable taking a storage and the string + input from the CLI, and should return the desired object or None if + it cannot be found. + none_strategies : List[Callable[:class:`openpathsampling.Storage, Any]] + The strategies to be used when the CLI does not provide a value for + this parameter. Each should be a callable taking a storage, and + returning the desired object or None if it cannot be found. + """ + def get(self, storage, names): + """Load desired objects from storage. + + Parameters + ---------- + storage : openpathsampling.Storage + the input storage to search + names : List[Str] or None + strings from CLI providing the identifier (name or index) for + this object; None if not provided + """ + if names == tuple(): + names = None + + if names is None or isinstance(names, (str, int)): + listified = True + names = [names] + else: + listified = False + + results = [super(OPSStorageLoadMultiple, self).get(storage, name) + for name in names] + + if listified: + results = results[0] + + return results diff --git a/paths_cli/parameters.py b/paths_cli/parameters.py index 70b8ec4c..e9bff767 100644 --- a/paths_cli/parameters.py +++ b/paths_cli/parameters.py @@ -1,8 +1,8 @@ import click from paths_cli.param_core import ( - Option, Argument, OPSStorageLoadSingle, OPSStorageLoadNames, - StorageLoader, GetByName, GetByNumber, GetOnly, GetOnlySnapshot, - GetPredefinedName + Option, Argument, OPSStorageLoadSingle, OPSStorageLoadMultiple, + OPSStorageLoadNames, StorageLoader, GetByName, GetByNumber, GetOnly, + GetOnlySnapshot, GetPredefinedName ) @@ -18,10 +18,10 @@ store='schemes', ) -INIT_CONDS = OPSStorageLoadSingle( - param=Option('-t', '--init-conds', +INIT_CONDS = OPSStorageLoadMultiple( + param=Option('-t', '--init-conds', multiple=True, help=("identifier for initial conditions " - + "(sample set or trajectory)")), + + "(sample set or trajectory)" + HELP_MULTIPLE)), store='samplesets', value_strategies=[GetByName('tags'), GetByNumber('samplesets'), GetByNumber('trajectories')], @@ -57,6 +57,11 @@ store='engines' ) +MULTI_ENSEMBLE = OPSStorageLoadNames( + param=Option('--ensemble', type=str, multiple=True, + help='name of index of ensemble' + HELP_MULTIPLE), + store='ensembles' +) STATES = OPSStorageLoadNames( param=Option('-s', '--state', type=str, multiple=True, diff --git a/paths_cli/tests/commands/test_append.py b/paths_cli/tests/commands/test_append.py index 40d50994..df8a4b90 100644 --- a/paths_cli/tests/commands/test_append.py +++ b/paths_cli/tests/commands/test_append.py @@ -8,13 +8,13 @@ import openpathsampling as paths def make_input_file(tps_network_and_traj): - input_file = paths.Storage("setup.py", mode='w') + input_file = paths.Storage("setup.nc", mode='w') for obj in tps_network_and_traj: input_file.save(obj) input_file.tags['template'] = input_file.snapshots[0] input_file.close() - return "setup.py" + return "setup.nc" def test_append(tps_network_and_traj): runner = CliRunner() @@ -22,8 +22,8 @@ def test_append(tps_network_and_traj): in_file = make_input_file(tps_network_and_traj) result = runner.invoke(append, [in_file, '-a', 'output.nc', '--volume', 'A', '--volume', 'B']) - assert result.exit_code == 0 assert result.exception is None + assert result.exit_code == 0 storage = paths.Storage('output.nc', mode='r') assert len(storage.volumes) == 2 assert len(storage.snapshots) == 0 diff --git a/paths_cli/tests/commands/test_contents.py b/paths_cli/tests/commands/test_contents.py index 246bafb9..0d136437 100644 --- a/paths_cli/tests/commands/test_contents.py +++ b/paths_cli/tests/commands/test_contents.py @@ -40,3 +40,44 @@ def test_contents(tps_fixture): assert results.output.split('\n') == expected for truth, beauty in zip(expected, results.output.split('\n')): assert truth == beauty + +@pytest.mark.parametrize('table', ['volumes', 'trajectories', 'tags']) +def test_contents_table(tps_fixture, table): + scheme, network, engine, init_conds = tps_fixture + runner = CliRunner() + with runner.isolated_filesystem(): + storage = paths.Storage("setup.nc", 'w') + for obj in tps_fixture: + storage.save(obj) + storage.tags['initial_conditions'] = init_conds + + results = runner.invoke(contents, ['setup.nc', '--table', table]) + cwd = os.getcwd() + expected = { + 'volumes': [ + f"Storage @ '{cwd}/setup.nc'", + "volumes: 8 items", "* A", "* B", "* plus 6 unnamed items", + "" + ], + 'trajectories': [ + f"Storage @ '{cwd}/setup.nc'", + "trajectories: 1 unnamed item", + "" + ], + 'tags': [ + f"Storage @ '{cwd}/setup.nc'", + "tags: 1 item", + "* initial_conditions", + "" + ], + }[table] + assert results.output.split("\n") == expected + assert results.exit_code == 0 + +def test_contents_table_error(): + runner = CliRunner() + with runner.isolated_filesystem(): + storage = paths.Storage("temp.nc", mode='w') + storage.close() + results = runner.invoke(contents, ['temp.nc', '--table', 'foo']) + assert results.exit_code != 0 diff --git a/paths_cli/tests/commands/test_md.py b/paths_cli/tests/commands/test_md.py new file mode 100644 index 00000000..f4aab68f --- /dev/null +++ b/paths_cli/tests/commands/test_md.py @@ -0,0 +1,188 @@ +import pytest +import os +import tempfile +from unittest.mock import patch, Mock +from click.testing import CliRunner + +from paths_cli.commands.md import * + +import openpathsampling as paths + +from openpathsampling.tests.test_helpers import \ + make_1d_traj, CalvinistDynamics + +class TestProgressReporter(object): + def setup(self): + self.progress = ProgressReporter(timestep=None, update_freq=5) + + @pytest.mark.parametrize('timestep', [None, 0.1]) + def test_progress_string(self, timestep): + progress = ProgressReporter(timestep, update_freq=5) + expected = "Ran 25 frames" + if timestep is not None: + expected += " [2.5]" + expected += '.\n' + assert progress.progress_string(25) == expected + + @pytest.mark.parametrize('n_steps', [0, 5, 6]) + @pytest.mark.parametrize('force', [True, False]) + @patch('openpathsampling.tools.refresh_output', + lambda s: print(s, end='')) + def test_report_progress(self, n_steps, force, capsys): + self.progress.report_progress(n_steps, force) + expected = "Ran {n_steps} frames.\n".format(n_steps=n_steps) + out, err = capsys.readouterr() + if (n_steps in [0, 5]) or force: + assert out == expected + else: + assert out == "" + +class TestEnsembleSatisfiedContinueConditions(object): + def setup(self): + cv = paths.CoordinateFunctionCV('x', lambda x: x.xyz[0][0]) + vol_A = paths.CVDefinedVolume(cv, float("-inf"), 0.0) + vol_B = paths.CVDefinedVolume(cv, 1.0, float("inf")) + ensembles = [ + paths.LengthEnsemble(1).named("len1"), + paths.LengthEnsemble(3).named("len3"), + paths.SequentialEnsemble([ + paths.LengthEnsemble(1) & paths.AllInXEnsemble(vol_A), + paths.AllOutXEnsemble(vol_A | vol_B), + paths.LengthEnsemble(1) & paths.AllInXEnsemble(vol_A) + ]).named('return'), + paths.SequentialEnsemble([ + paths.LengthEnsemble(1) & paths.AllInXEnsemble(vol_A), + paths.AllOutXEnsemble(vol_A | vol_B), + paths.LengthEnsemble(1) & paths.AllInXEnsemble(vol_B) + ]).named('transition'), + ] + self.ensembles = {ens.name: ens for ens in ensembles} + self.traj_vals = [-0.1, 1.1, 0.5, -0.2, 0.1, -0.3, 0.4, 1.4, -1.0] + self.trajectory = make_1d_traj(self.traj_vals) + self.engine = CalvinistDynamics(self.traj_vals) + self.satisfied_when_traj_len = { + "len1": 1, + "len3": 3, + "return": 6, + "transition": 8, + } + self.conditions = EnsembleSatisfiedContinueConditions(ensembles) + + + @pytest.mark.parametrize('trusted', [True, False]) + @pytest.mark.parametrize('traj_len,expected', [ + # expected = (num_calls, num_satisfied) + (0, (1, 0)), + (1, (2, 1)), + (2, (3, 1)), + (3, (3, 2)), + (5, (2, 2)), + (6, (3, 3)), + (7, (1, 3)), + (8, (3, 4)), + ]) + def test_call(self, traj_len, expected, trusted): + if trusted: + already_satisfied = [ + self.ensembles[key] + for key, val in self.satisfied_when_traj_len.items() + if traj_len > val + ] + for ens in already_satisfied: + self.conditions.satisfied[ens] = True + + traj = self.trajectory[:traj_len] + mock = Mock(wraps=self.conditions._check_previous_frame) + self.conditions._check_previous_frame = mock + expected_calls, expected_satisfied = expected + result = self.conditions(traj, trusted) + assert result == (expected_satisfied != 4) + assert sum(self.conditions.satisfied.values()) == expected_satisfied + if trusted: + # only test call count if we're trusted + assert mock.call_count == expected_calls + + def test_long_traj_untrusted(self): + traj = make_1d_traj(self.traj_vals + [1.0, 1.2, 1.3, 1.4]) + assert self.conditions(traj) is False + + def test_generate(self): + init_snap = self.trajectory[0] + traj = self.engine.generate(init_snap, self.conditions) + assert len(traj) == 8 + + +@pytest.fixture() +def md_fixture(tps_fixture): + _, _, engine, sample_set = tps_fixture + snapshot = sample_set[0].trajectory[0] + ensemble = paths.LengthEnsemble(5).named('len5') + return engine, ensemble, snapshot + +def print_test(output_storage, engine, ensembles, nsteps, initial_frame): + print(isinstance(output_storage, paths.Storage)) + print(engine.__uuid__) + print([e.__uuid__ for e in ensembles]) # only 1? + print(nsteps) + print(initial_frame.__uuid__) + +@patch('paths_cli.commands.md.md_main', print_test) +def test_md(md_fixture): + engine, ensemble, snapshot = md_fixture + runner = CliRunner() + with runner.isolated_filesystem(): + storage = paths.Storage("setup.nc", 'w') + storage.save([ensemble, snapshot, engine]) + storage.tags['initial_snapshot'] = snapshot + storage.close() + + results = runner.invoke( + md, + ["setup.nc", '-o', 'foo.nc', '--ensemble', 'len5', '-f', + 'initial_snapshot'] + ) + expected_output = "\n".join([ "True", str(engine.__uuid__), + '[' + str(ensemble.__uuid__) + ']', + 'None', str(snapshot.__uuid__)]) + "\n" + + assert results.output == expected_output + assert results.exit_code == 0 + +@pytest.mark.parametrize('inp', ['nsteps', 'ensemble']) +def test_md_main(md_fixture, inp): + tempdir = tempfile.mkdtemp() + try: + store_name = os.path.join(tempdir, "md.nc") + storage = paths.Storage(store_name, mode='w') + engine, ens, snapshot = md_fixture + if inp == 'nsteps': + nsteps, ensembles = 5, None + elif inp == 'ensemble': + nsteps, ensembles = None, [ens] + else: + raise RuntimeError("pytest went crazy") + + traj, foo = md_main( + output_storage=storage, + engine=engine, + ensembles=ensembles, + nsteps=nsteps, + initial_frame=snapshot + ) + assert isinstance(traj, paths.Trajectory) + assert foo is None + assert len(traj) == 5 + assert len(storage.trajectories) == 1 + storage.close() + finally: + os.remove(store_name) + os.rmdir(tempdir) + +def test_md_main_error(md_fixture): + engine, ensemble, snapshot = md_fixture + with pytest.raises(RuntimeError): + md_main(output_storage=None, + engine=engine, + ensembles=[ensemble], + nsteps=5, + initial_frame=snapshot) diff --git a/paths_cli/tests/commands/test_pathsampling.py b/paths_cli/tests/commands/test_pathsampling.py index be7b272e..dc5034a5 100644 --- a/paths_cli/tests/commands/test_pathsampling.py +++ b/paths_cli/tests/commands/test_pathsampling.py @@ -26,10 +26,11 @@ def test_pathsampling(tps_fixture): results = runner.invoke(pathsampling, ['setup.nc', '-o', 'foo.nc', '-n', '1000']) - assert results.exit_code == 0 expected_output = (f"True\n{scheme.__uuid__}\n{init_conds.__uuid__}" "\n1000\n") + assert results.output == expected_output + assert results.exit_code == 0 def test_pathsampling_main(tps_fixture): diff --git a/paths_cli/tests/test_parameters.py b/paths_cli/tests/test_parameters.py index af7a170a..b55c3714 100644 --- a/paths_cli/tests/test_parameters.py +++ b/paths_cli/tests/test_parameters.py @@ -2,10 +2,44 @@ import tempfile import os -import openpathsampling as paths +import paths_cli from openpathsampling.tests.test_helpers import make_1d_traj from paths_cli.parameters import * +import openpathsampling as paths + + +def pre_monkey_patch(): + # store things that get monkey-patched; ensure we un-patch + stored_functions = {} + CallableCV = paths.CallableCV + PseudoAttr = paths.netcdfplus.FunctionPseudoAttribute + stored_functions['CallableCV.from'] = CallableCV.from_dict + stored_functions['PseudoAttr.from'] = PseudoAttr.from_dict + stored_functions['TPSNetwork.from'] = paths.TPSNetwork.from_dict + stored_functions['MISTISNetwork.from'] = paths.MISTISNetwork.from_dict + stored_functions['PseudoAttr.to'] = PseudoAttr.to_dict + stored_functions['TPSNetwork.to'] = paths.TPSNetwork.to_dict + stored_functions['MISTISNetwork.to'] = paths.MISTISNetwork.to_dict + return stored_functions + +def undo_monkey_patch(stored_functions): + CallableCV = paths.CallableCV + PseudoAttr = paths.netcdfplus.FunctionPseudoAttribute + CallableCV.from_dict = stored_functions['CallableCV.from'] + PseudoAttr.from_dict = stored_functions['PseudoAttr.from'] + paths.TPSNetwork.from_dict = stored_functions['TPSNetwork.from'] + paths.MISTISNetwork.from_dict = stored_functions['MISTISNetwork.from'] + PseudoAttr.to_dict = stored_functions['PseudoAttr.to'] + paths.TPSNetwork.to_dict = stored_functions['TPSNetwork.to'] + paths.MISTISNetwork.to_dict = stored_functions['MISTISNetwork.to'] + paths_cli.param_core.StorageLoader.has_simstore_patch = False + paths.InterfaceSet.simstore = False + import importlib + importlib.reload(paths.netcdfplus) + importlib.reload(paths.collectivevariable) + importlib.reload(paths) + class ParameterTest(object): @@ -114,6 +148,17 @@ def setup(self): def test_get(self, getter): self._getter_test(getter) + def test_cannot_guess(self): + filename = self._filename('no-guess') + storage = paths.Storage(filename, 'w') + storage.save(self.engine) + storage.save(self.other_engine.named('other')) + storage.close() + + storage = paths.Storage(filename, mode='r') + with pytest.raises(RuntimeError): + self.PARAMETER.get(storage, None) + class TestSCHEME(ParamInstanceTest): PARAMETER = SCHEME @@ -234,6 +279,25 @@ def test_get_none(self, num_in_file): obj = INIT_CONDS.get(st, None) assert obj == stored_things[num_in_file - 1] + def test_get_multiple(self): + filename = self.create_file('number-traj') + storage = paths.Storage(filename, mode='r') + traj0, traj1 = self.PARAMETER.get(storage, (0, 1)) + assert traj0 == self.traj + assert traj1 == self.other_traj + + def test_cannot_guess(self): + filename = self._filename('no-guess') + storage = paths.Storage(filename, 'w') + storage.save(self.traj) + storage.save(self.other_traj) + storage.close() + + storage = paths.Storage(filename, 'r') + with pytest.raises(RuntimeError): + self.PARAMETER.get(storage, None) + + class TestINIT_SNAP(ParamInstanceTest): PARAMETER = INIT_SNAP def setup(self): @@ -270,6 +334,18 @@ def test_get(self, getter): obj = self.PARAMETER.get(storage, get_arg) assert obj == expected + def test_simstore_single_snapshot(self): + stored_functions = pre_monkey_patch() + filename = os.path.join(self.tempdir, "simstore.db") + storage = APPEND_FILE.get(filename) + storage.save(self.init_snap) + storage.close() + + storage = INPUT_FILE.get(filename) + snap = self.PARAMETER.get(storage, None) + assert snap == self.init_snap + undo_monkey_patch(stored_functions) + class MultiParamInstanceTest(ParamInstanceTest): def _getter_test(self, getter): @@ -376,21 +452,26 @@ def test_get(self, getter): self._getter_test(getter) -def test_OUTPUT_FILE(): +@pytest.mark.parametrize('ext', ['nc', 'db', 'sql']) +def test_OUTPUT_FILE(ext): + stored_functions = pre_monkey_patch() tempdir = tempfile.mkdtemp() - filename = os.path.join(tempdir, "test_output_file.nc") + filename = os.path.join(tempdir, "test_output_file." + ext) assert not os.path.exists(filename) storage = OUTPUT_FILE.get(filename) assert os.path.exists(filename) os.remove(filename) os.rmdir(tempdir) + undo_monkey_patch(stored_functions) -def test_APPEND_FILE(): +@pytest.mark.parametrize('ext', ['nc', 'db', 'sql']) +def test_APPEND_FILE(ext): + stored_functions = pre_monkey_patch() tempdir = tempfile.mkdtemp() - filename = os.path.join(tempdir, "test_append_file.nc") + filename = os.path.join(tempdir, "test_append_file." + ext) assert not os.path.exists(filename) storage = APPEND_FILE.get(filename) - print(storage) + # print(storage) # potentially useful debug; keep assert os.path.exists(filename) traj = make_1d_traj([0.0, 1.0]) storage.tags['first_save'] = traj[0] @@ -404,3 +485,4 @@ def test_APPEND_FILE(): storage.close() os.remove(filename) os.rmdir(tempdir) + undo_monkey_patch(stored_functions) diff --git a/paths_cli/utils.py b/paths_cli/utils.py new file mode 100644 index 00000000..5504e702 --- /dev/null +++ b/paths_cli/utils.py @@ -0,0 +1,16 @@ +def tag_final_result(result, storage, tag='final_conditions'): + """Save results to a tag in storage. + + Parameters + ---------- + result : UUIDObject + the result to store + storage : OPS storage + the output storage + tag : str + the name to tag it with; default is 'final_conditions' + """ + if storage: + print("Saving results to output file....") + storage.save(result) + storage.tags[tag] = result diff --git a/setup.cfg b/setup.cfg index 2ca6e44d..4969019c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = openpathsampling-cli -version = 0.1.1 +version = 0.2.0 # version should end in .dev0 if this isn't to be released description = Command line tool for OpenPathSampling long_description = file: README.md