diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..ab249ec --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] + branch = True + +omit = + frequenpy/version.py + frequenpy/constants.py \ No newline at end of file diff --git a/.gitignore b/.gitignore index 97af587..6f4a7e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ __pycache__ -test*.py dist build *egg-info* .venv workdir +.coverage \ No newline at end of file diff --git a/frequenpy/cli.py b/frequenpy/cli.py index c52113e..7aec675 100644 --- a/frequenpy/cli.py +++ b/frequenpy/cli.py @@ -3,7 +3,7 @@ import argparse -from frequenpy.loaded_string.animation import LoadedStringAnimation, DEFAULT_SPEED +from frequenpy.loaded_string.animation import LoadedStringAnimation from frequenpy.constants import APP_DESCRIPTION, APP_NAME @@ -31,6 +31,8 @@ EXAMPLE_LS = "frequenpy loaded_string --masses 3 --modes 1 2 3 --speed 0.1 --boundary 0" EPILOG_LS = "Example: {}".format(EXAMPLE_LS) +OUTPUT_FOLDER = 'workdir' + def setup_logger(verbose=False): logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, format=LOG_FORMAT) @@ -50,44 +52,47 @@ def add_loaded_string_parser(subparsers): o = p.add_argument_group('optional arguments') o.add_argument('--modes', type=int, default=[1], metavar='', nargs='+', help=HELP_LS_MODES) o.add_argument('--boundary', type=int, default=0, help=HELP_LS_BOUNDARY) - o.add_argument('--speed', type=float, default=DEFAULT_SPEED, help=HELP_LS_SPEED) + o.add_argument('--speed', type=float, default=1, help=HELP_LS_SPEED) o.add_argument('--save', action='store_true', help=HELP_LS_SAVE) -def parse_args(parser, subparsers): +def parse_args(args): help_arg = '--help' - if len(sys.argv) < 2: + if len(args) == 0: return [help_arg] - if len(sys.argv) == 2: - system = sys.argv[1] + if len(args) == 1: + system = args[0] if system not in AVAILABLE_SYSTEMS: raise ValueError(f'System {system} is not a valid option.') return [system, help_arg] - return sys.argv[1:] - + return args -def main(): - parser = argparse.ArgumentParser(prog=APP_NAME, description=GREETING, epilog=APP_EPILOG) - subparsers = parser.add_subparsers(dest='system', help='Choose a system to simulate') - add_loaded_string_parser(subparsers) +def run(args, folder=OUTPUT_FOLDER): setup_logger() - try: - args = parser.parse_args(args=parse_args(parser, subparsers)) + parser = argparse.ArgumentParser(prog=APP_NAME, description=GREETING, epilog=APP_EPILOG) + subparsers = parser.add_subparsers(dest='system', help='Choose a system to simulate') + add_loaded_string_parser(subparsers) + + args = parser.parse_args(args=parse_args(args)) if args.system == BEADED_STRING: animation = LoadedStringAnimation.build( - args.masses, args.modes, args.boundary, args.speed) + args.masses, args.modes, args.boundary, speed=args.speed, folder=folder) - animation.animate(save=args.save) + animation.start(save=args.save) except ValueError as e: logger.error(e) +def main(): + run(sys.argv[1:]) + + if __name__ == '__main__': main() diff --git a/frequenpy/loaded_string/animation.py b/frequenpy/loaded_string/animation.py index 97baadc..1061764 100644 --- a/frequenpy/loaded_string/animation.py +++ b/frequenpy/loaded_string/animation.py @@ -30,34 +30,73 @@ MARGIN_RIGHT = 0.95 MARGIN_TOP = 0.95 -ANIMATIONS_FOLDER = 'workdir' +OUTPUT_FOLDER = 'workdir' FILENAME_TPL = "{}masses_{}modes.mp4" -NUMBER_OF_FRAMES = 2000 -DEFAULT_SPEED = 1 +N_FRAMES = 2000 +SPEED = 1 -class LoadedStringAnimation(object): - def __init__(self, loaded_string, number_of_frames, speed=DEFAULT_SPEED): +class LoadedStringAnimation: + def __init__(self, loaded_string, n_frames=N_FRAMES, speed=SPEED, folder=OUTPUT_FOLDER): self._loaded_string = loaded_string - self._number_of_frames = number_of_frames + self._n_frames = n_frames self._speed = speed + self._folder = folder self._line = self._build_line() self._figure = self._build_figure() self._frames = self._build_frames() - def animate(self, save=False): + def start(self, save=False): anim = self._build_animation() if save: - self._save(anim) + return self._save(anim) plt.show() - def build(N, modes, boundary, speed): + @classmethod + def build(cls, N, modes, boundary, **kwargs): loaded_string = loaded_string_factory.create(N, modes, boundary) - return LoadedStringAnimation(loaded_string, NUMBER_OF_FRAMES, speed) + return cls(loaded_string, **kwargs) + + def _build_line(self): + if self._loaded_string.N == self._loaded_string.CONTINUOUS_LIMIT: + return self._build_line_without_markers() + else: + return self._build_line_with_markers() + + def _build_line_with_markers(self): + X, Y = self._loaded_string.rest_position + + return plt.Line2D( + X, Y, + marker=LINE_MARKERTYPE, + lw=LINE_WIDTH, + markersize=LINE_MARKERSIZE, + markerfacecolor=LINE_MARKERFACECOLOR, + color=LINE_COLOR, + markevery=slice(1, len(X) - 1, 1) + ) + + def _build_line_without_markers(self): + X, Y = self._loaded_string.rest_position + + return plt.Line2D( + X, Y, + lw=LINE_WIDTH, + color=LINE_COLOR + ) + + def _build_frames(self): + self._loaded_string.apply_speed(self._speed) + frames = range(0, self._n_frames) + + return [ + self._loaded_string.position_at_time_t(t) + for t in frames + ] def _build_figure(self): x_rest_position, _ = self._loaded_string.rest_position @@ -99,43 +138,6 @@ def _left_support(self, x_distance_from_origin): def _right_support(self, x_distance_from_origin): return self._support(x_distance_from_origin) - def _build_line(self): - if self._loaded_string.N == self._loaded_string.CONTINUOUS_LIMIT: - return self._build_line_without_markers() - else: - return self._build_line_with_markers() - - def _build_line_with_markers(self): - X, Y = self._loaded_string.rest_position - - return plt.Line2D( - X, Y, - marker=LINE_MARKERTYPE, - lw=LINE_WIDTH, - markersize=LINE_MARKERSIZE, - markerfacecolor=LINE_MARKERFACECOLOR, - color=LINE_COLOR, - markevery=slice(1, len(X) - 1, 1) - ) - - def _build_line_without_markers(self): - X, Y = self._loaded_string.rest_position - - return plt.Line2D( - X, Y, - lw=LINE_WIDTH, - color=LINE_COLOR - ) - - def _build_frames(self): - self._loaded_string.apply_speed(self._speed) - frames = range(0, self._number_of_frames) - - return [ - self._loaded_string.position_at_time_t(t) - for t in frames - ] - def _update(self, frame_number): self._line.set_data(self._frames[frame_number]) @@ -145,7 +147,7 @@ def _build_animation(self): return animation.FuncAnimation( self._figure, self._update, - frames=self._number_of_frames, + frames=self._n_frames, interval=5, blit=True, repeat=True) @@ -153,12 +155,14 @@ def _build_animation(self): def _save(self, animation): logger.info('Saving animation...this could take a while...') - self._create_directory(ANIMATIONS_FOLDER) + self._create_folder(self._folder) filename = FILENAME_TPL.format(self._loaded_string.N, self._loaded_string.modes) - filepath = path.join(ANIMATIONS_FOLDER, filename) + filepath = path.join(self._folder, filename) animation.save(filepath, savefig_kwargs={'facecolor': BACKGROUND_COLOR}) - def _create_directory(self, directory): - if not path.exists(directory): - makedirs(directory) + return filepath + + def _create_folder(self, folder): + if not path.exists(folder): + makedirs(folder) diff --git a/frequenpy/loaded_string/loaded_string.py b/frequenpy/loaded_string/loaded_string.py index 297023f..4a283ec 100644 --- a/frequenpy/loaded_string/loaded_string.py +++ b/frequenpy/loaded_string/loaded_string.py @@ -22,6 +22,9 @@ def __init__(self, N, modes): self.rest_position = self._get_rest_position() + def __len__(self): + return self.N + def position_at_time_t(self, t): X, _ = self.rest_position Y = self._y_position_at_time_t(t) @@ -70,7 +73,7 @@ def _standing_wave_equation(self, A, k, n, a, phi, omega, t, theta): @abstractmethod def _wavenumber(self, p): - pass + pass # pragma: nocover class LoadedStringFixed(LoadedString): diff --git a/frequenpy/loaded_string/loaded_string_factory.py b/frequenpy/loaded_string/loaded_string_factory.py index ce71ae4..2736250 100644 --- a/frequenpy/loaded_string/loaded_string_factory.py +++ b/frequenpy/loaded_string/loaded_string_factory.py @@ -18,7 +18,7 @@ def create(N, modes, boundary): if boundary == BOUNDARY_FREE: return LoadedStringFree(N, modes) else: - raise NotImplementedError( + raise ValueError( "{} is not a valid boundary condition".format(boundary) ) diff --git a/requirements.txt b/requirements.txt index ba70fb3..7023c43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flake8<7 +pytest-cov<5 -e . diff --git a/tests/loaded_string/test_animation.py b/tests/loaded_string/test_animation.py new file mode 100644 index 0000000..a4b601c --- /dev/null +++ b/tests/loaded_string/test_animation.py @@ -0,0 +1,18 @@ +import os + +from matplotlib import pyplot + +from frequenpy.loaded_string.animation import LoadedStringAnimation + + +def test_animation(monkeypatch, tmp_path): + anim = LoadedStringAnimation.build(N=5, modes=[1, 2], boundary=0, n_frames=1, folder=tmp_path) + monkeypatch.setattr(pyplot, "show", lambda *x, **y: 0) + anim.start() + + filepath = anim.start(save=True) + assert os.path.exists(filepath) + + folder = os.path.join(tmp_path, 'new') + anim = LoadedStringAnimation.build(N=30, modes=[1], boundary=0, n_frames=1, folder=folder) + anim.start(save=True) diff --git a/tests/loaded_string/test_loaded_string.py b/tests/loaded_string/test_loaded_string.py new file mode 100644 index 0000000..d065b57 --- /dev/null +++ b/tests/loaded_string/test_loaded_string.py @@ -0,0 +1,40 @@ +import pytest +import numpy as np + +from frequenpy.loaded_string import loaded_string_factory + + +def test_loaded_string_factory(): + + loaded_string_factory.create(N=5, modes=[1, 2], boundary=0) + loaded_string_factory.create(N=5, modes=[1, 2], boundary=1) + loaded_string_factory.create(N=5, modes=[1, 2], boundary=2) + + with pytest.raises(ValueError): + loaded_string_factory.create(N=5, modes=[1, 2], boundary=3) + + with pytest.raises(ValueError): + loaded_string_factory.create(N=7, modes=[1, 2], boundary=0) + + with pytest.raises(ValueError): + loaded_string_factory.create(N=2, modes=[1, 2, 3], boundary=0) + + with pytest.raises(ValueError): + loaded_string_factory.create(N=2, modes=[1, 3], boundary=0) + + with pytest.raises(ValueError): + loaded_string_factory.create(N=2, modes=[0, 2], boundary=0) + + +def test_loaded_string(): + ls = loaded_string_factory.create(N=5, modes=[1, 2], boundary=0) + + assert len(ls) == 5 + assert ls.modes == '1-2' + + ls.position_at_time_t(1) + + ls.apply_speed(2) + + _, Y = ls.rest_position + assert np.all(Y == 0) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..67d5f72 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,26 @@ +import pytest + +from frequenpy import cli +from frequenpy.loaded_string.animation import LoadedStringAnimation + + +class AnimationMock: + def start(self, *args, **kwargs): + pass + + +def test_cli(monkeypatch): + monkeypatch.setattr(LoadedStringAnimation, 'build', lambda *args, **kwargs: AnimationMock()) + + cli.run([ + 'loaded_string', '--masses', '5', '--modes', '1', '2', '--speed', '0.1', '--boundary', '0' + ]) + + with pytest.raises(SystemExit): + cli.run([]) + + with pytest.raises(ValueError): + cli.parse_args(['invalid']) + + with pytest.raises(AssertionError): + cli.run(['loaded_string'])