Skip to content

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 weight YoungDupre gives 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 reproduces YoungDupre to ~1e-14.
  • Edge sets :math:w_e = \ell_e / L, the literal per-edge formulation, kept so the measurement is reproducible.
  • Arclength sets :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 line k keeps the total least-squares weight YoungDupre gives it. Default.
  • Edge: w_e = l_e / L, the literal per-edge formulation. Silently reweights line k by 1 / sqrt(n_e), i.e. penalises well-resolved lines.
  • Arclength: w_e = sqrt(l_e / L), making the objective a discretisation of the continuum integral |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]

(n_edges, 2) vertex-key pairs.

midpoints NDArray[float64]

(n_edges, 3) edge midpoints, for rendering the field on the mesh.

lengths NDArray[float64]

(n_edges,) edge lengths.

regions NDArray[int64]

(n_edges, 3) the three region labels a < b < c meeting on each edge.

residual_vectors NDArray[float64]

(n_edges, 3) the unweighted three-row Young-Dupre defect [gamma_ab + gamma_bc cos a_b + gamma_ac cos a_a, ...] at the inferred tensions. Unweighted because that is the physical defect; the weighted version is the least-squares contribution and is residual_vectors * weights[:, None].

residual_norms NDArray[float64]

(n_edges,) Euclidean norm of residual_vectors.

weights NDArray[float64]

(n_edges,) the row weight w_e actually used in the solve, including any robust down-weighting.

robust_weights NDArray[float64]

(n_edges,) the IRLS multiplier alone (all ones without robust weighting), so a caller can see which edges were down-weighted and by how much.

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]

(n_interfaces,) inferred relative tensions, ordered by sorted interface (crop interfaces absent). float("nan") at any undetermined interface (foambryo.junction_graph) -- one with no surviving triple line.

dict_tensions dict[tuple[int, int], float]

the same, keyed by interface (label_1, label_2), label_1 < label_2.

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 _infer_tension_* functions.

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, None if undetermined. {key: 0 for every interface} when the graph is in one piece.

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 (foambryo.second_order_angle_reading). For the default "chord" reading this is the trivial complete-coverage report; for "quadric" it names every trijunction line whose angles are a mixture of the two conventions because the quadric fit was inadmissible on part of it. Carried on the result rather than warned about, so a caller who asked for the second-order reading always holds its coverage.

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

(n_edges, 3, 3) unweighted coefficient blocks.

required
columns NDArray

(n_edges, 3) interface column indices.

required
weights NDArray

(n_edges,) row weights.

required
n_interfaces int

Number of interfaces (columns of M).

required
mean_tension float

Expected mean tension. Defaults to 1.

1.0

Returns:

Type Description
tuple[NDArray[float64], NDArray[float64]]

tuple[NDArray, NDArray]: M of shape (3 * n_edges + 1, n_interfaces) and b.

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 mean_tension, exactly, in each connected piece of the junction graph. Defaults to 1.

1.0
huber_delta float | None

Huber threshold, in units of the robust scale, for IRLS down-weighting of outlying edges. None (the default) disables robust weighting entirely, which is what TensionComputationMethod.YoungDupreLocal uses — the robust variant is an option, not the shipped behaviour. Pass DEFAULT_HUBER_DELTA for the classical 1.345.

None
max_irls_iterations int

IRLS iteration cap. Defaults to 60 — convergence to irls_tolerance was measured at 45-50 iterations on the benchmark's meshes, and the answer at a 20-iteration cap already agrees with the converged one to ~1e-6.

60
weighting PerEdgeWeighting

row-weighting scheme. Defaults to LinePreserving, the only one that keeps each triple line's total influence equal to YoungDupre's — see the module docstring, this choice is worth two orders of magnitude.

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" (the default, and bit-identical to the historical behaviour) or "oriented_chord". Keyword-only. See foambryo.second_order_angle_reading.

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 ExcludeEdgeFromLineMean and DropLine are the same intervention here and agree exactly, while DownweightLine keeps the folded rows and scales their whole line. Refusing rows can leave an interface with no surviving line: it is then reported float("nan"), never a number. See foambryo.wedge_refusal.

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 float("nan"), with an empty residual field, when the refusal leaves no row at all — an empty answer rather than an exception, so a caller can count how often a policy costs a whole mesh. Emits no JunctionGraphGaugeWarning: the result carries fragmented, component_of and undetermined.

Raises:

Type Description
ValueError

if angle_reading is "quadric" or "oriented_quadric": the per-edge balance never performs the along-line average that cancels the quadric correction's per-face scatter, so it eats the scatter directly (measured on the benchmark's 42 equilibrium cases, as the mean per-case change of the relative RMS tension error: +0.0290 at min_distance=3 and +0.97 at min_distance=4; BENCHMARKS.md).

ValueError

if angle_reading is not one of foambryo.second_order_angle_reading.ANGLE_READINGS.

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 lsqr did not converge on a large system.