The junction graph and the normalisation¶
The junction graph of a mesh, and the tension solves normalised in each of its connected pieces.
The normalisation (the "gauge"). A mesh determines tension ratios only, so every tension
solve adds one condition that fixes the overall scale: the tensions are normalised so that their
mean is mean_tension, exactly, in each connected piece of the junction graph. This module calls
that condition the gauge, and a connected piece of the graph a component.
Why per piece. A Young-Dupre-family solver couples two interfaces only when they share a
triple line. Build the graph with interfaces as nodes and an edge wherever two interfaces meet at
a triple line (foambryo.validation.mesh_io.list_junctions). If that graph has k connected
components, the tension system decomposes into k blocks each determined only up to its own
scale, while mean(gamma) = mean_tension supplies a single global constraint. Ordinary least
squares then returns the minimum-norm solution, which drives the k - 1 unpinned blocks towards
zero -- giving a gauge-matched relative error of exactly 1.0, silently. This was found on 12 of the
72 ground-truth simulator meshes of 91-166 cells described in BENCHMARKS.md.
A degenerate sub-case. An interface that is a fully closed surface (an engulfed cell, a
sealed lumen) has no triple line at all, so it is an isolated node in no junction-graph
edge and no equation in any builder's physics rows. Its tension is not merely unpinned in
scale -- it is wholly undetermined, and this module reports it as such (float("nan")),
never as a number, never silently folded into a component.
The design. Infer per component, normalised per component: each block's tensions are
internally consistent and comparable only within the block. That is surfaced in the output, not
buried in a docstring -- ComponentTensionSolution carries, for every interface, which component
it belongs to and which normalisation was applied to that component; isolated/closed interfaces
are reported as undetermined, never as a component with a normalisation and never as a number;
and any code that would aggregate tensions across components must refuse rather than silently
blend two incompatible scales (require_single_component_solution below).
The normalisation is exact (since 0.7.0). The builders still append a row of ones, sum(gamma) =
n * f(mean_tension); solve_gauged_least_squares reads its value and replaces the row by
solve_with_exact_gauge: a gauge row weighted to the physics rows' median norm (the
best-conditioned weight), then an exact rescale (homogeneous rows) or shift (difference rows), so
that each component's tensions meet their gauge exactly. Until 0.6.0 the row of ones was a soft
least-squares row of weight 1. Because the physics rows are homogeneous, its weight changed only the
overall scale of the solution, never a tension ratio, but it missed the requested mean (by ~1e-6 for
YoungDupre, up to 80 % for Equilibrium) and raised the condition number by up to three orders of
magnitude (2.8e7 against 311 on a 1 866-cell tissue's YoungDupre system), which is what limited
the iterative solve's accuracy to ~1e-6 of the tension scale.
Scope: which methods. This module decomposes any solver whose assembled system is
[physics rows; one trailing global gauge rowsum(gamma) = n * f(mean_tension)], which is
every method in foambryo.tension_inference and foambryo.per_edge_tension_inference
except:
TensionComputationMethod.Equilibrium, whose physics rows are keyed by geometric mesh edges (len(edge.twin) != 1), which include quadrijunction edges thatmesh_io.list_junctionsdoes not enumerate (it only finds edges bordering exactly three regions) -- so this module's triple-line-only graph would not necessarily matchEquilibrium's actual row coupling. A known limitation:Equilibriumis normalised over the whole mesh at once, never decomposed;- the
ForceBalance*andVariationalfamily, whose operator additionally carries area and volume gradients tied to shared cells and is column-coupled through the pressure block rather than through the triple-junction graph alone -- measured not to fragment the same way, so these are normalised over the whole mesh at once and never decomposed here.
SparseSolveFallbackWarning ¶
Bases: UserWarning
lsqr did not converge on a large linear system.
The dense least-squares solve was run instead; or, when the dense solve would not fit in the
memory available, lsqr's last iterate is returned and the message says it is not converged.
MalformedTripleLineError ¶
MalformedTripleLineError(edge: tuple[int, int] | None, triple: tuple[int, int, int] | None, missing_interface: Interface)
Bases: RuntimeError
A triple-line edge names an interface pair absent from the mesh's own interface list.
Structurally, this should not be reachable after mesh_io.list_junctions's stricter
admission rule: a genuine triple line's three sub-interfaces are, by construction, its
three incident triangles' own region pairs (see that function's docstring). This is a
named, catchable safety net standing in for what used to be a bare KeyError naming an
internal union-find dict key -- for any future change to list_junctions's guarantee, or
any caller that builds junctions/edge_to_triple some other way.
Attributes:
| Name | Type | Description |
|---|---|---|
edge |
The mesh edge |
|
triple |
The region-triple |
|
missing_interface |
The interface pair from |
Build the error from the offending edge, its synthesized triple, and the missing interface.
JunctionGraphPartition
dataclass
¶
JunctionGraphPartition(interfaces: tuple[Interface, ...], components: tuple[tuple[Interface, ...], ...], component_of: dict[Interface, int], isolated: tuple[Interface, ...])
Connected-component partition of a mesh's interfaces by its triple-junction graph.
Attributes:
| Name | Type | Description |
|---|---|---|
interfaces |
tuple[Interface, ...]
|
Every interface present on the mesh ( |
components |
tuple[tuple[Interface, ...], ...]
|
One tuple of interface keys per connected component, ordered largest first (ties broken by the component's smallest key); a mesh with a fully connected junction graph has exactly one component containing every touched interface. |
component_of |
dict[Interface, int]
|
Map interface -> component index, for every interface except the
isolated ones (which are never assigned a component -- see |
isolated |
tuple[Interface, ...]
|
Interfaces touching no triple line at all (closed surfaces). Disjoint from
every entry of |
n_components
property
¶
n_components: int
Number of proper (gaugeable) components -- excludes isolated interfaces.
fragmented
property
¶
fragmented: bool
True iff a single global mean(gamma) = mean_tension normalisation no longer pins one scale.
This is the case whenever there is more than one component, or at least one isolated interface -- an isolated interface is already a failure of the single-gauge assumption even if the rest of the mesh's junction graph is fully connected.
JunctionGraphGaugeWarning ¶
Bases: UserWarning
Emitted when a mesh's triple-junction graph is in several pieces or has an undetermined interface.
Fires whenever JunctionGraphPartition.fragmented is true: more than one component (a
connected piece of the junction graph, normalised on its own), at least one undetermined
interface (no surviving triple line, e.g. a fully closed surface), or both. Tensions in
different components sit on different, arbitrary relative scales -- comparing or averaging
them directly is meaningless -- and an undetermined interface is reported as nan, never a
number. Emitted by infer_tensions, infer_tensions_and_residuals, infer_forces and
infer_tensions_over_seeds, whose flat maps hold the tensions of every component side by
side; use foambryo.tension_inference.infer_tensions_by_junction_component to get each
interface's component explicitly instead of blending them.
ComponentTensionSolution
dataclass
¶
ComponentTensionSolution(tensions: NDArray[float64], dict_tensions: dict[Interface, float], component_of: dict[Interface, int | None], gauge_notes: dict[int, str], undetermined: tuple[Interface, ...], lstsq_residuals: NDArray[float64], fragmented: bool, n_components: int, solver_notes: dict[int, dict[str, object]] = dict())
One inference's tensions, decomposed by the junction graph.
When fragmented is False this is the solve of the whole mesh with one normalisation,
mean(gamma) = mean_tension met exactly. When fragmented is True, each connected
component of the interfaces' triple-junction graph was solved with its own
mean(gamma) = mean_tension normalisation, met exactly; a component's
tensions are therefore comparable only to each other, never across components, and
gauge_notes says so per component. Isolated interfaces (no triple line at all) are
undetermined: dict_tensions maps them to float("nan"), and component_of maps them
to None.
Attributes:
| Name | Type | Description |
|---|---|---|
tensions |
NDArray[float64]
|
|
dict_tensions |
dict[Interface, float]
|
The same, keyed by interface. |
component_of |
dict[Interface, int | None]
|
Interface -> component index, or |
gauge_notes |
dict[int, str]
|
Component index -> a one-line description of the gauge applied to it. |
undetermined |
tuple[Interface, ...]
|
Interfaces with no triple line at all (closed surfaces). |
lstsq_residuals |
NDArray[float64]
|
Concatenation of each component's squared physics residual (a
length-1 array per component; the normalisation, met exactly, adds none). When
|
fragmented |
bool
|
Whether the junction graph required per-component gauging. |
n_components |
int
|
Number of gauged components (1 when not fragmented). |
solver_notes |
dict[int, dict[str, object]]
|
Component index -> which least-squares solver ran on it and, for the sparse
solver, its stop code, iteration count, residual norm and condition estimate
( |
GaugeNotExactWarning ¶
Bases: UserWarning
A gauge could not be made exact: the solution is the well-scaled soft row's.
Raised only for a system whose physics rows are neither homogeneous (zero right-hand side) nor differences (each row sums to zero): no method of this package builds one.
warn_on_junction_graph_fragmentation ¶
warn_on_junction_graph_fragmentation(fragmented: bool, n_components: int, isolated: tuple[Interface, ...], stacklevel: int = 3) -> None
Emit JunctionGraphGaugeWarning when a mesh's junction-graph gauging was fragmented.
Called only from the entry points that drop the underlying gauge structure on the floor --
mirrors second_order_angle_reading.warn_on_partial_coverage's precedent: the
report/solution-returning entry point (infer_tensions_by_junction_component) hands the
caller the ComponentTensionSolution this warning would have described, and stays
silent; infer_tensions_and_residuals (and, through it, infer_tensions) does not, so
it calls this instead.
Takes the three scalars directly, rather than a JunctionGraphPartition, so a caller that
already solved the system (and therefore already has a ComponentTensionSolution or
YoungDupreLocalResult carrying these same three fields) does not need to recompute
partition_from_mesh a second time just to decide whether to warn.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fragmented
|
bool
|
Whether the junction graph required per-component gauging. |
required |
n_components
|
int
|
Number of gauged components (1 when not fragmented). |
required |
isolated
|
tuple[Interface, ...]
|
Interfaces with no triple line at all. |
required |
stacklevel
|
int
|
passed through to |
3
|
partition_by_junction_graph ¶
partition_by_junction_graph(f: NDArray[int64], regions: NDArray[int64]) -> JunctionGraphPartition
Connected components of the triple-junction graph, from raw mesh arrays.
One node per interface (mesh_io.list_interfaces); an edge between two interfaces
whenever they co-occur in a triple line (mesh_io.list_junctions gives each triple
line's three constituent interfaces, all C(3,2) = 3 pairs among them become edges).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f
|
NDArray[int64]
|
Mesh triangles (vertex indices), shape |
required |
regions
|
NDArray[int64]
|
Per-triangle region pair, shape |
required |
Returns:
| Name | Type | Description |
|---|---|---|
JunctionGraphPartition |
JunctionGraphPartition
|
The partition. A "healthy" mesh (every interface reachable
from every other through some chain of shared triple lines, no closed interface)
returns exactly one component holding every interface and an empty |
Raises:
| Type | Description |
|---|---|
MalformedTripleLineError
|
If a triple line names an interface pair that is not
present in |
partition_over_surviving_junctions ¶
partition_over_surviving_junctions(f: NDArray[int64], regions: NDArray[int64], surviving: Collection[tuple[int, int, int]]) -> JunctionGraphPartition
:func:partition_by_junction_graph, restricted to the triple lines still carrying a row.
An estimator that declines some of its observations no longer couples the interfaces those
observations coupled, so the gauge structure has to be read off the surviving junction
lines rather than off the mesh. An interface left touching no surviving line is then
isolated — exactly what this module already reports as float("nan") for a
fully closed surface — and a survivor graph that falls into several pieces is gauged per
component by :func:solve_gauged_least_squares, as a fragmented mesh already is.
surviving only ever removes lines: a triple that this mesh's own
mesh_io.list_junctions does not admit as a genuine triple line is absent from the graph
here exactly as it is absent from :func:partition_by_junction_graph's, so an estimator
whose rows outrun that admission rule is treated identically with and without refusal.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f
|
NDArray[int64]
|
Mesh triangles (vertex indices), shape |
required |
regions
|
NDArray[int64]
|
Per-triangle region pair, shape |
required |
surviving
|
Collection[tuple[int, int, int]]
|
sorted region-triples whose row survived. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
JunctionGraphPartition |
JunctionGraphPartition
|
the partition of the same interfaces by the surviving lines
alone. Passing every triple the mesh has reproduces
:func: |
partition_from_mesh ¶
partition_from_mesh(mesh: object) -> JunctionGraphPartition
Convenience: partition a DcelData mesh directly (mesh.f's vertex/label columns).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
object
|
A |
required |
Returns:
| Name | Type | Description |
|---|---|---|
JunctionGraphPartition |
JunctionGraphPartition
|
See |
gauge_row_weight ¶
gauge_row_weight(physics: NDArray[float64] | csr_matrix, n_unknowns: int) -> float
Weight of the gauge row: its norm is the median non-zero physics-row norm (to a power of two).
The row is weight * 1^T, norm weight * sqrt(n). Matching it to the physics rows' typical norm
puts the system at the bottom of the condition number's V in the row weight (measured on a
1 866-cell tissue's Young-Dupre system: 311 against 2.8e7 for the unit row). 1.0 when no physics
row has an entry.
solve_with_exact_gauge ¶
solve_with_exact_gauge(physics: NDArray[float64] | csr_matrix, physics_b: NDArray[float64], gauge_total: float, *, system: str = 'tension system', use_sparse: bool | None = None, scale_columns: bool = False) -> tuple[NDArray[np.float64], NDArray[np.float64], dict[str, object]]
Least squares on physics @ x = physics_b with the gauge sum(x) = gauge_total met exactly.
The gauge row is appended with the weight gauge_row_weight (its norm equal to the median
physics-row norm, where the system is best conditioned), the system is solved by
_solve_least_squares (dense or sparse as the options say), and the gauge is then made exact
without moving the solution off the physics rows' least-squares set:
- homogeneous physics rows (
physics_b == 0: every Young-Dupre-family, cotangent, Lami, per-edge andEquilibriumsystem): the solution of a soft gauge row of any weight is the exact-gauge solution times one factor (Sherman-Morrison; the two were measured to agree to 1e-13), so it is rescaled tosum(x) = gauge_total. Tension ratios are those of the exact constrained least-squares solution; - difference rows (each physics row sums to zero, e.g.
LogLamiin log space): adding a constant to every unknown leaves every physics residual unchanged, so the solution is shifted to meet the gauge; - otherwise (no builder of this package): the soft row's solution is returned with a
GaugeNotExactWarning.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
physics
|
NDArray[float64] | csr_matrix
|
|
required |
physics_b
|
NDArray[float64]
|
|
required |
gauge_total
|
float
|
the value |
required |
system
|
str
|
label, for messages and diagnostics. Keyword-only. |
'tension system'
|
use_sparse
|
bool | None
|
force a path, see |
None
|
scale_columns
|
bool
|
see |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
tuple |
NDArray[float64]
|
|
NDArray[float64]
|
residual, |
|
dict[str, object]
|
contributes nothing. |
|
tuple[NDArray[float64], NDArray[float64], dict[str, object]]
|
made exact) and |
Nothing is raised beyond _solve_least_squares's exceptions; a zero-sum solution of homogeneous
rows (no scale to rescale) is returned as solved, with a GaugeNotExactWarning.
solve_gauged_least_squares ¶
solve_gauged_least_squares(matrix_m: NDArray[float64] | csr_matrix, vector_b: NDArray[float64], keys: Sequence[Interface], mean_tension: float, partition: JunctionGraphPartition, *, system: str = 'tension system') -> ComponentTensionSolution
Solve matrix_m @ x = vector_b, gauging per junction-graph component if fragmented.
matrix_m must be exactly the shape every builder in foambryo.tension_inference
produces: (n_rows, len(keys)), whose last row is the single global gauge
sum(gamma) = vector_b[-1] (vector_b[-1] is len(keys) * mean_tension for every
method except LogLami, which works in log-space -- this function reads
vector_b[-1] directly rather than recomputing it, so it is agnostic to which
convention the caller used). The row itself is not used: each component is solved by
solve_with_exact_gauge, which meets its share of the gauge exactly (sum(gamma) over the
component = vector_b[-1] * n_component / n, i.e. mean(gamma) = mean_tension). The residuals
returned are the physics rows' (the gauge contributes none).
Each component's solve is dense or sparse as foambryo.linear_solves's options in force say
(by default: dense below SPARSE_SOLVE_MIN_UNKNOWNS interfaces, lsqr at or above), see
_solve_least_squares.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix_m
|
NDArray[float64] | csr_matrix
|
Assembled system, gauge row last. A sparse system is never densified as a whole: only the components that take the dense path are. |
required |
vector_b
|
NDArray[float64]
|
Right-hand side, gauge value last. |
required |
keys
|
Sequence[Interface]
|
Interface key per column of |
required |
mean_tension
|
float
|
The gauge value the caller requested (only used for the
human-readable |
required |
partition
|
JunctionGraphPartition
|
This mesh's junction-graph partition. Must cover
exactly |
required |
system
|
str
|
what the system is, for messages and diagnostics. Keyword-only. |
'tension system'
|
Returns:
| Name | Type | Description |
|---|---|---|
ComponentTensionSolution |
ComponentTensionSolution
|
See its docstring. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
require_single_component_solution ¶
require_single_component_solution(solution: ComponentTensionSolution) -> int
Refuse to let a caller silently aggregate a fragmented solution across components.
Call this at the top of any function that reduces one mesh's tensions to a single number (a mean, a single gauge-matching scale, a pooled error). It raises rather than returning a number computed by blending two components that are not on the same scale.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
solution
|
ComponentTensionSolution
|
A solve from |
required |
Returns:
| Name | Type | Description |
|---|---|---|
int |
int
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |