Tensions¶
Surface tensions inferred from a multimaterial mesh, by any of the 13 methods of TensionComputationMethod.
The entry points are infer_tensions (a map interface -> tension), infer_tensions_and_residuals
(the same, with the solve's residuals), infer_tensions_and_residuals_with_report (plus the angle
reading's coverage, the entry point to use for anything published) and
infer_tensions_by_junction_component (each interface's connected piece of the junction graph made
explicit).
Conventions shared by every function here:
- an interface is keyed
(label_1, label_2)withlabel_1 < label_2; label 0 is the exterior medium; - tensions are relative: only their ratios are determined by the mesh. They are normalised so
that their mean is
mean_tension, exactly, in each connected piece of the junction graph (seeTensionComputationMethodfor the methods that normalise otherwise). Pressures computed from them (foambryo.pressure_inference) are in the same tension unit divided by the mesh's length unit; - an interface whose tension the mesh does not determine is
float("nan"), never a number; - angles are read in radians.
Sacha Ichbiah 2021 Matthieu Perez 2024
TensionComputationMethod ¶
Bases: Enum
Describe the formula used to infer the tensions from contact angles at each junction.
It can be:
- YoungDupre (Young-Dupré with cosines only),
- YoungDupreLocal (the same balance imposed per trijunctional edge instead of once per
triple line on angles averaged along it — see foambryo.per_edge_tension_inference),
- ProjectionYoungDupre (Young-Dupré with cosines and sines),
- Equilibrium,
- Cotan (cotangent formula, see Yamamoto et al. 2026),
- InvCotan (inverse of the cotangent formula),
- Lami (Lami's theorem),
- InvLami (inverse of the Lami's relation),
- LogLami (logarithm of the Lami's relation),
- Variational (variational formulation, see our article),
- ForceBalance (the unified discrete force-balance operator with the mean-tension
normalisation imposed exactly, through a KKT system),
- ForceBalanceSoftRow (the same operator, with the normalisation as one more least-squares
row, so its mean tension is only approximately mean_tension),
- ForceBalanceMAP (the same operator, with a Gaussian prior; the normalisation is anchored only
softly, so its mean tension is deliberately not exactly mean_tension; its relative tensions
do not depend on the mesh's length unit or on the value of mean_tension, see
foambryo.force_balance_inference._solve_bayesian_map).
The normalisation. Only tension ratios are determined by a mesh, so every method fixes the
overall scale (the "gauge") by one more condition: the tensions are normalised so that their
mean is mean_tension, exactly, in each connected piece of the junction graph. This holds
exactly for every method but two: YoungDupre, YoungDupreLocal, ProjectionYoungDupre,
Cotan, InvCotan, Lami and InvLami meet it in each piece of the junction graph
(foambryo.junction_graph.solve_gauged_least_squares); LogLami meets it on the geometric
mean; Equilibrium, Variational (both routes) and ForceBalance meet it over the whole mesh
at once; ForceBalanceSoftRow (a least-squares row by definition) and ForceBalanceMAP (a
prior) do not meet it exactly. On a cropped mesh the mean runs over the interfaces whose
tension is determined. Until 0.6.0 the first group, Equilibrium and the Gram route of
Variational imposed it as a least-squares row of ones too; that row changed only the overall
scale of their tensions, never a ratio (see foambryo.junction_graph).
Cost on large tissues. Equilibrium, Variational and ForceBalance solve dense systems only: on a
1 866-cell tissue each took one to two hours with a peak of about 68 GiB, where YoungDupre, the default, takes
seconds (see the user guide's page on large tissues).
The three ForceBalance* members are one estimator under three normalisations, not
three physical models; they exist as separate members so that the comparison between the
three is a regenerable benchmark row rather than a one-off table. See
foambryo.force_balance_inference.
Which methods read angles and which invert an operator, and why the distinction now has
measured consequences. YoungDupre, YoungDupreLocal, ProjectionYoungDupre, Lami,
InvLami and LogLami are all built from measured contact angles at triple junctions —
they read the mesh's local geometry pointwise and never assemble or invert a global
operator. Cotan, InvCotan, Variational and the three ForceBalance* members instead
assemble a discrete operator over the whole mesh (an area/volume-gradient system, or its
force-balance generalisation) and solve or invert it. Equilibrium is a geometric-edge
balance and does not cleanly fall in either family (see its own note in
foambryo.validation.force_balance). This split matters because mesh-reconstruction
quality affects the two families very differently: on dw3d-reconstructed meshes, the
operator-inverting family's error tracks the mesh's distance from mechanical equilibrium
(rho_free) far more strongly than the angle-reading family's does, so an operator-
inverting method can be dramatically more or less accurate depending on which meshing
pipeline produced its input, while an angle-reading method is comparatively insensitive to
it. See dw3d's BENCHMARKS.md and this package's own BENCHMARKS.md for the numbers.
Degenerate junctions. Cotan, InvCotan, Lami and LogLami have coefficients that are
infinite at a wedge of exactly 0 or 180 degrees (1/sin, cot(phi/2), log sin). Such wedges occur
on short triple lines whose faces are exactly coplanar (mesh vertices on the voxel lattice). These
four methods leave such a line's equations out, with a DegenerateWedgeWarning; an interface left
without any equation is nan. The other methods' coefficients stay finite and bounded there, and
they are unchanged. See DEGENERATE_WEDGE_TOLERANCE.
The operator-inverting family is unreliable on reconstructed meshes, measured. On the
benchmark cohort's shipped-default reconstructions rho_free -- the distance of the mesh
from any equilibrium of the force-balance operator -- has median 0.578 against the
reference meshes' 0.0118 (a factor of about 50), and all 40 equilibrium cases exceed the
0.2 threshold at which foambryo.uncertainty reports that no surface tensions balance the
mesh at all. Every ForceBalance* number obtained on a dw3d mesh, including every
published comparison between reconstruction variants through this family, should be read
with that floor in mind. See foambryo.force_balance_inference's module docstring.
VariationalRoute ¶
Bases: Enum
Which linear algebra TensionComputationMethod.Variational uses to reach its answer.
Both routes minimise the same discrete energy gradient; they differ in how, and the choice moves published numbers, which is why both are kept.
Gram(route P, the original): form the Gram normal equationsM^T M, divide every row by its own row sum, Schur-eliminate the pressures, then least-squares with the mean-tension normalisation met exactly (foambryo.junction_graph.solve_with_exact_gauge; until 0.6.0 it was a least-squares row of ones). It has no formulation for crop faces.Operator(route Q, the default): minimise||M beta||^2directly throughM, with the normalisation eliminated by substitution.M^T Mis never formed and there is no row normalisation.
TensionComputationMethod.Variational always takes the default route,
VARIATIONAL_ROUTE_DEFAULT; Gram is reachable only through the internal
_infer_tension_variational_yd(mesh, mean_tension, route=VariationalRoute.Gram).
The row normalisation is the substantive difference, and it has never been justified.
Dividing normal-equation row i by sum_j (M^T M)_ij is not a symmetric preconditioner
(it destroys the symmetry of the Gram matrix), it is not scale-invariant in any documented
sense, and it has no derivation in the paper or its Supplementary Note. Measured on
benchmarking-dataset: the divisors span
a factor 57-140 within a single mesh and change sign, so it is a genuine, arbitrary
reweighting of the physics rows — not a numerical nicety. That is why the two routes give
different answers at all (~1.4e-4 per tension).
Operator is the default, chosen on measured evidence. See VARIATIONAL_ROUTE_DEFAULT
and foambryo_benchmarks/run_variational_route.py. Gram stays reachable so every published
number remains reproducible.
DegenerateWedgeWarning ¶
Bases: UserWarning
Triple lines with a wedge at 0 or 180 degrees were left out of a method whose coefficients are singular there.
Emitted by Cotan, InvCotan, Lami and LogLami, once per call, naming how many lines were left
out and how many interfaces were left without any equation (reported as nan).
infer_tensions ¶
infer_tensions(mesh: DcelData, mean_tension: float = 1, mode: TensionComputationMethod = TensionComputationMethod.YoungDupre, *, angle_reading: str = CHORD_READING, refusal: WedgeRefusal = NO_REFUSAL, solver: str = 'auto', sparse_threshold: int | None = None, diagnostics: bool | str = False) -> dict[tuple[int, int], float] | tuple[dict[tuple[int, int], float], SolveDiagnostics]
Infer the relative surface tension of every interface of a mesh, by the chosen method.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyze, in any length unit (the tensions do not depend on it). |
required |
mean_tension
|
float
|
The normalisation: the tensions are normalised so that
their mean is |
1
|
mode
|
TensionComputationMethod
|
method to infer tensions. Defaults to
|
YoungDupre
|
angle_reading
|
str
|
which trijunction angle convention the angle-reading
methods ( |
CHORD_READING
|
refusal
|
WedgeRefusal
|
which trijunction observations the angle-reading
methods decline to use, and how -- see |
NO_REFUSAL
|
solver
|
str
|
|
'auto'
|
sparse_threshold
|
int | None
|
unknowns (interfaces) at which |
None
|
diagnostics
|
bool | str
|
|
False
|
Returns:
| Type | Description |
|---|---|
dict[tuple[int, int], float] | tuple[dict[tuple[int, int], float], SolveDiagnostics]
|
dict[tuple[int, int], float]: map interface |
Raises:
| Type | Description |
|---|---|
ValueError
|
on an invalid |
MalformedTripleLineError
|
if a triple line names an interface the mesh does not have
( |
Warns:
| Type | Description |
|---|---|
JunctionGraphGaugeWarning
|
when the junction graph is in several pieces or an interface is undetermined (the methods normalised per piece of the junction graph). |
AngleReadingCoverageWarning
|
when a quadric reading fell back to the chord on part of the mesh. |
DegenerateWedgeWarning
|
when |
SparseSolveFallbackWarning
|
when |
DenseSolveMemoryWarning
|
when |
infer_tensions_and_residuals ¶
infer_tensions_and_residuals(mesh: DcelData, mean_tension: float = 1, mode: TensionComputationMethod = TensionComputationMethod.YoungDupre, *, angle_reading: str = CHORD_READING, refusal: WedgeRefusal = NO_REFUSAL, solver: str = 'auto', sparse_threshold: int | None = None, diagnostics: bool | str = False) -> tuple
Infer the relative surface tensions by the chosen method, with the solve's residuals.
infer_tensions returns this function's second value. Every argument, warning and exception
is infer_tensions's; this function is where its JunctionGraphGaugeWarning and
AngleReadingCoverageWarning are emitted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyze, in any length unit. |
required |
mean_tension
|
float
|
The normalisation: the tensions are normalised so that
their mean is |
1
|
mode
|
TensionComputationMethod
|
method to infer tensions. Defaults to
|
YoungDupre
|
angle_reading
|
str
|
one of
|
CHORD_READING
|
refusal
|
WedgeRefusal
|
which trijunction observations the angle-reading
methods decline to use, and how -- see |
NO_REFUSAL
|
solver
|
str
|
|
'auto'
|
sparse_threshold
|
int | None
|
unknowns at which |
None
|
diagnostics
|
bool | str
|
|
False
|
Returns:
| Type | Description |
|---|---|
tuple
|
tuple[NDArray[np.float64], dict[tuple[int, int], float], NDArray[np.float64]]:
- the relative tensions as an array, in the map's key order (sorted interfaces);
- map interface |
Raises:
| Type | Description |
|---|---|
ValueError
|
as :func: |
MalformedTripleLineError
|
as :func: |
infer_tensions_and_residuals_with_report ¶
infer_tensions_and_residuals_with_report(mesh: DcelData, mean_tension: float = 1, mode: TensionComputationMethod = TensionComputationMethod.YoungDupre, *, angle_reading: str = CHORD_READING, refusal: WedgeRefusal = NO_REFUSAL, solver: str = 'auto', sparse_threshold: int | None = None, diagnostics: bool | str = False) -> tuple
:func:infer_tensions_and_residuals, plus which trijunction faces the reading covered.
The reporting entry point, and the one to use for anything that will be published or
benchmarked. It emits neither AngleReadingCoverageWarning nor JunctionGraphGaugeWarning:
the caller holds the coverage report, and can read the junction graph's pieces with
:func:infer_tensions_by_junction_component. Every other warning is
:func:infer_tensions's.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyze, in any length unit. |
required |
mean_tension
|
float
|
The normalisation: the tensions are normalised so that
their mean is |
1
|
mode
|
TensionComputationMethod
|
method to infer tensions. Defaults to
|
YoungDupre
|
angle_reading
|
str
|
one of
|
CHORD_READING
|
refusal
|
WedgeRefusal
|
which trijunction observations the angle-reading
methods decline to use, and how -- see |
NO_REFUSAL
|
solver
|
str
|
|
'auto'
|
sparse_threshold
|
int | None
|
unknowns at which |
None
|
diagnostics
|
bool | str
|
|
False
|
Returns:
| Type | Description |
|---|---|
tuple
|
tuple[NDArray[np.float64], dict[tuple[int, int], float], NDArray[np.float64],
AngleReadingReport]: the three values :func: |
Raises:
| Type | Description |
|---|---|
ValueError
|
as :func: |
MalformedTripleLineError
|
as :func: |
infer_tensions_by_junction_component ¶
infer_tensions_by_junction_component(mesh: DcelData, mean_tension: float = 1, mode: TensionComputationMethod = TensionComputationMethod.YoungDupre, *, refusal: WedgeRefusal = NO_REFUSAL, solver: str = 'auto', sparse_threshold: int | None = None, diagnostics: bool | str = False) -> ComponentTensionSolution | tuple[ComponentTensionSolution, SolveDiagnostics]
Infer tensions with each connected piece of the triple-junction graph made explicit.
Two interfaces are coupled only when they meet on a triple line. When the graph of these
couplings falls into several pieces, each piece's tensions are determined only up to their own
scale: this function normalises each piece separately (its mean tension is mean_tension,
exactly) and says which interface belongs to which piece, rather than collapsing them into one
map. See foambryo.junction_graph's module docstring for the mechanism.
Use this instead of infer_tensions/infer_tensions_and_residuals whenever a mesh might
have a disconnected triple-junction graph (12 of the 72 simulator meshes of 91-166 cells in
BENCHMARKS.md do) or a fully closed interface, and the caller needs to know which interfaces
are (or are not) on a common scale before comparing or aggregating their tensions. It always reads
the chord angles; it emits no JunctionGraphGaugeWarning, since the result carries what the warning would say.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyze, in any length unit. |
required |
mean_tension
|
float
|
The normalisation, applied per piece: the tensions of
each connected piece are normalised so that their mean is |
1
|
mode
|
TensionComputationMethod
|
Which method to use: one of |
YoungDupre
|
refusal
|
WedgeRefusal
|
which trijunction observations the estimator declines,
and how ( |
NO_REFUSAL
|
solver
|
str
|
|
'auto'
|
sparse_threshold
|
int | None
|
unknowns (interfaces of one piece) at which
|
None
|
diagnostics
|
bool | str
|
|
False
|
Returns:
| Name | Type | Description |
|---|---|---|
ComponentTensionSolution |
ComponentTensionSolution | tuple[ComponentTensionSolution, SolveDiagnostics]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
MalformedTripleLineError
|
as :func: |
Warns:
| Type | Description |
|---|---|
DegenerateWedgeWarning
|
as :func: |
SparseSolveFallbackWarning
|
as :func: |
DenseSolveMemoryWarning
|
as :func: |
compute_residual_junctions_dict ¶
compute_residual_junctions_dict(mesh: DcelData, dict_tensions: dict[tuple[int, int], float], alpha: float = 0.05, *, angle_reading: str = CHORD_READING) -> dict[tuple[int, int, int], float]
Compute residuals on trijunctions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyze. |
required |
dict_tensions
|
dict[tuple[int, int], float]
|
Inferred tensions. |
required |
alpha
|
float
|
Quantile to apply. Defaults to 0.05. |
0.05
|
angle_reading
|
str
|
one of
|
CHORD_READING
|
Returns:
| Type | Description |
|---|---|
dict[tuple[int, int, int], float]
|
dict[tuple[int, int, int], float]: Map trijunction -> residue. On a cropped mesh
( |