Skip to content

Wedge refusal

Refusal policies: declining a trijunction observation the mesh's own geometry makes infeasible.

A WedgeRefusal is passed as refusal= to infer_tensions and its relatives, and to infer_tensions_young_dupre_local. Only the methods of foambryo.tension_inference.ANGLE_READING_METHODS honour it; the others raise ValueError on any policy but NO_REFUSAL. refusal_report counts what a policy declines.

The problem

At a trijunctional edge three films meet and the three interior wedge angles sum to a full turn. A reflex wedge is one wider than a straight angle: the surface is locally folded there, the three films do not enclose the edge the way a trijunction requires, and the Young-Dupré balance every angle-reading estimator in this package imposes has no solution for such a triple. Feeding the estimator that row is feeding it an observation that cannot have come from the object being measured.

Two facts make this worth acting on rather than documenting:

  • foambryo.geometry's oriented reading can see the fold — but it cannot undo it. The per-edge estimator's rows are built from cos of the wedge, and cos is even about a full turn, so a reflex wedge and its reflection are literally the same row; no reading reaches it.
  • The log-of-sin estimator does not merely degrade on such a row, it fails: a reflex wedge has a negative oriented sine, and the row's logarithm is not defined.

So the estimator should refuse the observation. This module is the machinery for that, and it is off by default — NO_REFUSAL reproduces every existing number bitwise, and which policy (if any) should become a default is a separate decision taken on its own evidence.

The three policies, and the unit each acts on

Every angle-reading estimator here builds its rows from one of two observation units: a whole junction line (the region triple), for the methods that average the wedge angles along the line and weight the row by the line's arclength share; or a single trijunctional edge, for the per-edge method, whose row is one edge. The policies are defined on the unit, so each is one intervention with one meaning in both families:

  • :attr:WedgeRefusalPolicy.ExcludeEdgeFromLineMean (E) — a refused edge contributes neither its angles nor its length to its line. The line keeps its row, computed from the surviving edges and weighted by their surviving arclength. A line that loses every edge has no mean and therefore no row.
  • :attr:WedgeRefusalPolicy.DropLine (D) — a line carrying at least one refused edge loses its row entirely.
  • :attr:WedgeRefusalPolicy.DownweightLine (W) — nothing is removed from the measurement: the line's angle means are what they always were, and only its row weight is scaled, by the fraction of its arclength that is not refused. A line with no unrefused arclength has weight zero, which is the same as having no row.

A refused wedge takes the other two wedges of its own edge with it. The three are one observation of one edge and every row consumes all three; dropping one wedge key and keeping its two partners would leave a line's three means computed over different edge subsets and no longer summing to a full turn.

Two consequences follow from the definitions and are asserted as tests rather than assumed (tests/test_wedge_refusal.py):

  1. For the per-edge method the unit is one edge, so under E a refused edge's unit is empty and it has no row — E and D are the same intervention there and agree exactly. W is not: it keeps the refused rows and scales every row of their line instead.
  2. For the line-averaged methods E and W give a surviving line the same row weight (E's surviving arclength over the surviving total; W's full arclength times the surviving fraction, over that same total). So E and W differ only in the angle a line reports, and D and E only in whether a partly folded line reports at all.

Underdetermination is an outcome, not an error

Refusing rows can leave an interface with no surviving junction line at all, or split the junction graph into pieces that no longer share a gauge. That is handled exactly as foambryo.junction_graph already handles a closed interface and a fragmented mesh: the partition is rebuilt over the surviving lines (junction_graph.partition_over_surviving_junctions), an interface touched by no surviving line is reported float("nan") and never a number, a survivor graph in several pieces is normalised in each piece, and JunctionGraphGaugeWarning fires unchanged. A mesh all of whose lines are refused returns every interface undetermined rather than raising: an empty answer is a result, and it is one the caller can count.

Report the refused counts next to any accuracy number. A policy that improves an error statistic by declining a large share of the data has not improved anything, and :func:refusal_report is what makes that visible.

Two reasons to decline an edge: a fold, and the crop

A junction edge with a vertex on a crop face (foambryo.crop_faces) is not an observation of the tissue either: the tissue beyond the crop was cut away. It is declined for that reason on every cropped mesh, whatever the policy, and always edge by edge: it leaves its line's mean and length, and the rest of the line still reports. The policy then acts on what remains, as if the crop edges were not part of the line: under DropLine a line is dropped when one of its other edges is refused, and under DownweightLine its weight is the unrefused fraction of the arclength that is not on the crop. The two sets are held apart (WedgeRefusal.crop_edges beside the policy's own edges) and counted apart in RefusalReport. On a mesh without crop faces the crop set is empty and nothing here changes.

Reading nothing but the mesh

:func:reflex_trijunction_edges takes a mesh and a tolerance. There is no parameter through which a reference geometry, a target angle or an expected value could reach it, and the decision is therefore available on data that has no reference at all.

WedgeRefusalPolicy

Bases: Enum

Which observations an angle-reading estimator declines, and how. See the module docstring.

  • NoRefusal: the default. Nothing is declined and every result is bitwise what it was before this module existed.
  • ExcludeEdgeFromLineMean: a refused edge leaves its line's angle mean and its line's length; the line still reports, from what survives.
  • DropLine: a line carrying any refused edge does not report at all.
  • DownweightLine: every line reports its unchanged mean, with its row weight scaled by the fraction of its arclength that is not refused.

WedgeRefusal dataclass

WedgeRefusal(policy: WedgeRefusalPolicy = WedgeRefusalPolicy.NoRefusal, edges: frozenset[GeometricEdge] | None = None, tolerance_deg: float = REFLEX_TOLERANCE_DEG, crop_edges: frozenset[GeometricEdge] = frozenset())

A refusal policy together with the set of edges it applies to.

Attributes:

Name Type Description
policy WedgeRefusalPolicy

which of the three interventions to apply. NoRefusal by default, which is the shipped behaviour and is bitwise identical to not passing a refusal at all.

edges frozenset[GeometricEdge] | None

the geometric edges to refuse, as (min, max) vertex-key pairs, or None (the default) to take them from the mesh itself through :func:reflex_trijunction_edges. An explicit set exists so that a caller with its own criterion — a control arm that refuses a matched random set, a diagnostic, a flag computed upstream — runs through exactly this code path rather than a second copy of it. It is never a way to feed in a reference geometry: whatever the caller supplies is a set of edge keys, and nothing in this module consults anything else.

tolerance_deg float

excess past a straight angle above which a wedge is reflex, used only when edges is None.

crop_edges frozenset[GeometricEdge]

the edges declined because they touch a crop face, excluded from their line's mean whatever policy is (see "Two reasons to decline an edge" in the module docstring). Filled from the mesh by every estimator (:func:refusal_with_crop); a caller leaves it empty.

refuses_nothing property

refuses_nothing: bool

True when this refusal cannot change any number, so the untouched path is taken.

RefusalReport dataclass

RefusalReport(policy: WedgeRefusalPolicy, n_edges: int, n_edges_refused: int, n_lines: int, n_lines_without_row: int, n_lines_downweighted: int, length_total: float, length_refused: float, n_units: int, n_units_refused: int, n_units_used: int, surviving_lines: tuple[Triple, ...] = (), n_edges_touching_crop: int = 0, length_touching_crop: float = 0.0)

How much of a mesh one policy declined, in the units the estimator actually uses.

Every field is a count or a length, so a caller can put the refused share next to the accuracy number it produced. n_units_* are in whichever unit the estimator's rows are — lines for the line-averaged methods, edges for the per-edge one — and the edge and line counts are always both present so the two families can be compared.

Attributes:

Name Type Description
policy WedgeRefusalPolicy

the policy these counts describe.

n_edges int

trijunctional edges the mesh has.

n_edges_refused int

how many of them the refusal names.

n_lines int

junction lines the mesh has.

n_lines_without_row int

lines left with no row at all by this policy.

n_lines_downweighted int

lines that keep a row whose weight or angle the policy changed.

length_total float

total trijunction arclength, summed over geometric edges.

length_refused float

arclength carried by the refused edges.

n_units int

rows' observation units before refusal.

n_units_refused int

units the policy removed.

n_units_used int

units that still carry a row. n_units_refused + n_units_used == n_units.

surviving_lines tuple[Triple, ...]

the region triples that still carry a row, sorted.

n_edges_touching_crop int

edges declined because they touch a crop face (0 without crop faces). They are not counted in n_edges_refused, which counts the policy's own.

length_touching_crop float

arclength carried by those edges.

LineAveragedAngles dataclass

LineAveragedAngles(dict_rad: dict[Triple, float], dict_length: dict[Triple, float], partition: JunctionGraphPartition, report: AngleReadingReport)

The three maps a line-averaged builder consumes, after a refusal policy has been applied.

Attributes:

Name Type Description
dict_rad dict[Triple, float]

mean wedge angle in radians, keyed by wedge key, over the edges the policy left in the line.

dict_length dict[Triple, float]

accumulated length per wedge key, in the historical unique=False convention the builders expect, scaled by the policy's per-line weight factor.

partition JunctionGraphPartition

the junction-graph partition over the surviving lines, so an interface left with no surviving line is reported undetermined rather than given a number.

report AngleReadingReport

the angle reading's own coverage report, unchanged.

PerEdgeRows dataclass

PerEdgeRows(keep: tuple[int, ...], weight_scale: tuple[float, ...], surviving_lines: frozenset[Triple], n_refused: int)

Which of a mesh's trijunctional edges still carry a row, and how their weight is scaled.

Attributes:

Name Type Description
keep tuple[int, ...]

index, into the caller's own trijunction_edges list, of every edge that still carries a row, ascending.

weight_scale tuple[float, ...]

the multiplier on each kept row's weight, aligned with keep. All ones for every policy but DownweightLine, where it is the edge's line's unrefused arclength fraction.

surviving_lines frozenset[Triple]

the region triples still carrying at least one row.

n_refused int

how many edges lost their row, for either reason (the policy or the crop).

triple_of_edge

triple_of_edge(edge: TrijunctionEdge) -> Triple

The sorted region triple of one trijunctional edge, i.e. which junction line it lies on.

Parameters:

Name Type Description Default
edge TrijunctionEdge

one geometric trijunctional edge.

required

Returns:

Name Type Description
Triple Triple

(a, b, c) with a < b < c, the three regions meeting on it.

reflex_trijunction_edges

reflex_trijunction_edges(mesh: DcelData, tolerance_deg: float = REFLEX_TOLERANCE_DEG) -> frozenset[GeometricEdge]

Every geometric trijunctional edge of mesh whose mesh is locally folded.

An edge is reflex when its widest oriented wedge exceeds a straight angle by more than tolerance_deg. The oriented chord reading is used whatever reading the estimator itself consumes, so that "reflex" has exactly one definition here and the chord reading — still the shipped default — has a well-defined refused set too.

Reads the mesh and nothing else.

Parameters:

Name Type Description Default
mesh DcelData

Mesh to analyze.

required
tolerance_deg float

excess past a straight angle, in degrees, above which a wedge counts as reflex. Defaults to :data:REFLEX_TOLERANCE_DEG; see that constant for why it is not a free parameter.

REFLEX_TOLERANCE_DEG

Returns:

Type Description
frozenset[GeometricEdge]

frozenset[GeometricEdge]: (min, max) vertex-key pairs, one per folded edge.

resolve_refused_edges

resolve_refused_edges(mesh: DcelData, refusal: WedgeRefusal) -> frozenset[GeometricEdge]

The edges refusal declines on this mesh: its explicit set, or the folded ones.

Parameters:

Name Type Description Default
mesh DcelData

Mesh to analyze.

required
refusal WedgeRefusal

the policy and, optionally, an explicit edge set.

required

Returns:

Type Description
frozenset[GeometricEdge]

frozenset[GeometricEdge]: empty under NoRefusal. The crop edges are not part of it:

frozenset[GeometricEdge]

they are refusal.crop_edges.

refusal_with_crop

refusal_with_crop(mesh: DcelData, refusal: WedgeRefusal) -> WedgeRefusal

refusal, with the mesh's crop edges added as the second reason to decline an edge.

Every estimator calls this on its own refusal before reading any angle, so the crop is honoured whatever the caller passed. Idempotent; on a mesh without crop faces it returns refusal itself, unchanged, and the untouched code path stays untouched.

Parameters:

Name Type Description Default
mesh DcelData

the mesh, whose crop_faces decide.

required
refusal WedgeRefusal

the caller's refusal.

required

Returns:

Name Type Description
WedgeRefusal WedgeRefusal

the same policy, explicit set and tolerance, with crop_edges set to every

WedgeRefusal

edge that has a vertex on a crop face (foambryo.crop_faces.crop_edges).

refusal_report

refusal_report(mesh: DcelData, refusal: WedgeRefusal, *, per_edge_rows: bool = False) -> RefusalReport

Count what refusal declines on mesh, in the unit the estimator's rows use.

Parameters:

Name Type Description Default
mesh DcelData

Mesh to analyze.

required
refusal WedgeRefusal

the policy to account for.

required
per_edge_rows bool

True when the estimator's observation unit is a single trijunctional edge (the per-edge method), False (the default) when it is a whole junction line. Keyword-only.

False

Returns:

Name Type Description
RefusalReport RefusalReport

the counts, including which lines still carry a row.

line_averaged_angles

line_averaged_angles(mesh: DcelData, *, reading: str = CHORD_READING, refusal: WedgeRefusal = NO_REFUSAL) -> LineAveragedAngles

Angles, lengths and gauge partition for a line-averaged builder, under a refusal policy.

With :data:NO_REFUSAL this calls geometry.compute_angles_tri_with_report(unique=False) and junction_graph.partition_from_mesh and nothing else, in that order — so the untouched path really is untouched and every existing number is bitwise unchanged.

Parameters:

Name Type Description Default
mesh DcelData

Mesh to analyze.

required
reading str

one of foambryo.second_order_angle_reading.ANGLE_READINGS. Keyword-only.

CHORD_READING
refusal WedgeRefusal

which observations to decline. Keyword-only, defaults to refusing nothing.

NO_REFUSAL

Returns:

Name Type Description
LineAveragedAngles LineAveragedAngles

the maps, the partition and the coverage report. dict_rad is empty when the policy leaves no line reporting, which the builder turns into an all-undetermined answer rather than an exception.

per_edge_rows

per_edge_rows(mesh: DcelData, trijunction_edges: Sequence[TrijunctionEdge], refusal: WedgeRefusal = NO_REFUSAL) -> PerEdgeRows

Apply a refusal policy to a per-edge estimator, whose observation unit is one edge.

ExcludeEdgeFromLineMean and DropLine coincide here and the coincidence is exact, not approximate: the unit is a single edge, so excluding a folded edge from its own unit's mean empties that unit, and an empty unit has no row — which is what dropping it means. They are the same intervention. DownweightLine is genuinely different: it keeps every row, including the folded ones, and scales the whole line's rows by its unrefused arclength fraction.

Parameters:

Name Type Description Default
mesh DcelData

Mesh to analyze — read only to decide which edges are folded.

required
trijunction_edges Sequence[TrijunctionEdge]

the estimator's own edge list, under whichever reading it uses. The edge set and lengths do not depend on the reading.

required
refusal WedgeRefusal

which observations to decline. Defaults to refusing nothing, in which case every edge is kept with scale 1.0.

NO_REFUSAL

Returns:

Name Type Description
PerEdgeRows PerEdgeRows

the surviving rows and their weight scales.

all_interfaces_undetermined

all_interfaces_undetermined(keys: Sequence[Interface], mean_tension: float, partition: JunctionGraphPartition) -> ComponentTensionSolution

The answer when a policy leaves no observation at all: every interface nan.

Assembles the normalisation's row alone and hands it to junction_graph.solve_gauged_least_squares, so the undetermined interfaces are produced by the same machinery that already produces them for a closed surface, and the result carries the same undetermined / component_of / gauge_notes structure every caller reads.

Parameters:

Name Type Description Default
keys Sequence[Interface]

interface key per column, in order.

required
mean_tension float

the gauge value the caller requested.

required
partition JunctionGraphPartition

the partition over the surviving lines — with no surviving line every interface is isolated.

required

Returns:

Name Type Description
ComponentTensionSolution ComponentTensionSolution

float("nan") at every interface.

require_refusal_is_honoured

require_refusal_is_honoured(method_name: str, refusal: WedgeRefusal) -> None

Refuse a non-default policy on an estimator that reads no wedge angle.

The counterpart of tension_inference._require_reading_is_honoured, and for the same reason: an estimator that assembles an area/volume or force-balance operator has no trijunction row to decline, so silently accepting a policy would hand the caller a result they believe refused its folded observations and which never had any.

Parameters:

Name Type Description Default
method_name str

the estimator's name, for the message.

required
refusal WedgeRefusal

the requested refusal.

required

Raises:

Type Description
ValueError

if refusal is not NO_REFUSAL's policy.