Skip to content

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) with label_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 (see TensionComputationMethod for 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 equations M^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||^2 directly through M, with the normalisation eliminated by substitution. M^T M is 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 mean_tension, exactly, in each connected piece of the junction graph (for LogLami, their geometric mean; Equilibrium, Variational and ForceBalance normalise the whole mesh at once; ForceBalanceSoftRow meets it only approximately and ForceBalanceMAP deliberately not exactly; see TensionComputationMethod). Only tension ratios are measured, so this sets the unit of the tensions. Defaults to 1.

1
mode TensionComputationMethod

method to infer tensions. Defaults to TensionComputationMethod.YoungDupre.

YoungDupre
angle_reading str

which trijunction angle convention the angle-reading methods (ANGLE_READING_METHODS) consume, one of foambryo.second_order_angle_reading.ANGLE_READINGS: "chord" (the default, bit-identical to every published number), "quadric" (the second-order reading), "oriented_chord" or "oriented_quadric" (the same readings with reflex wedges reported above 180 degrees). Keyword-only. A quadric reading that could not cover every trijunction face falls back to the chord there and emits an AngleReadingCoverageWarning; use :func:infer_tensions_and_residuals_with_report to get the coverage numbers instead of the warning.

CHORD_READING
refusal WedgeRefusal

which trijunction observations the angle-reading methods decline to use, and how -- see foambryo.wedge_refusal. Keyword-only; defaults to NO_REFUSAL (refusing nothing), which is bitwise the historical behaviour. A non-default policy on a method that reads no wedge angle raises rather than being ignored. Refusing rows can leave an interface, or a whole mesh, with no surviving junction line: those interfaces are reported float("nan"), never a number, and how often a policy does that is a number to report beside any accuracy it buys.

NO_REFUSAL
solver str

"auto" (the default), "dense" or "sparse": how the linear systems are solved (foambryo.linear_solves). "auto" is dense below sparse_threshold unknowns and sparse at or above, and bitwise the dense result below it. "sparse" raises ValueError for a method with no sparse path (Equilibrium, Variational, ForceBalance*). Keyword-only.

'auto'
sparse_threshold int | None

unknowns (interfaces) at which "auto" goes sparse; None (the default) is foambryo.junction_graph.SPARSE_SOLVE_MIN_UNKNOWNS (2 000). Keyword-only.

None
diagnostics bool | str

False (the default), True or "spectrum": also return a foambryo.linear_solves.SolveDiagnostics, last, with one record per solve (solver, shape, rank, residual norm, condition number, time; "spectrum" adds the extreme singular values of the sparse solves). Keyword-only.

False

Returns:

Type Description
dict[tuple[int, int], float] | tuple[dict[tuple[int, int], float], SolveDiagnostics]

dict[tuple[int, int], float]: map interface (label_1, label_2), label_1 < label_2, label 0 the exterior medium -> relative tension on this interface. An interface whose tension the mesh does not determine is float("nan"), never a number: one with no triple line at all (a fully closed surface -- an engulfed cell, a sealed lumen), one whose every line was refused or left out, or, for Cotan, InvCotan, Lami and LogLami, one left without any equation by a degenerate wedge (DegenerateWedgeWarning). On a cropped mesh the interfaces made only of crop faces (DcelData.crop_interfaces) are absent. If the mesh's triple-junction graph is in more than one disconnected piece, or has any undetermined interface, this flat map holds tensions normalised separately in each piece, which are not on the same scale (a JunctionGraphGaugeWarning is emitted in that case); use :func:infer_tensions_by_junction_component to get each interface's piece explicitly, and never average or compare tensions across pieces from this map. With diagnostics on, the tuple (map, SolveDiagnostics).

Raises:

Type Description
ValueError

on an invalid solver, sparse_threshold or diagnostics; on an unknown angle_reading; on a non-default angle_reading or refusal with a method outside ANGLE_READING_METHODS; on solver="sparse" with Equilibrium, Variational or a ForceBalance* member; with YoungDupreLocal, on a quadric reading or on a mesh with no trijunctional edge; with Cotan, InvCotan, Lami or LogLami, if the system still holds a non-finite coefficient after the degenerate lines are left out.

MalformedTripleLineError

if a triple line names an interface the mesh does not have (foambryo.junction_graph; not expected on a well-formed mesh).

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 Cotan, InvCotan, Lami or LogLami left out lines with a wedge at 0 or 180 degrees.

SparseSolveFallbackWarning

when lsqr did not converge on a large system (the dense solve runs instead, or lsqr's last iterate is returned if the dense one cannot fit in memory).

DenseSolveMemoryWarning

when solver="dense" is forced on a system too large for the memory available.

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 mean_tension, exactly, in each connected piece of the junction graph (see :func:infer_tensions for the methods that normalise otherwise). Defaults to 1.

1
mode TensionComputationMethod

method to infer tensions. Defaults to TensionComputationMethod.YoungDupre.

YoungDupre
angle_reading str

one of foambryo.second_order_angle_reading.ANGLE_READINGS; "chord" is the default. Keyword-only. See :func:infer_tensions.

CHORD_READING
refusal WedgeRefusal

which trijunction observations the angle-reading methods decline to use, and how -- see foambryo.wedge_refusal. Keyword-only; defaults to NO_REFUSAL (refusing nothing), which is bitwise the historical behaviour. See :func:infer_tensions.

NO_REFUSAL
solver str

"auto" (the default), "dense" or "sparse", as for :func:infer_tensions. Keyword-only.

'auto'
sparse_threshold int | None

unknowns at which "auto" goes sparse; None (the default) is foambryo.junction_graph.SPARSE_SOLVE_MIN_UNKNOWNS (2 000). Keyword-only.

None
diagnostics bool | str

False (the default), True or "spectrum": also return a foambryo.linear_solves.SolveDiagnostics, last. Keyword-only.

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 (label_1, label_2) -> tension on this interface. See :func:infer_tensions's return value for the undetermined-interface (nan) and junction-graph (normalised per piece) caveats -- they apply here identically; - the squared norm of the residual of the solved equations, without the normalisation (which is met exactly and adds none): a length-1 array, one entry per connected piece of the junction graph when there are several, empty when no interface is determined; in log space for LogLami; - with diagnostics on, a fourth value: the SolveDiagnostics.

Raises:

Type Description
ValueError

as :func:infer_tensions.

MalformedTripleLineError

as :func:infer_tensions.

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 mean_tension, exactly, in each connected piece of the junction graph (see :func:infer_tensions for the methods that normalise otherwise). Defaults to 1.

1
mode TensionComputationMethod

method to infer tensions. Defaults to TensionComputationMethod.YoungDupre.

YoungDupre
angle_reading str

one of foambryo.second_order_angle_reading.ANGLE_READINGS; "chord" is the default. Keyword-only. See :func:infer_tensions.

CHORD_READING
refusal WedgeRefusal

which trijunction observations the angle-reading methods decline to use, and how -- see foambryo.wedge_refusal. Keyword-only; defaults to NO_REFUSAL (refusing nothing), which is bitwise the historical behaviour. See :func:infer_tensions.

NO_REFUSAL
solver str

"auto" (the default), "dense" or "sparse", as for :func:infer_tensions. Keyword-only.

'auto'
sparse_threshold int | None

unknowns at which "auto" goes sparse; None (the default) is foambryo.junction_graph.SPARSE_SOLVE_MIN_UNKNOWNS (2 000). Keyword-only.

None
diagnostics bool | str

False (the default), True or "spectrum": also return a foambryo.linear_solves.SolveDiagnostics, last. Keyword-only.

False

Returns:

Type Description
tuple

tuple[NDArray[np.float64], dict[tuple[int, int], float], NDArray[np.float64], AngleReadingReport]: the three values :func:infer_tensions_and_residuals returns, plus the coverage report. For a method outside ANGLE_READING_METHODS, or for a chord reading, the report is the trivial complete-coverage one. With diagnostics on, a fifth value: the SolveDiagnostics.

Raises:

Type Description
ValueError

as :func:infer_tensions; in particular if angle_reading is not "chord" and mode does not read angles.

MalformedTripleLineError

as :func:infer_tensions.

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 mean_tension, exactly (for LogLami, their geometric mean). Defaults to 1.

1
mode TensionComputationMethod

Which method to use: one of YoungDupre, YoungDupreLocal, ProjectionYoungDupre, Cotan, InvCotan, Lami, InvLami and LogLami. Defaults to YoungDupre.

YoungDupre
refusal WedgeRefusal

which trijunction observations the estimator declines, and how (foambryo.wedge_refusal). Keyword-only; defaults to NO_REFUSAL (refusing nothing), which is bitwise the historical behaviour. This is the entry point to prefer under a refusal policy: refusing rows can itself split the junction graph, and this function hands the caller the resulting pieces and undetermined interfaces rather than blending them.

NO_REFUSAL
solver str

"auto" (the default), "dense" or "sparse": how the linear systems are solved (foambryo.linear_solves). "auto" is dense below sparse_threshold unknowns and sparse at or above, and bitwise the dense result below it. Every method accepted here has a sparse path. Keyword-only.

'auto'
sparse_threshold int | None

unknowns (interfaces of one piece) at which "auto" goes sparse; None (the default) is foambryo.junction_graph.SPARSE_SOLVE_MIN_UNKNOWNS (2 000). Keyword-only.

None
diagnostics bool | str

False (the default), True or "spectrum": also return a foambryo.linear_solves.SolveDiagnostics, last, with one record per solve (solver, shape, rank, residual norm, condition number, time; "spectrum" adds the extreme singular values of the sparse solves). Keyword-only.

False

Returns:

Name Type Description
ComponentTensionSolution ComponentTensionSolution | tuple[ComponentTensionSolution, SolveDiagnostics]

dict_tensions maps every interface (label_1, label_2), label_1 < label_2, to its relative tension, or float("nan") if it is undetermined (no surviving triple line); component_of maps every interface to its piece, or None if undetermined; gauge_notes states, per piece, the normalisation applied; solver_notes which solver ran on each piece. Tensions of different pieces are on different scales and must not be compared. On a cropped mesh the crop interfaces are absent. With diagnostics on, the tuple (solution, SolveDiagnostics).

Raises:

Type Description
ValueError

If mode is Equilibrium, Variational or a ForceBalance* member -- these methods' coupling structure is not the triple-junction graph (see foambryo.junction_graph's "Scope: which methods"), so this decomposition does not apply to them; use infer_tensions(mesh, mean_tension, mode) instead. Also on an invalid solver option, and as :func:infer_tensions for the method itself.

MalformedTripleLineError

as :func:infer_tensions.

Warns:

Type Description
DegenerateWedgeWarning

as :func:infer_tensions.

SparseSolveFallbackWarning

as :func:infer_tensions.

DenseSolveMemoryWarning

as :func:infer_tensions.

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 foambryo.second_order_angle_reading.ANGLE_READINGS. Keyword-only. Defaults to "chord". Pass the reading the tensions were inferred with, or the residual is measured against a different angle convention than the one it is judging.

CHORD_READING

Returns:

Type Description
dict[tuple[int, int, int], float]

dict[tuple[int, int, int], float]: Map trijunction -> residue. On a cropped mesh (foambryo.crop_faces), only the junction lines the tension methods read: the edges that touch a crop face are left out of the line means, and a line with no other edge is absent.