milldem 0.2.0__tar.gz
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.
- milldem-0.2.0/PKG-INFO +56 -0
- milldem-0.2.0/README.md +20 -0
- milldem-0.2.0/milldem/__init__.py +46 -0
- milldem-0.2.0/milldem/__main__.py +5 -0
- milldem-0.2.0/milldem/cli.py +97 -0
- milldem-0.2.0/milldem/contact.py +89 -0
- milldem-0.2.0/milldem/engine.py +422 -0
- milldem-0.2.0/milldem/engine3d.py +232 -0
- milldem-0.2.0/milldem/metrics.py +122 -0
- milldem-0.2.0/milldem.egg-info/PKG-INFO +56 -0
- milldem-0.2.0/milldem.egg-info/SOURCES.txt +18 -0
- milldem-0.2.0/milldem.egg-info/dependency_links.txt +1 -0
- milldem-0.2.0/milldem.egg-info/entry_points.txt +2 -0
- milldem-0.2.0/milldem.egg-info/requires.txt +16 -0
- milldem-0.2.0/milldem.egg-info/top_level.txt +1 -0
- milldem-0.2.0/pyproject.toml +48 -0
- milldem-0.2.0/setup.cfg +4 -0
- milldem-0.2.0/tests/test_contact.py +50 -0
- milldem-0.2.0/tests/test_engine.py +95 -0
- milldem-0.2.0/tests/test_power3d.py +51 -0
milldem-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: milldem
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Cross-platform soft-sphere DEM for tumbling-mill charge motion and power (2D charge shape + thin-3D-slab power validated vs Hogg-Fuerstenau; no C++/WSL).
|
|
5
|
+
Author: Felipe Santibanez-Leal
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/fsantibanezleal/CAOS_MillDEM
|
|
8
|
+
Project-URL: Repository, https://github.com/fsantibanezleal/CAOS_MillDEM
|
|
9
|
+
Project-URL: Changelog, https://github.com/fsantibanezleal/CAOS_MillDEM/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: DEM,discrete element method,tumbling mill,SAG,ball mill,comminution,charge motion,mineral processing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: numpy<2.3,>=2.0
|
|
24
|
+
Requires-Dist: scipy>=1.13
|
|
25
|
+
Provides-Extra: jit
|
|
26
|
+
Requires-Dist: numba>=0.61; extra == "jit"
|
|
27
|
+
Provides-Extra: train
|
|
28
|
+
Requires-Dist: numba>=0.61; extra == "train"
|
|
29
|
+
Requires-Dist: torch-geometric>=2.5; extra == "train"
|
|
30
|
+
Requires-Dist: matplotlib>=3.8; extra == "train"
|
|
31
|
+
Requires-Dist: tqdm>=4.66; extra == "train"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
34
|
+
Requires-Dist: matplotlib>=3.8; extra == "dev"
|
|
35
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
36
|
+
|
|
37
|
+
# CAOS_MillDEM
|
|
38
|
+
|
|
39
|
+
A cross-platform 2D soft-sphere **discrete element method (DEM)** engine for tumbling-mill charge motion and
|
|
40
|
+
power. No C++ toolchain, no WSL: pure NumPy with an optional Numba JIT and an optional Torch-CUDA path.
|
|
41
|
+
|
|
42
|
+
It simulates a rotating mill disc slice (the Govender et al. 2015 reduced setup, width = one particle
|
|
43
|
+
diameter) with a soft-sphere contact law (linear Hookean or Hertzian, Coulomb friction, restitution damping)
|
|
44
|
+
and reports the DEM charge shape (toe/shoulder), the motion regime (cascading / cataracting / centrifuging),
|
|
45
|
+
and the net power via the van Nierop (2001) torque route `P = 2*pi*T*N`.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from milldem import simulate, MillConfig
|
|
49
|
+
m = simulate(MillConfig(diameter_m=5.0, phi_c=0.75, fill=0.30), sim_time=2.0)
|
|
50
|
+
print(m.net_power_kw, m.regime, m.toe_deg, m.shoulder_deg)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
CLI: `milldem run --D 5 --phi 0.75 --J 0.30 --time 2.0 --json out.json`
|
|
54
|
+
|
|
55
|
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the honest validated-scope statement, and `docs/` for the contact-model equations, the power routes, and the validation against the classical
|
|
56
|
+
Hogg-Fuerstenau and Morrell power models. MIT licensed.
|
milldem-0.2.0/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# CAOS_MillDEM
|
|
2
|
+
|
|
3
|
+
A cross-platform 2D soft-sphere **discrete element method (DEM)** engine for tumbling-mill charge motion and
|
|
4
|
+
power. No C++ toolchain, no WSL: pure NumPy with an optional Numba JIT and an optional Torch-CUDA path.
|
|
5
|
+
|
|
6
|
+
It simulates a rotating mill disc slice (the Govender et al. 2015 reduced setup, width = one particle
|
|
7
|
+
diameter) with a soft-sphere contact law (linear Hookean or Hertzian, Coulomb friction, restitution damping)
|
|
8
|
+
and reports the DEM charge shape (toe/shoulder), the motion regime (cascading / cataracting / centrifuging),
|
|
9
|
+
and the net power via the van Nierop (2001) torque route `P = 2*pi*T*N`.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from milldem import simulate, MillConfig
|
|
13
|
+
m = simulate(MillConfig(diameter_m=5.0, phi_c=0.75, fill=0.30), sim_time=2.0)
|
|
14
|
+
print(m.net_power_kw, m.regime, m.toe_deg, m.shoulder_deg)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
CLI: `milldem run --D 5 --phi 0.75 --J 0.30 --time 2.0 --json out.json`
|
|
18
|
+
|
|
19
|
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the honest validated-scope statement, and `docs/` for the contact-model equations, the power routes, and the validation against the classical
|
|
20
|
+
Hogg-Fuerstenau and Morrell power models. MIT licensed.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""CAOS_MillDEM, a cross-platform 2D soft-sphere DEM engine for tumbling-mill charge motion + power.
|
|
2
|
+
|
|
3
|
+
No C++ toolchain, no WSL: pure numpy with an optional numba JIT and an optional torch-CUDA path. The engine
|
|
4
|
+
simulates a rotating mill disc slice (Govender et al. 2015 reduced setup) and reports the DEM charge shape,
|
|
5
|
+
motion regime, and net power via the van Nierop (2001) torque route.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from milldem import simulate, MillConfig, ContactModel
|
|
10
|
+
m = simulate(MillConfig(diameter_m=5.0, phi_c=0.75, fill=0.30), sim_time=2.0)
|
|
11
|
+
print(m.net_power_kw, m.regime, m.toe_deg, m.shoulder_deg)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .contact import ContactModel
|
|
16
|
+
from .engine import G, MillConfig, MillDEM
|
|
17
|
+
from .engine3d import MillDEM3D
|
|
18
|
+
from .metrics import MillMetrics, compute_metrics
|
|
19
|
+
|
|
20
|
+
__version__ = "0.02.000" # X.XX.XXX display form (versioning.md)
|
|
21
|
+
__all__ = ["ContactModel", "MillConfig", "MillDEM", "MillDEM3D", "MillMetrics",
|
|
22
|
+
"compute_metrics", "simulate", "simulate_power", "G"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def simulate_power(cfg: MillConfig, sim_time: float = 1.5, slab_thickness_m: float | None = None,
|
|
26
|
+
seed: int = 42) -> dict:
|
|
27
|
+
"""Run the thin-3D-slab DEM and return the validated net power + charge geometry.
|
|
28
|
+
|
|
29
|
+
The 3D slab (axial-periodic) captures the force chains that carry the charge lift, so the net power is
|
|
30
|
+
size-consistent and lands within ~10-20% of the classical Hogg-Fuerstenau model across speed, fill and mill
|
|
31
|
+
size (see docs/VALIDATION.md). Returns {net_power_kw, arm_m, n_particles}.
|
|
32
|
+
"""
|
|
33
|
+
sim = MillDEM3D(cfg, slab_thickness_m=slab_thickness_m, seed=seed)
|
|
34
|
+
sim.run(sim_time)
|
|
35
|
+
return {"net_power_kw": sim.net_power_kw(), "arm_m": sim.arm_m(), "n_particles": sim.n}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def simulate(cfg: MillConfig, sim_time: float = 2.0, seed: int = 42, settle_frac: float = 0.5) -> MillMetrics:
|
|
39
|
+
"""Run a full mill DEM simulation and return the settled-state metrics.
|
|
40
|
+
|
|
41
|
+
``sim_time`` seconds of simulated mill operation; the last ``1 - settle_frac`` of the run is used for the
|
|
42
|
+
metrics (the first half lets the charge settle from its initial placement into steady motion).
|
|
43
|
+
"""
|
|
44
|
+
sim = MillDEM(cfg, seed=seed)
|
|
45
|
+
sim.run(sim_time, settle_frac=settle_frac)
|
|
46
|
+
return compute_metrics(sim, settle_frac=settle_frac)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""``milldem`` command-line interface.
|
|
2
|
+
|
|
3
|
+
milldem run --D 5.0 --phi 0.75 --J 0.30 --ball 0.10 --time 2.0 [--json out.json] [--frames frames.npz]
|
|
4
|
+
milldem power --D 5.0 --phi 0.75 --J 0.30 --ball 0.10 --L 6.0 [--time 1.5] [--json out.json]
|
|
5
|
+
|
|
6
|
+
``run`` gives the fast 2D qualitative charge-shape / regime read; ``power`` runs the validated thin-3D slab and
|
|
7
|
+
reports the size-consistent net power (within ~10-20% of Hogg-Fuerstenau, see docs/VALIDATION.md).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import asdict
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from . import ContactModel, MillConfig, MillDEM, __version__, compute_metrics, simulate_power
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
p = argparse.ArgumentParser(prog="milldem", description="Soft-sphere tumbling-mill DEM (2D charge shape + "
|
|
23
|
+
"validated thin-3D-slab power).")
|
|
24
|
+
p.add_argument("--version", action="version", version=f"milldem {__version__}")
|
|
25
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
26
|
+
|
|
27
|
+
r = sub.add_parser("run", help="run a 2D simulation and report the settled charge-shape / regime metrics")
|
|
28
|
+
r.add_argument("--D", type=float, default=5.0, help="mill diameter [m]")
|
|
29
|
+
r.add_argument("--L", type=float, default=6.0, help="mill length [m] (for net-power scaling)")
|
|
30
|
+
r.add_argument("--phi", type=float, default=0.75, help="fraction of critical speed")
|
|
31
|
+
r.add_argument("--J", type=float, default=0.30, help="fractional filling")
|
|
32
|
+
r.add_argument("--ball", type=float, default=0.10, help="top ball diameter [m]")
|
|
33
|
+
r.add_argument("--size-ratio", type=float, default=1.0, help="min/max ball diameter (1 = mono)")
|
|
34
|
+
r.add_argument("--lifters", type=int, default=8, help="number of lifter bars")
|
|
35
|
+
r.add_argument("--e", type=float, default=0.6, help="coefficient of restitution")
|
|
36
|
+
r.add_argument("--mu", type=float, default=0.5, help="sliding friction")
|
|
37
|
+
r.add_argument("--model", choices=["hooke", "hertz"], default="hooke")
|
|
38
|
+
r.add_argument("--time", type=float, default=2.0, help="simulated time [s]")
|
|
39
|
+
r.add_argument("--seed", type=int, default=42)
|
|
40
|
+
r.add_argument("--json", type=str, default=None, help="write the metrics to this JSON path")
|
|
41
|
+
r.add_argument("--frames", type=str, default=None, help="write a compact frame series (npz) to this path")
|
|
42
|
+
r.add_argument("--frame-stride", type=int, default=200, help="record one frame every N steps")
|
|
43
|
+
|
|
44
|
+
pw = sub.add_parser("power", help="run the validated thin-3D slab and report the net power [kW]")
|
|
45
|
+
pw.add_argument("--D", type=float, default=5.0, help="mill diameter [m]")
|
|
46
|
+
pw.add_argument("--L", type=float, default=6.0, help="mill length [m]")
|
|
47
|
+
pw.add_argument("--phi", type=float, default=0.75, help="fraction of critical speed")
|
|
48
|
+
pw.add_argument("--J", type=float, default=0.30, help="fractional filling")
|
|
49
|
+
pw.add_argument("--ball", type=float, default=0.24, help="top ball diameter [m]")
|
|
50
|
+
pw.add_argument("--size-ratio", type=float, default=1.0, help="min/max ball diameter (1 = mono)")
|
|
51
|
+
pw.add_argument("--slab", type=float, default=None, help="slab thickness [m] (default 4 ball diameters)")
|
|
52
|
+
pw.add_argument("--time", type=float, default=1.5, help="simulated time [s] after settling")
|
|
53
|
+
pw.add_argument("--seed", type=int, default=42)
|
|
54
|
+
pw.add_argument("--json", type=str, default=None, help="write the result to this JSON path")
|
|
55
|
+
|
|
56
|
+
a = p.parse_args(argv)
|
|
57
|
+
if a.cmd == "power":
|
|
58
|
+
cfg = MillConfig(diameter_m=a.D, length_m=a.L, phi_c=a.phi, fill=a.J, ball_diameter_m=a.ball,
|
|
59
|
+
size_ratio=a.size_ratio)
|
|
60
|
+
res = simulate_power(cfg, sim_time=a.time, slab_thickness_m=a.slab, seed=a.seed)
|
|
61
|
+
out = {"config": {"D": a.D, "L": a.L, "phi_c": a.phi, "J": a.J, "ball": a.ball, "seed": a.seed},
|
|
62
|
+
"net_power_kw": res["net_power_kw"], "arm_m": res["arm_m"], "n_particles": res["n_particles"]}
|
|
63
|
+
print(json.dumps(out, indent=2))
|
|
64
|
+
if a.json:
|
|
65
|
+
with open(a.json, "w", encoding="utf-8") as f:
|
|
66
|
+
json.dump(out, f, indent=2)
|
|
67
|
+
return 0
|
|
68
|
+
if a.cmd == "run":
|
|
69
|
+
cfg = MillConfig(
|
|
70
|
+
diameter_m=a.D, length_m=a.L, phi_c=a.phi, fill=a.J, ball_diameter_m=a.ball,
|
|
71
|
+
size_ratio=a.size_ratio, n_lifters=a.lifters,
|
|
72
|
+
contact=ContactModel(model=a.model, e=a.e, mu=a.mu),
|
|
73
|
+
)
|
|
74
|
+
sim = MillDEM(cfg, seed=a.seed)
|
|
75
|
+
n_steps = int(a.time / sim.dt)
|
|
76
|
+
frames = []
|
|
77
|
+
for i in range(n_steps):
|
|
78
|
+
sim.step()
|
|
79
|
+
if a.frames and (i % a.frame_stride == 0):
|
|
80
|
+
frames.append(np.stack([sim.px, sim.py], axis=1).astype(np.float32))
|
|
81
|
+
m = compute_metrics(sim)
|
|
82
|
+
md = asdict(m)
|
|
83
|
+
print(json.dumps(md, indent=2))
|
|
84
|
+
if a.json:
|
|
85
|
+
with open(a.json, "w", encoding="utf-8") as f:
|
|
86
|
+
json.dump({"config": {"D": a.D, "L": a.L, "phi_c": a.phi, "J": a.J, "ball": a.ball,
|
|
87
|
+
"model": a.model, "e": a.e, "mu": a.mu, "seed": a.seed},
|
|
88
|
+
"metrics": md}, f, indent=2)
|
|
89
|
+
if a.frames and frames:
|
|
90
|
+
np.savez_compressed(a.frames, frames=np.stack(frames), radii=sim.r.astype(np.float32),
|
|
91
|
+
R=sim.R, n_frames=len(frames))
|
|
92
|
+
print(f"wrote {len(frames)} frames to {a.frames}", file=sys.stderr)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Soft-sphere contact force models for DEM.
|
|
2
|
+
|
|
3
|
+
Two force laws behind one interface, both verified verbatim against the LAMMPS granular pair-style docs
|
|
4
|
+
(docs.lammps.org/pair_gran.html) and the Golpayegani & Rezai (2022) review (Eqs 23-25):
|
|
5
|
+
|
|
6
|
+
Linear Hookean (``gran/hooke``)::
|
|
7
|
+
|
|
8
|
+
F_n = k_n * delta - m_eff * gamma_n * v_n (normal: spring - dashpot)
|
|
9
|
+
F_t = -(k_t * ds_t) - m_eff * gamma_t * v_t (tangent: shear spring - dashpot)
|
|
10
|
+
|F_t| <= mu * |F_n| (Coulomb friction truncation)
|
|
11
|
+
|
|
12
|
+
Hertzian (``gran/hertz/history``): the Hookean force scaled by ``sqrt(delta) * sqrt(R_i R_j / (R_i + R_j))``.
|
|
13
|
+
|
|
14
|
+
Damping is not free: the dashpot constant ``gamma_n`` is set from a target coefficient of restitution ``e``.
|
|
15
|
+
For the linear model the closed form is exact::
|
|
16
|
+
|
|
17
|
+
gamma_n = -2 * ln(e) * sqrt(k_n / m_eff) / sqrt(pi**2 + ln(e)**2)
|
|
18
|
+
|
|
19
|
+
(the standard linear-spring-dashpot restitution relation). For Hertz the Tsuji (1992) velocity-independent
|
|
20
|
+
form is used. References: Cundall & Strack 1979 (DOI 10.1680/geot.1979.29.1.47); Tsuji, Tanaka & Ishida 1992
|
|
21
|
+
(DOI 10.1016/0032-5910(92)88030-L); LAMMPS granular docs.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ContactModel:
|
|
31
|
+
"""Contact parameters. ``model`` is ``"hooke"`` (linear) or ``"hertz"``.
|
|
32
|
+
|
|
33
|
+
``kn``/``kt`` are the normal/tangential stiffnesses (N/m for Hooke; the Hertz prefactor for hertz),
|
|
34
|
+
``e`` the coefficient of restitution (0..1), ``mu`` the sliding-friction coefficient, ``mu_r`` the
|
|
35
|
+
rolling-friction coefficient (a resistive moment, 0 disables it).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model: str = "hooke"
|
|
39
|
+
kn: float = 1.0e5 # normal stiffness FLOOR [N/m] (the engine auto-scales up for stable integration)
|
|
40
|
+
kt_ratio: float = 2.0 / 7 # kt = kt_ratio * kn (the standard 2/7 shear-to-normal ratio)
|
|
41
|
+
e: float = 0.5 # coefficient of restitution (0.45-0.90 typical for mill media)
|
|
42
|
+
mu: float = 0.25 # sliding friction (calibrated so the charge holds a stable lifted crescent;
|
|
43
|
+
# too high fluidizes the bed, too low lets it slump)
|
|
44
|
+
mu_r: float = 0.05 # rolling friction (resistive-moment coefficient)
|
|
45
|
+
|
|
46
|
+
def kt(self) -> float:
|
|
47
|
+
return self.kt_ratio * self.kn
|
|
48
|
+
|
|
49
|
+
def normal_damping(self, m_eff: float) -> float:
|
|
50
|
+
"""The normal dashpot constant gamma_n [1/s] giving the target restitution e, linear model."""
|
|
51
|
+
e = min(max(self.e, 1e-4), 0.999)
|
|
52
|
+
lne = math.log(e)
|
|
53
|
+
# gamma_n such that the linear spring-dashpot yields restitution e (Antypov & Elliott 2011 form)
|
|
54
|
+
return -2.0 * lne * math.sqrt(self.kn / m_eff) / math.sqrt(math.pi ** 2 + lne ** 2)
|
|
55
|
+
|
|
56
|
+
def contact_time(self, m_eff: float) -> float:
|
|
57
|
+
"""The linear contact half-period t_c = pi * sqrt(m_eff / kn) [s]; dt must be a fraction of this."""
|
|
58
|
+
return math.pi * math.sqrt(m_eff / self.kn)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def effective_mass(m_i: float, m_j: float) -> float:
|
|
62
|
+
"""m_eff = m_i m_j / (m_i + m_j); for a particle-wall contact pass m_j = inf (returns m_i)."""
|
|
63
|
+
if math.isinf(m_j):
|
|
64
|
+
return m_i
|
|
65
|
+
return m_i * m_j / (m_i + m_j)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normal_force(model: ContactModel, delta: float, v_n: float, m_eff: float, r_eff: float) -> float:
|
|
69
|
+
"""Scalar normal contact force [N] (positive = repulsive) for overlap ``delta`` >= 0 and normal
|
|
70
|
+
approach velocity ``v_n`` (positive when the bodies approach). ``r_eff`` = R_i R_j /(R_i + R_j)."""
|
|
71
|
+
if delta <= 0.0:
|
|
72
|
+
return 0.0
|
|
73
|
+
gamma_n = model.normal_damping(m_eff)
|
|
74
|
+
if model.model == "hertz":
|
|
75
|
+
scale = math.sqrt(delta) * math.sqrt(max(r_eff, 1e-12))
|
|
76
|
+
return scale * (model.kn * delta + m_eff * gamma_n * v_n)
|
|
77
|
+
# linear Hooke: spring + dashpot (damping opposes approach; v_n>0 approaching)
|
|
78
|
+
return model.kn * delta + m_eff * gamma_n * v_n
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def tangential_force(model: ContactModel, ds_t: float, v_t: float, fn: float, m_eff: float) -> float:
|
|
82
|
+
"""Scalar tangential force [N], Coulomb-truncated to |F_t| <= mu*|F_n|. ``ds_t`` is the accumulated
|
|
83
|
+
elastic tangential displacement, ``v_t`` the tangential relative velocity."""
|
|
84
|
+
gamma_t = model.normal_damping(m_eff) * 0.5 # tangential damping ~ half normal (common choice)
|
|
85
|
+
ft = -(model.kt() * ds_t) - m_eff * gamma_t * v_t
|
|
86
|
+
cap = model.mu * abs(fn)
|
|
87
|
+
if abs(ft) > cap:
|
|
88
|
+
ft = math.copysign(cap, ft)
|
|
89
|
+
return ft
|