jaxsim 0.6.2.dev194__py3-none-any.whl → 0.6.2.dev228__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jaxsim/_version.py +2 -2
- jaxsim/api/__init__.py +0 -1
- jaxsim/api/com.py +1 -3
- jaxsim/api/contact.py +140 -24
- jaxsim/api/data.py +39 -12
- jaxsim/api/integrators.py +16 -9
- jaxsim/api/model.py +19 -35
- jaxsim/api/ode.py +28 -6
- jaxsim/math/__init__.py +1 -1
- jaxsim/parsers/kinematic_graph.py +1 -1
- jaxsim/rbda/__init__.py +1 -1
- jaxsim/rbda/contacts/__init__.py +6 -2
- jaxsim/rbda/contacts/common.py +114 -4
- jaxsim/rbda/contacts/relaxed_rigid.py +51 -167
- jaxsim/rbda/contacts/rigid.py +538 -0
- jaxsim/rbda/contacts/soft.py +448 -0
- jaxsim/rbda/forward_kinematics.py +0 -29
- jaxsim/rbda/utils.py +2 -2
- {jaxsim-0.6.2.dev194.dist-info → jaxsim-0.6.2.dev228.dist-info}/METADATA +6 -9
- {jaxsim-0.6.2.dev194.dist-info → jaxsim-0.6.2.dev228.dist-info}/RECORD +23 -22
- {jaxsim-0.6.2.dev194.dist-info → jaxsim-0.6.2.dev228.dist-info}/WHEEL +1 -1
- jaxsim/api/contact_model.py +0 -101
- {jaxsim-0.6.2.dev194.dist-info → jaxsim-0.6.2.dev228.dist-info/licenses}/LICENSE +0 -0
- {jaxsim-0.6.2.dev194.dist-info → jaxsim-0.6.2.dev228.dist-info}/top_level.txt +0 -0
jaxsim/rbda/contacts/__init__.py
CHANGED
@@ -1,5 +1,9 @@
|
|
1
|
-
from . import relaxed_rigid
|
1
|
+
from . import relaxed_rigid, rigid, soft
|
2
2
|
from .common import ContactModel, ContactsParams
|
3
3
|
from .relaxed_rigid import RelaxedRigidContacts, RelaxedRigidContactsParams
|
4
|
+
from .rigid import RigidContacts, RigidContactsParams
|
5
|
+
from .soft import SoftContacts, SoftContactsParams
|
4
6
|
|
5
|
-
ContactParamsTypes =
|
7
|
+
ContactParamsTypes = (
|
8
|
+
SoftContactsParams | RigidContactsParams | RelaxedRigidContactsParams
|
9
|
+
)
|
jaxsim/rbda/contacts/common.py
CHANGED
@@ -9,6 +9,7 @@ import jax.numpy as jnp
|
|
9
9
|
import jaxsim.api as js
|
10
10
|
import jaxsim.terrain
|
11
11
|
import jaxsim.typing as jtp
|
12
|
+
from jaxsim.math import STANDARD_GRAVITY
|
12
13
|
from jaxsim.utils import JaxsimDataclass
|
13
14
|
|
14
15
|
try:
|
@@ -80,6 +81,86 @@ class ContactsParams(JaxsimDataclass):
|
|
80
81
|
"""
|
81
82
|
pass
|
82
83
|
|
84
|
+
def build_default_from_jaxsim_model(
|
85
|
+
self: type[Self],
|
86
|
+
model: js.model.JaxSimModel,
|
87
|
+
*,
|
88
|
+
stiffness: jtp.FloatLike | None = None,
|
89
|
+
damping: jtp.FloatLike | None = None,
|
90
|
+
standard_gravity: jtp.FloatLike = STANDARD_GRAVITY,
|
91
|
+
static_friction_coefficient: jtp.FloatLike = 0.5,
|
92
|
+
max_penetration: jtp.FloatLike = 0.001,
|
93
|
+
number_of_active_collidable_points_steady_state: jtp.IntLike = 1,
|
94
|
+
damping_ratio: jtp.FloatLike = 1.0,
|
95
|
+
p: jtp.FloatLike = 0.5,
|
96
|
+
q: jtp.FloatLike = 0.5,
|
97
|
+
**kwargs,
|
98
|
+
) -> Self:
|
99
|
+
"""
|
100
|
+
Create a `ContactsParams` instance with default parameters.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
model: The robot model considered by the contact model.
|
104
|
+
stiffness: The stiffness of the contact model.
|
105
|
+
damping: The damping of the contact model.
|
106
|
+
standard_gravity: The standard gravity acceleration.
|
107
|
+
static_friction_coefficient: The static friction coefficient.
|
108
|
+
max_penetration: The maximum penetration depth.
|
109
|
+
number_of_active_collidable_points_steady_state:
|
110
|
+
The number of active collidable points in steady state.
|
111
|
+
damping_ratio: The damping ratio.
|
112
|
+
p: The first parameter of the contact model.
|
113
|
+
q: The second parameter of the contact model.
|
114
|
+
**kwargs: Optional additional arguments.
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
The `ContactsParams` instance.
|
118
|
+
|
119
|
+
Note:
|
120
|
+
The `stiffness` is intended as the terrain stiffness in the Soft Contacts model,
|
121
|
+
while it is the Baumgarte stabilization stiffness in the Rigid Contacts model.
|
122
|
+
|
123
|
+
The `damping` is intended as the terrain damping in the Soft Contacts model,
|
124
|
+
while it is the Baumgarte stabilization damping in the Rigid Contacts model.
|
125
|
+
|
126
|
+
The `damping_ratio` parameter allows to operate on the following conditions:
|
127
|
+
- ξ > 1.0: over-damped
|
128
|
+
- ξ = 1.0: critically damped
|
129
|
+
- ξ < 1.0: under-damped
|
130
|
+
"""
|
131
|
+
|
132
|
+
# Use symbols for input parameters.
|
133
|
+
ξ = damping_ratio
|
134
|
+
δ_max = max_penetration
|
135
|
+
μc = static_friction_coefficient
|
136
|
+
|
137
|
+
# Compute the total mass of the model.
|
138
|
+
m = jnp.array(model.kin_dyn_parameters.link_parameters.mass).sum()
|
139
|
+
|
140
|
+
# Rename the standard gravity.
|
141
|
+
g = standard_gravity
|
142
|
+
|
143
|
+
# Compute the average support force on each collidable point.
|
144
|
+
f_average = m * g / number_of_active_collidable_points_steady_state
|
145
|
+
|
146
|
+
# Compute the stiffness to get the desired steady-state penetration.
|
147
|
+
# Note that this is dependent on the non-linear exponent used in
|
148
|
+
# the damping term of the Hunt/Crossley model.
|
149
|
+
K = f_average / jnp.power(δ_max, 1 + p) if stiffness is None else stiffness
|
150
|
+
|
151
|
+
# Compute the damping using the damping ratio.
|
152
|
+
critical_damping = 2 * jnp.sqrt(K * m)
|
153
|
+
D = ξ * critical_damping if damping is None else damping
|
154
|
+
|
155
|
+
return self.build(
|
156
|
+
K=K,
|
157
|
+
D=D,
|
158
|
+
mu=μc,
|
159
|
+
p=p,
|
160
|
+
q=q,
|
161
|
+
**kwargs,
|
162
|
+
)
|
163
|
+
|
83
164
|
@abc.abstractmethod
|
84
165
|
def valid(self, **kwargs) -> jtp.BoolLike:
|
85
166
|
"""
|
@@ -156,7 +237,7 @@ class ContactModel(JaxsimDataclass):
|
|
156
237
|
return {}
|
157
238
|
|
158
239
|
@property
|
159
|
-
def _parameters_class(
|
240
|
+
def _parameters_class(self) -> type[ContactsParams]:
|
160
241
|
"""
|
161
242
|
Return the class of the contact parameters.
|
162
243
|
|
@@ -168,8 +249,37 @@ class ContactModel(JaxsimDataclass):
|
|
168
249
|
return getattr(
|
169
250
|
importlib.import_module("jaxsim.rbda.contacts"),
|
170
251
|
(
|
171
|
-
|
172
|
-
if isinstance(
|
173
|
-
else
|
252
|
+
self.__name__ + "Params"
|
253
|
+
if isinstance(self, type)
|
254
|
+
else self.__class__.__name__ + "Params"
|
174
255
|
),
|
175
256
|
)
|
257
|
+
|
258
|
+
@abc.abstractmethod
|
259
|
+
def update_contact_state(
|
260
|
+
self: type[Self], old_contact_state: dict[str, jtp.Array]
|
261
|
+
) -> dict[str, jtp.Array]:
|
262
|
+
"""
|
263
|
+
Update the contact state.
|
264
|
+
|
265
|
+
Args:
|
266
|
+
old_contact_state: The old contact state.
|
267
|
+
|
268
|
+
Returns:
|
269
|
+
The updated contact state.
|
270
|
+
"""
|
271
|
+
|
272
|
+
@abc.abstractmethod
|
273
|
+
def update_velocity_after_impact(
|
274
|
+
self: type[Self], model: js.model.JaxSimModel, data: js.data.JaxSimModelData
|
275
|
+
) -> js.data.JaxSimModelData:
|
276
|
+
"""
|
277
|
+
Update the velocity after an impact.
|
278
|
+
|
279
|
+
Args:
|
280
|
+
model: The robot model considered by the contact model.
|
281
|
+
data: The data of the considered model.
|
282
|
+
|
283
|
+
Returns:
|
284
|
+
The updated data of the considered model.
|
285
|
+
"""
|
@@ -1,7 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import dataclasses
|
4
|
-
import functools
|
5
4
|
from collections.abc import Callable
|
6
5
|
from typing import Any
|
7
6
|
|
@@ -11,12 +10,10 @@ import jax_dataclasses
|
|
11
10
|
import optax
|
12
11
|
|
13
12
|
import jaxsim.api as js
|
14
|
-
import jaxsim.rbda.contacts
|
15
13
|
import jaxsim.typing as jtp
|
16
14
|
from jaxsim.api.common import ModelDataWithVelocityRepresentation, VelRepr
|
17
|
-
from jaxsim.terrain.terrain import Terrain
|
18
15
|
|
19
|
-
from . import common
|
16
|
+
from . import common, soft
|
20
17
|
|
21
18
|
try:
|
22
19
|
from typing import Self
|
@@ -64,12 +61,12 @@ class RelaxedRigidContactsParams(common.ContactsParams):
|
|
64
61
|
)
|
65
62
|
|
66
63
|
# Stiffness
|
67
|
-
|
64
|
+
K: jtp.Float = dataclasses.field(
|
68
65
|
default_factory=lambda: jnp.array(0.0, dtype=float)
|
69
66
|
)
|
70
67
|
|
71
68
|
# Damping
|
72
|
-
|
69
|
+
D: jtp.Float = dataclasses.field(
|
73
70
|
default_factory=lambda: jnp.array(0.0, dtype=float)
|
74
71
|
)
|
75
72
|
|
@@ -90,13 +87,16 @@ class RelaxedRigidContactsParams(common.ContactsParams):
|
|
90
87
|
HashedNumpyArray(self.width),
|
91
88
|
HashedNumpyArray(self.midpoint),
|
92
89
|
HashedNumpyArray(self.power),
|
93
|
-
HashedNumpyArray(self.
|
94
|
-
HashedNumpyArray(self.
|
90
|
+
HashedNumpyArray(self.K),
|
91
|
+
HashedNumpyArray(self.D),
|
95
92
|
HashedNumpyArray(self.mu),
|
96
93
|
)
|
97
94
|
)
|
98
95
|
|
99
96
|
def __eq__(self, other: RelaxedRigidContactsParams) -> bool:
|
97
|
+
if not isinstance(other, RelaxedRigidContactsParams):
|
98
|
+
return False
|
99
|
+
|
100
100
|
return hash(self) == hash(other)
|
101
101
|
|
102
102
|
@classmethod
|
@@ -110,9 +110,10 @@ class RelaxedRigidContactsParams(common.ContactsParams):
|
|
110
110
|
width: jtp.FloatLike | None = None,
|
111
111
|
midpoint: jtp.FloatLike | None = None,
|
112
112
|
power: jtp.FloatLike | None = None,
|
113
|
-
|
114
|
-
|
113
|
+
K: jtp.FloatLike | None = None,
|
114
|
+
D: jtp.FloatLike | None = None,
|
115
115
|
mu: jtp.FloatLike | None = None,
|
116
|
+
**kwargs,
|
116
117
|
) -> Self:
|
117
118
|
"""Create a `RelaxedRigidContactsParams` instance."""
|
118
119
|
|
@@ -151,13 +152,11 @@ class RelaxedRigidContactsParams(common.ContactsParams):
|
|
151
152
|
power=jnp.array(
|
152
153
|
power if power is not None else default("power"), dtype=float
|
153
154
|
),
|
154
|
-
|
155
|
-
|
155
|
+
K=jnp.array(
|
156
|
+
K if K is not None else default("K"),
|
156
157
|
dtype=float,
|
157
158
|
),
|
158
|
-
|
159
|
-
damping if damping is not None else default("damping"), dtype=float
|
160
|
-
),
|
159
|
+
D=jnp.array(D if D is not None else default("D"), dtype=float),
|
161
160
|
mu=jnp.array(mu if mu is not None else default("mu"), dtype=float),
|
162
161
|
)
|
163
162
|
|
@@ -243,6 +242,37 @@ class RelaxedRigidContacts(common.ContactModel):
|
|
243
242
|
**kwargs,
|
244
243
|
)
|
245
244
|
|
245
|
+
def update_contact_state(
|
246
|
+
self: type[Self], old_contact_state: dict[str, jtp.Array]
|
247
|
+
) -> dict[str, jtp.Array]:
|
248
|
+
"""
|
249
|
+
Update the contact state.
|
250
|
+
|
251
|
+
Args:
|
252
|
+
old_contact_state: The old contact state.
|
253
|
+
|
254
|
+
Returns:
|
255
|
+
The updated contact state.
|
256
|
+
"""
|
257
|
+
|
258
|
+
return {}
|
259
|
+
|
260
|
+
def update_velocity_after_impact(
|
261
|
+
self: type[Self], model: js.model.JaxSimModel, data: js.data.JaxSimModelData
|
262
|
+
) -> js.data.JaxSimModelData:
|
263
|
+
"""
|
264
|
+
Update the velocity after an impact.
|
265
|
+
|
266
|
+
Args:
|
267
|
+
model: The robot model considered by the contact model.
|
268
|
+
data: The data of the considered model.
|
269
|
+
|
270
|
+
Returns:
|
271
|
+
The updated data of the considered model.
|
272
|
+
"""
|
273
|
+
|
274
|
+
return data
|
275
|
+
|
246
276
|
@jax.jit
|
247
277
|
def compute_contact_forces(
|
248
278
|
self,
|
@@ -314,11 +344,11 @@ class RelaxedRigidContacts(common.ContactModel):
|
|
314
344
|
BW_ν = data.generalized_velocity
|
315
345
|
|
316
346
|
BW_ν̇_free = jnp.hstack(
|
317
|
-
js.
|
347
|
+
js.model.forward_dynamics_aba(
|
318
348
|
model=model,
|
319
349
|
data=data,
|
320
350
|
link_forces=references.link_forces(model=model, data=data),
|
321
|
-
|
351
|
+
joint_forces=references.joint_force_references(model=model),
|
322
352
|
)
|
323
353
|
)
|
324
354
|
|
@@ -341,7 +371,7 @@ class RelaxedRigidContacts(common.ContactModel):
|
|
341
371
|
model=model,
|
342
372
|
position_constraint=position_constraint,
|
343
373
|
velocity_constraint=velocity,
|
344
|
-
parameters=model.
|
374
|
+
parameters=model.contact_params,
|
345
375
|
)
|
346
376
|
|
347
377
|
# Compute the Delassus matrix and the free mixed linear acceleration of
|
@@ -425,7 +455,7 @@ class RelaxedRigidContacts(common.ContactModel):
|
|
425
455
|
|
426
456
|
# Initialize the optimized forces with a linear Hunt/Crossley model.
|
427
457
|
init_params = jax.vmap(
|
428
|
-
lambda p, v:
|
458
|
+
lambda p, v: soft.SoftContacts.hunt_crossley_contact_model(
|
429
459
|
position=p,
|
430
460
|
velocity=v,
|
431
461
|
terrain=model.terrain,
|
@@ -501,8 +531,8 @@ class RelaxedRigidContacts(common.ContactModel):
|
|
501
531
|
"width",
|
502
532
|
"midpoint",
|
503
533
|
"power",
|
504
|
-
"
|
505
|
-
"
|
534
|
+
"K",
|
535
|
+
"D",
|
506
536
|
"mu",
|
507
537
|
)
|
508
538
|
)
|
@@ -598,149 +628,3 @@ class RelaxedRigidContacts(common.ContactModel):
|
|
598
628
|
)
|
599
629
|
|
600
630
|
return a_ref, jnp.diag(R), K, D
|
601
|
-
|
602
|
-
@staticmethod
|
603
|
-
@functools.partial(jax.jit, static_argnames=("terrain",))
|
604
|
-
def _hunt_crossley_contact_model(
|
605
|
-
position: jtp.VectorLike,
|
606
|
-
velocity: jtp.VectorLike,
|
607
|
-
tangential_deformation: jtp.VectorLike,
|
608
|
-
terrain: Terrain,
|
609
|
-
K: jtp.FloatLike,
|
610
|
-
D: jtp.FloatLike,
|
611
|
-
mu: jtp.FloatLike,
|
612
|
-
p: jtp.FloatLike = 0.5,
|
613
|
-
q: jtp.FloatLike = 0.5,
|
614
|
-
) -> tuple[jtp.Vector, jtp.Vector]:
|
615
|
-
"""
|
616
|
-
Compute the contact force using the Hunt/Crossley model.
|
617
|
-
|
618
|
-
Args:
|
619
|
-
position: The position of the collidable point.
|
620
|
-
velocity: The velocity of the collidable point.
|
621
|
-
tangential_deformation: The material deformation of the collidable point.
|
622
|
-
terrain: The terrain model.
|
623
|
-
K: The stiffness parameter.
|
624
|
-
D: The damping parameter of the soft contacts model.
|
625
|
-
mu: The static friction coefficient.
|
626
|
-
p:
|
627
|
-
The exponent p corresponding to the damping-related non-linearity
|
628
|
-
of the Hunt/Crossley model.
|
629
|
-
q:
|
630
|
-
The exponent q corresponding to the spring-related non-linearity
|
631
|
-
of the Hunt/Crossley model
|
632
|
-
|
633
|
-
Returns:
|
634
|
-
A tuple containing the computed contact force and the derivative of the
|
635
|
-
material deformation.
|
636
|
-
"""
|
637
|
-
|
638
|
-
# Convert the input vectors to arrays.
|
639
|
-
W_p_C = jnp.array(position, dtype=float).squeeze()
|
640
|
-
W_ṗ_C = jnp.array(velocity, dtype=float).squeeze()
|
641
|
-
m = jnp.array(tangential_deformation, dtype=float).squeeze()
|
642
|
-
|
643
|
-
# Use symbol for the static friction.
|
644
|
-
μ = mu
|
645
|
-
|
646
|
-
# Compute the penetration depth, its rate, and the considered terrain normal.
|
647
|
-
δ, δ̇, n̂ = common.compute_penetration_data(p=W_p_C, v=W_ṗ_C, terrain=terrain)
|
648
|
-
|
649
|
-
# There are few operations like computing the norm of a vector with zero length
|
650
|
-
# or computing the square root of zero that are problematic in an AD context.
|
651
|
-
# To avoid these issues, we introduce a small tolerance ε to their arguments
|
652
|
-
# and make sure that we do not check them against zero directly.
|
653
|
-
ε = jnp.finfo(float).eps
|
654
|
-
|
655
|
-
# Compute the powers of the penetration depth.
|
656
|
-
# Inject ε to address AD issues in differentiating the square root when
|
657
|
-
# p and q are fractional.
|
658
|
-
δp = jnp.power(δ + ε, p)
|
659
|
-
δq = jnp.power(δ + ε, q)
|
660
|
-
|
661
|
-
# ========================
|
662
|
-
# Compute the normal force
|
663
|
-
# ========================
|
664
|
-
|
665
|
-
# Non-linear spring-damper model (Hunt/Crossley model).
|
666
|
-
# This is the force magnitude along the direction normal to the terrain.
|
667
|
-
force_normal_mag = (K * δp) * δ + (D * δq) * δ̇
|
668
|
-
|
669
|
-
# Depending on the magnitude of δ̇, the normal force could be negative.
|
670
|
-
force_normal_mag = jnp.maximum(0.0, force_normal_mag)
|
671
|
-
|
672
|
-
# Compute the 3D linear force in C[W] frame.
|
673
|
-
f_normal = force_normal_mag * n̂
|
674
|
-
|
675
|
-
# ============================
|
676
|
-
# Compute the tangential force
|
677
|
-
# ============================
|
678
|
-
|
679
|
-
# Extract the tangential component of the velocity.
|
680
|
-
v_tangential = W_ṗ_C - jnp.dot(W_ṗ_C, n̂) * n̂
|
681
|
-
|
682
|
-
# Extract the normal and tangential components of the material deformation.
|
683
|
-
m_normal = jnp.dot(m, n̂) * n̂
|
684
|
-
m_tangential = m - jnp.dot(m, n̂) * n̂
|
685
|
-
|
686
|
-
# Compute the tangential force in the sticking case.
|
687
|
-
# Using the tangential component of the material deformation should not be
|
688
|
-
# necessary if the sticking-slipping transition occurs in a terrain area
|
689
|
-
# with a locally constant normal. However, this assumption is not true in
|
690
|
-
# general, especially for highly uneven terrains.
|
691
|
-
f_tangential = -((K * δp) * m_tangential + (D * δq) * v_tangential)
|
692
|
-
|
693
|
-
# Detect the contact type (sticking or slipping).
|
694
|
-
# Note that if there is no contact, sticking is set to True, and this detail
|
695
|
-
# is exploited in the computation of the `contact_status` variable.
|
696
|
-
sticking = jnp.logical_or(
|
697
|
-
δ <= 0, f_tangential.dot(f_tangential) <= (μ * force_normal_mag) ** 2
|
698
|
-
)
|
699
|
-
|
700
|
-
# Compute the direction of the tangential force.
|
701
|
-
# To prevent dividing by zero, we use a switch statement.
|
702
|
-
norm = jaxsim.math.safe_norm(f_tangential, axis=-1)
|
703
|
-
f_tangential_direction = f_tangential / (
|
704
|
-
norm + jnp.finfo(float).eps * (norm == 0)
|
705
|
-
)
|
706
|
-
|
707
|
-
# Project the tangential force to the friction cone if slipping.
|
708
|
-
f_tangential = jnp.where(
|
709
|
-
sticking,
|
710
|
-
f_tangential,
|
711
|
-
jnp.minimum(μ * force_normal_mag, norm) * f_tangential_direction,
|
712
|
-
)
|
713
|
-
|
714
|
-
# Set the tangential force to zero if there is no contact.
|
715
|
-
f_tangential = jnp.where(δ <= 0, jnp.zeros(3), f_tangential)
|
716
|
-
|
717
|
-
# =====================================
|
718
|
-
# Compute the material deformation rate
|
719
|
-
# =====================================
|
720
|
-
|
721
|
-
# Compute the derivative of the material deformation.
|
722
|
-
# Note that we included an additional relaxation of `m_normal` in the
|
723
|
-
# sticking case, so that the normal deformation that could have accumulated
|
724
|
-
# from a previous slipping phase can relax to zero.
|
725
|
-
ṁ_no_contact = -(K / D) * m
|
726
|
-
ṁ_sticking = v_tangential - (K / D) * m_normal
|
727
|
-
ṁ_slipping = -(f_tangential + (K * δp) * m_tangential) / (D * δq)
|
728
|
-
|
729
|
-
# Compute the contact status:
|
730
|
-
# 0: slipping
|
731
|
-
# 1: sticking
|
732
|
-
# 2: no contact
|
733
|
-
contact_status = sticking.astype(int)
|
734
|
-
contact_status += (δ <= 0).astype(int)
|
735
|
-
|
736
|
-
# Select the right material deformation rate depending on the contact status.
|
737
|
-
ṁ = jax.lax.select_n(contact_status, ṁ_slipping, ṁ_sticking, ṁ_no_contact)
|
738
|
-
|
739
|
-
# ==========================================
|
740
|
-
# Compute and return the final contact force
|
741
|
-
# ==========================================
|
742
|
-
|
743
|
-
# Sum the normal and tangential forces.
|
744
|
-
CW_fl = f_normal + f_tangential
|
745
|
-
|
746
|
-
return CW_fl, ṁ
|