Repository: sympy/sympy

Issue:
Refactor `sympy.physics.mechanics._pathway` to use `extension_velocity` instead of `shortening_velocity`
The `shortening_velocity` attribute should be refactored to `extension_velocity` as this is more intuitive for mechanics problems.

https://github.com/sympy/sympy/blob/480254b6dd53f8d0b54ec757e438ad2d19a50cd9/sympy/physics/mechanics/_pathway.py#L72-L76

The reason being that using `shortening_velocity`  causes some confusion when creating a simple model like a forced mass-spring-damper system; the spring stiffness needs to be defined as `stiffness * pathway.length` while the damper damping needs to be defined as `damping * -pathway.shortening_velocity` in order to generate the correct EoMs. Having to use different signs for terms that will end up in the EoMs with the same sign is unintuitive.

Discussion:

Patch:
diff --git a/sympy/physics/mechanics/_actuator.py b/sympy/physics/mechanics/_actuator.py
index 47d355af6b..727b223427 100644
--- a/sympy/physics/mechanics/_actuator.py
+++ b/sympy/physics/mechanics/_actuator.py
@@ -67,8 +67,8 @@ class ForceActuator:
     Explanation
     ===========
 
-    A ``ForceActuator`` is an actuator that produces a (contractile) force
-    along its length.
+    A ``ForceActuator`` is an actuator that produces a (expansile) force along
+    its length.
 
     Examples
     ========
@@ -111,8 +111,8 @@ class ForceActuator:
     ==========
 
     force : Expr
-        The scalar expression defining the (contractile) force that the
-        actuator produces.
+        The scalar expression defining the (expansile) force that the actuator
+        produces.
     pathway : PathwayBase
         The pathway that the actuator follows. This must be an instance of a
         concrete subclass of ``PathwayBase``, e.g. ``LinearPathway``.
@@ -130,7 +130,7 @@ def __init__(
         ==========
 
         force : Expr
-            The scalar expression defining the (contractile) force that the
+            The scalar expression defining the (expansile) force that the
             actuator produces.
         pathway : PathwayBase
             The pathway that the actuator follows. This must be an instance of
@@ -207,11 +207,15 @@ def to_loads(self) -> list[tuple[Point, Vector]]:
         Now create a symbol ``k`` to describe the spring's stiffness and
         instantiate a force actuator that produces a (contractile) force
         proportional to both the spring's stiffness and the pathway's length.
+        Note that actuator classes use the sign convention that expansile
+        forces are positive, so for a spring to produce a contractile force the
+        spring force needs to be calculated as the negative for the stiffness
+        multiplied by the length.
 
         >>> from sympy import Symbol
         >>> from sympy.physics.mechanics._actuator import ForceActuator
         >>> stiffness = Symbol('k')
-        >>> spring_force = stiffness * pathway.length
+        >>> spring_force = -stiffness * pathway.length
         >>> spring = ForceActuator(spring_force, pathway)
 
         The forces produced by the spring can be generated in the list of loads
@@ -224,12 +228,12 @@ def to_loads(self) -> list[tuple[Point, Vector]]:
         A simple linear damper can be modeled in a similar way. Create another
         symbol ``c`` to describe the dampers damping coefficient. This time
         instantiate a force actuator that produces a force proportional to both
-        the damper's damping coefficient and the pathway's shortening velocity.
+        the damper's damping coefficient and the pathway's extension velocity.
         Note that the damping force is negative as it acts in the opposite
         direction to which the damper is changing in length.
 
         >>> damping_coefficient = Symbol('c')
-        >>> damping_force = -damping_coefficient * pathway.shortening_velocity
+        >>> damping_force = -damping_coefficient * pathway.extension_velocity
         >>> damper = ForceActuator(damping_force, pathway)
 
         Again, the forces produces by the damper can be generated by calling
diff --git a/sympy/physics/mechanics/_pathway.py b/sympy/physics/mechanics/_pathway.py
index e3460723df..196bee2f6f 100644
--- a/sympy/physics/mechanics/_pathway.py
+++ b/sympy/physics/mechanics/_pathway.py
@@ -71,8 +71,8 @@ def length(self) -> Expr:
 
     @property
     @abstractmethod
-    def shortening_velocity(self) -> Expr:
-        """An expression representing the pathway's shortening velocity."""
+    def extension_velocity(self) -> Expr:
+        """An expression representing the pathway's extension velocity."""
         pass
 
     @abstractmethod
@@ -133,7 +133,7 @@ class LinearPathway(PathwayBase):
     The pathway created above isn't very interesting without the positions and
     velocities of its attachment points being described. Without this its not
     possible to describe how the pathway moves, i.e. its length or its
-    shortening velocity.
+    extension velocity.
 
     >>> from sympy.physics.mechanics import ReferenceFrame
     >>> from sympy.physics.vector import dynamicsymbols
@@ -152,11 +152,11 @@ class LinearPathway(PathwayBase):
     is actually required as it ensures that a pathway's length is always
     positive.
 
-    A pathway's shortening velocity can be accessed similarly via its
-    ``shortening_velocity`` attribute.
+    A pathway's extension velocity can be accessed similarly via its
+    ``extension_velocity`` attribute.
 
-    >>> linear_pathway.shortening_velocity
-    -q(t)*Derivative(q(t), t)/sqrt(q(t)**2)
+    >>> linear_pathway.extension_velocity
+    q(t)*Derivative(q(t), t)/sqrt(q(t)**2)
 
     Parameters
     ==========
@@ -189,8 +189,8 @@ def length(self) -> Expr:
         return length
 
     @property
-    def shortening_velocity(self) -> Expr:
-        """Exact analytical expression for the pathway's shortening velocity."""
+    def extension_velocity(self) -> Expr:
+        """Exact analytical expression for the pathway's extension velocity."""
         relative_position = self.attachments[-1].pos_from(self.attachments[0])
         if not relative_position:
             return S.Zero
@@ -200,8 +200,8 @@ def shortening_velocity(self) -> Expr:
         # ``relative_position`` is defined using.
         frame = relative_position.args[0][1]
         relative_velocity = relative_position.diff(t, frame)
-        shortening_velocity = -relative_velocity.dot(relative_position.normalize())
-        return shortening_velocity
+        extension_velocity = relative_velocity.dot(relative_position.normalize())
+        return extension_velocity
 
     def compute_loads(self, force: Expr) -> list[tuple[Point, Vector]]:
         """Loads required by the equations of motion method classes.
@@ -222,9 +222,9 @@ def compute_loads(self, force: Expr) -> list[tuple[Point, Vector]]:
         ========
 
         The below example shows how to generate the loads produced in a linear
-        actuator that produces a contractile force ``F``. First, create a
-        linear actuator between two points separated by the coordinate ``q``
-        in the ``x`` direction of the global frame ``N``.
+        actuator that produces an expansile force ``F``. First, create a linear
+        actuator between two points separated by the coordinate ``q`` in the
+        ``x`` direction of the global frame ``N``.
 
         >>> from sympy.physics.mechanics import Point, ReferenceFrame
         >>> from sympy.physics.mechanics._pathway import LinearPathway
@@ -235,28 +235,27 @@ def compute_loads(self, force: Expr) -> list[tuple[Point, Vector]]:
         >>> pB.set_pos(pA, q * N.x)
         >>> linear_pathway = LinearPathway(pA, pB)
 
-        Now create a symbol ``F`` to describe the magnitude of the
-        (contractile) force that will be produced along the pathway. The list
-        of loads that ``KanesMethod`` requires can be produced by calling the
-        pathway's ``compute_loads`` method with ``F`` passed as the only
-        argument.
+        Now create a symbol ``F`` to describe the magnitude of the (expansile)
+        force that will be produced along the pathway. The list of loads that
+        ``KanesMethod`` requires can be produced by calling the pathway's
+        ``compute_loads`` method with ``F`` passed as the only argument.
 
         >>> from sympy import Symbol
         >>> F = Symbol('F')
         >>> linear_pathway.compute_loads(F)
-        [(pA, F*q(t)/sqrt(q(t)**2)*N.x), (pB, - F*q(t)/sqrt(q(t)**2)*N.x)]
+        [(pA, - F*q(t)/sqrt(q(t)**2)*N.x), (pB, F*q(t)/sqrt(q(t)**2)*N.x)]
 
         Parameters
         ==========
 
         force : Expr
             The force acting along the length of the pathway. It is assumed
-            that this ``Expr`` represents a contractile force.
+            that this ``Expr`` represents an expansile force.
 
         """
         relative_position = self.attachments[-1].pos_from(self.attachments[0])
         loads = [
-            (self.attachments[0], force * relative_position / self.length),
-            (self.attachments[-1], -force * relative_position / self.length),
+            (self.attachments[0], -force * relative_position / self.length),
+            (self.attachments[-1], force * relative_position / self.length),
         ]
         return loads


Test patch:
diff --git a/sympy/physics/mechanics/tests/test_actuator.py b/sympy/physics/mechanics/tests/test_actuator.py
index 70682ea5c6..7897f2b92c 100644
--- a/sympy/physics/mechanics/tests/test_actuator.py
+++ b/sympy/physics/mechanics/tests/test_actuator.py
@@ -74,8 +74,8 @@ def test_to_loads_static_pathway(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.N.x)
         actuator = ForceActuator(self.force, self.pathway)
         expected = [
-            (self.pA, self.force * self.N.x),
-            (self.pB, - self.force * self.N.x),
+            (self.pA, - self.force * self.N.x),
+            (self.pB, self.force * self.N.x),
         ]
         assert actuator.to_loads() == expected
 
@@ -83,8 +83,8 @@ def test_to_loads_2D_pathway(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.q1 * self.N.x)
         actuator = ForceActuator(self.force, self.pathway)
         expected = [
-            (self.pA, self.force * (self.q1 / sqrt(self.q1**2)) * self.N.x),
-            (self.pB, - self.force * (self.q1 / sqrt(self.q1**2)) * self.N.x),
+            (self.pA, - self.force * (self.q1 / sqrt(self.q1**2)) * self.N.x),
+            (self.pB, self.force * (self.q1 / sqrt(self.q1**2)) * self.N.x),
         ]
         assert actuator.to_loads() == expected
 
@@ -96,15 +96,15 @@ def test_to_loads_3D_pathway(self) -> None:
         actuator = ForceActuator(self.force, self.pathway)
         length = sqrt(self.q1**2 + self.q2**2 + 4*self.q3**2)
         pO_force = (
-            self.force * self.q1 * self.N.x / length
-            - self.force * self.q2 * self.N.y / length
-            + 2 * self.force * self.q3 * self.N.z / length
-        )
-        pI_force = (
             - self.force * self.q1 * self.N.x / length
             + self.force * self.q2 * self.N.y / length
             - 2 * self.force * self.q3 * self.N.z / length
         )
+        pI_force = (
+            self.force * self.q1 * self.N.x / length
+            - self.force * self.q2 * self.N.y / length
+            + 2 * self.force * self.q3 * self.N.z / length
+        )
         expected = [
             (self.pA, pO_force),
             (self.pB, pI_force),
@@ -148,9 +148,9 @@ def test_forced_mass_spring_damper_model():
 
     mass = Particle('mass', attachment, m)
     pathway = LinearPathway(origin, attachment)
-    stiffness = k * pathway.length  # positive as assumes contractile force
+    stiffness = -k * pathway.length  # positive as assumes expansile force
     spring = ForceActuator(stiffness, pathway)
-    damping = -c * pathway.shortening_velocity  # negative as acts against velocity
+    damping = -c * pathway.extension_velocity  # negative as acts against velocity
     damper = ForceActuator(damping, pathway)
 
     kanes_method = KanesMethod(
diff --git a/sympy/physics/mechanics/tests/test_pathway.py b/sympy/physics/mechanics/tests/test_pathway.py
index b812577449..05b28041a9 100644
--- a/sympy/physics/mechanics/tests/test_pathway.py
+++ b/sympy/physics/mechanics/tests/test_pathway.py
@@ -7,6 +7,7 @@
 import pytest
 
 from sympy.core.backend import Symbol, sqrt
+from sympy.simplify.simplify import simplify
 from sympy.physics.mechanics import Point, ReferenceFrame, dynamicsymbols
 from sympy.physics.mechanics._pathway import LinearPathway
 
@@ -74,15 +75,15 @@ def test_static_pathway_length(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.N.x)
         assert self.pathway.length == 2
 
-    def test_static_pathway_shortening_velocity(self) -> None:
+    def test_static_pathway_extension_velocity(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.N.x)
-        assert self.pathway.shortening_velocity == 0
+        assert self.pathway.extension_velocity == 0
 
     def test_static_pathway_compute_loads(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.N.x)
         expected = [
-            (self.pA, self.F * self.N.x),
-            (self.pB, - self.F * self.N.x),
+            (self.pA, - self.F * self.N.x),
+            (self.pB, self.F * self.N.x),
         ]
         assert self.pathway.compute_loads(self.F) == expected
 
@@ -91,16 +92,16 @@ def test_2D_pathway_length(self) -> None:
         expected = 2 * sqrt(self.q1**2)
         assert self.pathway.length == expected
 
-    def test_2D_pathway_shortening_velocity(self) -> None:
+    def test_2D_pathway_extension_velocity(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.q1 * self.N.x)
-        expected = -2 * self.q1 * self.q1d / sqrt(self.q1**2)
-        assert self.pathway.shortening_velocity == expected
+        expected = 2 * self.q1 * self.q1d / sqrt(self.q1**2)
+        assert self.pathway.extension_velocity == expected
 
     def test_2D_pathway_compute_loads(self) -> None:
         self.pB.set_pos(self.pA, 2 * self.q1 * self.N.x)
         expected = [
-            (self.pA, self.F * (self.q1 / sqrt(self.q1**2)) * self.N.x),
-            (self.pB, - self.F * (self.q1 / sqrt(self.q1**2)) * self.N.x),
+            (self.pA, - self.F * (self.q1 / sqrt(self.q1**2)) * self.N.x),
+            (self.pB, self.F * (self.q1 / sqrt(self.q1**2)) * self.N.x),
         ]
         assert self.pathway.compute_loads(self.F) == expected
 
@@ -110,20 +111,20 @@ def test_3D_pathway_length(self) -> None:
             self.q1*self.N.x - self.q2*self.N.y + 2*self.q3*self.N.z,
         )
         expected = sqrt(self.q1**2 + self.q2**2 + 4*self.q3**2)
-        assert self.pathway.length == expected
+        assert simplify(self.pathway.length - expected) == 0
 
-    def test_3D_pathway_shortening_velocity(self) -> None:
+    def test_3D_pathway_extension_velocity(self) -> None:
         self.pB.set_pos(
             self.pA,
             self.q1*self.N.x - self.q2*self.N.y + 2*self.q3*self.N.z,
         )
         length = sqrt(self.q1**2 + self.q2**2 + 4*self.q3**2)
         expected = (
-            - self.q1 * self.q1d / length
-            - self.q2 * self.q2d / length
-            - 4 * self.q3 * self.q3d / length
+            self.q1 * self.q1d / length
+            + self.q2 * self.q2d / length
+            + 4 * self.q3 * self.q3d / length
         )
-        assert self.pathway.shortening_velocity == expected
+        assert simplify(self.pathway.extension_velocity - expected) == 0
 
     def test_3D_pathway_compute_loads(self) -> None:
         self.pB.set_pos(
@@ -132,15 +133,15 @@ def test_3D_pathway_compute_loads(self) -> None:
         )
         length = sqrt(self.q1**2 + self.q2**2 + 4*self.q3**2)
         pO_force = (
-            self.F * self.q1 * self.N.x / length
-            - self.F * self.q2 * self.N.y / length
-            + 2 * self.F * self.q3 * self.N.z / length
-        )
-        pI_force = (
             - self.F * self.q1 * self.N.x / length
             + self.F * self.q2 * self.N.y / length
             - 2 * self.F * self.q3 * self.N.z / length
         )
+        pI_force = (
+            self.F * self.q1 * self.N.x / length
+            - self.F * self.q2 * self.N.y / length
+            + 2 * self.F * self.q3 * self.N.z / length
+        )
         expected = [
             (self.pA, pO_force),
             (self.pB, pI_force),