Install

Two runtime dependencies, numpy and numba, and the omissions are the design: no sympy, no scipy, no matplotlib. Every closed form here is elementary, and import mhs is gated to stay within a second of import numba so it can sit in a hot loop without apology.

pip install -e ".[test,tde]"
python tests/run_all.py --suite mhs      # the oracle-free suite, 10 gates
pytest -m "not slow" tests/test_gates.py

The tde extra pulls in cutde, which two gates need and which they fail rather than skip without — a skipped external anchor is an anchor that silently stopped holding. The oracle extra adds sympy and matplotlib for the nightly parity suite only; importing that reference costs 14 s, because it builds and lambdifies about ninety symbolic matrices at module scope.

Matrices, not fields

Every entry point returns a matrix, not a field, so the geometry is paid for once and then contracted against as many slip vectors as you like.

import numpy as np, mhs

mat  = mhs.Material(mu=30.0, lam=30.0)              # (mu, lam), never nu
G    = mhs.disp_matrix(obs, tris, mat, eps=0.1)     # (n_obs, 3, n_src, 3)
u    = np.einsum("oisk,sk->oi", G, slip)            # slip is CARTESIAN

S    = mhs.stress_matrix(obs, tris, mat, eps=0.1)   # (n_obs, 3, 3, n_src, 3)
                                                    # ELASTIC: eigenstress removed

Six conventions, each stated in exactly one place in the source:

On-fault interaction

For on-fault stress interaction — the object Coulomb, rate-and-state and earthquake-cycle models actually want — ask for it directly rather than contracting a stress matrix you cannot allocate:

K = mhs.interaction_matrix(tris, mat, eps=0.1,          # (n, n, 3, 3)
                           receiver=("strike", "dip", "normal"),
                           source=("strike", "dip", "tensile"))

K = mhs.interaction_matrix(tris, mat, eps=0.1,          # (n, n, 1, 1), 0.75 GiB
                           receiver="strike", source="strike")  # at n = 10k

T = mhs.traction_matrix(obs, normals, tris, mat, eps=0.1)   # (n_obs, 3, n_src, 3)

The component selection happens inside the assembly loop, so the full tensor is never materialised. Same arithmetic to 10⁻¹⁵ — gated as an identity against stress_matrix, not as a tolerance — same speed, and between 3× and 27× less memory. Receivers and sources can be different element sets via obs_tris, which is what makes a rectangular block of a larger matrix addressable.

The figure on the front page is one column of such a matrix: 256 triangles, receiver="strike" and receiver="normal" against source="strike", combined into ΔCFS with an apparent friction of 0.6.

Memory, and the refusal

A stress matrix is 216 bytes per observation/source pair, so 10k × 10k is 20.1 GiB. The ceiling refuses rather than trying, because a request that size does not fail on most machines — it swaps, and presents as a hang with no diagnosis. The refusal carries the arithmetic that makes the next step obvious.

bytes/pair10k × 10k
stress_matrix21620.1 GiB
disp_matrix, traction_matrix726.7 GiB
interaction_matrix, 2 shear × 2 slip323.0 GiB
interaction_matrix, strike only80.75 GiB

Nothing about the calculation changes across those rows — the component selection is only how much of the result you keep. The other escapes are out= (a memmap, a reused slab, or a float32 buffer to halve it) and input slicing, with mhs.chunking.chunk_plan() reporting the numbers without allocating anything. At order > 0 the counts are per DOF rather than per element, so P1 is 9× and P2 is 36× the pair count of P0.

Across cores

Each source element owns a disjoint column slice of the output, so the build parallelises over sources with no sharing at all. Use processes:

import functools, mhs, mhs.parallel as mp

call = functools.partial(mhs.interaction_matrix, material=mat, eps=0.1,
                         obs_tris=tris, receiver="strike", source="strike")
K = mp.by_source(call, tris, workers=12, source_axis=1, tris_kw="tris")
workerssµs/pairspeed-up
130.147.11.00×
216.525.71.83×
49.014.03.36×
84.77.36.41×
123.55.58.50×

Measured on 800 elements, 640k pairs, and bitwise identical to the serial result, because each source’s block depends on no other source. Extrapolating that rate, a 10k × 10k interaction matrix is about 9 minutes against roughly an hour on one core — an extrapolation, not a timed run.

Two things in there are counter-intuitive and both were measured:

The call site must be guarded. The workers are spawned, so they re-import the module they were launched from; from a script that means if __name__ == "__main__":, and from a function, notebook or interactive session it is automatic. Without it every child re-runs the script and spawns its own children, and the parent sees only a dead pool — so by_source catches that and says this instead.

Checked, not trusted

Fourteen gates, and two runners that assert different things on purpose: run_all.py spawns each gate and asserts its exit code; test_gates.py imports each gate and asserts its return value. Both then cross-check the printed verdict. A gate whose return value said PASS while its output said FAIL is the worst of both, and neither runner alone can see it. The per-suite gate count is itself asserted, because discovery is a glob: pointed at the wrong directory it finds nothing, every “all passed” check holds trivially, and the exit code is zero. A green empty suite is the one thing a test runner must not be able to report.

workflowwhenwhat
gates.ymlevery push, no path filter10 gates × (ubuntu, macOS) × (py3.11, py3.13)
parity.ymlnightlythe 4 sympy-backed oracle and parity gates

No path filter, because a path-filtered test job reports success for a commit it never tested. And macOS is in the matrix for one specific reason: numba parallel=True kernels must never be called from Python threads, and that crash is macOS-only — a Linux-only CI would never witness a violation of the rule that most constrains the assembly path.