Repository: GazzolaLab/PyElastica

Issue:
Converting nodal velocities to element velocities
In PyElastica we store `velocity` on nodes and for some computations (i.e. rod damping, plane damping or stokes flow) we have to convert `velocity` from `nodes` to `elements`. Currently we are averaging the neigboring node velocity to compute element velocity using the following function.

https://github.com/GazzolaLab/PyElastica/blob/fee87b9da22e310ff925c16fdc839bf8405c51a4/elastica/interaction.py#L664-L703

Although this function is correct for uniform rods (uniform cross-section, element length and density) for tapered rods, momentum is not conserved since neighboring node masses are not the same. Thus, we have to first compute the total momentum of element and divide by the element mass to compute element velocity. 

**How to fix** : 

1. Use the following function to compute element velocities

``` python

def node_to_element_velocity(mass, velocity_collection):
    """
    """
    n_elem = velocity_collection.shape[1] - 1
    element_velocity = np.empty((3, n_elem))
    for k in range(n_elem):
        element_velocity[0, k] = (
            mass[k+1] * velocity_collection[0, k + 1] + mass[k] * velocity_collection[0, k]
        )
        element_velocity[1, k] = (
            mass[k+1] * velocity_collection[1, k + 1] + mass[k] * velocity_collection[1, k]
        )
        element_velocity[2, k] =(
            mass[k+1] * velocity_collection[2, k + 1] + mass[k] * velocity_collection[2, k]
        )
        element_velocity[:,k] /= (mass[k+1] + mass[k])


    return element_velocity

```

2. Rename the function `node_to_element_pos_or_vel` as `node_to_element_position`, since this will be only used to compute the element positions.

Comments:

Patch:
diff --git a/elastica/interaction.py b/elastica/interaction.py
index 7504670..587598d 100644
--- a/elastica/interaction.py
+++ b/elastica/interaction.py
@@ -8,14 +8,11 @@ __all__ = [
 
 
 import numpy as np
-from elastica.utils import MaxDimension
 from elastica.external_forces import NoForces
 
 
-import numba
 from numba import njit
 from elastica._linalg import (
-    _batch_matmul,
     _batch_matvec,
     _batch_cross,
     _batch_norm,
@@ -27,6 +24,7 @@ from elastica._linalg import (
     _batch_matrix_transpose,
     _batch_vec_oneD_vec_cross,
 )
+import warnings
 
 
 @njit(cache=True)
@@ -71,21 +69,22 @@ def find_slipping_elements(velocity_slip, velocity_threshold):
 
 
 @njit(cache=True)
-def nodes_to_elements(input):
+def node_to_element_mass_or_force(input):
     """
-    This function converts the rod-like object dofs on nodes to
-    dofs on elements. For example, node velocity is converted to
-    element velocity.
+    This function converts the mass/forces on rod nodes to
+    elements, where special treatment is necessary at the ends.
 
     Parameters
     ----------
     input: numpy.ndarray
-        2D (dim, blocksize) array containing data with 'float' type.
+        2D (dim, blocksize) array containing nodal mass/forces
+        with 'float' type.
 
     Returns
     -------
     output: numpy.ndarray
-        2D (dim, blocksize) array containing data with 'float' type.
+        2D (dim, blocksize) array containing elemental mass/forces
+        with 'float' type.
     """
     """
     Developer Notes
@@ -107,6 +106,19 @@ def nodes_to_elements(input):
     return output
 
 
+def nodes_to_elements(input):
+    warnings.warn(
+        # Change the warning to error on v0.3.1
+        # Remove the function beyond v0.4.0
+        "This function is now deprecated (issue #80). Please use "
+        "elastica.interaction.node_to_element_mass_or_force() "
+        "instead for node-to-element interpolation of mass/forces. "
+        "The function will be removed in the future (v0.3.1).",
+        DeprecationWarning,
+    )
+    return node_to_element_mass_or_force(input)
+
+
 @njit(cache=True)
 def elements_to_nodes_inplace(vector_in_element_frame, vector_in_node_frame):
     """
@@ -202,6 +214,7 @@ class InteractionPlane:
             self.k,
             self.nu,
             system.radius,
+            system.mass,
             system.position_collection,
             system.velocity_collection,
             system.internal_forces,
@@ -217,6 +230,7 @@ def apply_normal_force_numba(
     k,
     nu,
     radius,
+    mass,
     position_collection,
     velocity_collection,
     internal_forces,
@@ -238,7 +252,7 @@ def apply_normal_force_numba(
 
     # Compute plane response force
     nodal_total_forces = _batch_vector_sum(internal_forces, external_forces)
-    element_total_forces = nodes_to_elements(nodal_total_forces)
+    element_total_forces = node_to_element_mass_or_force(nodal_total_forces)
 
     force_component_along_normal_direction = _batch_product_i_ik_to_k(
         plane_normal, element_total_forces
@@ -259,7 +273,7 @@ def apply_normal_force_numba(
     plane_response_force = -forces_along_normal_direction
 
     # Elastic force response due to penetration
-    element_position = node_to_element_pos_or_vel(position_collection)
+    element_position = node_to_element_position(position_collection)
     distance_from_plane = _batch_product_i_ik_to_k(
         plane_normal, (element_position - plane_origin)
     )
@@ -267,7 +281,9 @@ def apply_normal_force_numba(
     elastic_force = -k * _batch_product_i_k_to_ik(plane_normal, plane_penetration)
 
     # Damping force response due to velocity towards the plane
-    element_velocity = node_to_element_pos_or_vel(velocity_collection)
+    element_velocity = node_to_element_velocity(
+        mass=mass, node_velocity_collection=velocity_collection
+    )
     normal_component_of_element_velocity = _batch_product_i_ik_to_k(
         plane_normal, element_velocity
     )
@@ -400,6 +416,7 @@ class AnisotropicFrictionalPlane(NoForces, InteractionPlane):
             self.static_mu_backward,
             self.static_mu_sideways,
             system.radius,
+            system.mass,
             system.tangents,
             system.position_collection,
             system.director_collection,
@@ -427,6 +444,7 @@ def anisotropic_friction(
     static_mu_backward,
     static_mu_sideways,
     radius,
+    mass,
     tangents,
     position_collection,
     director_collection,
@@ -444,6 +462,7 @@ def anisotropic_friction(
         k,
         nu,
         radius,
+        mass,
         position_collection,
         velocity_collection,
         internal_forces,
@@ -466,7 +485,9 @@ def anisotropic_friction(
         1 / (tangent_perpendicular_to_normal_direction_mag + 1e-14),
         tangent_perpendicular_to_normal_direction,
     )
-    element_velocity = node_to_element_pos_or_vel(velocity_collection)
+    element_velocity = node_to_element_velocity(
+        mass=mass, node_velocity_collection=velocity_collection
+    )
     # first apply axial kinetic friction
     velocity_mag_along_axial_direction = _batch_dot(element_velocity, axial_direction)
     velocity_along_axial_direction = _batch_product_k_ik_to_ik(
@@ -550,7 +571,7 @@ def anisotropic_friction(
 
     # now axial static friction
     nodal_total_forces = _batch_vector_sum(internal_forces, external_forces)
-    element_total_forces = nodes_to_elements(nodal_total_forces)
+    element_total_forces = node_to_element_mass_or_force(nodal_total_forces)
     force_component_along_axial_direction = _batch_dot(
         element_total_forces, axial_direction
     )
@@ -661,21 +682,24 @@ def sum_over_elements(input):
 
 
 @njit(cache=True)
-def node_to_element_pos_or_vel(vector_in_node_frame):
+def node_to_element_position(node_position_collection):
     """
-    This function computes the velocity of the elements.
+    This function computes the position of the elements
+    from the nodal values.
     Here we define a separate function because benchmark results
     showed that using Numba, we get more than 3 times faster calculation.
 
     Parameters
     ----------
-    vector_in_node_frame: numpy.ndarray
-        2D (dim, blocksize) array containing data with 'float' type.
+    node_position_collection: numpy.ndarray
+        2D (dim, blocksize) array containing nodal positions with
+        'float' type.
 
     Returns
     -------
-    vector_in_element_frame: numpy.ndarray
-        2D (dim, blocksize) array containing data with 'float' type.
+    element_position_collection: numpy.ndarray
+        2D (dim, blocksize) array containing elemental positions with
+        'float' type.
     """
     """
     Developer Notes
@@ -687,25 +711,77 @@ def node_to_element_pos_or_vel(vector_in_node_frame):
     This version: 729 ns ± 14.3 ns per loop
 
     """
-    n_elem = vector_in_node_frame.shape[1] - 1
-    vector_in_element_frame = np.empty((3, n_elem))
+    n_elem = node_position_collection.shape[1] - 1
+    element_position_collection = np.empty((3, n_elem))
+    for k in range(n_elem):
+        element_position_collection[0, k] = 0.5 * (
+            node_position_collection[0, k + 1] + node_position_collection[0, k]
+        )
+        element_position_collection[1, k] = 0.5 * (
+            node_position_collection[1, k + 1] + node_position_collection[1, k]
+        )
+        element_position_collection[2, k] = 0.5 * (
+            node_position_collection[2, k + 1] + node_position_collection[2, k]
+        )
+
+    return element_position_collection
+
+
+@njit(cache=True)
+def node_to_element_velocity(mass, node_velocity_collection):
+    """
+    This function computes the velocity of the elements
+    from the nodal values. Uses the velocity of center of mass
+    in order to conserve momentum during computation.
+
+    Parameters
+    ----------
+    mass: numpy.ndarray
+        2D (dim, blocksize) array containing nodal masses with
+        'float' type.
+    node_velocity_collection: numpy.ndarray
+        2D (dim, blocksize) array containing nodal velocities with
+        'float' type.
+
+    Returns
+    -------
+    element_velocity_collection: numpy.ndarray
+        2D (dim, blocksize) array containing elemental velocities with
+        'float' type.
+    """
+    n_elem = node_velocity_collection.shape[1] - 1
+    element_velocity_collection = np.empty((3, n_elem))
     for k in range(n_elem):
-        vector_in_element_frame[0, k] = 0.5 * (
-            vector_in_node_frame[0, k + 1] + vector_in_node_frame[0, k]
+        element_velocity_collection[0, k] = (
+            mass[k + 1] * node_velocity_collection[0, k + 1]
+            + mass[k] * node_velocity_collection[0, k]
         )
-        vector_in_element_frame[1, k] = 0.5 * (
-            vector_in_node_frame[1, k + 1] + vector_in_node_frame[1, k]
+        element_velocity_collection[1, k] = (
+            mass[k + 1] * node_velocity_collection[1, k + 1]
+            + mass[k] * node_velocity_collection[1, k]
         )
-        vector_in_element_frame[2, k] = 0.5 * (
-            vector_in_node_frame[2, k + 1] + vector_in_node_frame[2, k]
+        element_velocity_collection[2, k] = (
+            mass[k + 1] * node_velocity_collection[2, k + 1]
+            + mass[k] * node_velocity_collection[2, k]
         )
+        element_velocity_collection[:, k] /= mass[k + 1] + mass[k]
+
+    return element_velocity_collection
+
 
-    return vector_in_element_frame
+def node_to_element_pos_or_vel(vector_in_node_frame):
+    # Remove the function beyond v0.4.0
+    raise NotImplementedError(
+        "This function is removed in v0.3.0. For node-to-element interpolation please use: \n"
+        "elastica.interaction.node_to_element_position() for rod position \n"
+        "elastica.interaction.node_to_element_velocity() for rod velocity. \n"
+        "For detail, refer to issue #80."
+    )
 
 
 @njit(cache=True)
 def slender_body_forces(
-    tangents, velocity_collection, dynamic_viscosity, lengths, radius
+    tangents, velocity_collection, dynamic_viscosity, lengths, radius, mass
 ):
     r"""
     This function computes hydrodynamic forces on a body using slender body theory.
@@ -732,6 +808,9 @@ def slender_body_forces(
     radius: numpy.ndarray
         1D (blocksize) array containing data with 'float' type.
         Rod-like object element radius.
+    mass: numpy.ndarray
+        1D (blocksize) array containing data with 'float' type.
+        Rod-like object node mass.
 
     Returns
     -------
@@ -751,7 +830,9 @@ def slender_body_forces(
 
     f = np.empty((tangents.shape[0], tangents.shape[1]))
     total_length = sum_over_elements(lengths)
-    element_velocity = node_to_element_pos_or_vel(velocity_collection)
+    element_velocity = node_to_element_velocity(
+        mass=mass, node_velocity_collection=velocity_collection
+    )
 
     for k in range(tangents.shape[1]):
         # compute the entries of t`t. a[#][#] are the the
@@ -844,6 +925,7 @@ class SlenderBodyTheory(NoForces):
             self.dynamic_viscosity,
             system.lengths,
             system.radius,
+            system.mass,
         )
         elements_to_nodes_inplace(stokes_force, system.external_forces)
 


Test patch:
diff --git a/tests/test_interaction.py b/tests/test_interaction.py
index 82ac01a..a7c7a84 100644
--- a/tests/test_interaction.py
+++ b/tests/test_interaction.py
@@ -8,6 +8,7 @@ from elastica.interaction import (
     InteractionPlane,
     find_slipping_elements,
     AnisotropicFrictionalPlane,
+    node_to_element_mass_or_force,
     nodes_to_elements,
     SlenderBodyTheory,
 )
@@ -48,6 +49,7 @@ class BaseRodClass(MockTestRod):
         self.internal_forces = np.zeros((MaxDimension.value(), n_elem + 1))
         self.internal_torques = np.zeros((MaxDimension.value(), n_elem))
         self.lengths = np.ones(n_elem) * base_length / n_elem
+        self.mass = np.ones(n_elem + 1)
 
     def _compute_internal_forces(self):
         return np.zeros((MaxDimension.value(), self.n_elem + 1))
@@ -365,13 +367,32 @@ class TestAuxiliaryFunctions:
         assert_allclose(correct_slip_function, slip_function, atol=Tolerance.atol())
 
     @pytest.mark.parametrize("n_elem", [2, 3, 5, 10, 20])
-    def test_nodes_to_elements(self, n_elem):
+    def test_node_to_element_mass_or_force(self, n_elem):
         random_vector = np.random.rand(3).reshape(3, 1)
         input = np.repeat(random_vector, n_elem + 1, axis=1)
         input[..., 0] *= 0.5
         input[..., -1] *= 0.5
         correct_output = np.repeat(random_vector, n_elem, axis=1)
-        output = nodes_to_elements(input)
+        output = node_to_element_mass_or_force(input)
+        assert_allclose(correct_output, output, atol=Tolerance.atol())
+        assert_allclose(np.sum(input), np.sum(output), atol=Tolerance.atol())
+
+    @pytest.mark.parametrize("n_elem", [2, 3, 5, 10, 20])
+    def test_deprecated_nodes_to_elements(self, n_elem):
+        random_vector = np.random.rand(3).reshape(3, 1)
+        input = np.repeat(random_vector, n_elem + 1, axis=1)
+        input[..., 0] *= 0.5
+        input[..., -1] *= 0.5
+        correct_output = np.repeat(random_vector, n_elem, axis=1)
+        correct_warning_message = (
+            "This function is now deprecated (issue #80). Please use "
+            "elastica.interaction.node_to_element_mass_or_force() "
+            "instead for node-to-element interpolation of mass/forces. "
+            "The function will be removed in the future (v0.3.1)."
+        )
+        with pytest.warns(DeprecationWarning) as record:
+            output = nodes_to_elements(input)
+        assert record[0].message.args[0] == correct_warning_message
         assert_allclose(correct_output, output, atol=Tolerance.atol())
         assert_allclose(np.sum(input), np.sum(output), atol=Tolerance.atol())
 
@@ -566,7 +587,7 @@ class TestAnisotropicFriction:
         )
 
         assert_allclose(correct_forces, rod.external_forces, atol=Tolerance.atol())
-        forces_on_elements = nodes_to_elements(external_forces_collection)
+        forces_on_elements = node_to_element_mass_or_force(external_forces_collection)
         correct_torques = np.zeros((3, n_elem))
         correct_torques[2] += (
             -1.0
@@ -607,7 +628,7 @@ class TestAnisotropicFriction:
         correct_forces[0] = 2.0 / 3.0 * external_forces_collection[0]
         assert_allclose(correct_forces, rod.external_forces, atol=Tolerance.atol())
 
-        forces_on_elements = nodes_to_elements(external_forces_collection)
+        forces_on_elements = node_to_element_mass_or_force(external_forces_collection)
         correct_torques = np.zeros((3, n_elem))
         correct_torques[2] += (
             -1.0
@@ -650,7 +671,7 @@ class TestAnisotropicFriction:
         ) * np.fabs(external_forces_collection[1])
         assert_allclose(correct_forces, rod.external_forces, atol=Tolerance.atol())
 
-        forces_on_elements = nodes_to_elements(external_forces_collection)
+        forces_on_elements = node_to_element_mass_or_force(external_forces_collection)
         correct_torques = np.zeros((3, n_elem))
         correct_torques[2] += (
             -1.0
@@ -736,7 +757,7 @@ class TestAnisotropicFriction:
         )
         assert_allclose(correct_forces, rod.external_forces, atol=Tolerance.atol())
 
-        forces_on_elements = nodes_to_elements(external_forces_collection)
+        forces_on_elements = node_to_element_mass_or_force(external_forces_collection)
         correct_torques = external_torques
         correct_torques[2] += -(
             np.sign(torque_mag) * np.fabs(forces_on_elements[1]) * rod.radius
@@ -748,7 +769,12 @@ class TestAnisotropicFriction:
 # Slender Body Theory Unit Tests
 
 try:
-    from elastica.interaction import sum_over_elements, node_to_element_pos_or_vel
+    from elastica.interaction import (
+        sum_over_elements,
+        node_to_element_position,
+        node_to_element_velocity,
+        node_to_element_pos_or_vel,
+    )
 
     # These functions are used in the case if Numba is available
     class TestAuxiliaryFunctionsForSlenderBodyTheory:
@@ -774,9 +800,31 @@ try:
             assert_allclose(correct_output, output, atol=Tolerance.atol())
 
         @pytest.mark.parametrize("n_elem", [2, 3, 5, 10, 20])
-        def test_node_to_elements(self, n_elem):
+        def test_node_to_element_position(self, n_elem):
             """
-            This function test node_to_element_velocity function. We are
+            This function tests node_to_element_position function. We are
+            converting node positions to element positions. Here also
+            we are using numba to speed up the process.
+
+            Parameters
+            ----------
+            n_elem
+
+            Returns
+            -------
+
+            """
+            random = np.random.rand()  # Adding some random numbers
+            input_position = random * np.ones((3, n_elem + 1))
+            correct_output = random * np.ones((3, n_elem))
+
+            output = node_to_element_position(input_position)
+            assert_allclose(correct_output, output, atol=Tolerance.atol())
+
+        @pytest.mark.parametrize("n_elem", [2, 3, 5, 10, 20])
+        def test_node_to_element_velocity(self, n_elem):
+            """
+            This function tests node_to_element_velocity function. We are
             converting node velocities to element velocities. Here also
             we are using numba to speed up the process.
 
@@ -789,12 +837,29 @@ try:
 
             """
             random = np.random.rand()  # Adding some random numbers
-            input_variable = random * np.ones((3, n_elem + 1))
+            input_velocity = random * np.ones((3, n_elem + 1))
+            input_mass = 2.0 * random * np.ones(n_elem + 1)
             correct_output = random * np.ones((3, n_elem))
 
-            output = node_to_element_pos_or_vel(input_variable)
+            output = node_to_element_velocity(
+                mass=input_mass, node_velocity_collection=input_velocity
+            )
             assert_allclose(correct_output, output, atol=Tolerance.atol())
 
+        @pytest.mark.parametrize("n_elem", [2, 3, 5, 10, 20])
+        def test_not_impl_error_for_node_to_element_pos_or_vel(self, n_elem):
+            random = np.random.rand()  # Adding some random numbers
+            input_velocity = random * np.ones((3, n_elem + 1))
+            error_message = (
+                "This function is removed in v0.3.0. For node-to-element interpolation please use: \n"
+                "elastica.interaction.node_to_element_position() for rod position \n"
+                "elastica.interaction.node_to_element_velocity() for rod velocity. \n"
+                "For detail, refer to issue #80."
+            )
+            with pytest.raises(NotImplementedError) as error_info:
+                node_to_element_pos_or_vel(input_velocity)
+            assert error_info.value.args[0] == error_message
+
 except ImportError:
     pass