Per-edge Young-Dupré¶
Per-edge Young-Dupre tension inference: TensionComputationMethod.YoungDupreLocal.
TensionComputationMethod.YoungDupre imposes the Young-Dupre balance once per triple line,
on angles that have already been averaged along it (geometry.compute_angles_tri returns
np.mean(...) per wedge key). Young-Dupre is a pointwise balance, so averaging first is valid
only when the angle varies little along the line — and a review of the code during development
measured per-line angle standard deviations of 0.7-14.1 degrees, so it does not. Averaging also
destroys any route to per-edge residuals, robust weighting or uncertainty.
This module imposes the same balance once per geometric trijunctional edge:
.. math::
w_e \begin{pmatrix}
1 & \cos\alpha^e_b & \cos\alpha^e_a \\
\cos\alpha^e_b & 1 & \cos\alpha^e_c \\
\cos\alpha^e_a & \cos\alpha^e_c & 1
\end{pmatrix}
\begin{pmatrix} \gamma_{ab} \\ \gamma_{bc} \\ \gamma_{ac} \end{pmatrix} = 0
with a < b < c the three regions meeting on edge e, :math:\alpha^e_f the interior wedge
angle in region f measured on that edge (see geometry._wedges_at_half_edge for this
convention), and :math:w_e = \ell_e / L the edge's share of the mesh's
total junction length. Summing the rows of one triple line reproduces the averaged builder's
weight for that line exactly, so the two methods differ only in where the averaging happens:
YoungDupre averages the angles and then takes the cosine; YoungDupreLocal takes the cosine
per edge and lets least squares do the averaging. The difference is the Jensen gap of cos
along the line, which is second order in the angle spread — so on a line whose angle is
constant the two are identical, and they separate exactly where the angle varies along the
line, i.e. where the mesh is noisy.
Row weighting is not a detail — it dominates the comparison, as measured. The naive
choice :math:w_e = \ell_e / L (the literal per-edge formulation) does not keep a triple
line's total influence fixed when its single row block is split into :math:n_e blocks:
:math:n_e identical rows of weight :math:w are equivalent to one row of weight
:math:w\sqrt{n_e}, so the naive choice silently reweights line k by :math:1/\sqrt{n_e} —
it penalises well-resolved lines. On the benchmark's 42 equilibrium cases that reweighting alone costs
two orders of magnitude, more than the per-edge idea itself is worth.
Hence :class:PerEdgeWeighting:
LinePreserving(the default) sets :math:w_e = (\ell_k / L)\sqrt{\ell_e / \ell_k}, so :math:\sum_{e \in k} w_e^2 = (\ell_k / L)^2— each line keeps exactly the total least-squares weightYoungDupregives it, and the only remaining difference between the two methods is per-edge versus averaged angles. This is the variant that isolates the per-edge question; with the angles held fixed it reproducesYoungDupreto ~1e-14.Edgesets :math:w_e = \ell_e / L, the literal per-edge formulation, kept so the measurement is reproducible.Arclengthsets :math:w_e = \sqrt{\ell_e / L}, which makes the objective a discretisation of :math:\int |r|^2\,\mathrm{d}\ell.
Read this outcome before using this. YoungDupreLocal was measured to be decisively
worse than YoungDupre on the 42 equilibrium cases, for a reason that turns out to be
structural: at a
triple line in mechanical equilibrium the three wedge angles are fixed by the three (constant)
tensions alone, so the true angle is constant along each line and every bit of measured
along-line variation is error. Averaging the angles first is therefore a noise filter, while the
pointwise form rectifies that noise into a systematic bias of order
:math:\tfrac{1}{2}\mathrm{Var}(\alpha) through the Jensen gap of cos. The method is kept —
as an additional, non-default member — because its per-edge residual field is a
genuinely more informative diagnostic than the per-junction one, and because a negative result
that is reproducible is worth more than a deleted branch.
The normalisation is that of every junction-line method: the tensions are normalised so that their
mean is mean_tension, exactly, in each connected piece of the junction graph
(foambryo.junction_graph.solve_gauged_least_squares).
PerEdgeWeighting ¶
Bases: Enum
How each edge's row block is weighted. See the module docstring — this choice dominates.
LinePreserving:w_e = (l_k / L) * sqrt(l_e / l_k), so each triple linekkeeps the total least-squares weightYoungDupregives it. Default.Edge:w_e = l_e / L, the literal per-edge formulation. Silently reweights linekby1 / sqrt(n_e), i.e. penalises well-resolved lines.Arclength:w_e = sqrt(l_e / L), making the objective a discretisation of the continuumintegral |r|^2 dl.
PerEdgeResidualField
dataclass
¶
PerEdgeResidualField(vertices: NDArray[int64], midpoints: NDArray[float64], lengths: NDArray[float64], regions: NDArray[int64], residual_vectors: NDArray[float64], residual_norms: NDArray[float64], weights: NDArray[float64], robust_weights: NDArray[float64])
The Young-Dupre residual of every trijunctional edge, as a renderable field.
The paper plots one residual per junction; this is one per edge, which is strictly more
informative — it localises a bad junction to the part of the line that is bad. All arrays
are aligned index-wise and ordered as geometry.compute_trijunction_edges.
Attributes:
| Name | Type | Description |
|---|---|---|
vertices |
NDArray[int64]
|
|
midpoints |
NDArray[float64]
|
|
lengths |
NDArray[float64]
|
|
regions |
NDArray[int64]
|
|
residual_vectors |
NDArray[float64]
|
|
residual_norms |
NDArray[float64]
|
|
weights |
NDArray[float64]
|
|
robust_weights |
NDArray[float64]
|
|
YoungDupreLocalResult
dataclass
¶
YoungDupreLocalResult(tensions: NDArray[float64], dict_tensions: dict[tuple[int, int], float], lstsq_residuals: NDArray[float64], residual_field: PerEdgeResidualField, n_rows: int, n_edges: int, irls_iterations: int, component_of: dict[tuple[int, int], int | None], gauge_notes: dict[int, str], undetermined: tuple[tuple[int, int], ...], fragmented: bool, n_components: int, angle_reading_report: AngleReadingReport = CHORD_READING_REPORT)
Everything the per-edge solve produces, including the diagnostics infer_tensions drops.
Attributes:
| Name | Type | Description |
|---|---|---|
tensions |
NDArray[float64]
|
|
dict_tensions |
dict[tuple[int, int], float]
|
the same, keyed by interface |
lstsq_residuals |
NDArray[float64]
|
the squared residual of the solved equations, one entry per connected
piece of the junction graph, for signature compatibility with the other
|
residual_field |
PerEdgeResidualField
|
the per-edge residual field. |
n_rows |
int
|
number of rows in the assembled system, gauge row included. |
n_edges |
int
|
number of geometric trijunctional edges that carried a row block. |
irls_iterations |
int
|
number of IRLS passes actually run (0 without robust weighting). |
component_of |
dict[tuple[int, int], int | None]
|
interface -> connected piece of the junction graph, |
gauge_notes |
dict[int, str]
|
piece -> one-line description of the normalisation applied to it. |
undetermined |
tuple[tuple[int, int], ...]
|
interfaces with no triple line at all (closed surfaces). |
fragmented |
bool
|
whether the mesh's triple-junction graph required per-component gauging. |
n_components |
int
|
number of gauged components (1 when not fragmented). |
angle_reading_report |
AngleReadingReport
|
which trijunction faces the angle reading actually covered
( |
build_matrix_tension_local_yd ¶
build_matrix_tension_local_yd(blocks: NDArray[float64], columns: NDArray[int64], weights: NDArray[float64], n_interfaces: int, mean_tension: float = 1.0) -> tuple[NDArray[np.float64], NDArray[np.float64]]
Scatter the weighted per-edge blocks into M, and append the row of the normalisation.
The last row is the one every other builder in foambryo.tension_inference appends
(matrix_m[-1, :] = 1, vector_b[-1] = nm * mean_tension), so YoungDupreLocal is
comparable with YoungDupre row for row. The solve reads it for its value only and meets the
normalisation exactly (foambryo.junction_graph.solve_gauged_least_squares); until 0.6.0 it
was solved as a least-squares row, which changed only the overall scale of the tensions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blocks
|
NDArray
|
|
required |
columns
|
NDArray
|
|
required |
weights
|
NDArray
|
|
required |
n_interfaces
|
int
|
Number of interfaces (columns of |
required |
mean_tension
|
float
|
Expected mean tension. Defaults to 1. |
1.0
|
Returns:
| Type | Description |
|---|---|
tuple[NDArray[float64], NDArray[float64]]
|
tuple[NDArray, NDArray]: |
infer_tensions_young_dupre_local ¶
infer_tensions_young_dupre_local(mesh: DcelData, mean_tension: float = 1.0, huber_delta: float | None = None, max_irls_iterations: int = 60, weighting: PerEdgeWeighting = PerEdgeWeighting.LinePreserving, irls_tolerance: float = 1e-10, *, angle_reading: str = CHORD_READING, refusal: WedgeRefusal = NO_REFUSAL) -> YoungDupreLocalResult
Infer tensions with one Young-Dupre block per trijunctional edge.
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.0
|
huber_delta
|
float | None
|
Huber threshold, in units of the robust scale, for
IRLS down-weighting of outlying edges. |
None
|
max_irls_iterations
|
int
|
IRLS iteration cap. Defaults to 60 — convergence
to |
60
|
weighting
|
PerEdgeWeighting
|
row-weighting scheme. Defaults to
|
LinePreserving
|
irls_tolerance
|
float
|
Stop IRLS when the max tension change falls below this. Defaults to 1e-10. |
1e-10
|
angle_reading
|
str
|
which trijunction angle convention to read, |
CHORD_READING
|
refusal
|
WedgeRefusal
|
which trijunctional edges this estimator declines to
use, and how. Keyword-only; defaults to refusing nothing, which is bitwise the
historical behaviour. This method's observation unit is a single edge, so
|
NO_REFUSAL
|
The solve runs under the solver options in force (foambryo.linear_solves): called
directly, dense below 2 000 interfaces per piece of the junction graph and lsqr at or above;
foambryo.linear_solves.solve_options sets others around the call. On a cropped mesh every
junction edge that touches a crop face is declined, whatever refusal is, and the crop
interfaces are absent from the result.
Returns:
| Name | Type | Description |
|---|---|---|
YoungDupreLocalResult |
YoungDupreLocalResult
|
tensions plus the per-edge residual field and solve diagnostics.
Every interface is |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
ValueError
|
if |
ValueError
|
if the mesh has no trijunctional edge at all, which would leave the system with nothing but the normalisation. |
MalformedTripleLineError
|
if a triple line names an interface the mesh does not have. |
Warns:
| Type | Description |
|---|---|
SparseSolveFallbackWarning
|
when |