From 88f60c2f57cb8142cd6b170e465fb6707d1da668 Mon Sep 17 00:00:00 2001 From: "Josef M. Galletzer" Date: Tue, 28 May 2024 10:25:14 +0200 Subject: [PATCH 01/27] Added benchmark groups --- benchmarks/core/benchmark_cell.py | 12 ++++++------ benchmarks/core/benchmark_traj_reader.py | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/benchmarks/core/benchmark_cell.py b/benchmarks/core/benchmark_cell.py index e4944fe2..c1d76105 100644 --- a/benchmarks/core/benchmark_cell.py +++ b/benchmarks/core/benchmark_cell.py @@ -1,19 +1,19 @@ +import pytest import numpy as np from PQAnalysis.core.cell import Cell + +@pytest.mark.benchmark(group="Cell") class BenchmarkCell: + def benchmark_image_orthorhombic(self, benchmark): - cell = Cell( - 10, 10, 10, 90, 90, 90 - ) + cell = Cell(10, 10, 10, 90, 90, 90) pos = np.array([5, 5, 5]) benchmark(cell.image, pos) def benchmark_image_triclinic(self, benchmark): - cell = Cell( - 10, 10, 10, 90.00000000001, 90.00000000001, 90.00000000001 - ) + cell = Cell(10, 10, 10, 90.00000000001, 90.00000000001, 90.00000000001) pos = np.array([5, 5, 5]) benchmark(cell.image, pos) diff --git a/benchmarks/core/benchmark_traj_reader.py b/benchmarks/core/benchmark_traj_reader.py index 9a3b6bdb..9c4866ce 100644 --- a/benchmarks/core/benchmark_traj_reader.py +++ b/benchmarks/core/benchmark_traj_reader.py @@ -1,7 +1,10 @@ +import pytest + from PQAnalysis.io.traj_file import TrajectoryReader +@pytest.mark.benchmark(group="TrajectoryReader") class BenchmarkTrajReader: def benchmark_read_2frames(self, benchmark): From 978a6d491158b7127eca43af83c027dc322f8b8c Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Tue, 28 May 2024 13:03:23 +0200 Subject: [PATCH 02/27] added possibility to add comments when averaging equivalents --- PQAnalysis/topology/shake_topology.py | 44 +++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/PQAnalysis/topology/shake_topology.py b/PQAnalysis/topology/shake_topology.py index 6aa9822d..2661a475 100644 --- a/PQAnalysis/topology/shake_topology.py +++ b/PQAnalysis/topology/shake_topology.py @@ -2,6 +2,7 @@ A module containing the ShakeTopologyGenerator class. """ +import logging import numpy as np from beartype.typing import List @@ -10,6 +11,9 @@ from PQAnalysis.types import Np1DIntArray, Np2DIntArray from PQAnalysis.io import BaseWriter, FileWritingMode from PQAnalysis.type_checking import runtime_type_checking +from PQAnalysis.utils.custom_logging import setup_logger +from PQAnalysis.exceptions import PQValueError +from PQAnalysis import __package_name__ from .selection import SelectionCompatible, Selection @@ -21,6 +25,9 @@ class ShakeTopologyGenerator: A class for generating the shake topology for a given trajectory """ + logger = logging.getLogger(__package_name__).getChild(__qualname__) + logger = setup_logger(logger) + @runtime_type_checking def __init__( self, @@ -46,6 +53,7 @@ def __init__( self.target_indices = None self.distances = None self._topology = None + self.line_comments = None @runtime_type_checking def generate_topology(self, trajectory: Trajectory) -> None: @@ -69,8 +77,7 @@ def generate_topology(self, trajectory: Trajectory) -> None: self._topology = trajectory.topology indices = self.selection.select( - self._topology, - self._use_full_atom_info + self._topology, self._use_full_atom_info ) target_indices, distances = atomic_system.nearest_neighbours( @@ -96,7 +103,8 @@ def generate_topology(self, trajectory: Trajectory) -> None: @runtime_type_checking def average_equivalents( self, - indices: List[Np1DIntArray] | Np2DIntArray + indices: List[Np1DIntArray] | Np2DIntArray, + comments: List[str] | None = None ) -> None: """ Averages the distances for equivalent atoms. @@ -109,6 +117,8 @@ def average_equivalents( ---------- indices : List[Np1DIntArray] | Np2DIntArray The indices of the equivalent atoms. + comments : List[str], optional + The comments for the topology, by default None """ for equivalent_indices in indices: @@ -118,6 +128,19 @@ def average_equivalents( self.distances[_indices] = mean_distance + if comments is not None: + if len(comments) != len(indices): + self.logger.error( + "The number of comments does not match the number of indices.", + exception=PQValueError + ) + + self.line_comments = [] + + for i, equivalent_indices in enumerate(indices): + for _ in equivalent_indices: + self.line_comments.append(comments[i]) + @runtime_type_checking def write_topology( self, @@ -147,8 +170,8 @@ def write_topology( print( ( - f"SHAKE {len(self.indices)} " - f"{len(np.unique(self.target_indices))} 0" + f"SHAKE {len(self.indices)} " + f"{len(np.unique(self.target_indices))} 0" ), file=writer.file ) @@ -157,7 +180,16 @@ def write_topology( target_index = self.target_indices[i] distance = self.distances[i] - print(f"{index+1} {target_index+1} {distance}", file=writer.file) + print( + f"{index+1} {target_index+1} {distance}", + end="", + file=writer.file + ) + + if self.line_comments is not None: + print(f" # {self.line_comments[i]}", file=writer.file) + else: + print("", file=writer.file) print("END", file=writer.file) From 1bec238428404cfc6aff87420c1b4fcb35eda646 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Tue, 28 May 2024 13:29:52 +0200 Subject: [PATCH 03/27] small bug fix for adding comments --- PQAnalysis/topology/shake_topology.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PQAnalysis/topology/shake_topology.py b/PQAnalysis/topology/shake_topology.py index 2661a475..d25d1a4f 100644 --- a/PQAnalysis/topology/shake_topology.py +++ b/PQAnalysis/topology/shake_topology.py @@ -135,11 +135,11 @@ def average_equivalents( exception=PQValueError ) - self.line_comments = [] + self.line_comments = [""] * len(self.indices) for i, equivalent_indices in enumerate(indices): for _ in equivalent_indices: - self.line_comments.append(comments[i]) + self.line_comments[equivalent_indices[i]] = comments[i] @runtime_type_checking def write_topology( From ed04e19f1b4c6d2974bd3c7e89d7929fd8821db9 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Tue, 28 May 2024 13:39:09 +0200 Subject: [PATCH 04/27] small bug fix for adding comments --- PQAnalysis/topology/shake_topology.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/PQAnalysis/topology/shake_topology.py b/PQAnalysis/topology/shake_topology.py index d25d1a4f..fe3c880b 100644 --- a/PQAnalysis/topology/shake_topology.py +++ b/PQAnalysis/topology/shake_topology.py @@ -138,8 +138,12 @@ def average_equivalents( self.line_comments = [""] * len(self.indices) for i, equivalent_indices in enumerate(indices): - for _ in equivalent_indices: - self.line_comments[equivalent_indices[i]] = comments[i] + _indices = np.nonzero( + np.in1d(self.indices, equivalent_indices) + )[0] + + for index in _indices: + self.line_comments[index] = comments[i] @runtime_type_checking def write_topology( From f556c9581d0781e02df43d984e3064a3b15bfdb0 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Wed, 29 May 2024 00:12:01 +0200 Subject: [PATCH 05/27] chore: Add `isclose` method to Cell class for comparing cell objects --- PQAnalysis/core/cell/cell.py | 65 +++++++++++++++++------ tests/core/test_cell.py | 100 ++++++++++------------------------- 2 files changed, 76 insertions(+), 89 deletions(-) diff --git a/PQAnalysis/core/cell/cell.py b/PQAnalysis/core/cell/cell.py index 2db3cfd2..935b1447 100644 --- a/PQAnalysis/core/cell/cell.py +++ b/PQAnalysis/core/cell/cell.py @@ -13,7 +13,7 @@ from beartype.vale import Is from PQAnalysis.type_checking import runtime_type_checking -from PQAnalysis.types import Np3x3NumberArray, Np2DNumberArray, NpnDNumberArray +from PQAnalysis.types import Np3x3NumberArray, Np2DNumberArray, NpnDNumberArray, PositiveReal from ._standard_properties import _StandardPropertiesMixin @@ -98,7 +98,8 @@ def bounding_edges(self) -> Np2DNumberArray: for i, x in enumerate([-0.5, 0.5]): for j, y in enumerate([-0.5, 0.5]): for k, z in enumerate([-0.5, 0.5]): - edges[i*4+j*2+k, :] = self.box_matrix @ np.array([x, y, z]) + edges[i * 4 + j * 2 + + k, :] = self.box_matrix @ np.array([x, y, z]) return edges @@ -107,8 +108,7 @@ def volume(self) -> Real: """volume: The volume of the unit cell.""" with warnings.catch_warnings(): warnings.filterwarnings( - "ignore", - message="overflow encountered in det" + "ignore", message="overflow encountered in det" ) volume = np.linalg.det(self.box_matrix) @@ -169,12 +169,49 @@ def __eq__(self, other: Any) -> bool: True if the Cells are equal, False otherwise. """ + return self.isclose(other) + + def isclose( + self, + other: Any, + rtol: PositiveReal = 1e-5, + atol: PositiveReal = 1e-8, + ) -> bool: + """ + Checks if the Cell is close to another Cell. + + Parameters + ---------- + other : Cell + The Cell to compare with. + rtol : PositiveReal, optional + The relative tolerance parameter. + atol : PositiveReal, optional + The absolute tolerance parameter. + + Returns + ------- + bool + True if the Cells are close, False otherwise. + """ + if not isinstance(other, Cell): return False - is_equal = True - is_equal &= np.allclose(self.box_lengths, other.box_lengths) - is_equal &= np.allclose(self.box_angles, other.box_angles) + is_equal = np.allclose( + self.box_lengths, + other.box_lengths, + rtol=rtol, + atol=atol, + ) + + is_equal &= np.allclose( + self.box_angles, + other.box_angles, + rtol=rtol, + atol=atol, + ) + return is_equal def __str__(self) -> str: @@ -233,8 +270,8 @@ def init_from_box_matrix(cls, box_matrix: Np3x3NumberArray) -> "Cell": beta = np.arccos(box_matrix[0][2] / z) alpha = np.arccos( ( - box_matrix[0][1] * box_matrix[0][2] + - box_matrix[1][1] * box_matrix[1][2] + box_matrix[0][1] * box_matrix[0][2] + + box_matrix[1][1] * box_matrix[1][2] ) / (y * z) ) @@ -242,12 +279,7 @@ def init_from_box_matrix(cls, box_matrix: Np3x3NumberArray) -> "Cell": print(np.rad2deg(alpha), np.rad2deg(beta), np.rad2deg(gamma)) return cls( - x, - y, - z, - np.rad2deg(alpha), - np.rad2deg(beta), - np.rad2deg(gamma) + x, y, z, np.rad2deg(alpha), np.rad2deg(beta), np.rad2deg(gamma) ) @@ -256,6 +288,5 @@ def init_from_box_matrix(cls, box_matrix: Np3x3NumberArray) -> "Cell": Cells = NewType( "Cells", Annotated[list, - Is[lambda list: all(isinstance(atom, - Cell) for atom in list)]] + Is[lambda list: all(isinstance(atom, Cell) for atom in list)]] ) diff --git a/tests/core/test_cell.py b/tests/core/test_cell.py index f3a6db41..aa937e29 100644 --- a/tests/core/test_cell.py +++ b/tests/core/test_cell.py @@ -18,16 +18,7 @@ def test__init__(self): assert cell.beta == 90 assert cell.gamma == 90 assert np.allclose( - cell.box_matrix, - np.array([[1, - 0, - 0], - [0, - 2, - 0], - [0, - 0, - 3]]) + cell.box_matrix, np.array([[1, 0, 0], [0, 2, 0], [0, 0, 3]]) ) cell = Cell(1, 2, 3, 60, 90, 120) @@ -40,15 +31,7 @@ def test__init__(self): assert np.allclose( cell.box_matrix, np.array( - [[1, - -1, - 0], - [0, - 1.73205081, - 1.73205081], - [0, - 0, - 2.44948974]] + [[1, -1, 0], [0, 1.73205081, 1.73205081], [0, 0, 2.44948974]] ) ) @@ -61,16 +44,7 @@ def test_set_box_lengths(self): cell.box_lengths = np.array([2, 3, 4]) assert np.allclose(cell.box_lengths, np.array([2, 3, 4])) assert np.allclose( - cell.box_matrix, - np.array([[2, - 0, - 0], - [0, - 3, - 0], - [0, - 0, - 4]]) + cell.box_matrix, np.array([[2, 0, 0], [0, 3, 0], [0, 0, 4]]) ) def test_box_angles(self): @@ -84,15 +58,7 @@ def test_set_box_angles(self): assert np.allclose( cell.box_matrix, np.array( - [[1, - -1, - 0], - [0, - 1.73205081, - 1.73205081], - [0, - 0, - 2.44948974]] + [[1, -1, 0], [0, 1.73205081, 1.73205081], [0, 0, 2.44948974]] ) ) @@ -102,7 +68,8 @@ def test_volume(self): assert np.isclose( cell.volume, np.prod(cell.box_lengths) * np.sqrt( - 1 - sum(np.cos(box_angles)**2) + 2 * np.prod(np.cos(box_angles)) + 1 - sum(np.cos(box_angles)**2) + + 2 * np.prod(np.cos(box_angles)) ) ) @@ -119,6 +86,7 @@ def test_bounding_edges(self): assert np.allclose(edges[7], np.array([0, 1.73205081, 1.22474487])) def test__eq__(self): + cell1 = Cell(1, 2, 3, 60, 90, 120) cell2 = Cell(1, 2, 3, 60, 90, 120) assert cell1 == cell2 @@ -130,39 +98,35 @@ def test__eq__(self): cell1 = Cell(1, 2, 3, 60, 90, 120) assert cell1 != 1 + cell1 = Cell(1, 2, 3, 60, 90, 120) + cell2 = Cell(1, 3, 3, 60, 90, 120) + + assert cell1 != cell2 + + def test_isclose(self): + cell1 = Cell(1, 2, 3, 60, 90, 120.1) + cell2 = Cell(1, 2, 3, 60, 90, 120.0) + assert cell1.isclose(cell2, atol=1e-1) + assert not cell1.isclose(cell2, atol=1e-2) + assert cell1.isclose(cell2, rtol=1e-3) + assert not cell1.isclose(cell2, rtol=1e-4) + def test_image(self): cell = Cell(1, 2, 3, 60, 90, 120) assert np.allclose( - cell.image(np.array([0, - 0, - 0])), - np.array([0, - 0, - 0]) + cell.image(np.array([0, 0, 0])), np.array([0, 0, 0]) ) assert np.allclose( - cell.image(np.array([0.75, - 0.5, - 0.5])), - np.array([-0.25, - 0.5, - 0.5]) + cell.image(np.array([0.75, 0.5, 0.5])), + np.array([-0.25, 0.5, 0.5]) ) assert np.allclose( - cell.image(np.array([1, - 2, - 3])), - np.array([0., - 0.267949192, - 0.550510257]) + cell.image(np.array([1, 2, 3])), + np.array([0., 0.267949192, 0.550510257]) ) assert np.allclose( - cell.image(np.array([-1, - -2, - -3])), - np.array([0., - -0.267949192, - -0.550510257]) + cell.image(np.array([-1, -2, -3])), + np.array([0., -0.267949192, -0.550510257]) ) def test__str__(self): @@ -189,15 +153,7 @@ def test_init_from_box_matrix(self): assert np.isclose(cell.volume, 6) box_matrix = np.array( - [[1, - -1, - 0], - [0, - 1.73205081, - 1.73205081], - [0, - 0, - 2.44948974]] + [[1, -1, 0], [0, 1.73205081, 1.73205081], [0, 0, 2.44948974]] ) cell = Cell.init_from_box_matrix(box_matrix) assert np.isclose(cell.x, 1) From 26ede9d3301b114c8888cda04cc2eeb8a99d6efb Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Wed, 29 May 2024 00:12:07 +0200 Subject: [PATCH 06/27] chore: Add isclose method to AtomicSystem class for comparing AtomicSystem objects --- PQAnalysis/atomic_system/atomic_system.py | 38 +++++++++++++++++--- tests/atomicSystem/test_atomic_system.py | 42 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/PQAnalysis/atomic_system/atomic_system.py b/PQAnalysis/atomic_system/atomic_system.py index 3e1d19d2..e6ffa087 100644 --- a/PQAnalysis/atomic_system/atomic_system.py +++ b/PQAnalysis/atomic_system/atomic_system.py @@ -547,6 +547,34 @@ def __eq__(self, other: Any) -> bool: bool Whether the AtomicSystem is equal to the other AtomicSystem. """ + + return self.isclose(other) + + @runtime_type_checking + def isclose( + self, + other: Any, + rtol: PositiveReal = 1e-5, + atol: PositiveReal = 1e-8, + ) -> bool: + """ + Checks whether the AtomicSystem is close to another AtomicSystem. + + Parameters + ---------- + other : AtomicSystem + The other AtomicSystem to compare to. + rtol : PositiveReal, optional + The relative tolerance, by default 1e-5 + atol : PositiveReal, optional + The absolute tolerance, by default 1e-8 + + Returns + ------- + bool + Whether the AtomicSystem is close to the other AtomicSystem. + """ + if not isinstance(other, AtomicSystem): return False @@ -556,19 +584,19 @@ def __eq__(self, other: Any) -> bool: if self.topology != other.topology: return False - if self.cell != other.cell: + if not self.cell.isclose(other.cell, rtol=rtol, atol=atol): return False - if not np.allclose(self.pos, other.pos): + if not np.allclose(self.pos, other.pos, rtol=rtol, atol=atol): return False - if not np.allclose(self.vel, other.vel): + if not np.allclose(self.vel, other.vel, rtol=rtol, atol=atol): return False - if not np.allclose(self.forces, other.forces): + if not np.allclose(self.forces, other.forces, rtol=rtol, atol=atol): return False - if not np.allclose(self.charges, other.charges): + if not np.allclose(self.charges, other.charges, rtol=rtol, atol=atol): return False return True diff --git a/tests/atomicSystem/test_atomic_system.py b/tests/atomicSystem/test_atomic_system.py index 45f4484a..8cd02c5d 100644 --- a/tests/atomicSystem/test_atomic_system.py +++ b/tests/atomicSystem/test_atomic_system.py @@ -220,6 +220,48 @@ def test__eq__(self): assert system1 != system2 assert system3 != system5 + def test_isclose(self): + system3 = AtomicSystem( + pos=np.array([[0, 0, 0], [1, 1, 1]]), + atoms=[Atom('C'), Atom('H')], + cell=Cell(0.75, 0.75, 0.75) + ) + system4 = AtomicSystem( + pos=np.array([[0.00001, 0, 0], [1, 1.00001, 1]]), + atoms=[Atom('C'), Atom('H')], + cell=Cell(0.75, 0.750001, 0.75) + ) + system5 = AtomicSystem( + pos=np.array([[0, 0.01, 0], [1, 1.01, 1]]), + atoms=[Atom('C'), Atom('H')], + cell=Cell(0.75, 0.76, 0.75) + ) + + assert system3.isclose(system4, atol=1e-5) + assert system3.isclose(system4, rtol=1) + assert not system3.isclose(system4, atol=1e-6) + assert not system3.isclose(system4, rtol=1e-6) + + assert system3.isclose(system5, atol=1e-1) + assert not system3.isclose(system5, rtol=1e-1) + assert not system3.isclose(system5, atol=1e-3) + assert not system3.isclose(system5, rtol=1e-2) + + system1 = AtomicSystem( + pos=np.array([[0.0, 0, 0], [1, 100.1, 1]]), + atoms=[Atom('C'), Atom('H')] + ) + + system2 = AtomicSystem( + pos=np.array([[0.0, 0, 0], [1, 100.11, 1]]), + atoms=[Atom('C'), Atom('H')] + ) + + assert system1.isclose(system2, atol=1.1e-2) + assert system1.isclose(system2, rtol=1.1e-4) + assert not system1.isclose(system2, atol=1.0e-3) + assert not system1.isclose(system2, rtol=1.0e-5) + def test__getitem__(self): system = AtomicSystem( pos=np.array([[0, 0, 0], [1, 1, 1]]), From 041a5fcb0674c8aaa35d4d459cf5f080426b916e Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Wed, 29 May 2024 00:28:08 +0200 Subject: [PATCH 07/27] feat: Add functionality to add line comments to the topology --- PQAnalysis/topology/shake_topology.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/PQAnalysis/topology/shake_topology.py b/PQAnalysis/topology/shake_topology.py index fe3c880b..eeea5b87 100644 --- a/PQAnalysis/topology/shake_topology.py +++ b/PQAnalysis/topology/shake_topology.py @@ -118,7 +118,7 @@ def average_equivalents( indices : List[Np1DIntArray] | Np2DIntArray The indices of the equivalent atoms. comments : List[str], optional - The comments for the topology, by default None + The line comments for the averaged distances, by default None """ for equivalent_indices in indices: @@ -145,6 +145,29 @@ def average_equivalents( for index in _indices: self.line_comments[index] = comments[i] + @runtime_type_checking + def add_comments(self, comments: List[str]) -> None: + """ + Adds comments to the topology. + + Parameters + ---------- + comments : List[str] + The comments to add to each line of the shake topology. + """ + + if len(comments) != len(self.indices): + self.logger.error( + "The number of comments does not match the number of indices.", + exception=PQValueError + ) + + if self.line_comments is None: + self.line_comments = [""] * len(self.indices) + + for i, comment in enumerate(comments): + self.line_comments[i] = comment + @runtime_type_checking def write_topology( self, From 8bc5c188ddaa64f284016aacba6d76aedd411e52 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Wed, 29 May 2024 00:40:14 +0200 Subject: [PATCH 08/27] Add line comments functionality to the topology generator tests --- PQAnalysis/topology/shake_topology.py | 2 +- tests/topology/test_shakeTopology.py | 161 +++++++++++--------------- 2 files changed, 70 insertions(+), 93 deletions(-) diff --git a/PQAnalysis/topology/shake_topology.py b/PQAnalysis/topology/shake_topology.py index eeea5b87..bb3df741 100644 --- a/PQAnalysis/topology/shake_topology.py +++ b/PQAnalysis/topology/shake_topology.py @@ -156,7 +156,7 @@ def add_comments(self, comments: List[str]) -> None: The comments to add to each line of the shake topology. """ - if len(comments) != len(self.indices): + if self.indices is None or len(comments) != len(self.indices): self.logger.error( "The number of comments does not match the number of indices.", exception=PQValueError diff --git a/tests/topology/test_shakeTopology.py b/tests/topology/test_shakeTopology.py index 6b66a7e3..8655c53b 100644 --- a/tests/topology/test_shakeTopology.py +++ b/tests/topology/test_shakeTopology.py @@ -1,11 +1,15 @@ +import pytest import numpy as np -from . import pytestmark - from PQAnalysis.topology.shake_topology import ShakeTopologyGenerator from PQAnalysis.core import Atom from PQAnalysis.atomic_system import AtomicSystem from PQAnalysis.traj import Trajectory +from PQAnalysis.exceptions import PQValueError + +from . import pytestmark # pylint: disable=unused-import + +#pylint: disable=protected-access @@ -31,38 +35,10 @@ def test__init__(self): def test_generate_topology(self): atoms = [Atom('C'), Atom('H'), Atom('H'), Atom('O'), Atom('H')] pos = np.array( - [[0.1, - 0, - 0], - [1, - 0, - 0], - [2.1, - 0, - 0], - [3, - 0, - 0], - [4, - 0, - 0]] + [[0.1, 0, 0], [1, 0, 0], [2.1, 0, 0], [3, 0, 0], [4, 0, 0]] ) pos2 = np.array( - [[0.5, - 0, - 0], - [1, - 0.5, - 0], - [2.5, - 0, - 0], - [3, - 0.5, - 0], - [4.5, - 0, - 0]] + [[0.5, 0, 0], [1, 0.5, 0], [2.5, 0, 0], [3, 0.5, 0], [4.5, 0, 0]] ) system = AtomicSystem(pos=pos, atoms=atoms) system2 = AtomicSystem(pos=pos2, atoms=atoms) @@ -80,39 +56,11 @@ def test_generate_topology(self): def test_average_equivalents(self): atoms = [Atom('C'), Atom('H'), Atom('H'), Atom('O'), Atom('H')] pos = np.array( - [[0.1, - 0, - 0], - [1, - 0, - 0], - [2.1, - 0, - 0], - [3, - 0, - 0], - [4, - 0, - 0]] + [[0.1, 0, 0], [1, 0, 0], [2.1, 0, 0], [3, 0, 0], [4, 0, 0]] ) pos2 = np.array( - [[0.5, - 0, - 0], - [1, - 0.5, - 0], - [2.5, - 0, - 0], - [3, - 0.5, - 0], - [4.5, - 0, - 0]] + [[0.5, 0, 0], [1, 0.5, 0], [2.5, 0, 0], [3, 0.5, 0], [4.5, 0, 0]] ) system = AtomicSystem(pos=pos, atoms=atoms) @@ -129,43 +77,41 @@ def test_average_equivalents(self): assert np.allclose(indices, [1, 2, 4]) assert np.allclose(target_indices, [0, 3, 3]) assert np.allclose(distances, [0.80355339, 1.047061405, 1.047061405]) + assert generator.line_comments is None + + generator.average_equivalents( + [np.array([1]), np.array([2, 4])], + comments=['test', 'test2'], + ) + assert generator.line_comments == ['test', 'test2', 'test2'] + + def test_add_comments(self): + generator = ShakeTopologyGenerator() + with pytest.raises(PQValueError) as exception: + generator.add_comments(['test', 'test2']) + assert str( + exception.value + ) == "The number of comments does not match the number of indices." + + generator.indices = np.array([1, 2, 3]) + + with pytest.raises(PQValueError) as exception: + generator.add_comments(['test', 'test2']) + assert str( + exception.value + ) == "The number of comments does not match the number of indices." + + generator.add_comments(['test', 'test2', 'test3']) + assert generator.line_comments == ['test', 'test2', 'test3'] def test_write_topology(self, capsys): atoms = [Atom('C'), Atom('H'), Atom('H'), Atom('O'), Atom('H')] pos = np.array( - [[0.1, - 0, - 0], - [1, - 0, - 0], - [2.1, - 0, - 0], - [3, - 0, - 0], - [4, - 0, - 0]] + [[0.1, 0, 0], [1, 0, 0], [2.1, 0, 0], [3, 0, 0], [4, 0, 0]] ) pos2 = np.array( - [[0.5, - 0, - 0], - [1, - 0.5, - 0], - [2.5, - 0, - 0], - [3, - 0.5, - 0], - [4.5, - 0, - 0]] + [[0.5, 0, 0], [1, 0.5, 0], [2.5, 0, 0], [3, 0.5, 0], [4.5, 0, 0]] ) system = AtomicSystem(pos=pos, atoms=atoms) @@ -186,4 +132,35 @@ def test_write_topology(self, capsys): 3 4 0.8035533905932737 5 4 1.290569415042095 END +""" + + print() + + generator.add_comments(['test', 'test2', 'test3']) + generator.write_topology() + + captured = capsys.readouterr() + assert captured.out == """ +SHAKE 3 2 0 +2 1 0.8035533905932738 # test +3 4 0.8035533905932737 # test2 +5 4 1.290569415042095 # test3 +END +""" + + print() + + generator.average_equivalents( + [np.array([1]), np.array([2, 4])], + comments=['test', 'test2'], + ) + generator.write_topology() + + captured = capsys.readouterr() + assert captured.out == """ +SHAKE 3 2 0 +2 1 0.8035533905932738 # test +3 4 1.0470614028176843 # test2 +5 4 1.0470614028176843 # test2 +END """ From 22b5ff2b8381f993291226a048bc632f98934069 Mon Sep 17 00:00:00 2001 From: "Josef M. Galletzer" Date: Wed, 29 May 2024 08:11:06 +0200 Subject: [PATCH 09/27] feat: Add isclose method to Trajectory class for comparing trajectories --- PQAnalysis/traj/trajectory.py | 35 +++++++++++++++++++++++++++++++++-- tests/traj/test_trajectory.py | 27 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/PQAnalysis/traj/trajectory.py b/PQAnalysis/traj/trajectory.py index cd6b2e06..88418b23 100644 --- a/PQAnalysis/traj/trajectory.py +++ b/PQAnalysis/traj/trajectory.py @@ -8,7 +8,7 @@ from beartype.typing import List, Any, Iterable from PQAnalysis.topology import Topology -from PQAnalysis.types import Np2DNumberArray, Np1DNumberArray +from PQAnalysis.types import Np2DNumberArray, Np1DNumberArray, PositiveReal from PQAnalysis.exceptions import PQIndexError, PQTypeError from PQAnalysis.core import Cell from PQAnalysis.atomic_system import AtomicSystem @@ -344,13 +344,44 @@ def __eq__(self, other: Any) -> bool: The other trajectory to compare. """ + return self.isclose(other) + + def isclose( + self, + other: "Trajectory", + rtol: PositiveReal = 1e-5, + atol: PositiveReal = 1e-8, + ) -> bool: + """ + This method allows two trajectories to be compared for closeness. + + Parameters + ---------- + other : Trajectory + The other trajectory to compare. + rtol : PositiveReal, optional + The relative tolerance parameter, by default 1e-5 + atol : PositiveReal, optional + The absolute tolerance parameter, by default 1e-8 + + Returns + ------- + bool + Whether the two trajectories are close. + """ + if not isinstance(other, Trajectory): return False if len(self.frames) != len(other.frames): return False - return self.frames == other.frames + return np.all( + [ + self.frames[i].isclose(other.frames[i], rtol=rtol, atol=atol) + for i in np.arange(len(self.frames)) + ] + ) def __str__(self) -> str: """ diff --git a/tests/traj/test_trajectory.py b/tests/traj/test_trajectory.py index 02d1f414..bf320336 100644 --- a/tests/traj/test_trajectory.py +++ b/tests/traj/test_trajectory.py @@ -309,6 +309,33 @@ def test__eq__(self): assert Trajectory() != 1 + def test_isclose(self): + frame1 = AtomicSystem( + atoms=self.atoms1, + pos=np.array([[1.0001, 1.0002, 2.0003]]), + cell=Cell(100.1, 100.1, 100.1) + ) + frame2 = AtomicSystem( + atoms=self.atoms1, + pos=np.array([[1.0002, 1.0003, 2.0004]]), + cell=Cell(100.1001, 100.1001, 100.1001) + ) + frame3 = AtomicSystem( + atoms=self.atoms1, + pos=np.array([[1.0001, 1.0002, 2.0003]]), + cell=Cell(100.2, 100.2, 100.2) + ) + + traj1 = Trajectory([frame1, frame1]) + traj2 = Trajectory([frame2, frame2]) + traj3 = Trajectory([frame3, frame3]) + + assert traj1.isclose(traj2, atol=1e-4) + assert not traj1.isclose(traj2, atol=1e-5) + + assert traj1.isclose(traj3, rtol=1e-3) + assert not traj1.isclose(traj3, rtol=1e-4) + def test_append(self): traj = Trajectory() print(len(traj)) From b70f7efe0888140a1b8524d848eda0bb77d4ed2a Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 01:24:19 +0200 Subject: [PATCH 10/27] feat: Add vectorized allclose function for element-wise comparison of numpy arrays --- PQAnalysis/utils/math.py | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 PQAnalysis/utils/math.py diff --git a/PQAnalysis/utils/math.py b/PQAnalysis/utils/math.py new file mode 100644 index 00000000..8dd518c3 --- /dev/null +++ b/PQAnalysis/utils/math.py @@ -0,0 +1,54 @@ +""" +A collection of utility functions for mathematical operations. +""" + +import math +import numpy as np + +from PQAnalysis.types import PositiveReal + + + +def allclose_vectorized( + a, + b, + rtol: PositiveReal = 1e-09, + atol: PositiveReal = 0.0, +): + """ + Perform element-wise comparison of two arrays to determine if they + are equal within a tolerance. This function is a vectorized version + of `math.isclose` that can be applied to numpy arrays. + + This function uses the full symmetric definition of `math.isclose` to + compare two arrays, by applying the following formula element-wise: + + .. math:: + + |a - b| <= max(rtol * max(|a|, |b|), atol)) + + For a full discussion on why this function is needed, and replaces `np.allclose`, + see [pull request #74](https://github.com/MolarVerse/PQAnalysis/pull/74) + + Parameters + ---------- + a: np.ndarray + first array to compare + b : np.ndarray + second array to compare + rtol : PositiveReal, optional + the relative tolerance parameter, by default 1e-09 + atol : PositiveReal, optional + the absolute tolerance parameter, by default 0.0 + + Returns + ------- + _type_ + _description_ + """ + + isclose_vectorized = np.vectorize(math.isclose) + + result = isclose_vectorized(a, b, rel_tol=rtol, abs_tol=atol) + + return np.all(result) From 088c7bc9ed1ee7a24170f4b78414dde30f7503ec Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 01:24:31 +0200 Subject: [PATCH 11/27] chore: Add unit test for allclose_vectorized function in test_math.py --- tests/utils/test_math.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/utils/test_math.py diff --git a/tests/utils/test_math.py b/tests/utils/test_math.py new file mode 100644 index 00000000..1381a150 --- /dev/null +++ b/tests/utils/test_math.py @@ -0,0 +1,39 @@ +import numpy as np + +from PQAnalysis.utils.math import allclose_vectorized + +from . import pytestmark + + + +def test_allclose_vectorized(): + a = np.array([1, 2, 3]) + b = np.array([1, 2, 3]) + + assert allclose_vectorized(a, b) + + a = np.array([1, 2, 3]) + b = np.array([1, 2, 3.0000009]) + + assert not allclose_vectorized(a, b) + assert allclose_vectorized(a, b, atol=1e-6) + assert not allclose_vectorized(a, b, atol=1e-7) + assert allclose_vectorized(a, b, rtol=1e-6) + assert not allclose_vectorized(a, b, rtol=1e-7) + + a = np.array([1, 2, 10]) + b = np.array([1, 2, 10.000001]) + + assert not allclose_vectorized(a, b, atol=0.9e-6) + assert allclose_vectorized(a, b, atol=1e-6) + assert allclose_vectorized(a, b, rtol=1e-6) + assert allclose_vectorized(a, b, rtol=1.1e-6) + assert not allclose_vectorized(a, b, rtol=0.9e-7) + + assert allclose_vectorized(a, b, atol=0.9e-6, rtol=1e-6) + assert allclose_vectorized(a, b, atol=1e-6, rtol=0.9e-7) + assert not allclose_vectorized(a, b, atol=0.9e-6, rtol=0.9e-7) + + + +# Compare this snippet from tests/utils/test_math.py: From f21e07d5f4050c9093aa011c3742ed35bed4b114 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 01:24:38 +0200 Subject: [PATCH 12/27] chore: Add utils marker to pytest.ini --- pytest.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/pytest.ini b/pytest.ini index 19dace22..88269560 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,6 +6,7 @@ markers = traj io analysis + utils testpaths = tests From 1100b4d00442285d4fb76eff5bd3ffc81792bbf2 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 01:24:49 +0200 Subject: [PATCH 13/27] feat: Add pytest marker for utils module tests --- tests/utils/__init__.py | 2 ++ tests/utils/test_common.py | 2 ++ tests/utils/test_decorators.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 8b137891..0901e3ed 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1 +1,3 @@ +import pytest +pytestmark = pytest.mark.utils diff --git a/tests/utils/test_common.py b/tests/utils/test_common.py index ae78be17..23a771a2 100644 --- a/tests/utils/test_common.py +++ b/tests/utils/test_common.py @@ -5,6 +5,8 @@ from PQAnalysis.utils import print_header from PQAnalysis._version import __version__ +from . import pytestmark + def test_print_header(capsys: CaptureFixture): diff --git a/tests/utils/test_decorators.py b/tests/utils/test_decorators.py index 0509531b..f66401df 100644 --- a/tests/utils/test_decorators.py +++ b/tests/utils/test_decorators.py @@ -1,5 +1,7 @@ from PQAnalysis.utils import count_decorator, instance_function_count_decorator +from . import pytestmark + def test_count_decorator(): From 28b3bee6b202a7da2f3c90b0f8ec305c3fbbf8c7 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 01:49:48 +0200 Subject: [PATCH 14/27] feat: Update isclose method to accept any object for comparison --- PQAnalysis/traj/trajectory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PQAnalysis/traj/trajectory.py b/PQAnalysis/traj/trajectory.py index 88418b23..513a7ba5 100644 --- a/PQAnalysis/traj/trajectory.py +++ b/PQAnalysis/traj/trajectory.py @@ -348,7 +348,7 @@ def __eq__(self, other: Any) -> bool: def isclose( self, - other: "Trajectory", + other: Any, rtol: PositiveReal = 1e-5, atol: PositiveReal = 1e-8, ) -> bool: @@ -357,8 +357,8 @@ def isclose( Parameters ---------- - other : Trajectory - The other trajectory to compare. + other : Any + The other object to compare with. rtol : PositiveReal, optional The relative tolerance parameter, by default 1e-5 atol : PositiveReal, optional From 4dbe4a0267381b5835957efde141d89530110795 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 01:50:18 +0200 Subject: [PATCH 15/27] chore: Add Bool as new type for beartype and Format type hints in types.py for better readability --- PQAnalysis/types.py | 70 ++++++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/PQAnalysis/types.py b/PQAnalysis/types.py index 20fb3809..7e93a5e1 100644 --- a/PQAnalysis/types.py +++ b/PQAnalysis/types.py @@ -10,68 +10,84 @@ from beartype.typing import NewType #: A type hint for positive integers -PositiveInt = NewType("PositiveInt", Annotated[int, Is[lambda int: int > 0]]) +PositiveInt = NewType( + "PositiveInt", + Annotated[int, Is[lambda int: int > 0]], +) # :A type hint for positive real numbers PositiveReal = NewType( - "PositiveReal", - Annotated[Real, - Is[lambda real: real >= 0.0]] + "PositiveReal", Annotated[ + Real, + Is[lambda real: real >= 0.0], + ] ) #: A type variable for a 1D np.ndarray with dtype np.number Np1DNumberArray = NewType( "Np1DNumberArray", - Annotated[np.ndarray, - Is[lambda array: array.ndim == 1 and - (np.issubdtype(array.dtype, - np.number)) or len(array) == 0]] + Annotated[ + np.ndarray, + Is[lambda array: array.ndim == 1 and + (np.issubdtype(array.dtype, np.number)) or len(array) == 0], + ] ) #: A type hint for a 2D np.ndarray with dtype np.number Np2DNumberArray = NewType( "Np2DNumberArray", - Annotated[np.ndarray, - Is[lambda array: array.ndim == 2 and - (np.issubdtype(array.dtype, - np.number))]] + Annotated[ + np.ndarray, + Is[lambda array: array.ndim == 2 and + (np.issubdtype(array.dtype, np.number))], + ] ) #: A type hint for a nD np.ndarray with dtype np.number NpnDNumberArray = NewType( "NpnDNumberArray", - Annotated[np.ndarray, - Is[lambda array: array.ndim > 0 and - (np.issubdtype(array.dtype, - np.number))]] + Annotated[ + np.ndarray, + Is[lambda array: array.ndim > 0 and + (np.issubdtype(array.dtype, np.number))], + ] ) #: A type hint for a 3x3 np.ndarray matrix with dtype np.number Np3x3NumberArray = NewType( "Np2x2NumberArray", Annotated[np.ndarray, - Is[lambda array: array.ndim == 2 and array.shape == (3, - 3) and (np.issubdtype(array.dtype, - np.number))]] + Is[lambda array: array.ndim == 2 and array.shape == (3, 3) and + (np.issubdtype(array.dtype, np.number))]] ) #: A type hint for a 2D np.ndarray with dtype np.integer Np2DIntArray = NewType( "Np2DIntArray", - Annotated[np.ndarray, - Is[lambda array: array.ndim == 2 and - (np.issubdtype(array.dtype, - np.integer))]] + Annotated[ + np.ndarray, + Is[lambda array: array.ndim == 2 and + (np.issubdtype(array.dtype, np.integer))], + ] ) #: A type hint for a 1D np.ndarray with dtype np.integer Np1DIntArray = NewType( "Np1DIntArray", - Annotated[np.ndarray, - Is[lambda array: array.ndim == 1 and - (np.issubdtype(array.dtype, - np.integer)) or len(array) == 0]] + Annotated[ + np.ndarray, + Is[lambda array: array.ndim == 1 and + (np.issubdtype(array.dtype, np.integer)) or len(array) == 0], + ] ) #: A type hint for a range object Range = NewType("Range", Annotated[range, Is[lambda r: isinstance(r, range)]]) + +Bool = NewType( + "Bool", + Annotated[ + bool, + Is[lambda b: isinstance(b, bool) or isinstance(b, np.bool_)], + ] +) From aeb9acfadcd018445111a22ed6471264a61d7b3a Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 02:03:16 +0200 Subject: [PATCH 16/27] chore: Add Bool type to AtomicSystem and Trajectory classes for improved comparison functionality --- PQAnalysis/atomic_system/atomic_system.py | 6 +++--- PQAnalysis/traj/trajectory.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/PQAnalysis/atomic_system/atomic_system.py b/PQAnalysis/atomic_system/atomic_system.py index e6ffa087..ceff0167 100644 --- a/PQAnalysis/atomic_system/atomic_system.py +++ b/PQAnalysis/atomic_system/atomic_system.py @@ -15,7 +15,7 @@ from PQAnalysis.core import Atom, Atoms, Cell, distance from PQAnalysis.topology import Topology -from PQAnalysis.types import PositiveReal, PositiveInt +from PQAnalysis.types import PositiveReal, PositiveInt, Bool from PQAnalysis.type_checking import runtime_type_checking from PQAnalysis.exceptions import PQNotImplementedError from PQAnalysis.utils.random import get_random_seed @@ -533,7 +533,7 @@ def copy(self) -> "AtomicSystem": topology=self.topology ) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: Any) -> Bool: """ Checks whether the AtomicSystem is equal to another AtomicSystem. @@ -556,7 +556,7 @@ def isclose( other: Any, rtol: PositiveReal = 1e-5, atol: PositiveReal = 1e-8, - ) -> bool: + ) -> Bool: """ Checks whether the AtomicSystem is close to another AtomicSystem. diff --git a/PQAnalysis/traj/trajectory.py b/PQAnalysis/traj/trajectory.py index 513a7ba5..dbd99c4e 100644 --- a/PQAnalysis/traj/trajectory.py +++ b/PQAnalysis/traj/trajectory.py @@ -8,12 +8,17 @@ from beartype.typing import List, Any, Iterable from PQAnalysis.topology import Topology -from PQAnalysis.types import Np2DNumberArray, Np1DNumberArray, PositiveReal from PQAnalysis.exceptions import PQIndexError, PQTypeError from PQAnalysis.core import Cell from PQAnalysis.atomic_system import AtomicSystem from PQAnalysis.utils.custom_logging import setup_logger from PQAnalysis import __package_name__ +from PQAnalysis.types import ( + Np2DNumberArray, + Np1DNumberArray, + PositiveReal, + Bool, +) from PQAnalysis.type_checking import ( runtime_type_checking, runtime_type_checking_setter, @@ -334,7 +339,7 @@ def __add__(self, other: "Trajectory") -> "Trajectory": return Trajectory(self.frames + other.frames) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: Any) -> Bool: """ This method allows two trajectories to be compared for equality. @@ -351,7 +356,7 @@ def isclose( other: Any, rtol: PositiveReal = 1e-5, atol: PositiveReal = 1e-8, - ) -> bool: + ) -> Bool: """ This method allows two trajectories to be compared for closeness. From 834bedc3c445d90e80271188e61d53bdff89f924 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 02:03:34 +0200 Subject: [PATCH 17/27] chore: bugfix in Bool type definition --- PQAnalysis/types.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/PQAnalysis/types.py b/PQAnalysis/types.py index 7e93a5e1..db172695 100644 --- a/PQAnalysis/types.py +++ b/PQAnalysis/types.py @@ -56,9 +56,11 @@ #: A type hint for a 3x3 np.ndarray matrix with dtype np.number Np3x3NumberArray = NewType( "Np2x2NumberArray", - Annotated[np.ndarray, - Is[lambda array: array.ndim == 2 and array.shape == (3, 3) and - (np.issubdtype(array.dtype, np.number))]] + Annotated[ + np.ndarray, + Is[lambda array: array.ndim == 2 and array.shape == (3, 3) and + (np.issubdtype(array.dtype, np.number))], + ] ) #: A type hint for a 2D np.ndarray with dtype np.integer @@ -87,7 +89,7 @@ Bool = NewType( "Bool", Annotated[ - bool, - Is[lambda b: isinstance(b, bool) or isinstance(b, np.bool_)], + bool | np.bool_, + Is[lambda b: isinstance(b, (bool, np.bool_))], ] ) From 7b4829f1d9c3ab6a7ca2b6506455040dc089a179 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 02:09:09 +0200 Subject: [PATCH 18/27] chore: Add error handling for pytest.sh script --- pytest.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pytest.sh b/pytest.sh index 998efb91..a1c816de 100644 --- a/pytest.sh +++ b/pytest.sh @@ -1,4 +1,8 @@ export PQANALYSIS_BEARTYPE_LEVEL=DEBUG python -m pytest $@ +if [ $? -ne 0 ]; then + exit 1 +fi + export PQANALYSIS_BEARTYPE_LEVEL=RELEASE python -m pytest $@ From 84608c4421004c9476b559fd6265d3498ff7e3e9 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:15:18 +0200 Subject: [PATCH 19/27] refactor: Use vectorized allclose function for element-wise comparison of arrays --- PQAnalysis/atomic_system/atomic_system.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/PQAnalysis/atomic_system/atomic_system.py b/PQAnalysis/atomic_system/atomic_system.py index ceff0167..74a80da8 100644 --- a/PQAnalysis/atomic_system/atomic_system.py +++ b/PQAnalysis/atomic_system/atomic_system.py @@ -20,6 +20,7 @@ from PQAnalysis.exceptions import PQNotImplementedError from PQAnalysis.utils.random import get_random_seed from PQAnalysis.utils.custom_logging import setup_logger +from PQAnalysis.utils.math import allclose_vectorized from PQAnalysis import __package_name__ from PQAnalysis.types import ( @@ -587,16 +588,20 @@ def isclose( if not self.cell.isclose(other.cell, rtol=rtol, atol=atol): return False - if not np.allclose(self.pos, other.pos, rtol=rtol, atol=atol): + if not allclose_vectorized(self.pos, other.pos, rtol=rtol, atol=atol): return False - if not np.allclose(self.vel, other.vel, rtol=rtol, atol=atol): + if not allclose_vectorized(self.vel, other.vel, rtol=rtol, atol=atol): return False - if not np.allclose(self.forces, other.forces, rtol=rtol, atol=atol): + if not allclose_vectorized( + self.forces, other.forces, rtol=rtol, atol=atol + ): return False - if not np.allclose(self.charges, other.charges, rtol=rtol, atol=atol): + if not allclose_vectorized( + self.charges, other.charges, rtol=rtol, atol=atol + ): return False return True From 6e9881395f00749d06e8e295b1f6d62c4161908d Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:16:21 +0200 Subject: [PATCH 20/27] feat: Use vectorized allclose function for element-wise comparison of arrays in Cell class --- PQAnalysis/core/cell/cell.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PQAnalysis/core/cell/cell.py b/PQAnalysis/core/cell/cell.py index 935b1447..4cfae460 100644 --- a/PQAnalysis/core/cell/cell.py +++ b/PQAnalysis/core/cell/cell.py @@ -14,6 +14,7 @@ from PQAnalysis.type_checking import runtime_type_checking from PQAnalysis.types import Np3x3NumberArray, Np2DNumberArray, NpnDNumberArray, PositiveReal +from PQAnalysis.utils.math import allclose_vectorized from ._standard_properties import _StandardPropertiesMixin @@ -198,14 +199,14 @@ def isclose( if not isinstance(other, Cell): return False - is_equal = np.allclose( + is_equal = allclose_vectorized( self.box_lengths, other.box_lengths, rtol=rtol, atol=atol, ) - is_equal &= np.allclose( + is_equal &= allclose_vectorized( self.box_angles, other.box_angles, rtol=rtol, From 0de69bc3212694a5773a9328ddb2d07da06b9b94 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:32:51 +0200 Subject: [PATCH 21/27] set custom Bool for isclose and __eq__ in Cell --- PQAnalysis/core/cell/cell.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/PQAnalysis/core/cell/cell.py b/PQAnalysis/core/cell/cell.py index 4cfae460..90ae9b06 100644 --- a/PQAnalysis/core/cell/cell.py +++ b/PQAnalysis/core/cell/cell.py @@ -13,7 +13,7 @@ from beartype.vale import Is from PQAnalysis.type_checking import runtime_type_checking -from PQAnalysis.types import Np3x3NumberArray, Np2DNumberArray, NpnDNumberArray, PositiveReal +from PQAnalysis.types import Np3x3NumberArray, Np2DNumberArray, NpnDNumberArray, PositiveReal, Bool from PQAnalysis.utils.math import allclose_vectorized from ._standard_properties import _StandardPropertiesMixin @@ -155,7 +155,7 @@ def image(self, pos: NpnDNumberArray) -> NpnDNumberArray: return np.reshape(pos, original_shape) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: Any) -> Bool: """ Checks if the Cell is equal to another Cell. @@ -166,18 +166,19 @@ def __eq__(self, other: Any) -> bool: Returns ------- - bool + Bool True if the Cells are equal, False otherwise. """ return self.isclose(other) + @runtime_type_checking def isclose( self, other: Any, rtol: PositiveReal = 1e-5, atol: PositiveReal = 1e-8, - ) -> bool: + ) -> Bool: """ Checks if the Cell is close to another Cell. @@ -192,7 +193,7 @@ def isclose( Returns ------- - bool + Bool True if the Cells are close, False otherwise. """ From 3242cebd0d8813075e10bb44ebc5929469386bc8 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:33:20 +0200 Subject: [PATCH 22/27] bug: otypes set in math --- PQAnalysis/utils/math.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/PQAnalysis/utils/math.py b/PQAnalysis/utils/math.py index 8dd518c3..2875320d 100644 --- a/PQAnalysis/utils/math.py +++ b/PQAnalysis/utils/math.py @@ -5,7 +5,7 @@ import math import numpy as np -from PQAnalysis.types import PositiveReal +from PQAnalysis.types import PositiveReal, Bool @@ -14,7 +14,7 @@ def allclose_vectorized( b, rtol: PositiveReal = 1e-09, atol: PositiveReal = 0.0, -): +) -> Bool: """ Perform element-wise comparison of two arrays to determine if they are equal within a tolerance. This function is a vectorized version @@ -43,11 +43,12 @@ def allclose_vectorized( Returns ------- - _type_ - _description_ + Bool + True if the arrays are element-wise equal within the given tolerance, False otherwise """ - isclose_vectorized = np.vectorize(math.isclose) + # otypes needed: ValueError: cannot call `vectorize` on size 0 inputs unless `otypes` is set + isclose_vectorized = np.vectorize(math.isclose, otypes=[bool]) result = isclose_vectorized(a, b, rel_tol=rtol, abs_tol=atol) From f9fa8c83e2c0a876d632aa59d8f4eb57b6a9587d Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:39:47 +0200 Subject: [PATCH 23/27] refactor: Update Trajectory class to use vectorized allclose function for element-wise comparison of arrays --- PQAnalysis/traj/trajectory.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/PQAnalysis/traj/trajectory.py b/PQAnalysis/traj/trajectory.py index dbd99c4e..ecc30ac2 100644 --- a/PQAnalysis/traj/trajectory.py +++ b/PQAnalysis/traj/trajectory.py @@ -347,6 +347,11 @@ def __eq__(self, other: Any) -> Bool: ---------- other : Trajectory The other trajectory to compare. + + Returns + ------- + Bool + Whether the two trajectories are equal. """ return self.isclose(other) @@ -371,7 +376,7 @@ def isclose( Returns ------- - bool + Bool Whether the two trajectories are close. """ From 43ab020c2e379daa7421645986798f24b5e9f9b0 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:42:04 +0200 Subject: [PATCH 24/27] refactor: Update isclose method to use smaller default tolerances for element-wise comparison --- PQAnalysis/atomic_system/atomic_system.py | 8 ++++---- PQAnalysis/core/cell/cell.py | 8 ++++---- PQAnalysis/traj/trajectory.py | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/PQAnalysis/atomic_system/atomic_system.py b/PQAnalysis/atomic_system/atomic_system.py index 74a80da8..01dad207 100644 --- a/PQAnalysis/atomic_system/atomic_system.py +++ b/PQAnalysis/atomic_system/atomic_system.py @@ -555,8 +555,8 @@ def __eq__(self, other: Any) -> Bool: def isclose( self, other: Any, - rtol: PositiveReal = 1e-5, - atol: PositiveReal = 1e-8, + rtol: PositiveReal = 1e-9, + atol: PositiveReal = 0.0, ) -> Bool: """ Checks whether the AtomicSystem is close to another AtomicSystem. @@ -566,9 +566,9 @@ def isclose( other : AtomicSystem The other AtomicSystem to compare to. rtol : PositiveReal, optional - The relative tolerance, by default 1e-5 + The relative tolerance, by default 1e-9 atol : PositiveReal, optional - The absolute tolerance, by default 1e-8 + The absolute tolerance, by default 0.0 Returns ------- diff --git a/PQAnalysis/core/cell/cell.py b/PQAnalysis/core/cell/cell.py index 90ae9b06..a8ba9e74 100644 --- a/PQAnalysis/core/cell/cell.py +++ b/PQAnalysis/core/cell/cell.py @@ -176,8 +176,8 @@ def __eq__(self, other: Any) -> Bool: def isclose( self, other: Any, - rtol: PositiveReal = 1e-5, - atol: PositiveReal = 1e-8, + rtol: PositiveReal = 1e-9, + atol: PositiveReal = 0.0, ) -> Bool: """ Checks if the Cell is close to another Cell. @@ -187,9 +187,9 @@ def isclose( other : Cell The Cell to compare with. rtol : PositiveReal, optional - The relative tolerance parameter. + The relative tolerance parameter. Default is 1e-9. atol : PositiveReal, optional - The absolute tolerance parameter. + The absolute tolerance parameter. Default is 0.0. Returns ------- diff --git a/PQAnalysis/traj/trajectory.py b/PQAnalysis/traj/trajectory.py index ecc30ac2..b6b4efac 100644 --- a/PQAnalysis/traj/trajectory.py +++ b/PQAnalysis/traj/trajectory.py @@ -359,8 +359,8 @@ def __eq__(self, other: Any) -> Bool: def isclose( self, other: Any, - rtol: PositiveReal = 1e-5, - atol: PositiveReal = 1e-8, + rtol: PositiveReal = 1e-9, + atol: PositiveReal = 0.0, ) -> Bool: """ This method allows two trajectories to be compared for closeness. @@ -370,9 +370,9 @@ def isclose( other : Any The other object to compare with. rtol : PositiveReal, optional - The relative tolerance parameter, by default 1e-5 + The relative tolerance parameter, by default 1e-9 atol : PositiveReal, optional - The absolute tolerance parameter, by default 1e-8 + The absolute tolerance parameter, by default 0.0 Returns ------- From 48668aa3f9783e675c248d4e9b8bfbd24f68469e Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" Date: Thu, 30 May 2024 19:52:07 +0200 Subject: [PATCH 25/27] refactor: Update isclose method to use smaller default tolerances for element-wise comparison --- tests/atomicSystem/test_atomic_system.py | 2 +- tests/traj/test_trajectory.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/atomicSystem/test_atomic_system.py b/tests/atomicSystem/test_atomic_system.py index 8cd02c5d..9bcc15db 100644 --- a/tests/atomicSystem/test_atomic_system.py +++ b/tests/atomicSystem/test_atomic_system.py @@ -237,7 +237,7 @@ def test_isclose(self): cell=Cell(0.75, 0.76, 0.75) ) - assert system3.isclose(system4, atol=1e-5) + assert system3.isclose(system4, atol=1.0001e-5) assert system3.isclose(system4, rtol=1) assert not system3.isclose(system4, atol=1e-6) assert not system3.isclose(system4, rtol=1e-6) diff --git a/tests/traj/test_trajectory.py b/tests/traj/test_trajectory.py index bf320336..4c008545 100644 --- a/tests/traj/test_trajectory.py +++ b/tests/traj/test_trajectory.py @@ -330,7 +330,7 @@ def test_isclose(self): traj2 = Trajectory([frame2, frame2]) traj3 = Trajectory([frame3, frame3]) - assert traj1.isclose(traj2, atol=1e-4) + assert traj1.isclose(traj2, atol=1.0001e-4) assert not traj1.isclose(traj2, atol=1e-5) assert traj1.isclose(traj3, rtol=1e-3) From 15a5cfcb7c65d717b4544e185158af76ae0220a9 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 21:10:55 +0200 Subject: [PATCH 26/27] refactor: Update .github/workflows/pylint.yml to include .github/.pylint_cache in the commit --- .github/workflows/pylint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index ce237102..f78bc666 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -99,7 +99,7 @@ jobs: #add changes of .github/.pylint_cache to the commit if it is based on push event - name: Add .github/.pylint_cache to the commit - if: github.event_name == 'push' && github.ref_name != 'main' + if: (github.event_name == 'push' || github.event.pull_request.merged == true) && github.ref_name != 'main' run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" From 13d763ed99485c0af0232e0684a84b846dfd3bb5 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 30 May 2024 21:16:02 +0200 Subject: [PATCH 27/27] added new pylint stats file --- .github/.pylint_cache/PQAnalysis_1.stats | Bin 16870 -> 16993 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/.pylint_cache/PQAnalysis_1.stats b/.github/.pylint_cache/PQAnalysis_1.stats index 9e668da42053e82a00c94eed8962fea65f10377f..76a0a4cfa3b263abb0b3970b3cfeb1981212abc3 100644 GIT binary patch delta 4312 zcmZu!dvH|M8RsUO>~40G%_f^{vb)(wmOv8nB2V&8WDsJAk}`mmp|B)N&R%!3OLhas zYJnn?&Uk6A{z54;b&z2!wGI`e+A;{KkJd8UDt2h9ts?D=jzg7Fb?7)%`kiyLVZr{9 z&AsP*=lg!=_dV~2d!@^dNEy3z2iD*5SjJcO#kDLN3D(Mik=oc;R1OX$;~U%;I7=c2!oeiw81OZa128$xM~c)QsKSGo^Rq*Y~FXlKDVUaD|nRhkcZo;;ju z%fztN0h6Z(Pq;hab3asAux7A#G!UMMhvGHkiBLFR6A2_39Dx>k259ZxYyW? z&SEqA>avk*s=`I%APyRf@pe`j7G{OkXA)LreNPm~@qRu+oEz<~XEg2WCv$q*5$xEW+<>Ralj8LCn^P>HNh-w+BkT2nWS0 zgj;Nt__wXjnwAnxX%tPPcq6}GI`+8zE__kYiH&YE{@Z2-7e8Crhx)>L>+O1LaoALV z@3xunx1yiox%P5_{ZqQ(UH0!;3sMa5aNUloq5)j8uYEi}j31WiaVXCxRY1yjqNl-v zj4TKC=j$c6Sn10vBfs;#7MmZWQmVws953!(su8p~XONuASFUDP;#zZq^Wo`oRz!JG z+@A_lBE!4zb2ZDqNDL?_78T0?ZpZG;tp29X4s^LbBbMHE94%o<~nQPNHa#qBe98TqDeqw ztw~&iY(G)9+gk`zk8PKLpjkajRzI32;E;SLztKvrQm`iCC~^M<5*J z7T7_$$03!R92|;{1rpIQu{zG$Ig*?5!_m1NTGi@1{+wnl=NRqWn4s@77H40vUlDte zq~HEPbUmun3I?ZIb9b>lf~=&+xW9VY;iSjjVIOOr7d+&q^brm^E}%Wbp1;Y+(PW@- zckP$&;#WSS**+;86;(P?w1jXr#|G}nk@8$@(bp{zPUF-aV=1&H@lXUMJyWg1>vo{8 z%Pxgr==SUGBU=b`Ih&hUNL5m?ik>I1E@$VJoD};*;m|bYLyM}|(N$-MuddU)Tps4A z*Rnosv3Z(!PdO8c&Z)Ye5^E*fNLWA8lItiX*C46msnv(yRCv7X8J<>J)I6UQ(qef( zJE({nSQCharstRDC((OtKuq$(1QAAT1Q=U-- zv%dK#GbXkVM`{A&2@UxQHmfR@lp9f?iP)3FWNY4_7O%4+F?dE&HeV#K>?750lIlTP zxh?|xR%MB?fXoTfE}fddLOtD$vXs(umTJU3#NCVrmR(ozWM$zleo|cd6RIM!N$w`e zw73(yGG{XDCVt@#QNO-XIg-(MTiGIRZ`O$B z@1}cdD9LKZhPMefji`_u#X=E1zErF!8o}w#g=w^~r`TK1HIoJtbw%oWv2ZY<6-K&p z_E>0$ZKjP_U@Shrs?Ci*>nTrXQ5D^#rAAUA|C~5cspKy*^EXj@$2HC<<#$rT$A?0( ziJEvUkO&3BH59g^aq8m{0h8~i+&~+eNn$uE_BCEf=>JdFLd796!h4IjD?L(iOFmK` z!f$IWI#Q6FXLAJRX9eaHdKaH}^OCqlK8Uc#Tg{G4>RIfxilrqb!jF4zNoA3JDn?5j z+?f2Tgo|AZ&98F3Y@b-cV0R%@udZ02+<(hyQ3zDr(__!#vTyLPn!@EuUk0_LU@uxIfF|} zYYk$mI3uUSQeT!PTXDGFpLSLSO-NOwmI@k`d0x_l0j&&))90ybre@!}W}luPI4e&= zX)aB_z-Dlx$!nOQO3x4AWV2g0qiUVO#5)6*o+0UHM)iZg+wiZ3HnC%)F9uf*zrIaua=+l3Z86kFoq2wVx&oih@OfiNmMB6T z4__+9`-S=fe%#5A-`tyTMutqWGl}mtEz*l)MB@i`mvQa_gy*fMC2noUnvz(dsOWxN z_au6o7iVZTpENu8P%0Qv7474vtj&@>%P!*6whA2|&TzTC!lBr1RkkBEM#)T*or?GE z4cx0#+EWO-B_h46WqP<2(ep7KHuN+(2bb?iO9ao!|+2t?EhN$VH zhlmk|L!l1~Lu|VUk1w&MrCHDA)cFzkkkk!dYXi)!Hu1Dl&m%`w>XRd@X})wR9dm0X z+46dBY=w|aj%N7=VOX7;c3*3~11}djM48}|Je4+2@hxxp^zCmA$&>PV(?Fj$?_25fF-tLxgri#n;mxB_s=F(D i{Mkh>e&&U|i74xL%HN@XE4yF1_V+UX(US44HU9&7&cL() delta 4159 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