diff --git a/src/asyncmd/config.py b/src/asyncmd/config.py index c21ef04..74cf0b4 100644 --- a/src/asyncmd/config.py +++ b/src/asyncmd/config.py @@ -73,7 +73,11 @@ def set_max_process(num: int | None = None, max_num: int | None = None) -> None: # fallback if os.cpu_count() can not determine the number of cpus # play it save and not have more than 2? num = 2 + elif num < 1: + raise ValueError("`num` must be at least 1!") if max_num is not None: + if max_num < 1: + raise ValueError("`max_num` must be at least 1!") num = min((num, max_num)) _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS] = asyncio.BoundedSemaphore(num) diff --git a/tests/conftest.py b/tests/conftest.py index ce0cf4b..9c37b8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,13 @@ reason="Need gromacs (gmx) executable.") +@pytest.fixture +def h5py(): + """Fixture to import h5py.""" + return pytest.importorskip("h5py", minversion=None, + reason="Requires 'h5py' to run.") + + def pytest_addoption(parser): parser.addoption( "--runslow", action="store_true", default=False, help="run slow tests" diff --git a/tests/gromacs/test_mdengine.py b/tests/gromacs/test_mdengine.py index dc4392f..d55fbc3 100644 --- a/tests/gromacs/test_mdengine.py +++ b/tests/gromacs/test_mdengine.py @@ -15,6 +15,7 @@ import pytest import os import logging +import asyncio import numpy as np import MDAnalysis as mda @@ -33,7 +34,9 @@ def setup_method(self): self.gro = "tests/test_data/gromacs/conf.gro" self.ndx = "tests/test_data/gromcas/index.ndx" self.top = "tests/test_data/gromacs/topol_amber99sbildn.top" - self.mdp_md_compressed_out = MDP("tests/test_data/gromacs/md_compressed_out.mdp") + self.mdp_md_compressed_out = MDP( + "tests/test_data/gromacs/md_compressed_out.mdp" + ) self.mdp_md_full_prec_out = MDP("tests/test_data/gromacs/md_full_prec_out.mdp") @pytest.mark.parametrize("integrator", ["steep", "cg", "l-bfgs"]) @@ -41,11 +44,15 @@ def test_mpd_check_minimize(self, integrator, monkeypatch): # init an engine so we can use its mdconfig property (which does the checks) with monkeypatch.context() as m: # monkeypatch so we dont need to find a gromacs executable - m.setattr("asyncmd.gromacs.mdengine.ensure_executable_available", - lambda _: "/usr/bin/true") - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top) + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, + gro_file=self.gro, + top_file=self.top, + ) self.mdp_md_compressed_out["integrator"] = integrator with pytest.raises(ValueError): engine.mdp = self.mdp_md_compressed_out @@ -58,11 +65,15 @@ def test_mdp_check_nsteps(self, caplog, monkeypatch): # init an engine so we can use its mdconfig property (which does the checks) with monkeypatch.context() as m: # monkeypatch so we dont need to find a gromacs executable - m.setattr("asyncmd.gromacs.mdengine.ensure_executable_available", - lambda _: "/usr/bin/true") - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top) + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, + gro_file=self.gro, + top_file=self.top, + ) # check that an nsteps value that is not -1 (but set) is changed self.mdp_md_compressed_out["nsteps"] = 100 with caplog.at_level(logging.INFO): @@ -88,57 +99,71 @@ def test_mdp_check_no_mdp_class(self, monkeypatch): # init should already fail with monkeypatch.context() as m: # monkeypatch so we dont need to find a gromacs executable - m.setattr("asyncmd.gromacs.mdengine.ensure_executable_available", - lambda _: "/usr/bin/true") + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) with pytest.raises(TypeError): - engine = GmxEngine(mdconfig=None, - gro_file=self.gro, - top_file=self.top) - - @pytest.mark.parametrize(["conversion_factor", "raises"], - [(-1., True), - (1.1, True), - (0., True), - (0.9, False), - (0.1, False), - ] - ) + engine = GmxEngine(mdconfig=None, gro_file=self.gro, top_file=self.top) + + @pytest.mark.parametrize( + ["conversion_factor", "raises"], + [ + (-1.0, True), + (1.1, True), + (0.0, True), + (0.9, False), + (0.1, False), + ], + ) def test_check_valid_and_invalid_mdrun_time_conversion_factor( - self, monkeypatch, conversion_factor, raises, - ): + self, + monkeypatch, + conversion_factor, + raises, + ): with monkeypatch.context() as m: # monkeypatch so we dont need to find a gromacs executable - m.setattr("asyncmd.gromacs.mdengine.ensure_executable_available", - lambda _: "/usr/bin/true") + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) if raises: # init should already fail with pytest.raises(ValueError): - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top, - mdrun_time_conversion_factor=conversion_factor, - ) + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, + gro_file=self.gro, + top_file=self.top, + mdrun_time_conversion_factor=conversion_factor, + ) else: - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top, - mdrun_time_conversion_factor=conversion_factor, - ) + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, + gro_file=self.gro, + top_file=self.top, + mdrun_time_conversion_factor=conversion_factor, + ) assert engine.mdrun_time_conversion_factor == conversion_factor - @pytest.mark.parametrize(["output_traj_type", "raises"], - [("NOT_A_TRAJ_TYPE", True), - ("also not a traj type", True), - ("neither", True), - ("xtc", False), - ("XTC", False), - ("trr", False), - ("TRR", False), - ] - ) + @pytest.mark.parametrize( + ["output_traj_type", "raises"], + [ + ("NOT_A_TRAJ_TYPE", True), + ("also not a traj type", True), + ("neither", True), + ("xtc", False), + ("XTC", False), + ("trr", False), + ("TRR", False), + ], + ) def test_check_valid_and_invalid_output_traj_type( - self, monkeypatch, output_traj_type, raises, - ): + self, + monkeypatch, + output_traj_type, + raises, + ): if output_traj_type.lower() == "trr": # use a MDP that has trajectory output configured for TRR mdconfig = self.mdp_md_full_prec_out @@ -148,40 +173,48 @@ def test_check_valid_and_invalid_output_traj_type( mdconfig = self.mdp_md_compressed_out with monkeypatch.context() as m: # monkeypatch so we dont need to find a gromacs executable - m.setattr("asyncmd.gromacs.mdengine.ensure_executable_available", - lambda _: "/usr/bin/true") + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) if raises: with pytest.raises(ValueError): # init should already fail - engine = GmxEngine(mdconfig=mdconfig, - gro_file=self.gro, - top_file=self.top, - output_traj_type=output_traj_type, - ) + engine = GmxEngine( + mdconfig=mdconfig, + gro_file=self.gro, + top_file=self.top, + output_traj_type=output_traj_type, + ) else: - engine = GmxEngine(mdconfig=mdconfig, - gro_file=self.gro, - top_file=self.top, - output_traj_type=output_traj_type, - ) + engine = GmxEngine( + mdconfig=mdconfig, + gro_file=self.gro, + top_file=self.top, + output_traj_type=output_traj_type, + ) assert engine.output_traj_type == output_traj_type.lower() @pytest.mark.slow @needs_gmx_install - @pytest.mark.parametrize("starting_conf", - [None, - Trajectory("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.tpr") - ] - ) + @pytest.mark.parametrize( + "starting_conf", + [ + None, + Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ), + ], + ) @pytest.mark.asyncio async def test_run_MD_compressed_out(self, tmp_path, starting_conf): - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top) - await engine.prepare(starting_configuration=starting_conf, - workdir=tmp_path, - deffnm="test") + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, gro_file=self.gro, top_file=self.top + ) + await engine.prepare( + starting_configuration=starting_conf, workdir=tmp_path, deffnm="test" + ) nsteps = 10 traj = await engine.run(nsteps=nsteps) # some basic checks @@ -191,21 +224,27 @@ async def test_run_MD_compressed_out(self, tmp_path, starting_conf): @pytest.mark.slow @needs_gmx_install - @pytest.mark.parametrize("starting_conf", - [None, - Trajectory("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.tpr") - ] - ) + @pytest.mark.parametrize( + "starting_conf", + [ + None, + Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ), + ], + ) @pytest.mark.asyncio async def test_run_MD_full_prec_out(self, tmp_path, starting_conf): - engine = GmxEngine(mdconfig=self.mdp_md_full_prec_out, - gro_file=self.gro, - top_file=self.top, - output_traj_type="trr") - await engine.prepare(starting_configuration=starting_conf, - workdir=tmp_path, - deffnm="test") + engine = GmxEngine( + mdconfig=self.mdp_md_full_prec_out, + gro_file=self.gro, + top_file=self.top, + output_traj_type="trr", + ) + await engine.prepare( + starting_configuration=starting_conf, workdir=tmp_path, deffnm="test" + ) nsteps = 10 traj = await engine.run(nsteps=nsteps) # some basic checks @@ -217,16 +256,18 @@ async def test_run_MD_full_prec_out(self, tmp_path, starting_conf): @needs_gmx_install @pytest.mark.asyncio async def test_generate_velocities(self, tmp_path): - initial_conf = Trajectory("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.tpr") - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top) + initial_conf = Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ) + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, gro_file=self.gro, top_file=self.top + ) new_conf = await engine.generate_velocities( - conf_in=initial_conf, - conf_out_name=os.path.join(tmp_path, "out.trr"), - workdir=tmp_path, - ) + conf_in=initial_conf, + conf_out_name=os.path.join(tmp_path, "out.trr"), + workdir=tmp_path, + ) # TODO: this is essentially a smoke test... # ... what else can we test for except the length?! assert len(new_conf) == 1 @@ -235,16 +276,610 @@ async def test_generate_velocities(self, tmp_path): @needs_gmx_install @pytest.mark.asyncio async def test_apply_constraints(self, tmp_path): - initial_conf = Trajectory("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.tpr") - engine = GmxEngine(mdconfig=self.mdp_md_compressed_out, - gro_file=self.gro, - top_file=self.top) + initial_conf = Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ) + engine = GmxEngine( + mdconfig=self.mdp_md_compressed_out, gro_file=self.gro, top_file=self.top + ) new_conf = await engine.apply_constraints( - conf_in=initial_conf, - conf_out_name=os.path.join(tmp_path, "out.trr"), - workdir=tmp_path, - ) + conf_in=initial_conf, + conf_out_name=os.path.join(tmp_path, "out.trr"), + workdir=tmp_path, + ) # TODO: this is essentially a smoke test... # ... what else can we test for except the length?! assert len(new_conf) == 1 + + +class Test_GmxEngine_PropertySetters: + @pytest.mark.parametrize( + ["gro_file", "raises"], + [ + ("tests/test_data/gromacs/conf.gro", False), + ("tests/test_data/gromacs/NON_EXISTING.gro", True), + ], + ) + def test_gro_file_validator(self, gro_file, raises, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + if raises: + with pytest.raises(FileNotFoundError): + _ = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file=gro_file, + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + else: + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file=gro_file, + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + assert engine.gro_file == os.path.relpath(gro_file) + + @pytest.mark.parametrize( + ["top_file", "raises"], + [ + ("tests/test_data/gromacs/topol_amber99sbildn.top", False), + ("tests/test_data/gromacs/NON_EXISTING.top", True), + ], + ) + def test_top_file_validator(self, top_file, raises, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + if raises: + with pytest.raises(FileNotFoundError): + _ = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file=top_file, + ) + else: + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file=top_file, + ) + assert engine.top_file == os.path.relpath(top_file) + + @pytest.mark.parametrize( + ["ndx_file", "raises"], + [ + ("tests/test_data/gromacs/index.ndx", False), + (None, False), + ("tests/test_data/gromacs/NON_EXISTING.ndx", True), + ], + ) + def test_ndx_file_validator(self, ndx_file, raises, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + if raises: + with pytest.raises(FileNotFoundError): + _ = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ndx_file=ndx_file, + ) + else: + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ndx_file=ndx_file, + ) + if ndx_file is None: + assert engine.ndx_file is None + else: + assert engine.ndx_file == os.path.relpath(ndx_file) + + @pytest.mark.parametrize( + ["workdir", "raises"], + [ + ("tests/test_data", False), + ("tests/test_data/gromacs", False), + ("tests/test_data/gromacs/conf.gro", True), + ("tests/test_data/NON_EXISTING_DIR", True), + ], + ) + def test_workdir_validator(self, workdir, raises, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + if raises: + with pytest.raises(TypeError): + engine.workdir = workdir + else: + engine.workdir = workdir + assert engine.workdir == os.path.relpath(workdir) + + @pytest.mark.parametrize( + ["output_traj_type", "raises"], + [ + ("xtc", False), + ("XTC", False), + ("trr", False), + ("TRR", False), + ("NOT_A_TRAJ_TYPE", True), + ("also not a traj type", True), + ("neither", True), + ], + ) + def test_output_traj_type_validator(self, monkeypatch, output_traj_type, raises): + if output_traj_type.lower() == "trr": + mdconfig = MDP("tests/test_data/gromacs/md_full_prec_out.mdp") + else: + mdconfig = MDP("tests/test_data/gromacs/md_compressed_out.mdp") + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + if raises: + with pytest.raises(ValueError): + _ = GmxEngine( + mdconfig=mdconfig, + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + output_traj_type=output_traj_type, + ) + else: + engine = GmxEngine( + mdconfig=mdconfig, + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + output_traj_type=output_traj_type, + ) + assert engine.output_traj_type == output_traj_type.lower() + + +class Test_GmxEngine_StateProperties: + def test_current_trajectory_none_before_prepare(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + assert engine.current_trajectory is None + + def test_state_properties_initial(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + assert engine.deffnm is None + assert engine.simulation_part == 0 + assert engine.steps_done == 0 + assert engine.frames_done == 0 + assert engine.time_done == 0.0 + + @pytest.mark.asyncio + async def test_state_properties_after_prepare(self, tmp_path, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + assert engine.deffnm == "test" + assert engine.simulation_part == 0 + assert engine.steps_done == 0 + assert engine.frames_done == 0 + assert engine.time_done == 0.0 + assert engine.current_trajectory is None + + @pytest.mark.asyncio + async def test_state_properties_after_run(self, tmp_path, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + traj = await engine.run(nsteps=10) + assert engine.steps_done == 10 + assert engine.frames_done == 2 + assert np.isclose(engine.time_done, 0.02) + + +class Test_GmxEngine_CommandGeneration: + def test_grompp_cmd_basic(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._grompp_cmd( + mdp_in="tests/test_data/gromacs/md_compressed_out.mdp", + tpr_out="tests/test_data/test.tpr", + workdir="tests/test_data", + ) + assert "gmx grompp" in cmd + assert "-f gromacs/md_compressed_out.mdp" in cmd + assert "-c gromacs/conf.gro" in cmd + assert "-p gromacs/topol_amber99sbildn.top" in cmd + assert "-o test.tpr" in cmd + + def test_grompp_cmd_with_ndx(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ndx_file="tests/test_data/gromacs/index.ndx", + ) + engine.workdir = "tests/test_data" + cmd = engine._grompp_cmd( + mdp_in="tests/test_data/gromacs/md_compressed_out.mdp", + tpr_out="tests/test_data/test.tpr", + workdir="tests/test_data", + trr_in=None, + ) + assert "-n gromacs/index.ndx" in cmd + + def test_grompp_cmd_with_trr_in(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._grompp_cmd( + mdp_in="tests/test_data/gromacs/md_compressed_out.mdp", + tpr_out="tests/test_data/test.tpr", + workdir="tests/test_data", + trr_in="tests/test_data/trajectory/ala_traj.trr", + ) + assert "-t trajectory/ala_traj.trr" in cmd + + def test_grompp_cmd_with_mdp_out(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._grompp_cmd( + mdp_in="tests/test_data/gromacs/md_compressed_out.mdp", + tpr_out="tests/test_data/test.tpr", + workdir="tests/test_data", + mdp_out="tests/test_data/test_mdout.mdp", + ) + assert "-po test_mdout.mdp" in cmd + + def test_mdrun_cmd_basic(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._mdrun_cmd( + tpr="tests/test_data/test.tpr", workdir="tests/test_data", deffnm="test" + ) + assert "gmx mdrun" in cmd + assert "-s test.tpr" in cmd + assert "-cpi test.cpt" in cmd + assert "-o test.trr" in cmd + assert "-x test.xtc" in cmd + assert "-c test.confout.gro" in cmd + assert "-e test.edr" in cmd + assert "-g test.log" in cmd + + def test_mdrun_cmd_with_walltime(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._mdrun_cmd( + tpr="tests/test_data/test.tpr", + workdir="tests/test_data", + deffnm="test", + maxh=1.0, + ) + assert "-maxh 1.0" in cmd + + def test_mdrun_cmd_with_nsteps(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._mdrun_cmd( + tpr="tests/test_data/test.tpr", + workdir="tests/test_data", + deffnm="test", + nsteps=100, + ) + assert "-nsteps 100" in cmd + + def test_mdrun_cmd_with_extra_args(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + mdrun_extra_args="-ntomp 4", + ) + cmd = engine._mdrun_cmd( + tpr="tests/test_data/test.tpr", workdir="tests/test_data", deffnm="test" + ) + assert "-ntomp 4" in cmd + + def test_deffnm_fallback(self, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + engine.workdir = "tests/test_data" + cmd = engine._mdrun_cmd( + tpr="tests/test_data/test.tpr", workdir="tests/test_data" + ) + assert "-cpi test.cpt" in cmd + assert "-cpo test.cpt" in cmd + assert "-o test.trr" in cmd + assert "-x test.xtc" in cmd + + +class Test_GmxEngine_RunEdgeCases: + @pytest.mark.asyncio + async def test_run_raises_without_prepare(self, monkeypatch, tmp_path): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + with pytest.raises(RuntimeError): + await engine.run(nsteps=10) + + @pytest.mark.asyncio + async def test_run_raises_invalid_starting_configuration( + self, monkeypatch, tmp_path + ): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + with pytest.raises(TypeError): + await engine.prepare( + starting_configuration=123, workdir=str(tmp_path), deffnm="test2" + ) + + @pytest.mark.asyncio + async def test_prepare_raises_invalid_workdir(self, monkeypatch, tmp_path): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + with pytest.raises(TypeError): + await engine.prepare( + starting_configuration=None, + workdir="tests/test_data/gromacs/conf.gro", + deffnm="test", + ) + + @pytest.mark.asyncio + async def test_run_nsteps_not_multiple_of_nstout(self, monkeypatch, tmp_path): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + with pytest.raises(ValueError): + await engine.run(nsteps=7) + + @pytest.mark.asyncio + async def test_run_nsteps_negative(self, monkeypatch, tmp_path): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + with pytest.raises(ValueError): + await engine.run(nsteps=-10) + + @pytest.mark.asyncio + async def test_run_walltime_without_nsteps(self, monkeypatch, tmp_path): + # Test that calling run with neither nsteps nor walltime raises ValueError + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + with pytest.raises(ValueError): + await engine.run() + + +class Test_GmxEngine_TrajectorySingleton: + def test_trajectory_singleton_same_object(self): + from asyncmd.trajectory import _forget_all_trajectories + + _forget_all_trajectories() + traj1 = Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ) + traj2 = Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ) + assert traj1 is traj2 + assert id(traj1) == id(traj2) + + def test_trajectory_singleton_different_files(self): + traj1 = Trajectory( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + ) + traj2 = Trajectory( + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala.tpr", + ) + assert traj1 is not traj2 + + +class Test_GmxEngine_PrepareFromFile: + @pytest.mark.slow + @needs_gmx_install + @pytest.mark.asyncio + async def test_prepare_from_files_basic(self, tmp_path, monkeypatch): + with monkeypatch.context() as m: + m.setattr( + "asyncmd.gromacs.mdengine.ensure_executable_available", + lambda _: "/usr/bin/true", + ) + engine = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine.prepare( + starting_configuration=None, workdir=str(tmp_path), deffnm="test" + ) + traj1 = await engine.run(nsteps=10) + assert traj1 is not None + steps_after_first_run = engine.steps_done + + engine2 = GmxEngine( + mdconfig=MDP("tests/test_data/gromacs/md_compressed_out.mdp"), + gro_file="tests/test_data/gromacs/conf.gro", + top_file="tests/test_data/gromacs/topol_amber99sbildn.top", + ) + await engine2.prepare_from_files(workdir=str(tmp_path), deffnm="test") + assert engine2.deffnm == "test" + assert engine2.simulation_part > 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f547d57 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,572 @@ +# This file is part of asyncmd. +# +# asyncmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# asyncmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with asyncmd. If not, see . +""" +Comprehensive test suite for asyncmd.config module. + +Tests the configuration functions for: +- Resource management (processes, files) +- Cache type configuration +- H5PY cache registration/deregistration +- SLURM configuration + +Note: H5PY-specific tests for cache operations are covered more thoroughly +in tests/trajectory/test_trajectory.py which tests the full integration. +""" + +import pytest +import os +import asyncio +import resource +import sys +import numpy as np +from io import StringIO +from unittest.mock import patch + + +from asyncmd import config +from asyncmd import trajectory +from asyncmd._config import ( + _GLOBALS, + _SEMAPHORES, + _OPT_SEMAPHORES, + _GLOBALS_KEYS, + _SEMAPHORES_KEYS, + _OPT_SEMAPHORES_KEYS, +) + + +@pytest.fixture +def reset_config(): + """Fixture to reset config to initial state after test.""" + # Import here to avoid circular imports + import asyncio + from asyncmd import config + + # Store initial state before test + initial_globals = dict(config._GLOBALS) + initial_semaphores = {key: sem._value for key, sem in config._SEMAPHORES.items()} + initial_opt_semaphores = { + key: (sem._value if sem is not None else None) + for key, sem in config._OPT_SEMAPHORES.items() + } + + yield + + # Restore _GLOBALS + config._GLOBALS.clear() + config._GLOBALS.update(initial_globals) + + # Restore _SEMAPHORES + config._SEMAPHORES.clear() + for key, value in initial_semaphores.items(): + config._SEMAPHORES[key] = asyncio.BoundedSemaphore(value) + + # Restore _OPT_SEMAPHORES + config._OPT_SEMAPHORES.clear() + for key, value in initial_opt_semaphores.items(): + if value is not None: + config._OPT_SEMAPHORES[key] = asyncio.BoundedSemaphore(value) + else: + config._OPT_SEMAPHORES[key] = None + + # Clear trajectory registry + trajectory._forget_all_trajectories() + + +class Test_set_max_process: + def test_set_max_process_default(self, reset_config): + """Test set_max_process with default (None) values.""" + config.set_max_process() + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS] is not None + assert isinstance( + _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS], asyncio.BoundedSemaphore + ) + # Default should be cpu_count() / 4 + cpu_count = os.cpu_count() + expected = max(1, int(cpu_count / 4)) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == expected + + def test_set_max_process_with_integer(self, reset_config): + """Test set_max_process with specific integer value.""" + config.set_max_process(num=5) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == 5 + + def test_set_max_process_with_max_num(self, reset_config): + """Test set_max_process with max_num constraint.""" + cpu_count = os.cpu_count() + expected = min(int(cpu_count / 4), 2) + config.set_max_process(num=None, max_num=2) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == expected + + def test_set_max_process_with_max_num_higher_than_cpu(self, reset_config): + """Test max_num caps the value even when cpu_count is high.""" + cpu_count = os.cpu_count() + config.set_max_process(num=None, max_num=1) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == 1 + + def test_set_max_process_without_cpu_count(self, reset_config): + """Test fallback when os.cpu_count() returns None.""" + with patch("os.cpu_count", return_value=None): + config.set_max_process() + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == 2 + + def test_set_max_process_changes_semaphore(self, reset_config): + """Test that semaphore is actually updated.""" + config.set_max_process(num=3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == 3 + config.set_max_process(num=7) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_PROCESS]._value == 7 + + def test_set_max_process_with_zero(self, reset_config): + """Test set_max_process with zero.""" + with pytest.raises(ValueError, match=" must be at least 1!"): + config.set_max_process(num=0) + # same for max_num + with pytest.raises(ValueError, match=" must be at least 1!"): + config.set_max_process(num=1, max_num=0) + + def test_set_max_process_with_negative(self, reset_config): + """Test set_max_process with negative value raises ValueError.""" + with pytest.raises(ValueError, match=" must be at least 1!"): + config.set_max_process(num=-1) + with pytest.raises(ValueError, match=" must be at least 1!"): + config.set_max_process(num=1, max_num=-1) + + +class Test_set_max_files_open: + def test_set_max_files_open_default(self, reset_config): + """Test set_max_files_open with default (None) values.""" + config.set_max_files_open() + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN] is not None + assert isinstance( + _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN], asyncio.BoundedSemaphore + ) + # Default should be system limit + rlim_soft = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + expected_semaval = int((rlim_soft - 30) / 3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value == expected_semaval + + def test_set_max_files_open_with_custom_value(self, reset_config): + """Test set_max_files_open with custom value.""" + config.set_max_files_open(num=100, margin=10) + rlim_soft = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + expected_semaval = int((min(100, rlim_soft) - 10) / 3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value == expected_semaval + + def test_set_max_files_open_with_margin_parameter(self, reset_config): + """Test set_max_files_open with custom margin.""" + config.set_max_files_open(num=200, margin=50) + rlim_soft = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + expected_semaval = int((min(200, rlim_soft) - 50) / 3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value == expected_semaval + + def test_set_max_files_open_num_less_than_margin(self, reset_config): + """Test set_max_files_open raises ValueError when num <= margin.""" + with pytest.raises(ValueError, match="num must be larger than margin"): + config.set_max_files_open(num=10, margin=20) + + def test_set_max_files_open_num_equals_margin(self, reset_config): + """Test set_max_files_open raises ValueError when num == margin.""" + with pytest.raises(ValueError, match="num must be larger than margin"): + config.set_max_files_open(num=20, margin=20) + + def test_set_max_files_open_num_greater_than_system_limit( + self, reset_config, caplog + ): + """Test warning when num > system limit.""" + rlim_soft = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + config.set_max_files_open(num=rlim_soft + 1000) + assert "larger than the systems soft resource limit" in caplog.text + + def test_set_max_files_open_changes_semaphore(self, reset_config): + """Test that semaphore is actually updated.""" + config.set_max_files_open(num=100, margin=10) + initial_value = _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value + config.set_max_files_open(num=200, margin=10) + new_value = _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value + assert new_value > initial_value + + def test_set_max_files_open_edge_cases(self, reset_config): + """Test set_max_files_open with various valid edge cases.""" + # Very small margin (but num > margin) + config.set_max_files_open(num=100, margin=1) + rlim_soft = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + expected = int((min(100, rlim_soft) - 1) / 3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value == expected + + # Large margin (but still less than num) + config.set_max_files_open(num=10000, margin=1000) + expected = int((min(10000, rlim_soft) - 1000) / 3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value == expected + + # Num barely larger than margin + config.set_max_files_open(num=35, margin=30) + expected = int((min(35, rlim_soft) - 30) / 3) + assert _SEMAPHORES[_SEMAPHORES_KEYS.MAX_FILES_OPEN]._value == expected + + +class Test_set_slurm_max_jobs: + def test_set_slurm_max_jobs_with_none(self, reset_config): + """Test set_slurm_max_jobs with None (unlimited).""" + config.set_slurm_max_jobs(None) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB] is None + + def test_set_slurm_max_jobs_with_integer(self, reset_config): + """Test set_slurm_max_jobs with specific integer value.""" + config.set_slurm_max_jobs(num=5) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB] is not None + assert isinstance( + _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB], + asyncio.BoundedSemaphore, + ) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB]._value == 5 + + def test_set_slurm_max_jobs_changes_semaphore(self, reset_config): + """Test that semaphore is actually updated.""" + config.set_slurm_max_jobs(num=3) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB]._value == 3 + config.set_slurm_max_jobs(num=7) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB]._value == 7 + + def test_set_slurm_max_jobs_twice(self, reset_config): + """Test setting slurm max jobs twice with different values.""" + config.set_slurm_max_jobs(num=2) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB]._value == 2 + config.set_slurm_max_jobs(num=10) + assert _OPT_SEMAPHORES[_OPT_SEMAPHORES_KEYS.SLURM_MAX_JOB]._value == 10 + + +class Test_set_trajectory_cache_type: + def test_set_trajectory_cache_type_valid_npz(self, reset_config): + """Test set_trajectory_cache_type with valid npz value.""" + config.set_trajectory_cache_type(cache_type="npz") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "npz" + + def test_set_trajectory_cache_type_valid_memory(self, reset_config): + """Test set_trajectory_cache_type with valid memory value.""" + config.set_trajectory_cache_type(cache_type="memory") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_invalid_value(self, reset_config): + """Test set_trajectory_cache_type with invalid value raises ValueError.""" + with pytest.raises(ValueError, match="Given cache type must be one of"): + config.set_trajectory_cache_type(cache_type="invalid") + + def test_set_trajectory_cache_type_case_insensitive(self, reset_config): + """Test set_trajectory_cache_type is case insensitive.""" + config.set_trajectory_cache_type(cache_type="H5PY") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "h5py" + config.set_trajectory_cache_type(cache_type="NPZ") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "npz" + config.set_trajectory_cache_type(cache_type="MEMORY") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_copy_content_true(self, reset_config): + """Test set_trajectory_cache_type with copy_content=True (default).""" + config.set_trajectory_cache_type(cache_type="memory", copy_content=True) + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_copy_content_false(self, reset_config): + """Test set_trajectory_cache_type with copy_content=False.""" + config.set_trajectory_cache_type(cache_type="memory", copy_content=False) + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_clear_old_cache_true(self, reset_config): + """Test set_trajectory_cache_type with clear_old_cache=True.""" + config.set_trajectory_cache_type(cache_type="memory", clear_old_cache=True) + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_clear_old_cache_false(self, reset_config): + """Test set_trajectory_cache_type with clear_old_cache=False (default).""" + config.set_trajectory_cache_type(cache_type="memory", clear_old_cache=False) + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_when_already_set(self, reset_config): + """Test set_trajectory_cache_type when type already set (no-op path).""" + from asyncmd import config as cfg + + # Set cache type + config.set_trajectory_cache_type(cache_type="memory") + initial_value = _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] + assert initial_value == "memory" + + # Set same type again - should be no-op (no exception raised) + config.set_trajectory_cache_type(cache_type="memory") + + # Verify no change + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + # Set different type + config.set_trajectory_cache_type(cache_type="npz") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "npz" + + # Set back to memory - should work + config.set_trajectory_cache_type(cache_type="memory") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + def test_set_trajectory_cache_type_transition_npz_to_memory(self, reset_config): + """Test transition between different cache types.""" + # Start with npz + config.set_trajectory_cache_type(cache_type="npz") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "npz" + + # Transition to memory + config.set_trajectory_cache_type(cache_type="memory") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "memory" + + # Transition to h5py + config.set_trajectory_cache_type(cache_type="h5py") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "h5py" + + # And back to npz + config.set_trajectory_cache_type(cache_type="npz") + assert _GLOBALS[_GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] == "npz" + + def test_set_trajectory_cache_type_invalid_type(self, reset_config): + """Test set_trajectory_cache_type with completely invalid type.""" + with pytest.raises(ValueError, match="Given cache type must be one of"): + config.set_trajectory_cache_type(cache_type="unknown_type") + + def test_set_trajectory_cache_type_empty_string(self, reset_config): + """Test set_trajectory_cache_type with empty string.""" + with pytest.raises(ValueError, match="Given cache type must be one of"): + config.set_trajectory_cache_type(cache_type="") + + +class Test_show_config: + def test_show_config_prints_globals(self, reset_config): + """Test show_config prints _GLOBALS.""" + config.set_trajectory_cache_type(cache_type="memory") + old_stdout = sys.stdout + sys.stdout = StringIO() + try: + config.show_config() + output = sys.stdout.getvalue() + finally: + sys.stdout = old_stdout + + assert "TRAJECTORY_FUNCTION_CACHE_TYPE" in output + assert "memory" in output + # Verify the output is actually a dict representation + assert output.startswith("Values controlling caching:") + + def test_show_config_prints_semaphores(self, reset_config): + """Test show_config prints semaphores.""" + config.set_max_process(num=5) + config.set_max_files_open(num=100) + old_stdout = sys.stdout + sys.stdout = StringIO() + try: + config.show_config() + output = sys.stdout.getvalue() + finally: + sys.stdout = old_stdout + + assert "MAX_PROCESS" in output + assert "MAX_FILES_OPEN" in output + # Verify semaphore values are shown + assert "5" in output + # Verify the output format - just check both lines are present + lines = output.split("\n") + assert any("Semaphores controlling resource usage:" in line for line in lines) + + def test_show_config_after_modifications(self, reset_config): + """Test show_config reflects changes.""" + config.set_trajectory_cache_type(cache_type="memory") + config.set_max_process(num=7) + old_stdout = sys.stdout + sys.stdout = StringIO() + try: + config.show_config() + output = sys.stdout.getvalue() + finally: + sys.stdout = old_stdout + + assert "memory" in output + assert "7" in output + # Verify order and content + lines = output.split("\n") + assert any( + "memory" in line + for line in lines + if "TRAJECTORY_FUNCTION_CACHE_TYPE" in line + ) + assert any("7" in line for line in lines if "MAX_PROCESS" in line) + + +class Test_integration_with_trajectory: + def test_set_trajectory_cache_type_affects_new_trajectories( + self, reset_config, tmp_path + ): + """Test that cache type changes affect Trajectory objects.""" + from asyncmd import Trajectory + + # Start with npz + config.set_trajectory_cache_type(cache_type="npz") + + # Forget existing trajectories to get fresh start + trajectory._forget_all_trajectories() + + traj1 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + + # Verify traj1 uses npz cache + from asyncmd.trajectory.trajectory_cache import ( + TrajectoryFunctionValueCacheInNPZ, + ) + + assert isinstance(traj1._cache, TrajectoryFunctionValueCacheInNPZ) + + # Change to memory - this updates ALL trajectories including traj1 + config.set_trajectory_cache_type(cache_type="memory") + + # traj1 should now use memory cache (global change affects all instances) + from asyncmd.trajectory.trajectory_cache import ( + TrajectoryFunctionValueCacheInMemory, + ) + + assert isinstance(traj1._cache, TrajectoryFunctionValueCacheInMemory) + + # Create a NEW trajectory after the cache type change + # We need to forget traj1 first to get a truly new object + trajectory._forget_all_trajectories() + traj2 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + + # traj2 should also use memory cache (current setting) + assert isinstance(traj2._cache, TrajectoryFunctionValueCacheInMemory) + + # Create yet another trajectory without forgetting + # This should return the same object as traj2 (singleton pattern) + traj3 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + + # traj3 should be the same object as traj2 + assert traj2 is traj3 + assert isinstance(traj3._cache, TrajectoryFunctionValueCacheInMemory) + + def test_clear_all_cache_values_integration(self, reset_config): + """Test clear_all_cache_values clears all trajectory caches.""" + import asyncmd.trajectory.trajectory as traj_module + from asyncmd import Trajectory + from asyncmd.trajectory.functionwrapper import TrajectoryFunctionWrapper + from unittest.mock import Mock, PropertyMock + + config.set_trajectory_cache_type(cache_type="memory") + + # Forget trajectories first + trajectory._forget_all_trajectories() + + traj = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + + # Create and register some dummy CV data + func_id = "test_func_123" + func_values = np.random.random(size=(len(traj), 3)) + wrapped_func = Mock(TrajectoryFunctionWrapper) + type(wrapped_func).id = PropertyMock(return_value=func_id) + traj._register_cached_values(values=func_values, func_wrapper=wrapped_func) + + # Verify values are cached + retrieved = traj._retrieve_cached_values(wrapped_func) + assert retrieved is not None + assert len(retrieved) == len(traj) + + # Clear all caches + traj_module.clear_all_cache_values_for_all_trajectories() + + # Verify cache is actually cleared + retrieved_after = traj._retrieve_cached_values(wrapped_func) + assert retrieved_after is None, "Cache values should be cleared" + + def test_update_cache_type_integration(self, reset_config, tmp_path): + """Test update_cache_type_for_all_trajectories migrates caches.""" + from asyncmd import Trajectory + from asyncmd.trajectory.functionwrapper import TrajectoryFunctionWrapper + from asyncmd.trajectory.trajectory_cache import ( + TrajectoryFunctionValueCacheInNPZ, + ) + from unittest.mock import Mock, PropertyMock + import numpy as np + + # Forget trajectories first to start clean + trajectory._forget_all_trajectories() + + # Also remove any npz cache files that might interfere + npz_cache_file = TrajectoryFunctionValueCacheInNPZ.get_cache_filename( + traj_files=["tests/test_data/trajectory/ala_traj.trr"] + ) + if os.path.isfile(npz_cache_file): + os.unlink(npz_cache_file) + + config.set_trajectory_cache_type(cache_type="memory") + + traj1 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + + # Verify cache is empty before adding values + assert len(traj1._cache) == 0, "Cache should start empty" + + # Add some cached values to traj1 + func_id = "migration_test_func" + func_values = np.random.random(size=(len(traj1), 2)) + wrapped_func = Mock(TrajectoryFunctionWrapper) + type(wrapped_func).id = PropertyMock(return_value=func_id) + traj1._register_cached_values(values=func_values, func_wrapper=wrapped_func) + + # Change to npz - this should migrate traj1's cache + config.set_trajectory_cache_type(cache_type="npz", copy_content=True) + + # traj1 should now use npz cache (same object, cache migrated) + assert isinstance(traj1._cache, TrajectoryFunctionValueCacheInNPZ) + + # Verify cached values were migrated + retrieved = traj1._retrieve_cached_values(wrapped_func) + assert retrieved is not None + assert np.array_equal(retrieved, func_values) + + # Now forget and create new trajectory with npz setting + trajectory._forget_all_trajectories() + + # Remove npz file for the new trajectory so we don't have cached values + npz_cache_file2 = TrajectoryFunctionValueCacheInNPZ.get_cache_filename( + traj_files=["tests/test_data/trajectory/ala_traj.trr"] + ) + if os.path.isfile(npz_cache_file2): + os.unlink(npz_cache_file2) + + # Cache type should still be npz + assert ( + config._GLOBALS[config._GLOBALS_KEYS.TRAJECTORY_FUNCTION_CACHE_TYPE] + == "npz" + ) + + traj2 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + + # traj2 should use npz cache + assert isinstance(traj2._cache, TrajectoryFunctionValueCacheInNPZ) diff --git a/tests/trajectory/test_trajectory.py b/tests/trajectory/test_trajectory.py index 945279f..7306541 100644 --- a/tests/trajectory/test_trajectory.py +++ b/tests/trajectory/test_trajectory.py @@ -23,13 +23,14 @@ import asyncmd from asyncmd import Trajectory from asyncmd.config import _GLOBALS # noqa: F401 -from asyncmd.trajectory.trajectory_cache import (TrajectoryFunctionValueCacheInMemory, - TrajectoryFunctionValueCacheInNPZ, - TrajectoryFunctionValueCacheInH5PY, - OneH5PYGroupTrajectoryFunctionValueCache, - ValuesAlreadyStoredError, - CanNotChangeReadOnlyH5PYError, - ) +from asyncmd.trajectory.trajectory_cache import ( + TrajectoryFunctionValueCacheInMemory, + TrajectoryFunctionValueCacheInNPZ, + TrajectoryFunctionValueCacheInH5PY, + OneH5PYGroupTrajectoryFunctionValueCache, + ValuesAlreadyStoredError, + CanNotChangeReadOnlyH5PYError, +) from asyncmd.trajectory.functionwrapper import TrajectoryFunctionWrapper @@ -44,19 +45,22 @@ def setup_method(self): ii64 = np.iinfo(np.int64) def make_trajectory_hash() -> int: - return self.ran_gen.integers(low=ii64.min, - high=ii64.max, - endpoint=True, - ) + return self.ran_gen.integers( + low=ii64.min, + high=ii64.max, + endpoint=True, + ) self.make_trajectory_hash = make_trajectory_hash def make_func_id(): - return "".join(self.ran_gen.choice([f"{i}" for i in range(10)], - size=20, - replace=True, - ) - ) + return "".join( + self.ran_gen.choice( + [f"{i}" for i in range(10)], + size=20, + replace=True, + ) + ) self.make_func_id = make_func_id @@ -76,70 +80,88 @@ def setup_method(self): super().setup_method() asyncmd.trajectory._forget_all_trajectories() - @pytest.mark.parametrize(["trajectory_files", "structure_file"], - [("tests/test_data/trajectory/ala_traj.trr", # existing traj - "tests/test_data/trajectory/NON_EXISTING_STRUCTURE", # ... - ), - (["tests/test_data/trajectory/ala_traj.trr"], # existing traj (list) - "tests/test_data/trajectory/NON_EXISTING_STRUCTURE", # ... - ), - # and the other way around: non-existing traj, but struct present - ("tests/test_data/trajectory/NON_EXISTING_TRAJECTORY", - "tests/test_data/trajectory/ala.tpr") - ] - ) + @pytest.mark.parametrize( + ["trajectory_files", "structure_file"], + [ + ( + "tests/test_data/trajectory/ala_traj.trr", # existing traj + "tests/test_data/trajectory/NON_EXISTING_STRUCTURE", # ... + ), + ( + ["tests/test_data/trajectory/ala_traj.trr"], # existing traj (list) + "tests/test_data/trajectory/NON_EXISTING_STRUCTURE", # ... + ), + # and the other way around: non-existing traj, but struct present + ( + "tests/test_data/trajectory/NON_EXISTING_TRAJECTORY", + "tests/test_data/trajectory/ala.tpr", + ), + ], + ) def test_init_raises_non_existing_files(self, trajectory_files, structure_file): with pytest.raises(FileNotFoundError): - _ = Trajectory(trajectory_files=trajectory_files, - structure_file=structure_file) - - @pytest.mark.parametrize(["traj_file", "struct_file", "truth"], - [("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.tpr", - {"nstout": None, - "first_step": 67740, - "last_step": 68080, - "dt": 0.04, - "first_time": 135.48, - "last_time": 136.16, - } - ), - ("tests/test_data/trajectory/ala_traj.xtc", - "tests/test_data/trajectory/ala.tpr", - {"nstout": None, - "first_step": 67740, - "last_step": 68080, - "dt": 0.04, - "first_time": 135.48, - "last_time": 136.16, - } - ), - ("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.gro", - {"nstout": None, - "first_step": 67740, - "last_step": 68080, - "dt": 0.04, - "first_time": 135.48, - "last_time": 136.16, - } - ), - ("tests/test_data/trajectory/ala_traj.xtc", - "tests/test_data/trajectory/ala.gro", - {"nstout": None, - "first_step": 67740, - "last_step": 68080, - "dt": 0.04, - "first_time": 135.48, - "last_time": 136.16, - } - ), - ] - ) + _ = Trajectory( + trajectory_files=trajectory_files, structure_file=structure_file + ) + + @pytest.mark.parametrize( + ["traj_file", "struct_file", "truth"], + [ + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + { + "nstout": None, + "first_step": 67740, + "last_step": 68080, + "dt": 0.04, + "first_time": 135.48, + "last_time": 136.16, + }, + ), + ( + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala.tpr", + { + "nstout": None, + "first_step": 67740, + "last_step": 68080, + "dt": 0.04, + "first_time": 135.48, + "last_time": 136.16, + }, + ), + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.gro", + { + "nstout": None, + "first_step": 67740, + "last_step": 68080, + "dt": 0.04, + "first_time": 135.48, + "last_time": 136.16, + }, + ), + ( + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala.gro", + { + "nstout": None, + "first_step": 67740, + "last_step": 68080, + "dt": 0.04, + "first_time": 135.48, + "last_time": 136.16, + }, + ), + ], + ) def test_properties(self, traj_file, struct_file, truth): - traj = Trajectory(trajectory_files=traj_file, - structure_file=struct_file, - ) + traj = Trajectory( + trajectory_files=traj_file, + structure_file=struct_file, + ) # put the comparisson in a function to call it twice # this way we check both code-paths: value is None and needs to be read # from trajectory and value is already cached @@ -155,36 +177,48 @@ def compare_all(): assert np.isclose(test_val, truth_value, atol=1e-5, rtol=0) else: assert test_val == truth_value + compare_all() # this one reads the values and populates traj compare_all() # this one should only take cached values - @pytest.mark.parametrize(["traj_file", "struct_file", "truth"], - [("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.tpr", - {"__len__": 18, - } - ), - ("tests/test_data/trajectory/ala_traj.xtc", - "tests/test_data/trajectory/ala.tpr", - {"__len__": 18, - } - ), - ("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala.gro", - {"__len__": 18, - } - ), - ("tests/test_data/trajectory/ala_traj.xtc", - "tests/test_data/trajectory/ala.gro", - {"__len__": 18, - } - ), - ] - ) + @pytest.mark.parametrize( + ["traj_file", "struct_file", "truth"], + [ + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.tpr", + { + "__len__": 18, + }, + ), + ( + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala.tpr", + { + "__len__": 18, + }, + ), + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala.gro", + { + "__len__": 18, + }, + ), + ( + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala.gro", + { + "__len__": 18, + }, + ), + ], + ) def test_unary_magic_methods(self, traj_file, struct_file, truth): - traj = Trajectory(trajectory_files=traj_file, - structure_file=struct_file, - ) + traj = Trajectory( + trajectory_files=traj_file, + structure_file=struct_file, + ) # same logic as for properties, put in func to call twice to check both # code paths (retrieve value from underlying traj and get cached value) @@ -192,117 +226,169 @@ def compare_all(): for mm_name, truth_value in truth.items(): mm = getattr(traj, mm_name) assert mm() == truth_value + compare_all() compare_all() - @pytest.mark.parametrize(["traj_files", "struct_files", "truth"], - [ - # two times the same traj with the same struct - (("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr", - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.tpr", - ), - {"__eq__": True, - "__ne__": False, - } - ), - # two times the same traj with different structs - (("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr", - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.gro", - ), - {"__eq__": True, - "__ne__": False, - } - ), - # different trajs with the same struct file ;) - (("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.xtc", - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.tpr", - ), - {"__eq__": False, - "__ne__": True, - } - ), - # different trajs with different struct files - (("tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.xtc", - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.gro", - ), - {"__eq__": False, - "__ne__": True, - } - ), - # same for lists of trajs - # two times the same traj with the same struct - ((["tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr"], - ["tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr"], - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.tpr", - ), - {"__eq__": True, - "__ne__": False, - } - ), - # two times the same traj with different structs - ((["tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr"], - ["tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr"], - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.gro", - ), - {"__eq__": True, - "__ne__": False, - } - ), - # different trajs with the same struct file ;) - ((["tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr"], - ["tests/test_data/trajectory/ala_traj.xtc", - "tests/test_data/trajectory/ala_traj.xtc"], - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.tpr", - ), - {"__eq__": False, - "__ne__": True, - } - ), - # different trajs with different struct files - ((["tests/test_data/trajectory/ala_traj.trr", - "tests/test_data/trajectory/ala_traj.trr"], - ["tests/test_data/trajectory/ala_traj.xtc", - "tests/test_data/trajectory/ala_traj.xtc"], - ), - ("tests/test_data/trajectory/ala.tpr", - "tests/test_data/trajectory/ala.gro", - ), - {"__eq__": False, - "__ne__": True, - } - ), - ] - ) + @pytest.mark.parametrize( + ["traj_files", "struct_files", "truth"], + [ + # two times the same traj with the same struct + ( + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.tpr", + ), + { + "__eq__": True, + "__ne__": False, + }, + ), + # two times the same traj with different structs + ( + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.gro", + ), + { + "__eq__": True, + "__ne__": False, + }, + ), + # different trajs with the same struct file ;) + ( + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.xtc", + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.tpr", + ), + { + "__eq__": False, + "__ne__": True, + }, + ), + # different trajs with different struct files + ( + ( + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.xtc", + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.gro", + ), + { + "__eq__": False, + "__ne__": True, + }, + ), + # same for lists of trajs + # two times the same traj with the same struct + ( + ( + [ + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ], + [ + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ], + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.tpr", + ), + { + "__eq__": True, + "__ne__": False, + }, + ), + # two times the same traj with different structs + ( + ( + [ + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ], + [ + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ], + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.gro", + ), + { + "__eq__": True, + "__ne__": False, + }, + ), + # different trajs with the same struct file ;) + ( + ( + [ + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ], + [ + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala_traj.xtc", + ], + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.tpr", + ), + { + "__eq__": False, + "__ne__": True, + }, + ), + # different trajs with different struct files + ( + ( + [ + "tests/test_data/trajectory/ala_traj.trr", + "tests/test_data/trajectory/ala_traj.trr", + ], + [ + "tests/test_data/trajectory/ala_traj.xtc", + "tests/test_data/trajectory/ala_traj.xtc", + ], + ), + ( + "tests/test_data/trajectory/ala.tpr", + "tests/test_data/trajectory/ala.gro", + ), + { + "__eq__": False, + "__ne__": True, + }, + ), + ], + ) def test_binary_magic_methods(self, traj_files, struct_files, truth): - traj1 = Trajectory(trajectory_files=traj_files[0], - structure_file=struct_files[0], - ) - traj2 = Trajectory(trajectory_files=traj_files[1], - structure_file=struct_files[1], - ) + traj1 = Trajectory( + trajectory_files=traj_files[0], + structure_file=struct_files[0], + ) + traj2 = Trajectory( + trajectory_files=traj_files[1], + structure_file=struct_files[1], + ) for mm_name, truth_value in truth.items(): mm = getattr(traj1, mm_name) assert mm(traj2) == truth_value @@ -315,42 +401,43 @@ def make_traj(): # a new object for the same trajectory(_files) asyncmd.trajectory._forget_all_trajectories() return Trajectory( - trajectory_files="tests/test_data/trajectory/ala_traj.trr", - structure_file="tests/test_data/trajectory/ala.tpr", - ) + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) def assert_neq(traj1, traj2): # check both eq and neq at once assert not traj1 == traj2 assert traj1 != traj2 + traj1 = make_traj() traj2 = make_traj() assert traj1 == traj2 # make sure they are equal to begin with assert not traj1 != traj2 # and check that neq also works # get another traj2 to test [not] eq (for two trajs which are different) traj2 = Trajectory( - trajectory_files="tests/test_data/trajectory/ala_conf_in_no_state.trr", - structure_file="tests/test_data/trajectory/ala.tpr", - ) + trajectory_files="tests/test_data/trajectory/ala_conf_in_no_state.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) assert_neq(traj1, traj2) # test for non trajectory objects assert_neq(traj1, object()) - @pytest.mark.parametrize("cache_type", - ["npz", "h5py", "memory"]) - @pytest.mark.parametrize("initial_cache_type", - ["npz", "h5py", "memory"]) + @pytest.mark.parametrize("cache_type", ["npz", "h5py", "memory"]) + @pytest.mark.parametrize("initial_cache_type", ["npz", "h5py", "memory"]) def test__setup_cache(self, tmp_path, cache_type, initial_cache_type): # we create a trajectory with given initial_cache_type # (after setting config-cache type), append some values, then # (re)set the cache_type and check that everything is there and in the # correct cache global _GLOBALS - if (initial_cache_type == "h5py" or cache_type == "h5py"): + if initial_cache_type == "h5py" or cache_type == "h5py": # setup the h5py file if we use a h5py cache - h5py = pytest.importorskip("h5py", minversion=None, - reason="Requires 'h5py' to run.", - ) + h5py = pytest.importorskip( + "h5py", + minversion=None, + reason="Requires 'h5py' to run.", + ) h5file = h5py.File(tmp_path / "h5py_file.h5", mode="w") if initial_cache_type == "h5py": asyncmd.config.register_h5py_cache(h5py_group=h5file) @@ -359,16 +446,16 @@ def test__setup_cache(self, tmp_path, cache_type, initial_cache_type): asyncmd.config.set_trajectory_cache_type(cache_type=initial_cache_type) traj = Trajectory( - trajectory_files="tests/test_data/trajectory/ala_traj.trr", - structure_file="tests/test_data/trajectory/ala.tpr", - ) + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) traj.clear_all_cache_values() # make sure the cache is empty # create some dummy CV data and attach it to traj n_cached_cvs = 4 cv_dims = [self.ran_gen.integers(300) for _ in range(n_cached_cvs)] - func_values = [self.make_func_values(traj_len=200, cv_dim=cv_dim) - for cv_dim in cv_dims - ] + func_values = [ + self.make_func_values(traj_len=200, cv_dim=cv_dim) for cv_dim in cv_dims + ] func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] # use a mock wrapped CV to attach the values wrapped_func = Mock(TrajectoryFunctionWrapper) @@ -386,56 +473,57 @@ def test__setup_cache(self, tmp_path, cache_type, initial_cache_type): type(wrapped_func).id = PropertyMock(side_effect=func_ids) for func_vals in func_values: retrieved_func_vals = traj._retrieve_cached_values( - func_wrapper=wrapped_func, - ) + func_wrapper=wrapped_func, + ) assert np.all(np.equal(retrieved_func_vals, func_vals)) # and check that the correct cache (class) is used/bound to traj - cache_type_to_class = {"memory": TrajectoryFunctionValueCacheInMemory, - "npz": TrajectoryFunctionValueCacheInNPZ, - "h5py": TrajectoryFunctionValueCacheInH5PY, - } + cache_type_to_class = { + "memory": TrajectoryFunctionValueCacheInMemory, + "npz": TrajectoryFunctionValueCacheInNPZ, + "h5py": TrajectoryFunctionValueCacheInH5PY, + } assert isinstance(traj._cache, cache_type_to_class[cache_type]) # cleanup # remove the npz cache file (if it can be there)! fname_npz_cache = TrajectoryFunctionValueCacheInNPZ.get_cache_filename( - traj_files=traj.trajectory_files, - ) + traj_files=traj.trajectory_files, + ) if "npz" in (cache_type, initial_cache_type): # npz cache explicitly used os.unlink(fname_npz_cache) else: # there should be no file created if npz cache is not involved! assert not os.path.isfile(fname_npz_cache) - @pytest.mark.parametrize("cache_type", - ["npz", "h5py", "memory"]) - @pytest.mark.parametrize("change_wdir_between_pickle_unpickle", - [True, False]) + @pytest.mark.parametrize("cache_type", ["npz", "h5py", "memory"]) + @pytest.mark.parametrize("change_wdir_between_pickle_unpickle", [True, False]) def test_pickle_and_wrapped_func_application( - self, - tmp_path, - cache_type, - change_wdir_between_pickle_unpickle, - ): + self, + tmp_path, + cache_type, + change_wdir_between_pickle_unpickle, + ): global _GLOBALS if cache_type == "h5py": - h5py = pytest.importorskip("h5py", minversion=None, - reason="Requires 'h5py' to run.", - ) + h5py = pytest.importorskip( + "h5py", + minversion=None, + reason="Requires 'h5py' to run.", + ) h5file = h5py.File(tmp_path / "h5py_file.h5", mode="w") asyncmd.config.register_h5py_cache(h5py_group=h5file) else: asyncmd.config.set_trajectory_cache_type(cache_type=cache_type) traj = Trajectory( - trajectory_files="tests/test_data/trajectory/ala_traj.trr", - structure_file="tests/test_data/trajectory/ala.tpr", - ) + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) traj.clear_all_cache_values() # make sure the cache is empty # create some dummy CV data and attach it to traj n_cached_cvs = 4 cv_dims = [self.ran_gen.integers(300) for _ in range(n_cached_cvs)] - func_values = [self.make_func_values(traj_len=200, cv_dim=cv_dim) - for cv_dim in cv_dims - ] + func_values = [ + self.make_func_values(traj_len=200, cv_dim=cv_dim) for cv_dim in cv_dims + ] func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] # use a mock wrapped CV to attach the values wrapped_func = Mock(TrajectoryFunctionWrapper) @@ -461,15 +549,15 @@ def test_pickle_and_wrapped_func_application( type(wrapped_func).id = PropertyMock(side_effect=func_ids) for func_vals in func_values: loaded_func_vals = loaded_traj._retrieve_cached_values( - func_wrapper=wrapped_func, - ) + func_wrapper=wrapped_func, + ) assert np.all(np.equal(loaded_func_vals, func_vals)) # cleanup # remove the npz cache file! fname_npz_cache = TrajectoryFunctionValueCacheInNPZ.get_cache_filename( - traj_files=traj.trajectory_files, - ) + traj_files=traj.trajectory_files, + ) if ( cache_type == "npz" # npz cache explicitly used or cache_type == "memory" # memory will write to npz when pickled @@ -484,17 +572,21 @@ class Test_TrajectoryFunctionValueCache(TBase): def setup_method(self): super().setup_method() - @pytest.mark.parametrize("cache_class", - [TrajectoryFunctionValueCacheInNPZ, - TrajectoryFunctionValueCacheInH5PY, - TrajectoryFunctionValueCacheInMemory, - ] - ) + @pytest.mark.parametrize( + "cache_class", + [ + TrajectoryFunctionValueCacheInNPZ, + TrajectoryFunctionValueCacheInH5PY, + TrajectoryFunctionValueCacheInMemory, + ], + ) def test_append_iter_len___getitem___errs(self, tmp_path, cache_class): if cache_class is TrajectoryFunctionValueCacheInH5PY: - h5py = pytest.importorskip("h5py", minversion=None, - reason="Requires 'h5py' to run.", - ) + h5py = pytest.importorskip( + "h5py", + minversion=None, + reason="Requires 'h5py' to run.", + ) fname_traj_cache = tmp_path / "traj_cache.h5" h5file = h5py.File(fname_traj_cache, mode="w") asyncmd.config.register_h5py_cache(h5py_group=h5file) @@ -505,8 +597,9 @@ def test_append_iter_len___getitem___errs(self, tmp_path, cache_class): traj_len = 23 cv_dims = [1, 223] test_data_func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] - test_data_func_values = [self.make_func_values(traj_len, cv_dim) - for cv_dim in cv_dims] + test_data_func_values = [ + self.make_func_values(traj_len, cv_dim) for cv_dim in cv_dims + ] # now create a fresh cache and append cache = cache_class(traj_hash=traj_hash, traj_files=traj_files) # make sure we get a KeyError trying to access non-existing values @@ -515,46 +608,46 @@ def test_append_iter_len___getitem___errs(self, tmp_path, cache_class): _ = cache[func_id] # now add the values and test that we still get errs when trying to # access non-existant stuff - for func_id, func_values in zip(test_data_func_ids, - test_data_func_values): + for func_id, func_values in zip(test_data_func_ids, test_data_func_values): cache.append(func_id=func_id, values=func_values) for _ in range(3): with pytest.raises(KeyError): _ = cache[self.make_func_id()] # check that appending again raises an error - for func_id, func_values in zip(test_data_func_ids, - test_data_func_values): + for func_id, func_values in zip(test_data_func_ids, test_data_func_values): with pytest.raises(ValuesAlreadyStoredError): cache.append(func_id=func_id, values=func_values) # and finally test that everything is there as expected for func_id in cache: idx_in_test_data = test_data_func_ids.index(func_id) - assert np.all(np.equal(cache[func_id], - test_data_func_values[idx_in_test_data] - ) - ) + assert np.all( + np.equal(cache[func_id], test_data_func_values[idx_in_test_data]) + ) assert len(cache) == n_cached_cvs ran_idx = self.ran_gen.integers(n_cached_cvs) - assert np.all(np.equal(cache[test_data_func_ids[ran_idx]], - test_data_func_values[ran_idx] - ) - ) + assert np.all( + np.equal(cache[test_data_func_ids[ran_idx]], test_data_func_values[ran_idx]) + ) # now check that clear works as expected cache.clear_all_values() assert len(cache) == 0 # Note these test dont work for the memory cache as it is not stateful, # i.e. recreating it will empty it (as there is no file to back it) - @pytest.mark.parametrize("cache_class", - [TrajectoryFunctionValueCacheInNPZ, - TrajectoryFunctionValueCacheInH5PY, - ] - ) + @pytest.mark.parametrize( + "cache_class", + [ + TrajectoryFunctionValueCacheInNPZ, + TrajectoryFunctionValueCacheInH5PY, + ], + ) def test_stateful_append_iter_len(self, tmp_path, cache_class): if cache_class is TrajectoryFunctionValueCacheInH5PY: - h5py = pytest.importorskip("h5py", minversion=None, - reason="Requires 'h5py' to run.", - ) + h5py = pytest.importorskip( + "h5py", + minversion=None, + reason="Requires 'h5py' to run.", + ) fname_traj_cache = tmp_path / "traj_cache.h5" h5file = h5py.File(fname_traj_cache, mode="w") asyncmd.config.register_h5py_cache(h5py_group=h5file) @@ -564,16 +657,16 @@ def test_stateful_append_iter_len(self, tmp_path, cache_class): n_cached_cvs = 5 n_initial_cvs = 2 traj_len = 123 - cv_dims = [1] + [self.ran_gen.integers(300) - for _ in range(n_cached_cvs - 1) - ] + cv_dims = [1] + [self.ran_gen.integers(300) for _ in range(n_cached_cvs - 1)] test_data_func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] - test_data_func_values = [self.make_func_values(traj_len, cv_dim) - for cv_dim in cv_dims] + test_data_func_values = [ + self.make_func_values(traj_len, cv_dim) for cv_dim in cv_dims + ] # now create a fresh cache and append cache = cache_class(traj_hash=traj_hash, traj_files=traj_files) - for func_id, func_values in zip(test_data_func_ids[:n_initial_cvs], - test_data_func_values[:n_initial_cvs]): + for func_id, func_values in zip( + test_data_func_ids[:n_initial_cvs], test_data_func_values[:n_initial_cvs] + ): cache.append(func_id=func_id, values=func_values) # now create a new cache, make sure it has the right "len" and that the # values are as expected (use iter to check!) @@ -581,23 +674,23 @@ def test_stateful_append_iter_len(self, tmp_path, cache_class): assert len(cache2) == n_initial_cvs for func_id in cache2: idx_in_test_data = test_data_func_ids.index(func_id) - assert np.all(np.equal(cache2[func_id], - test_data_func_values[idx_in_test_data] - ) - ) + assert np.all( + np.equal(cache2[func_id], test_data_func_values[idx_in_test_data]) + ) # now append the initial part again and check for the err - for func_id, func_values in zip(test_data_func_ids[:n_initial_cvs], - test_data_func_values[:n_initial_cvs]): + for func_id, func_values in zip( + test_data_func_ids[:n_initial_cvs], test_data_func_values[:n_initial_cvs] + ): with pytest.raises(ValuesAlreadyStoredError): cache2.append(func_id=func_id, values=func_values) # now append the rest and check that everything is correct - for func_id, func_values in zip(test_data_func_ids[n_initial_cvs:], - test_data_func_values[n_initial_cvs:]): + for func_id, func_values in zip( + test_data_func_ids[n_initial_cvs:], test_data_func_values[n_initial_cvs:] + ): cache2.append(func_id=func_id, values=func_values) cache3 = cache_class(traj_hash=traj_hash, traj_files=traj_files) assert len(cache3) == n_cached_cvs - for func_id, func_values in zip(test_data_func_ids, - test_data_func_values): + for func_id, func_values in zip(test_data_func_ids, test_data_func_values): assert np.all(np.equal(cache3[func_id], func_values)) # check that stateful caches initialize empty if we clear them before cache3.clear_all_values() @@ -616,19 +709,20 @@ def test__ensure_consistent_npz(self, tmp_path): traj_len = 223 cv_dims = [1, 223, 10, 321] test_data_func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] - test_data_func_values = [self.make_func_values(traj_len, cv_dim) - for cv_dim in cv_dims] + test_data_func_values = [ + self.make_func_values(traj_len, cv_dim) for cv_dim in cv_dims + ] # now create a fresh cache and append - npz_cache = TrajectoryFunctionValueCacheInNPZ(traj_hash=traj_hash, - traj_files=traj_files) - for func_id, func_values in zip(test_data_func_ids, - test_data_func_values): + npz_cache = TrajectoryFunctionValueCacheInNPZ( + traj_hash=traj_hash, traj_files=traj_files + ) + for func_id, func_values in zip(test_data_func_ids, test_data_func_values): npz_cache.append(func_id=func_id, values=func_values) # now create a second npz cache to test that it will load the data - npz_cache2 = TrajectoryFunctionValueCacheInNPZ(traj_hash=traj_hash, - traj_files=traj_files) - for func_id, func_values in zip(test_data_func_ids, - test_data_func_values): + npz_cache2 = TrajectoryFunctionValueCacheInNPZ( + traj_hash=traj_hash, traj_files=traj_files + ) + for func_id, func_values in zip(test_data_func_ids, test_data_func_values): # and check that the loaded and saved data are equal assert np.all(np.equal(npz_cache2[func_id], func_values)) # now check that the npz file will be removed if the traj hashes dont @@ -640,18 +734,19 @@ def test__ensure_consistent_npz(self, tmp_path): cache_file_name = npz_cache.get_cache_filename(traj_files=traj_files) # creating a cache with a matching fname_trajs but mismatching hash # should remove the npz file - npz_cache_mm = TrajectoryFunctionValueCacheInNPZ(traj_hash=hash_traj_mm, - traj_files=traj_files) + npz_cache_mm = TrajectoryFunctionValueCacheInNPZ( + traj_hash=hash_traj_mm, traj_files=traj_files + ) assert not os.path.exists(cache_file_name) # recreate the npz file by appending the values again - for func_id, func_values in zip(test_data_func_ids, - test_data_func_values): + for func_id, func_values in zip(test_data_func_ids, test_data_func_values): npz_cache_mm.append(func_id=func_id, values=func_values) # and now test for removal of npz if mismatching npz file format is # detected (triggered by changing the key for the hash_traj in npz) TrajectoryFunctionValueCacheInNPZ._TRAJ_HASH_NPZ_KEY = "TEST123" - _ = TrajectoryFunctionValueCacheInNPZ(traj_hash=traj_hash, - traj_files=traj_files) + _ = TrajectoryFunctionValueCacheInNPZ( + traj_hash=traj_hash, traj_files=traj_files + ) assert not os.path.exists(cache_file_name) # NOTE: this test is only relevant for the h5py cache as it is the only one @@ -668,16 +763,19 @@ def test_h5py_cache_not_set(self, tmp_path): traj_hash = self.make_trajectory_hash() traj_files = [tmp_path / "traj_name.traj"] with pytest.raises(RuntimeError): - TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) + TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) # Note: this test is only for h5py, since that is the only cache source that # allows for multiple cache files used simultaneously @pytest.mark.parametrize("copy_h5py", [True, False]) def test_h5py_multiple_cache_sources(self, tmp_path, copy_h5py, caplog): - h5py = pytest.importorskip("h5py", minversion=None, - reason="Requires 'h5py' to run.", - ) + h5py = pytest.importorskip( + "h5py", + minversion=None, + reason="Requires 'h5py' to run.", + ) # make our first cache file (writeable) fname_traj_cache = tmp_path / "traj_cache.h5" h5file = h5py.File(fname_traj_cache, mode="w") @@ -689,17 +787,18 @@ def test_h5py_multiple_cache_sources(self, tmp_path, copy_h5py, caplog): n_cached_cvs = 5 n_initial_cvs = 2 # number of CVs to add initially traj_len = 123 - cv_dims = [1] + [self.ran_gen.integers(300) - for _ in range(n_cached_cvs - 1) - ] + cv_dims = [1] + [self.ran_gen.integers(300) for _ in range(n_cached_cvs - 1)] test_data_func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] - test_data_func_values = [self.make_func_values(traj_len, cv_dim) - for cv_dim in cv_dims] + test_data_func_values = [ + self.make_func_values(traj_len, cv_dim) for cv_dim in cv_dims + ] # append it - cache = TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) - for func_id, func_values in zip(test_data_func_ids[:n_initial_cvs], - test_data_func_values[:n_initial_cvs]): + cache = TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) + for func_id, func_values in zip( + test_data_func_ids[:n_initial_cvs], test_data_func_values[:n_initial_cvs] + ): cache.append(func_id=func_id, values=func_values) assert len(cache) == n_initial_cvs # make sure length is as expected # now reset the cache to a new one @@ -729,11 +828,13 @@ def test_h5py_multiple_cache_sources(self, tmp_path, copy_h5py, caplog): # and the empty read-only file asyncmd.config.register_h5py_cache(h5py_group=h5file_r_empty) # create a new cache (which should now be read-only) - cache = TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) + cache = TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) # make sure it is all there as expected - for func_id, func_values in zip(test_data_func_ids[:n_initial_cvs], - test_data_func_values[:n_initial_cvs]): + for func_id, func_values in zip( + test_data_func_ids[:n_initial_cvs], test_data_func_values[:n_initial_cvs] + ): assert np.all(np.equal(cache[func_id], func_values)) # The next line just to test that deregistering an unknown # (not registered) cache also works @@ -748,29 +849,35 @@ def test_h5py_multiple_cache_sources(self, tmp_path, copy_h5py, caplog): asyncmd.config.register_h5py_cache(h5py_group=h5file_r_with_data) asyncmd.config.register_h5py_cache(h5py_group=h5file_r_empty) # and instantiate a new cache so it uses the now set h5py caches - cache = TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) + cache = TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) # make sure we cant clear it with pytest.raises(CanNotChangeReadOnlyH5PYError): cache.clear_all_values() # and make sure we can not append, currently it does not raise # but just log an error (and naturally does not append) with caplog.at_level(logging.ERROR): - cache.append(func_id=test_data_func_ids[n_initial_cvs + 1], - values=test_data_func_values[n_initial_cvs + 1]) + cache.append( + func_id=test_data_func_ids[n_initial_cvs + 1], + values=test_data_func_values[n_initial_cvs + 1], + ) assert "Can not append" in caplog.text assert len(cache) == n_initial_cvs # make sure length is as expected # now register the writeable h5py_cache - asyncmd.config.register_h5py_cache(h5py_group=h5file_w_empty, - copy_h5py=copy_h5py, - ) + asyncmd.config.register_h5py_cache( + h5py_group=h5file_w_empty, + copy_h5py=copy_h5py, + ) # and create a new cache (which should now use both the writeable and # read-only caches) - cache = TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) + cache = TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) # append the rest - for func_id, func_values in zip(test_data_func_ids[n_initial_cvs:], - test_data_func_values[n_initial_cvs:]): + for func_id, func_values in zip( + test_data_func_ids[n_initial_cvs:], test_data_func_values[n_initial_cvs:] + ): cache.append(func_id=func_id, values=func_values) assert len(cache) == n_cached_cvs # make sure length is as expected # now deregister and close the initial cache file @@ -800,8 +907,9 @@ def test_h5py_multiple_cache_sources(self, tmp_path, copy_h5py, caplog): h5file_w_empty.close() h5file_r_empty.close() # now init a ne cache to make sure we use the most current cache setting - cache = TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) + cache = TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) # and make sure everything is there, i.e. all n_cached_cvs values assert len(cache) == n_cached_cvs for func_id, func_values in zip(test_data_func_ids, test_data_func_values): @@ -811,27 +919,76 @@ def test_h5py_multiple_cache_sources(self, tmp_path, copy_h5py, caplog): # for this reopen and re-add one of the caches (with data to make sure it # will be attached to the cache class) as read-only h5file_r_with_data = h5py.File(fname_traj_cache2, mode="r") - asyncmd.config.register_h5py_cache(h5py_group=h5file_r_with_data, copy_h5py=False) - cache = TrajectoryFunctionValueCacheInH5PY(traj_hash=traj_hash, - traj_files=traj_files) + asyncmd.config.register_h5py_cache( + h5py_group=h5file_r_with_data, copy_h5py=False + ) + cache = TrajectoryFunctionValueCacheInH5PY( + traj_hash=traj_hash, traj_files=traj_files + ) with pytest.raises(KeyError): _ = cache["NOT_SET_KEY"] - def test_OneH5PYGroupTrajectoryFunctionValueCache_raises_on_read_only(self, tmp_path): - h5py = pytest.importorskip("h5py", minversion=None, - reason="Requires 'h5py' to run.", - ) + def test_TrajectorySingleton(self): + """Test that Trajectory implements singleton pattern for same files.""" + # Test 1: Same files should return same object + traj1 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + traj2 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + assert traj1 is traj2, "Same trajectory files should return identical object" + + # Test 2: Same object has same hash + assert traj1.trajectory_hash == traj2.trajectory_hash + + # Test 3: Changes to one affect the other (since they're the same) + traj1.nstout = 100 + assert traj2.nstout == 100, "Modifications to one instance affect the singleton" + + # Test 4: Different files return different objects + traj3 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_conf_in_no_state.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + assert ( + traj1 is not traj3 + ), "Different trajectory files should return different objects" + + # Test 5: Explicitly test cache type change propagates to singleton + asyncmd.config.set_trajectory_cache_type(cache_type="memory", copy_content=True) + # traj1 is still the same object, so it should now use memory cache + assert isinstance(traj1._cache, TrajectoryFunctionValueCacheInMemory) + + # Test 6: Clearing trajectories allows recreation + asyncmd.trajectory._forget_all_trajectories() + traj4 = Trajectory( + trajectory_files="tests/test_data/trajectory/ala_traj.trr", + structure_file="tests/test_data/trajectory/ala.tpr", + ) + assert traj1 is not traj4, "After forgetting trajectories, new object created" + assert traj4.trajectory_hash == traj1.trajectory_hash + + def test_OneH5PYGroupTrajectoryFunctionValueCache_raises_on_read_only( + self, tmp_path + ): + h5py = pytest.importorskip( + "h5py", + minversion=None, + reason="Requires 'h5py' to run.", + ) # make some values to append traj_hash = self.make_trajectory_hash() traj_files = [tmp_path / "traj_name.traj"] n_cached_cvs = 2 traj_len = 123 - cv_dims = [1] + [self.ran_gen.integers(300) - for _ in range(n_cached_cvs - 1) - ] + cv_dims = [1] + [self.ran_gen.integers(300) for _ in range(n_cached_cvs - 1)] test_data_func_ids = [self.make_func_id() for _ in range(n_cached_cvs)] - test_data_func_values = [self.make_func_values(traj_len, cv_dim) - for cv_dim in cv_dims] + test_data_func_values = [ + self.make_func_values(traj_len, cv_dim) for cv_dim in cv_dims + ] # make our first cache file (writeable) fname_traj_cache = tmp_path / "traj_cache.h5" @@ -839,16 +996,18 @@ def test_OneH5PYGroupTrajectoryFunctionValueCache_raises_on_read_only(self, tmp_ # for the trajectory cached values and funcIDs (but the groups are empty, # i.e. no values and funcIDs in there) h5file = h5py.File(fname_traj_cache, mode="w") - root_grp = h5file.require_group(f"asyncmd/TrajectoryFunctionValueCache/{traj_hash}") + root_grp = h5file.require_group( + f"asyncmd/TrajectoryFunctionValueCache/{traj_hash}" + ) _ = root_grp.require_group("FunctionIDs") _ = root_grp.require_group("FunctionValues") h5file.close() # reopen in read-only h5file = h5py.File(fname_traj_cache, mode="r") # now init the (sub)cache class - cache = OneH5PYGroupTrajectoryFunctionValueCache(traj_hash=traj_hash, - traj_files=traj_files, - h5py_cache=h5file) + cache = OneH5PYGroupTrajectoryFunctionValueCache( + traj_hash=traj_hash, traj_files=traj_files, h5py_cache=h5file + ) # and make sure it know it is read-only assert cache._read_only # and raises