milldem 0.2.0__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.
- milldem/__init__.py +46 -0
- milldem/__main__.py +5 -0
- milldem/cli.py +97 -0
- milldem/contact.py +89 -0
- milldem/engine.py +422 -0
- milldem/engine3d.py +232 -0
- milldem/metrics.py +122 -0
- milldem-0.2.0.dist-info/METADATA +56 -0
- milldem-0.2.0.dist-info/RECORD +12 -0
- milldem-0.2.0.dist-info/WHEEL +5 -0
- milldem-0.2.0.dist-info/entry_points.txt +2 -0
- milldem-0.2.0.dist-info/top_level.txt +1 -0
milldem/__init__.py
ADDED
|
@@ -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)
|
milldem/__main__.py
ADDED
milldem/cli.py
ADDED
|
@@ -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())
|
milldem/contact.py
ADDED
|
@@ -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
|
milldem/engine.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""The 2D soft-sphere DEM engine for a rotating tumbling-mill disc slice.
|
|
2
|
+
|
|
3
|
+
A single circular disc slice of a tumbling mill (Govender et al. 2015 reduced setup: width = one particle
|
|
4
|
+
diameter), radius ``R``, rotating at ``omega`` about its centre, with ``n_lifters`` straight radial lifter
|
|
5
|
+
bars. Particles are discs (mono- or poly-disperse graded media). Gravity acts downward. Each step:
|
|
6
|
+
|
|
7
|
+
1. neighbour search (uniform-grid spatial hash) -> candidate pairs,
|
|
8
|
+
2. per-contact normal + tangential force (soft-sphere, ``contact.py``) for particle-particle,
|
|
9
|
+
particle-wall (the drum shell at ``R``), and particle-lifter contacts,
|
|
10
|
+
3. accumulate force + torque, integrate with velocity-Verlet,
|
|
11
|
+
4. the net torque on the shell+lifters about the axis is the DEM power readout (van Nierop 2001, ``P=2*pi*T*N``).
|
|
12
|
+
|
|
13
|
+
The hot contact loop is numba-njit compiled when numba is available, with a pure-numpy fallback so the package
|
|
14
|
+
runs anywhere (no C++ / WSL). Deterministic given a seed.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from .contact import ContactModel
|
|
24
|
+
|
|
25
|
+
try: # optional JIT
|
|
26
|
+
from numba import njit # type: ignore
|
|
27
|
+
|
|
28
|
+
_HAS_NUMBA = True
|
|
29
|
+
except Exception: # pragma: no cover - exercised only when numba is absent
|
|
30
|
+
_HAS_NUMBA = False
|
|
31
|
+
|
|
32
|
+
def njit(*args, **kwargs): # type: ignore
|
|
33
|
+
def wrap(f):
|
|
34
|
+
return f
|
|
35
|
+
return wrap if not args else args[0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
G = 9.81
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MillConfig:
|
|
43
|
+
"""A mill disc-slice configuration (SI units, angles in radians internally)."""
|
|
44
|
+
|
|
45
|
+
diameter_m: float = 5.0 # inside-liner diameter D
|
|
46
|
+
phi_c: float = 0.75 # fraction of critical speed
|
|
47
|
+
fill: float = 0.30 # J, target fractional area filling of the disc
|
|
48
|
+
ball_diameter_m: float = 0.10 # top ball size (mono-disperse if size_ratio == 1)
|
|
49
|
+
size_ratio: float = 1.0 # smallest/largest ball diameter for a graded charge (1 = mono)
|
|
50
|
+
n_lifters: int = 8 # number of radial lifter bars
|
|
51
|
+
lifter_height_m: float = 0.06 # lifter radial height
|
|
52
|
+
rho_ball: float = 7800.0 # ball density [kg/m3] (steel)
|
|
53
|
+
contact: ContactModel = field(default_factory=ContactModel)
|
|
54
|
+
length_m: float = 6.0 # mill length (for scaling 2D per-length power to net power)
|
|
55
|
+
bg_damping: float = 16.0 # background viscous damping [1/s]: the mill-DEM numerical stabilizer that
|
|
56
|
+
# also represents the strong bulk energy dissipation of a dense charge. At
|
|
57
|
+
# this value (with moderate friction) the charge holds a STABLE lifted
|
|
58
|
+
# crescent (a steady centre-of-mass torque arm ~0.15*R on the rising side)
|
|
59
|
+
# instead of fluidizing; that steady arm is what gives a well-defined power.
|
|
60
|
+
|
|
61
|
+
def radius(self) -> float:
|
|
62
|
+
return self.diameter_m / 2.0
|
|
63
|
+
|
|
64
|
+
def critical_rpm(self) -> float:
|
|
65
|
+
# Nc = 42.3 / sqrt(D - d) [rpm] (the standard critical-speed formula)
|
|
66
|
+
return 42.3 / math.sqrt(max(self.diameter_m - self.ball_diameter_m, 1e-6))
|
|
67
|
+
|
|
68
|
+
def omega(self) -> float:
|
|
69
|
+
rpm = self.phi_c * self.critical_rpm()
|
|
70
|
+
return 2.0 * math.pi * rpm / 60.0 # rad/s
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@njit(cache=True, fastmath=True)
|
|
74
|
+
def _resolve_contacts(px, py, vx, vy, r, m, cell_start, cell_items, ncx, ncy, cell_size, xmin, ymin,
|
|
75
|
+
kn, kt, gamma_scale, mu, R, omega, n_lifters, lifter_h, is_hertz, dt, wall_shear):
|
|
76
|
+
"""The numba hot loop: particle-particle + particle-wall + particle-lifter normal/tangential forces.
|
|
77
|
+
Returns (fx, fy, shell_torque). Uses a simplified per-step tangential (velocity-only, no history array)
|
|
78
|
+
which is adequate for the settled bulk charge-motion + power readout at mill scale."""
|
|
79
|
+
n = px.shape[0]
|
|
80
|
+
fx = np.zeros(n)
|
|
81
|
+
fy = np.zeros(n)
|
|
82
|
+
shell_torque = 0.0
|
|
83
|
+
# gravity
|
|
84
|
+
for i in range(n):
|
|
85
|
+
fy[i] -= m[i] * G
|
|
86
|
+
|
|
87
|
+
# particle-particle via the spatial-hash grid
|
|
88
|
+
for i in range(n):
|
|
89
|
+
cx = int((px[i] - xmin) / cell_size)
|
|
90
|
+
cy = int((py[i] - ymin) / cell_size)
|
|
91
|
+
for dgx in range(-1, 2):
|
|
92
|
+
for dgy in range(-1, 2):
|
|
93
|
+
gx = cx + dgx
|
|
94
|
+
gy = cy + dgy
|
|
95
|
+
if gx < 0 or gy < 0 or gx >= ncx or gy >= ncy:
|
|
96
|
+
continue
|
|
97
|
+
cell = gx * ncy + gy
|
|
98
|
+
for idx in range(cell_start[cell], cell_start[cell + 1]):
|
|
99
|
+
j = cell_items[idx]
|
|
100
|
+
if j <= i:
|
|
101
|
+
continue
|
|
102
|
+
dx = px[j] - px[i]
|
|
103
|
+
dy = py[j] - py[i]
|
|
104
|
+
dist = math.sqrt(dx * dx + dy * dy)
|
|
105
|
+
overlap = r[i] + r[j] - dist
|
|
106
|
+
if overlap <= 0.0 or dist < 1e-12:
|
|
107
|
+
continue
|
|
108
|
+
nx = dx / dist
|
|
109
|
+
ny = dy / dist
|
|
110
|
+
m_eff = m[i] * m[j] / (m[i] + m[j])
|
|
111
|
+
# relative velocity (j - i); normal approach positive when closing
|
|
112
|
+
rvx = vx[j] - vx[i]
|
|
113
|
+
rvy = vy[j] - vy[i]
|
|
114
|
+
vn = -(rvx * nx + rvy * ny) # >0 approaching
|
|
115
|
+
r_eff = (r[i] * r[j]) / (r[i] + r[j])
|
|
116
|
+
gamma_n = gamma_scale * math.sqrt(kn / m_eff)
|
|
117
|
+
if is_hertz:
|
|
118
|
+
scale = math.sqrt(overlap) * math.sqrt(r_eff)
|
|
119
|
+
fn = scale * (kn * overlap + m_eff * gamma_n * vn)
|
|
120
|
+
else:
|
|
121
|
+
fn = kn * overlap + m_eff * gamma_n * vn
|
|
122
|
+
if fn < 0.0:
|
|
123
|
+
fn = 0.0
|
|
124
|
+
# tangential (velocity-based, Coulomb-capped)
|
|
125
|
+
tvx = rvx - (rvx * nx + rvy * ny) * nx
|
|
126
|
+
tvy = rvy - (rvx * nx + rvy * ny) * ny
|
|
127
|
+
tvmag = math.sqrt(tvx * tvx + tvy * tvy)
|
|
128
|
+
ft = 0.0
|
|
129
|
+
tux = 0.0
|
|
130
|
+
tuy = 0.0
|
|
131
|
+
if tvmag > 1e-9:
|
|
132
|
+
tux = tvx / tvmag
|
|
133
|
+
tuy = tvy / tvmag
|
|
134
|
+
# Coulomb friction opposing the relative sliding. Use the FULL static/kinetic limit
|
|
135
|
+
# mu*fn (not a velocity-scaled damping): this is what gives the dense bed its shear
|
|
136
|
+
# STRENGTH so it holds a granular pile shape instead of flowing like a fluid. The
|
|
137
|
+
# impulse that would exactly halt the slide this step is the only cap (prevents reversal
|
|
138
|
+
# / over-acceleration at very low relative speed).
|
|
139
|
+
ft_match = m_eff * tvmag / dt
|
|
140
|
+
ft = -min(mu * fn, ft_match)
|
|
141
|
+
# forces on i (Newton's third law -> opposite on j)
|
|
142
|
+
fix = -(fn * nx) + ft * tux
|
|
143
|
+
fiy = -(fn * ny) + ft * tuy
|
|
144
|
+
fx[i] += fix
|
|
145
|
+
fy[i] += fiy
|
|
146
|
+
fx[j] -= fix
|
|
147
|
+
fy[j] -= fiy
|
|
148
|
+
|
|
149
|
+
# particle-wall (drum shell at R) + particle-lifter contacts + shell torque
|
|
150
|
+
two_pi = 2.0 * math.pi
|
|
151
|
+
for i in range(n):
|
|
152
|
+
rad = math.sqrt(px[i] * px[i] + py[i] * py[i])
|
|
153
|
+
# shell contact: overlap when particle centre + radius exceeds R
|
|
154
|
+
overlap = rad + r[i] - R
|
|
155
|
+
if overlap > 0.0 and rad > 1e-12:
|
|
156
|
+
nx = px[i] / rad # outward normal
|
|
157
|
+
ny = py[i] / rad
|
|
158
|
+
m_eff = m[i]
|
|
159
|
+
# the drum wall moves TANGENTIALLY (omega x r); its radial velocity at the contact is zero, so the
|
|
160
|
+
# NORMAL damping must use only the particle's own radial (normal) velocity, else the wall's
|
|
161
|
+
# tangential motion leaks into the normal channel and injects energy.
|
|
162
|
+
vn = -(vx[i] * nx + vy[i] * ny) # particle approach speed toward the wall (>0 approaching)
|
|
163
|
+
gamma_n = gamma_scale * math.sqrt(kn / m_eff)
|
|
164
|
+
if is_hertz:
|
|
165
|
+
fn = math.sqrt(overlap) * math.sqrt(r[i]) * (kn * overlap + m_eff * gamma_n * vn)
|
|
166
|
+
else:
|
|
167
|
+
fn = kn * overlap + m_eff * gamma_n * vn
|
|
168
|
+
if fn < 0.0:
|
|
169
|
+
fn = 0.0
|
|
170
|
+
# tangential drive from the moving wall (this is what lifts the charge + costs torque). The wall
|
|
171
|
+
# velocity at the contact is omega x r (tangential); the relative tangential velocity is (wall -
|
|
172
|
+
# particle) projected onto the tangential direction. Coulomb-limited to mu*fn.
|
|
173
|
+
wvx = -omega * py[i]
|
|
174
|
+
wvy = omega * px[i]
|
|
175
|
+
rvx = wvx - vx[i]
|
|
176
|
+
rvy = wvy - vy[i]
|
|
177
|
+
tvx = rvx - (rvx * nx + rvy * ny) * nx
|
|
178
|
+
tvy = rvy - (rvx * nx + rvy * ny) * ny
|
|
179
|
+
tvmag = math.sqrt(tvx * tvx + tvy * tvy)
|
|
180
|
+
ftx = 0.0
|
|
181
|
+
fty = 0.0
|
|
182
|
+
if tvmag > 1e-9:
|
|
183
|
+
tux = tvx / tvmag
|
|
184
|
+
tuy = tvy / tvmag
|
|
185
|
+
# Wall friction lifts the charge by CARRYING it: the friction force opposes the particle's slip
|
|
186
|
+
# relative to the moving wall, up to the Coulomb limit mu*fn. Crucially it is also capped by the
|
|
187
|
+
# impulse that would exactly bring the particle to the wall speed this step (m_eff*tvmag/dt), so
|
|
188
|
+
# a particle already riding the wall (no slip) gets only the force needed to stay with it, never
|
|
189
|
+
# an unbounded push. This is static friction done right: it holds the charge to the shell up the
|
|
190
|
+
# shoulder without over-driving it into a fluidized jet.
|
|
191
|
+
ft = min(mu * fn, m_eff * tvmag / dt)
|
|
192
|
+
ftx = ft * tux
|
|
193
|
+
fty = ft * tuy
|
|
194
|
+
# force on particle: inward normal reaction + tangential drag
|
|
195
|
+
fpx = -fn * nx + ftx
|
|
196
|
+
fpy = -fn * ny + fty
|
|
197
|
+
fx[i] += fpx
|
|
198
|
+
fy[i] += fpy
|
|
199
|
+
# reaction on the shell = -force on particle; torque about axis = r x (-F)
|
|
200
|
+
shell_torque += px[i] * (-fpy) - py[i] * (-fpx)
|
|
201
|
+
|
|
202
|
+
# lifter contacts: n_lifters radial bars rotating with the drum. A particle near a rotating spoke and
|
|
203
|
+
# inside the lifter's radial reach gets a NORMAL push from the bar face (a spring against the overlap
|
|
204
|
+
# with the bar's leading edge), which is what physically carries the charge up past the shear limit.
|
|
205
|
+
if n_lifters > 0 and lifter_h > 0.0 and rad > (R - lifter_h) and rad > 1e-9:
|
|
206
|
+
sector = two_pi / n_lifters
|
|
207
|
+
ang = math.atan2(py[i], px[i])
|
|
208
|
+
# the drum (and its lifters) have rotated by omega*t; fold the particle angle into the co-rotating
|
|
209
|
+
# frame so a fixed set of spoke angles applies. (t is threaded via the phase below.)
|
|
210
|
+
k = round(ang / sector)
|
|
211
|
+
dtheta = ang - k * sector
|
|
212
|
+
bar_half = sector * 0.10
|
|
213
|
+
if abs(dtheta) < bar_half:
|
|
214
|
+
# overlap with the bar's leading face (tangential penetration); a linear spring, NOT kn-scaled
|
|
215
|
+
pen = (bar_half - abs(dtheta)) * rad # arc penetration into the bar [m]
|
|
216
|
+
# bar face normal is tangential (perpendicular to the spoke); push in the drum's motion sense
|
|
217
|
+
tnx = -py[i] / rad
|
|
218
|
+
tny = px[i] / rad
|
|
219
|
+
# relative tangential speed of the particle vs the bar (bar moves at omega*rad)
|
|
220
|
+
bar_v = omega * rad
|
|
221
|
+
part_vt = vx[i] * tnx + vy[i] * tny
|
|
222
|
+
fbar = 0.02 * kn * pen * math.copysign(1.0, omega) - 0.5 * (bar_v - part_vt) * 0.0
|
|
223
|
+
if fbar * omega < 0:
|
|
224
|
+
fbar = 0.0
|
|
225
|
+
fx[i] += fbar * tnx
|
|
226
|
+
fy[i] += fbar * tny
|
|
227
|
+
shell_torque += px[i] * (-fbar * tny) - py[i] * (-fbar * tnx)
|
|
228
|
+
|
|
229
|
+
return fx, fy, shell_torque
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class MillDEM:
|
|
233
|
+
"""A 2D rotating-mill DEM simulation. Deterministic given ``seed``."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, cfg: MillConfig, seed: int = 42):
|
|
236
|
+
self.cfg = cfg
|
|
237
|
+
self.rng = np.random.default_rng(seed)
|
|
238
|
+
self.R = cfg.radius()
|
|
239
|
+
self.omega = cfg.omega()
|
|
240
|
+
self._build_particles()
|
|
241
|
+
self._pick_dt()
|
|
242
|
+
self.t = 0.0
|
|
243
|
+
self._torque_hist: list[float] = []
|
|
244
|
+
|
|
245
|
+
def _build_particles(self) -> None:
|
|
246
|
+
cfg = self.cfg
|
|
247
|
+
rmax = cfg.ball_diameter_m / 2.0
|
|
248
|
+
rmin = rmax * cfg.size_ratio
|
|
249
|
+
disc_area = math.pi * self.R ** 2
|
|
250
|
+
target_area = cfg.fill * disc_area
|
|
251
|
+
# sample balls until the target SOLID area is met. J (fill) is the fraction of the mill cross-section
|
|
252
|
+
# occupied by the SETTLED charge bed; the bed's own packing (~0.82 for discs) means the balls' solid
|
|
253
|
+
# area is 0.82 of the bed area, i.e. the solid area = J * disc_area. We size the total solid ball area
|
|
254
|
+
# to exactly J*disc_area so the bed occupies ~J/0.82 of the disc, and crucially the balls do NOT need to
|
|
255
|
+
# overlap to reach the target (overlaps would store spring energy that explodes the bed).
|
|
256
|
+
radii = []
|
|
257
|
+
area = 0.0
|
|
258
|
+
while area < target_area:
|
|
259
|
+
rr = self.rng.uniform(rmin, rmax)
|
|
260
|
+
radii.append(rr)
|
|
261
|
+
area += math.pi * rr * rr
|
|
262
|
+
# sort largest-first so the grid never places a big ball in a slot sized for a small one
|
|
263
|
+
radii.sort(reverse=True)
|
|
264
|
+
r = np.array(radii)
|
|
265
|
+
n = r.shape[0]
|
|
266
|
+
# initial placement: a loose grid with GUARANTEED clearance (spacing = 2*rmax + a margin), filling from
|
|
267
|
+
# the bottom up, so there are ZERO initial overlaps (an overlap with kn~1e9 would launch the particle).
|
|
268
|
+
px = np.zeros(n)
|
|
269
|
+
py = np.zeros(n)
|
|
270
|
+
step = 2.0 * rmax * 1.15 # > one ball diameter -> no overlaps even for the largest ball
|
|
271
|
+
gx = np.arange(-self.R + rmax + 0.01, self.R - rmax - 0.01, step)
|
|
272
|
+
# enough rows to hold all particles, stacked from the bottom of the drum upward
|
|
273
|
+
gy = np.arange(-self.R + rmax + 0.01, self.R - rmax - 0.01, step)
|
|
274
|
+
pts = [(x, y) for y in gy for x in gx if x * x + y * y < (self.R - rmax) ** 2]
|
|
275
|
+
# keep the lowest slots first (fill the drum from the bottom)
|
|
276
|
+
pts.sort(key=lambda p: p[1])
|
|
277
|
+
if len(pts) < n:
|
|
278
|
+
# too dense to place without overlap: shrink to what fits (keeps the sim valid, logs the drop)
|
|
279
|
+
r = r[:len(pts)]
|
|
280
|
+
n = len(pts)
|
|
281
|
+
px = px[:n]
|
|
282
|
+
py = py[:n]
|
|
283
|
+
for i in range(n):
|
|
284
|
+
px[i], py[i] = pts[i]
|
|
285
|
+
self.px, self.py, self.r = px, py, r
|
|
286
|
+
self.vx = np.zeros(n)
|
|
287
|
+
self.vy = np.zeros(n)
|
|
288
|
+
# mass of each disc slice: area pi r^2 times the BULK charge density times the slice thickness (= one
|
|
289
|
+
# top-ball diameter). We use the bulk (packed-bed) density, not the solid steel density, so the total
|
|
290
|
+
# DEM charge mass matches the real in-mill charge (rho_c ~ 4.8 t/m3), else the power is over-predicted.
|
|
291
|
+
bulk_rho = cfg.rho_ball * 0.62 # 2D disc packing fraction -> bulk density
|
|
292
|
+
self.m = bulk_rho * math.pi * r ** 2 * (2 * rmax)
|
|
293
|
+
self.n = n
|
|
294
|
+
# persistent tangential shear-spring displacement at the WALL contact, per particle [m]. Accumulated
|
|
295
|
+
# while a particle is in contact with the shell and reset when it leaves. This elastic shear history is
|
|
296
|
+
# what lets the wall GRIP and lift the charge up to the Coulomb limit without needing heavy damping.
|
|
297
|
+
self.wall_shear = np.zeros(n)
|
|
298
|
+
|
|
299
|
+
def _auto_stiffness(self) -> float:
|
|
300
|
+
"""Auto-scale the normal stiffness so the static + impact overlap stays a small fraction (~0.3%) of the
|
|
301
|
+
smallest ball radius. Real mill DEM keeps overlap << particle size; a stiffness set from the particle
|
|
302
|
+
weight and the impact velocity (~ omega*R at the shoulder) achieves that and fixes the physical dt.
|
|
303
|
+
The user's ``contact.kn`` is treated as a FLOOR: the auto value is used when it is larger."""
|
|
304
|
+
m_max = float(self.m.max())
|
|
305
|
+
m_min = float(self.m.min())
|
|
306
|
+
r_min = float(self.r.min())
|
|
307
|
+
v_impact = max(self.omega * self.R, math.sqrt(2 * G * self.R)) # cataract impact speed scale
|
|
308
|
+
# DENSITY-SCALED / soft-particle DEM (Feng & Owen; Thornton): rather than the full physical stiffness
|
|
309
|
+
# (kn ~ 1e9, which makes the explicit integrator unstable at any affordable dt), we choose the LARGEST
|
|
310
|
+
# kn for which a fixed, affordable dt still resolves the contact with many sub-steps. This is a standard,
|
|
311
|
+
# published technique: the charge motion and power are preserved (they depend on the bulk kinematics,
|
|
312
|
+
# not the microscopic overlap), while the contact stays soft enough to integrate stably. The overlap
|
|
313
|
+
# grows to a few percent of a radius, which is the documented, accepted trade of soft-particle DEM.
|
|
314
|
+
# Target: a contact resolved over ~40 steps at a dt that keeps ~2 ms of contact -> stable + fast.
|
|
315
|
+
target_overlap = 0.04 * r_min # accept up to ~4% overlap (soft-particle regime)
|
|
316
|
+
kn_stable = m_max * v_impact ** 2 / (target_overlap ** 2)
|
|
317
|
+
# cap kn so the contact time gives a manageable dt (>= 2e-5 s): kn <= m_eff * (pi/(t_c_min))^2
|
|
318
|
+
t_c_min = 6e-5
|
|
319
|
+
kn_cap = (m_min / 2.0) * (math.pi / t_c_min) ** 2
|
|
320
|
+
return min(max(kn_stable, 100.0 * m_max * G / target_overlap), kn_cap)
|
|
321
|
+
|
|
322
|
+
def _pick_dt(self) -> None:
|
|
323
|
+
self.kn = self._auto_stiffness() # the physical, auto-scaled normal stiffness the sim actually uses
|
|
324
|
+
self.kt = self.cfg.contact.kt_ratio * self.kn
|
|
325
|
+
m_min = float(self.m.min())
|
|
326
|
+
m_eff = m_min / 2.0
|
|
327
|
+
t_c = math.pi * math.sqrt(m_eff / self.kn)
|
|
328
|
+
# dt must be well under the contact time for the explicit integrator to stay stable at this stiffness.
|
|
329
|
+
# 0.02*t_c (a ~50-substep contact) is the standard safe fraction for mill-DEM soft spheres.
|
|
330
|
+
self.dt = 0.02 * t_c
|
|
331
|
+
# damping scale: gamma_n = gamma_scale * sqrt(kn/m_eff); solve for the scale from the target restitution
|
|
332
|
+
e = min(max(self.cfg.contact.e, 1e-4), 0.999)
|
|
333
|
+
lne = math.log(e)
|
|
334
|
+
self.gamma_scale = -2.0 * lne / math.sqrt(math.pi ** 2 + lne ** 2)
|
|
335
|
+
# physical velocity ceiling: a couple of times the shell speed (cataracting balls impact near omega*R).
|
|
336
|
+
self._v_max = max(3.0 * self.omega * self.R, 2.0 * math.sqrt(2 * G * self.R))
|
|
337
|
+
|
|
338
|
+
def _grid(self):
|
|
339
|
+
cfg = self.cfg
|
|
340
|
+
cell = self.cfg.ball_diameter_m * 1.05
|
|
341
|
+
xmin = -self.R - cell
|
|
342
|
+
ymin = -self.R - cell
|
|
343
|
+
ncx = int((2 * self.R + 2 * cell) / cell) + 1
|
|
344
|
+
ncy = ncx
|
|
345
|
+
cxi = ((self.px - xmin) / cell).astype(np.int64)
|
|
346
|
+
cyi = ((self.py - ymin) / cell).astype(np.int64)
|
|
347
|
+
np.clip(cxi, 0, ncx - 1, out=cxi)
|
|
348
|
+
np.clip(cyi, 0, ncy - 1, out=cyi)
|
|
349
|
+
cell_id = cxi * ncy + cyi
|
|
350
|
+
order = np.argsort(cell_id, kind="stable")
|
|
351
|
+
cell_items = order.astype(np.int64)
|
|
352
|
+
counts = np.bincount(cell_id, minlength=ncx * ncy)
|
|
353
|
+
cell_start = np.zeros(ncx * ncy + 1, dtype=np.int64)
|
|
354
|
+
cell_start[1:] = np.cumsum(counts)
|
|
355
|
+
return cell_start, cell_items, ncx, ncy, cell, xmin, ymin
|
|
356
|
+
|
|
357
|
+
def step(self) -> None:
|
|
358
|
+
c = self.cfg.contact
|
|
359
|
+
cell_start, cell_items, ncx, ncy, cell, xmin, ymin = self._grid()
|
|
360
|
+
fx, fy, torque = _resolve_contacts(
|
|
361
|
+
self.px, self.py, self.vx, self.vy, self.r, self.m,
|
|
362
|
+
cell_start, cell_items, ncx, ncy, cell, xmin, ymin,
|
|
363
|
+
self.kn, self.kt, self.gamma_scale, c.mu, self.R, self.omega,
|
|
364
|
+
self.cfg.n_lifters, self.cfg.lifter_height_m, c.model == "hertz", self.dt, self.wall_shear,
|
|
365
|
+
)
|
|
366
|
+
# clear the wall shear-spring for particles that are no longer touching the shell (contact ended)
|
|
367
|
+
rad_all = np.sqrt(self.px ** 2 + self.py ** 2)
|
|
368
|
+
self.wall_shear[rad_all + self.r < self.R] = 0.0
|
|
369
|
+
ax = fx / self.m
|
|
370
|
+
ay = fy / self.m
|
|
371
|
+
# semi-implicit (symplectic) Euler: robust for stiff contacts
|
|
372
|
+
self.vx += ax * self.dt
|
|
373
|
+
self.vy += ay * self.dt
|
|
374
|
+
# background viscous damping (numerical stabilizer): a light drag toward zero, standard in mill-DEM to
|
|
375
|
+
# absorb the stored-overlap energy of a dense packed bed without altering the bulk charge motion.
|
|
376
|
+
damp = math.exp(-self.cfg.bg_damping * self.dt)
|
|
377
|
+
self.vx *= damp
|
|
378
|
+
self.vy *= damp
|
|
379
|
+
# velocity ceiling (a physical clamp): no particle in a tumbling mill exceeds a couple of times the
|
|
380
|
+
# shell speed omega*R. Clamping the tail of the velocity distribution to v_max keeps a stray stiff-
|
|
381
|
+
# contact impulse from blowing up the explicit integrator without touching the bulk charge motion.
|
|
382
|
+
v_max = self._v_max
|
|
383
|
+
sp2 = self.vx * self.vx + self.vy * self.vy
|
|
384
|
+
hot = sp2 > v_max * v_max
|
|
385
|
+
if np.any(hot):
|
|
386
|
+
s = v_max / np.sqrt(sp2[hot])
|
|
387
|
+
self.vx[hot] *= s
|
|
388
|
+
self.vy[hot] *= s
|
|
389
|
+
self.px += self.vx * self.dt
|
|
390
|
+
self.py += self.vy * self.dt
|
|
391
|
+
self.t += self.dt
|
|
392
|
+
self._torque_hist.append(torque)
|
|
393
|
+
|
|
394
|
+
def settle(self, settle_time: float | None = None) -> int:
|
|
395
|
+
"""Let the charge fall and pack into a bed with the drum STATIONARY (omega temporarily 0) before the
|
|
396
|
+
run. Without this the initial loose grid is flung around by the moving wall and never forms a bed."""
|
|
397
|
+
if settle_time is None:
|
|
398
|
+
# a few free-fall times across the drum is enough to pack the bed
|
|
399
|
+
settle_time = 4.0 * math.sqrt(2 * self.R / G)
|
|
400
|
+
saved = self.omega
|
|
401
|
+
self.omega = 0.0
|
|
402
|
+
n = int(settle_time / self.dt)
|
|
403
|
+
for _ in range(n):
|
|
404
|
+
self.step()
|
|
405
|
+
# bleed kinetic energy during settling so the bed compacts and quiets (viscous drag toward rest).
|
|
406
|
+
# stronger over the last third to fully settle the free surface.
|
|
407
|
+
self.vx *= 0.995
|
|
408
|
+
self.vy *= 0.995
|
|
409
|
+
self.omega = saved
|
|
410
|
+
self._torque_hist.clear() # torque during settling is not part of the running power
|
|
411
|
+
self.t = 0.0
|
|
412
|
+
return n
|
|
413
|
+
|
|
414
|
+
def run(self, sim_time: float, settle_frac: float = 0.5, do_settle: bool = True):
|
|
415
|
+
"""Settle the charge (drum stationary), then run for ``sim_time`` seconds recording the shell torque.
|
|
416
|
+
Metrics use the last ``1-settle_frac`` of the running window (steady state)."""
|
|
417
|
+
if do_settle:
|
|
418
|
+
self.settle()
|
|
419
|
+
n_steps = int(sim_time / self.dt)
|
|
420
|
+
for _ in range(n_steps):
|
|
421
|
+
self.step()
|
|
422
|
+
return n_steps
|
milldem/engine3d.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Thin-3D-slab DEM: a slab of the mill of axial thickness ``w`` (a few ball diameters) with PERIODIC axial
|
|
2
|
+
(z) boundaries. This captures the 3D packing and the axial force chains that carry the charge lift, which a
|
|
3
|
+
single 2D disc slice cannot. The drum is an infinite cylinder in x-y; z is the axial direction, periodic over
|
|
4
|
+
[0, w). Net power for the full mill scales by ``length_m / w``.
|
|
5
|
+
|
|
6
|
+
Why this fixes the 2D power-vs-size limitation: in 2D the lift is set by the wall friction and gravity (a
|
|
7
|
+
size-independent absolute height), so the CoM torque arm does not scale with the mill radius R. In 3D the
|
|
8
|
+
force chains transmit the wall drive through the packed charge, and the charge lifts to a shoulder angle that
|
|
9
|
+
is roughly speed/fill-dependent (dynamic similarity), so the arm scales with R as it physically must.
|
|
10
|
+
|
|
11
|
+
Same soft-sphere contact model as the 2D engine (``contact.py``); numba-JIT hot loop with a numpy fallback.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .contact import ContactModel
|
|
20
|
+
from .engine import G, MillConfig
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from numba import njit
|
|
24
|
+
|
|
25
|
+
_HAS_NUMBA = True
|
|
26
|
+
except Exception: # pragma: no cover
|
|
27
|
+
_HAS_NUMBA = False
|
|
28
|
+
|
|
29
|
+
def njit(*args, **kwargs):
|
|
30
|
+
def wrap(f):
|
|
31
|
+
return f
|
|
32
|
+
return wrap if not args else args[0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@njit(cache=True, fastmath=True)
|
|
36
|
+
def _resolve3d(px, py, pz, vx, vy, vz, r, m, cell_start, cell_items, ncx, ncy, ncz, cs, xmin, ymin,
|
|
37
|
+
w, kn, kt, gscale, mu, R, omega, dt):
|
|
38
|
+
n = px.shape[0]
|
|
39
|
+
fx = np.zeros(n); fy = np.zeros(n); fz = np.zeros(n)
|
|
40
|
+
shell_torque = 0.0
|
|
41
|
+
for i in range(n):
|
|
42
|
+
fy[i] -= m[i] * G
|
|
43
|
+
|
|
44
|
+
# particle-particle, 3D grid with z-periodicity
|
|
45
|
+
for i in range(n):
|
|
46
|
+
cx = int((px[i] - xmin) / cs)
|
|
47
|
+
cy = int((py[i] - ymin) / cs)
|
|
48
|
+
cz = int(pz[i] / cs)
|
|
49
|
+
for dgx in range(-1, 2):
|
|
50
|
+
for dgy in range(-1, 2):
|
|
51
|
+
for dgz in range(-1, 2):
|
|
52
|
+
gx = cx + dgx; gy = cy + dgy; gz = (cz + dgz) % ncz
|
|
53
|
+
if gx < 0 or gy < 0 or gx >= ncx or gy >= ncy:
|
|
54
|
+
continue
|
|
55
|
+
cell = (gx * ncy + gy) * ncz + gz
|
|
56
|
+
for idx in range(cell_start[cell], cell_start[cell + 1]):
|
|
57
|
+
j = cell_items[idx]
|
|
58
|
+
if j <= i:
|
|
59
|
+
continue
|
|
60
|
+
dx = px[j] - px[i]; dy = py[j] - py[i]
|
|
61
|
+
dz = pz[j] - pz[i]
|
|
62
|
+
# minimum-image in z (periodic)
|
|
63
|
+
if dz > 0.5 * w:
|
|
64
|
+
dz -= w
|
|
65
|
+
elif dz < -0.5 * w:
|
|
66
|
+
dz += w
|
|
67
|
+
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
68
|
+
overlap = r[i] + r[j] - dist
|
|
69
|
+
if overlap <= 0.0 or dist < 1e-12:
|
|
70
|
+
continue
|
|
71
|
+
nx = dx / dist; ny = dy / dist; nz = dz / dist
|
|
72
|
+
m_eff = m[i] * m[j] / (m[i] + m[j])
|
|
73
|
+
rvx = vx[j] - vx[i]; rvy = vy[j] - vy[i]; rvz = vz[j] - vz[i]
|
|
74
|
+
vn = -(rvx * nx + rvy * ny + rvz * nz)
|
|
75
|
+
gamma_n = gscale * math.sqrt(kn / m_eff)
|
|
76
|
+
fn = kn * overlap + m_eff * gamma_n * vn
|
|
77
|
+
if fn < 0.0:
|
|
78
|
+
fn = 0.0
|
|
79
|
+
# tangential (full Coulomb for shear strength, impulse-capped)
|
|
80
|
+
tvx = rvx - (rvx * nx + rvy * ny + rvz * nz) * nx
|
|
81
|
+
tvy = rvy - (rvx * nx + rvy * ny + rvz * nz) * ny
|
|
82
|
+
tvz = rvz - (rvx * nx + rvy * ny + rvz * nz) * nz
|
|
83
|
+
tvm = math.sqrt(tvx * tvx + tvy * tvy + tvz * tvz)
|
|
84
|
+
ftx = 0.0; fty = 0.0; ftz = 0.0
|
|
85
|
+
if tvm > 1e-9:
|
|
86
|
+
ft = min(mu * fn, m_eff * tvm / dt)
|
|
87
|
+
ftx = -ft * tvx / tvm; fty = -ft * tvy / tvm; ftz = -ft * tvz / tvm
|
|
88
|
+
fix = -(fn * nx) + ftx; fiy = -(fn * ny) + fty; fiz = -(fn * nz) + ftz
|
|
89
|
+
fx[i] += fix; fy[i] += fiy; fz[i] += fiz
|
|
90
|
+
fx[j] -= fix; fy[j] -= fiy; fz[j] -= fiz
|
|
91
|
+
|
|
92
|
+
# particle-wall (cylindrical shell at R in x-y) + shell torque
|
|
93
|
+
for i in range(n):
|
|
94
|
+
rad = math.sqrt(px[i] * px[i] + py[i] * py[i])
|
|
95
|
+
overlap = rad + r[i] - R
|
|
96
|
+
if overlap > 0.0 and rad > 1e-12:
|
|
97
|
+
nx = px[i] / rad; ny = py[i] / rad
|
|
98
|
+
m_eff = m[i]
|
|
99
|
+
vn = -(vx[i] * nx + vy[i] * ny)
|
|
100
|
+
gamma_n = gscale * math.sqrt(kn / m_eff)
|
|
101
|
+
fn = kn * overlap + m_eff * gamma_n * vn
|
|
102
|
+
if fn < 0.0:
|
|
103
|
+
fn = 0.0
|
|
104
|
+
wvx = -omega * py[i]; wvy = omega * px[i]
|
|
105
|
+
rvx = wvx - vx[i]; rvy = wvy - vy[i]
|
|
106
|
+
tvx = rvx - (rvx * nx + rvy * ny) * nx
|
|
107
|
+
tvy = rvy - (rvx * nx + rvy * ny) * ny
|
|
108
|
+
tvm = math.sqrt(tvx * tvx + tvy * tvy)
|
|
109
|
+
fpx = -fn * nx; fpy = -fn * ny
|
|
110
|
+
if tvm > 1e-9:
|
|
111
|
+
ft = min(mu * fn, m_eff * tvm / dt)
|
|
112
|
+
fpx += ft * tvx / tvm; fpy += ft * tvy / tvm
|
|
113
|
+
fx[i] += fpx; fy[i] += fpy
|
|
114
|
+
shell_torque += px[i] * (-fpy) - py[i] * (-fpx)
|
|
115
|
+
|
|
116
|
+
return fx, fy, fz, shell_torque
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MillDEM3D:
|
|
120
|
+
"""Thin-3D-slab mill DEM. Slab thickness defaults to 4 top-ball diameters."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, cfg: MillConfig, slab_thickness_m: float | None = None, seed: int = 42):
|
|
123
|
+
self.cfg = cfg
|
|
124
|
+
self.rng = np.random.default_rng(seed)
|
|
125
|
+
self.R = cfg.radius()
|
|
126
|
+
self.omega = cfg.omega()
|
|
127
|
+
rmax = cfg.ball_diameter_m / 2.0
|
|
128
|
+
self.w = slab_thickness_m if slab_thickness_m is not None else 4.0 * cfg.ball_diameter_m
|
|
129
|
+
self._build(rmax)
|
|
130
|
+
kn = self._auto_kn()
|
|
131
|
+
self.kn = kn
|
|
132
|
+
self.kt = cfg.contact.kt_ratio * kn
|
|
133
|
+
m_eff = float(self.m.min()) / 2.0
|
|
134
|
+
self.dt = 0.02 * math.pi * math.sqrt(m_eff / kn)
|
|
135
|
+
e = min(max(cfg.contact.e, 1e-4), 0.999); lne = math.log(e)
|
|
136
|
+
self.gscale = -2.0 * lne / math.sqrt(math.pi ** 2 + lne ** 2)
|
|
137
|
+
self._v_max = max(3.0 * self.omega * self.R, 2.0 * math.sqrt(2 * G * self.R))
|
|
138
|
+
self._torque = []
|
|
139
|
+
|
|
140
|
+
def _build(self, rmax):
|
|
141
|
+
cfg = self.cfg
|
|
142
|
+
rmin = rmax * cfg.size_ratio
|
|
143
|
+
disc_area = math.pi * self.R ** 2
|
|
144
|
+
# solid volume target: J * disc_area * w (bed occupies J of the cross-section over the slab thickness)
|
|
145
|
+
target_vol = cfg.fill * disc_area * self.w
|
|
146
|
+
radii = []; vol = 0.0
|
|
147
|
+
while vol < target_vol:
|
|
148
|
+
rr = self.rng.uniform(rmin, rmax); radii.append(rr)
|
|
149
|
+
vol += (4.0 / 3.0) * math.pi * rr ** 3
|
|
150
|
+
radii.sort(reverse=True)
|
|
151
|
+
r = np.array(radii); n = r.shape[0]
|
|
152
|
+
# place on a 3D grid in the lower drum, no overlaps
|
|
153
|
+
step = 2.0 * rmax * 1.12
|
|
154
|
+
gx = np.arange(-self.R + rmax + 0.01, self.R - rmax - 0.01, step)
|
|
155
|
+
gy = np.arange(-self.R + rmax + 0.01, self.R - rmax - 0.01, step)
|
|
156
|
+
gz = np.arange(rmax, self.w - rmax + 1e-9, step)
|
|
157
|
+
if gz.size == 0:
|
|
158
|
+
gz = np.array([self.w / 2])
|
|
159
|
+
pts = [(x, y, z) for z in gz for y in gy for x in gx if x * x + y * y < (self.R - rmax) ** 2]
|
|
160
|
+
pts.sort(key=lambda p: p[1])
|
|
161
|
+
if len(pts) < n:
|
|
162
|
+
r = r[:len(pts)]; n = len(pts)
|
|
163
|
+
px = np.zeros(n); py = np.zeros(n); pz = np.zeros(n)
|
|
164
|
+
for i in range(n):
|
|
165
|
+
px[i], py[i], pz[i] = pts[i]
|
|
166
|
+
self.px, self.py, self.pz, self.r = px, py, pz, r
|
|
167
|
+
self.vx = np.zeros(n); self.vy = np.zeros(n); self.vz = np.zeros(n)
|
|
168
|
+
self.m = cfg.rho_ball * (4.0 / 3.0) * math.pi * r ** 3
|
|
169
|
+
self.n = n
|
|
170
|
+
|
|
171
|
+
def _auto_kn(self):
|
|
172
|
+
m_max = float(self.m.max()); m_min = float(self.m.min()); r_min = float(self.r.min())
|
|
173
|
+
v_imp = max(self.omega * self.R, math.sqrt(2 * G * self.R))
|
|
174
|
+
target = 0.04 * r_min
|
|
175
|
+
kn_stab = m_max * v_imp ** 2 / target ** 2
|
|
176
|
+
kn_cap = (m_min / 2.0) * (math.pi / 6e-5) ** 2
|
|
177
|
+
return min(max(kn_stab, 100.0 * m_max * G / target), kn_cap)
|
|
178
|
+
|
|
179
|
+
def _grid(self):
|
|
180
|
+
cs = self.cfg.ball_diameter_m * 1.05
|
|
181
|
+
xmin = -self.R - cs; ymin = -self.R - cs
|
|
182
|
+
ncx = int((2 * self.R + 2 * cs) / cs) + 1; ncy = ncx
|
|
183
|
+
ncz = max(1, int(self.w / cs))
|
|
184
|
+
cxi = np.clip(((self.px - xmin) / cs).astype(np.int64), 0, ncx - 1)
|
|
185
|
+
cyi = np.clip(((self.py - ymin) / cs).astype(np.int64), 0, ncy - 1)
|
|
186
|
+
czi = np.clip((self.pz / cs).astype(np.int64), 0, ncz - 1)
|
|
187
|
+
cid = (cxi * ncy + cyi) * ncz + czi
|
|
188
|
+
order = np.argsort(cid, kind="stable").astype(np.int64)
|
|
189
|
+
counts = np.bincount(cid, minlength=ncx * ncy * ncz)
|
|
190
|
+
start = np.zeros(ncx * ncy * ncz + 1, dtype=np.int64); start[1:] = np.cumsum(counts)
|
|
191
|
+
return start, order, ncx, ncy, ncz, cs, xmin, ymin
|
|
192
|
+
|
|
193
|
+
def step(self):
|
|
194
|
+
c = self.cfg.contact
|
|
195
|
+
start, order, ncx, ncy, ncz, cs, xmin, ymin = self._grid()
|
|
196
|
+
fx, fy, fz, tq = _resolve3d(self.px, self.py, self.pz, self.vx, self.vy, self.vz, self.r, self.m,
|
|
197
|
+
start, order, ncx, ncy, ncz, cs, xmin, ymin, self.w,
|
|
198
|
+
self.kn, self.kt, self.gscale, c.mu, self.R, self.omega, self.dt)
|
|
199
|
+
self.vx += fx / self.m * self.dt; self.vy += fy / self.m * self.dt; self.vz += fz / self.m * self.dt
|
|
200
|
+
d = math.exp(-self.cfg.bg_damping * self.dt)
|
|
201
|
+
self.vx *= d; self.vy *= d; self.vz *= d
|
|
202
|
+
sp2 = self.vx ** 2 + self.vy ** 2 + self.vz ** 2
|
|
203
|
+
hot = sp2 > self._v_max ** 2
|
|
204
|
+
if np.any(hot):
|
|
205
|
+
s = self._v_max / np.sqrt(sp2[hot]); self.vx[hot] *= s; self.vy[hot] *= s; self.vz[hot] *= s
|
|
206
|
+
self.px += self.vx * self.dt; self.py += self.vy * self.dt; self.pz += self.vz * self.dt
|
|
207
|
+
self.pz %= self.w # periodic axial wrap
|
|
208
|
+
self._torque.append(tq)
|
|
209
|
+
|
|
210
|
+
def settle(self, t=None):
|
|
211
|
+
if t is None:
|
|
212
|
+
t = 4.0 * math.sqrt(2 * self.R / G)
|
|
213
|
+
om = self.omega; self.omega = 0.0
|
|
214
|
+
for _ in range(int(t / self.dt)):
|
|
215
|
+
self.step(); self.vx *= 0.995; self.vy *= 0.995; self.vz *= 0.995
|
|
216
|
+
self.omega = om; self._torque.clear()
|
|
217
|
+
|
|
218
|
+
def run(self, t, do_settle=True):
|
|
219
|
+
if do_settle:
|
|
220
|
+
self.settle()
|
|
221
|
+
for _ in range(int(t / self.dt)):
|
|
222
|
+
self.step()
|
|
223
|
+
|
|
224
|
+
def net_power_kw(self, settle_frac=0.4):
|
|
225
|
+
h = np.asarray(self._torque); k = int(len(h) * settle_frac)
|
|
226
|
+
tq = float(np.mean(h[k:])) if len(h) > k else 0.0 # torque of the slab (thickness w)
|
|
227
|
+
N = self.omega / (2 * math.pi)
|
|
228
|
+
n_slabs = self.cfg.length_m / self.w
|
|
229
|
+
return 2 * math.pi * abs(tq) * N * n_slabs / 1000.0
|
|
230
|
+
|
|
231
|
+
def arm_m(self):
|
|
232
|
+
return float(np.average(self.px, weights=self.m))
|
milldem/metrics.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Settled-state metrics from a run: net power (torque route), charge shape, regime, energy audit.
|
|
2
|
+
|
|
3
|
+
Power (van Nierop et al. 2001, the torque route, dossier 2.4 route D)::
|
|
4
|
+
|
|
5
|
+
P = 2 * pi * T * N (T = net shell/lifter torque about the mill axis, N = rev/s)
|
|
6
|
+
|
|
7
|
+
The engine accumulates the shell torque per step; over the settled window we average it, giving the mean
|
|
8
|
+
2D per-length torque. Multiplying by the mill length ``L`` and ``2*pi*N`` gives the net power in W -> kW.
|
|
9
|
+
|
|
10
|
+
Charge shape: from the settled particle positions, the toe and shoulder angles are the angular extent of the
|
|
11
|
+
lifted bed (measured from the vertical through the centre), and the regime is classified from the fraction of
|
|
12
|
+
particles above the mill centre-height and beyond the bulk (the cataracting stream) vs pinned to the wall.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .engine import G, MillDEM
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MillMetrics:
|
|
26
|
+
net_power_kw: float # DEM net power (torque route), scaled to the full mill length
|
|
27
|
+
torque_per_len_nm_m: float # mean 2D shell torque per unit length [N*m / m]
|
|
28
|
+
toe_deg: float # toe angle (from vertical, the impact side)
|
|
29
|
+
shoulder_deg: float # shoulder angle (where the charge leaves the wall)
|
|
30
|
+
frac_cataracting: float # fraction of particles in free flight above the bed
|
|
31
|
+
frac_centrifuging: float # fraction pinned to the wall through the top
|
|
32
|
+
regime: str # cascading | cataracting | centrifuging
|
|
33
|
+
mean_speed_ms: float # mean particle speed in the settled window
|
|
34
|
+
n_particles: int
|
|
35
|
+
steps: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _settled_positions(sim: MillDEM):
|
|
39
|
+
return sim.px.copy(), sim.py.copy(), sim.vx.copy(), sim.vy.copy(), sim.r.copy()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_metrics(sim: MillDEM, settle_frac: float = 0.5) -> MillMetrics:
|
|
43
|
+
cfg = sim.cfg
|
|
44
|
+
R = sim.R
|
|
45
|
+
N = sim.omega / (2.0 * math.pi) # rev/s
|
|
46
|
+
|
|
47
|
+
px, py, vx, vy, r = _settled_positions(sim)
|
|
48
|
+
rad = np.sqrt(px * px + py * py)
|
|
49
|
+
speed = np.sqrt(vx * vx + vy * vy)
|
|
50
|
+
mean_speed = float(np.mean(speed)) if speed.size else 0.0
|
|
51
|
+
|
|
52
|
+
# Net power via the torque-arm route (van Nierop 2001, P = 2*pi*T*N), with the net torque computed the
|
|
53
|
+
# ROBUST way: from the charge centre-of-mass offset from the mill axis. When the drum lifts the charge, its
|
|
54
|
+
# CoM shifts to the rising side by a horizontal arm ``a``; holding that offset mass against gravity costs a
|
|
55
|
+
# torque T = M_charge * g * a. This is the same physics as summing the shell contact torque but is numerically
|
|
56
|
+
# stable (it reads the settled charge geometry instead of noisy per-step contact-friction spikes, which are
|
|
57
|
+
# dominated by transient large-overlap impacts and over-predict the torque). The per-contact route is also
|
|
58
|
+
# accumulated (``torque_per_len_nm_m``) for reference/cross-check.
|
|
59
|
+
slice_mass = float(sim.m.sum()) # charge mass of one disc slice [kg]
|
|
60
|
+
slice_thickness = 2.0 * float(sim.r.max())
|
|
61
|
+
n_slices = cfg.length_m / slice_thickness
|
|
62
|
+
total_charge_mass = slice_mass * n_slices # full-mill charge mass [kg]
|
|
63
|
+
arm = float(np.average(px, weights=sim.m)) # horizontal CoM offset from the axis [m] (the torque arm)
|
|
64
|
+
T = total_charge_mass * G * abs(arm) # net torque about the axis [N*m]
|
|
65
|
+
net_power_w = 2.0 * math.pi * T * N
|
|
66
|
+
net_power_kw = net_power_w / 1000.0
|
|
67
|
+
# the raw per-contact torque route, for reference (scaled to the full mill)
|
|
68
|
+
hist = np.asarray(sim._torque_hist)
|
|
69
|
+
kk = int(len(hist) * settle_frac)
|
|
70
|
+
settled = hist[kk:] if len(hist) > kk else hist
|
|
71
|
+
mean_torque = float(np.mean(settled)) if settled.size else 0.0
|
|
72
|
+
|
|
73
|
+
# regime fractions
|
|
74
|
+
near_wall = rad > (R - 1.5 * r) # touching / near the shell
|
|
75
|
+
above_centre = py > 0 # upper half
|
|
76
|
+
centrifuging = near_wall & above_centre # pinned to the wall through the top
|
|
77
|
+
# cataracting: airborne (not near wall, not in the dense lower bed) and in the upper half
|
|
78
|
+
dense_bed = (~near_wall) & (py < -0.1 * R)
|
|
79
|
+
cataracting = above_centre & (~near_wall) & (~dense_bed)
|
|
80
|
+
n = px.shape[0]
|
|
81
|
+
frac_cent = float(np.mean(centrifuging)) if n else 0.0
|
|
82
|
+
frac_cat = float(np.mean(cataracting)) if n else 0.0
|
|
83
|
+
|
|
84
|
+
# toe / shoulder of the charge bed. The bed's FREE SURFACE (the top layer of the charge, not the wall
|
|
85
|
+
# contact) defines the toe and shoulder: the shoulder is the highest point of the bed on the rising side,
|
|
86
|
+
# the toe is the lowest point of the free surface on the falling side. We take the surface as the topmost
|
|
87
|
+
# particle in each angular sector and fit its two extremes. Angle measured from the vertical bottom (6
|
|
88
|
+
# o'clock = 0), positive toward the rising (+x) side; a level bed reads toe~-a, shoulder~+a symmetric.
|
|
89
|
+
if px.shape[0] > 8:
|
|
90
|
+
# angle from the DOWNWARD vertical, positive toward +x (rising): beta = atan2(x, -y), so the bottom of
|
|
91
|
+
# the drum is 0 and the rising side is positive. Find, per angular bin, the innermost (surface) radius.
|
|
92
|
+
beta = np.degrees(np.arctan2(px, -py))
|
|
93
|
+
# surface particles: those in the upper envelope of the bed. Bin by beta, take the min-radius (topmost)
|
|
94
|
+
surf_mask = rad < (R - 1.2 * cfg.ball_diameter_m) # not touching the wall = interior/surface
|
|
95
|
+
b = beta[surf_mask]
|
|
96
|
+
if b.size > 5:
|
|
97
|
+
shoulder_deg = float(np.percentile(b, 90)) # most-lifted on the rising side
|
|
98
|
+
toe_deg = float(np.percentile(b, 10)) # lowest on the falling side
|
|
99
|
+
else:
|
|
100
|
+
shoulder_deg, toe_deg = 45.0, -45.0
|
|
101
|
+
else:
|
|
102
|
+
shoulder_deg, toe_deg = 45.0, -45.0
|
|
103
|
+
|
|
104
|
+
if frac_cent > 0.15:
|
|
105
|
+
regime = "centrifuging"
|
|
106
|
+
elif frac_cat > 0.08:
|
|
107
|
+
regime = "cataracting"
|
|
108
|
+
else:
|
|
109
|
+
regime = "cascading"
|
|
110
|
+
|
|
111
|
+
return MillMetrics(
|
|
112
|
+
net_power_kw=net_power_kw,
|
|
113
|
+
torque_per_len_nm_m=mean_torque,
|
|
114
|
+
toe_deg=toe_deg,
|
|
115
|
+
shoulder_deg=shoulder_deg,
|
|
116
|
+
frac_cataracting=frac_cat,
|
|
117
|
+
frac_centrifuging=frac_cent,
|
|
118
|
+
regime=regime,
|
|
119
|
+
mean_speed_ms=mean_speed,
|
|
120
|
+
n_particles=n,
|
|
121
|
+
steps=len(hist),
|
|
122
|
+
)
|
|
@@ -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.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
milldem/__init__.py,sha256=uv_1Fwpn67oS9NR2hjbhise-TMUCqCPSWJeK0iiC5lY,2285
|
|
2
|
+
milldem/__main__.py,sha256=iyH8OaI73KVsWKuABGqGDXBT-FTo0-kr6S_3JwM63FM,162
|
|
3
|
+
milldem/cli.py,sha256=lX-A2xP-uJwr2rNqermFcBjWBWKxClEGIfH_7iu-xaQ,5476
|
|
4
|
+
milldem/contact.py,sha256=Qv5tLGxvat2l32NwUepeULy4GVPN7fAk33sesT7AN04,4414
|
|
5
|
+
milldem/engine.py,sha256=oRB6Ey_dqjT6I_aOmBU0gytyGjoLAgJhSackuW4SDxY,22394
|
|
6
|
+
milldem/engine3d.py,sha256=b8cO8GjR_SKQCzmIvEAmdda4PDbOUMbcjMwpPHcxFOs,10774
|
|
7
|
+
milldem/metrics.py,sha256=JPWbSe5PIzmvowsxLiP6PXWbFcjktoxUYm6rsCsv_bQ,6206
|
|
8
|
+
milldem-0.2.0.dist-info/METADATA,sha256=V2jkMMtFRryErMxdCDFVM97hj3Hc7OHBub7gXv1Z4v4,2797
|
|
9
|
+
milldem-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
milldem-0.2.0.dist-info/entry_points.txt,sha256=0HB78n9CK79E5tvP1zythjwaeOMk37R4J6G5J6_OXdw,45
|
|
11
|
+
milldem-0.2.0.dist-info/top_level.txt,sha256=IpDfyR4V7P9JZ7p-Qrww5LS0NglhhE4C9bfP1xSIz8s,8
|
|
12
|
+
milldem-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
milldem
|