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 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
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m milldem`` as an alias for the ``milldem`` console script."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ milldem = milldem.cli:main
@@ -0,0 +1 @@
1
+ milldem