Skip to content

Periodic microstructures and bug fixes - #114

Open
rimoli wants to merge 52 commits into
masterfrom
develop
Open

rimoli wants to merge 52 commits into
masterfrom
develop

Conversation

@rimoli

@rimoli rimoli commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

This branch adds periodic microstructures in 2D and 3D, fixes a round of bugs found while going through the package, and gets the CI running again. The full list of changes is in CHANGELOG.rst under "Unreleased".

Periodic microstructures

  • <periodic> in <domain> selects the periodic axes (True, or e.g. xy); the same option is an argument of cli.run, SeedList.position and PolyMesh.from_seeds.
  • Seeds are placed with their periodic images, and the Laguerre tessellation is periodic: cells crossing a face are cut, and their pieces tile the domain.
  • The triangular, tetrahedral and raster meshes have matching nodes on opposite faces. The quality and size settings (mesh_min_angle, mesh_max_volume, per-phase max_volume, mesh_max_edge_length) act as on non-periodic meshes.
  • The Abaqus output has one node set per periodic face, in matching order, ready for periodic boundary conditions. The text files store the pairs of nodes and facets.
  • A new setting, periodic_margin, keeps seeds from leaving thin slivers on the faces. It is off by default, and the examples use auto. edge_opt now also thickens thin pieces of cells at the faces and opens narrow corners there.
  • New examples, each with a page in the docs: periodic_2D.xml, periodic_3D.xml, pbx_2D.xml, pbx_3D.xml, pbx_interface_2D.xml, pbx_interface_3D.xml and periodic_tiling.py.

Bug fixes and changes

The fixes are listed under "Fixed" in the changelog. They include reproducible seeding, cdf size distributions, TetGen volume constraints, the orientation of some ellipsoid breakdowns, cells clipped by circular and elliptical domains, several hangs, and the raster mesh and TriMesh.write outputs.

Some changes affect existing inputs, listed under "Changed":

  • mesh_max_edge_length now also refines the grain boundaries of 3D meshes.
  • The facets of triangular and tetrahedral meshes are sorted, so repeated runs give identical files. Triangle and TetGen list edges in an order that varies between runs.

Tests and docs

  • 222 tests, against 13 on master. They include geometric checks of periodic tessellations and meshes against tiled non-periodic references.
  • The documentation builds with Sphinx without warnings, including the new example pages.

CI

CI runs again and passes on Linux, macOS and Windows. The fixes on this branch:

  • The import order (isort) in the files I touched.
  • The test jobs could not start. The tox==3.14.0 pin in requirements.txt forced an old pluggy, with which pytest itself fails to import. This would also have hit master on its next push.
  • pyvoro-mmalahe on PyPI bundles Voro++ 0.4.6, whose radical tessellation can return a cell uncut when the compiler fuses multiply-adds. On macOS, pip compiles pyvoro-mmalahe from source, and Apple clang fuses them by default on Apple silicon. The cells then overlap, and Triangle segfaulted on the result in the macOS jobs. For example, built that way, four discs of radius 0.1 at the quarter points of a unit square give four cells of area 1.0 instead of 0.25. Built without fused multiply-adds, as in the Linux wheels of pyvoro-mmalahe, the same case gives 0.25. Voro++ made these tests robust upstream in February 2026. As we discussed, I published a fork, pyvoro-rimoli (repository), which bundles the current Voro++ and has wheels for Linux, macOS and Windows, and MicroStructPy now depends on it. With it, the whole test suite passes, and our regression cases give bitwise the same meshes as the build we used during development.
  • The Linux jobs were killed for a different reason. One of the new tests built a reference tessellation that needed about 10 GB of memory, and the runners ran out of it and were shut down. The reference now uses only the copies of the seeds near the domain, with the same values and a third of the memory.
  • Read the Docs used Python 3.8, which pyvoro-rimoli does not support. It now uses Python 3.10 and installs requirements.txt, like the documentation check.

One check is still red. Read the Docs now installs everything, but the build runs every example, and with the new periodic examples it goes past the 15-minute limit of the free plan. The documentation checks on GitHub build the same pages and pass.

Trying it

pyvoro-rimoli installs the same pyvoro module as pyvoro-mmalahe, so uninstall the old one first:

pip uninstall pyvoro-mmalahe
pip install -e .
microstructpy --demo=periodic_2D.xml

🤖 Generated with Claude Code

rimoli and others added 30 commits September 22, 2026 20:02
- Ellipsoid.approximate: correct axis mapping for the c >= a >= b ordering
  and sort the b >= c >= a ordering explicitly (grains were tessellated
  with the wrong orientation)
- Ellipsoid.limits: exact bounding box for rotated ellipsoids
- Ellipsoid: fix the (c, ratio_bc) solver, write rot_seq with quoted axes
- Ellipse: axes keyword, orthonormality check of matrix/orientation,
  'axes' in area_expectation
- Ellipse/Ellipsoid.reflect: broadcasting and unscaling
- *_expectation: accept numpy scalars; seed the Monte-Carlo estimates
- NBox.within honours the rotation; NBox.__str__ full precision
- Rectangle(length=...) alone; Square.area_expectation return value
- Sphere.plot on a fresh figure; NSphere.best_fit with integer points
- __eq__ for Ellipse, Ellipsoid and NBox

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- from_info: iterate keywords in sorted order (the RNG chain depended on
  the per-process set order) and do not modify the rng_seeds argument
- calc_rtol is the single implementation of the overlap-tolerance fit
  (position() used a duplicate); handles a single seed
- Seed: breakdowns are float arrays also when read from a file (seeds
  loaded from seeds.txt could not be repositioned); a geometry center or
  position is respected; update_breakdown(); robust __eq__; clear error
  for unsupported shapes; parseable __str__/__repr__
- position(): 'random' entries in per-axis position distributions
- sample_pos_within raises instead of looping forever
- SeedList(seeds=None); numpy arrays as per-item plot keywords;
  plot_breakdown in 3D on a fresh figure; remove unused sampling helpers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Cells that intersect a circular/elliptical domain without a vertex
  inside it are no longer dropped; cells cut twice are clipped correctly;
  a cell containing the domain becomes a boundary polygon
- _loop_area indexes the loop (stored areas of clipped cells were wrong)
- _segment_cross uses a fixed number of bisections (hung for large
  coordinates)
- edge_opt works on a copy, displaces seeds rigidly and applies only the
  accepted moves to the caller's seeds; quiet unless verbose
- Full-precision points in text files; write(format='poly') writes the
  file; plot() in 3D on a fresh figure; numpy arrays as per-item plot
  keywords; __eq__ is silent and linear

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- 3D: honour mesh_max_volume and per-phase max_volume (TetGen never saw
  them); 2D: per-phase values are not capped by the global one and an
  infinite global value is not passed as the mis-parsed switch 'ainf'
- RasterMesh: 'ij' grid indexing (elements were clockwise / inverted),
  facets derived from cell differences, correct Abaqus face ids, vtk
  output (also with voids), 3D plot
- tet/tri writer: valid .ele/.edge/.face files
- Abaqus: exterior surface unions only reference defined surfaces;
  polymesh optional
- Full-precision points in text files; no dangling headers; clear errors
  for unknown meshers and non-simplex elements; gmsh path with default
  phases and without unused nodes; _sort_facets reports disjoint loops

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- cdf inputs: pass density=False to rv_histogram (distributions were
  distorted for unevenly spaced x); 'pdf' as an alias of 'histogram'
- plot_tri: the 3D visibility walk terminates when voids touch the
  boundary (it looped forever)
- dict_convert passes the input file path into repeated tags (relative
  filenames failed with more than one material)
- from_str: no infinite recursion on 'true'/'false' substrings; inf/nan
- run(): no mutable defaults, arguments are not modified; unsupported
  output types and missing input files reported clearly; mesher names
  case-insensitive
- <include>: repeated materials are concatenated instead of discarded
- verification: angle_rad inputs are kept, 'random' orientations and
  vector-valued parameters work, unknown phase fields are ignored, the
  caller's phases are not modified; single-material colour maps

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SeedList.position(periodic=...) accepts True, a list of booleans or axis
names ('x', 'xy', ...). A seed that crosses a periodic face of a
rectangular domain takes part in the overlap test through its images
translated across the domain (all combinations of the crossed faces, so
edges and corners are covered); placed seeds enter the AABB tree with
their images. _misc.periodic_axes parses the specification and
_misc.periodic_domain_limits checks the domain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PolyMesh.from_seeds(periodic=...) computes the Laguerre tessellation in
Voro++'s periodic mode (breakdown centers wrapped into the domain), cuts
each wrapped cell at the periodic faces and translates the outside
pieces into the domain, so the pieces of a seed tile the unit cell. Cut
edges become wall facets; neighbors are resolved to the pieces sharing
the edge. Points and facets on opposite faces are paired, snapped to
exact translates, and stored in periodic_axes / periodic_points /
periodic_facets, which the text format persists. 3D raises
NotImplementedError for now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A periodic PolyMesh gives a periodic TriMesh: the facets on opposite
periodic faces are subdivided identically and Triangle runs with -Y (no
Steiner points on the boundary), so the nodes on opposite faces are
images of each other. Nodes and facets are paired, snapped to exact
translates and stored in periodic_axes / periodic_nodes /
periodic_facets, persisted in the text format and written to Abaqus as
node sets in matching order. Raster meshes of periodic polymeshes are
paired too (the mesh size must divide the domain length). gmsh is not
supported for periodic meshes. The pairing helpers live in _misc and are
shared with PolyMesh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ation

<periodic> is a field of <domain> in the XML input (True, a list of
flags, or axis names such as 'xy'); cli.run(periodic=...) passes it to
the seed placement and the tessellation, the meshes inherit it.
seeds_of_best_fit unwraps the points of grains split by the periodic
faces around their seed before fitting. Documented in the domain page
and the changelog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cells that cross a periodic face of a box are cut by the face planes
(convex polyhedron clipping, with the cap face taken as the convex hull
of the cut points), the pieces are translated back into the domain and
their neighbours are resolved, so the polyhedral mesh tiles space along
the periodic axes. Vertices within a small tolerance of a cut plane are
snapped onto it before cutting and nearly coincident points are merged
globally afterwards, which keeps the cuts of the two cells sharing a
face consistent and lets the points and facets on opposite faces be
paired exactly, as in 2D. Pieces are dropped only when they are flat.

Validated against the non-periodic tessellation of the 3x3x3 tiled
seeds (per-seed volumes) and against a single sphere tiling the cube.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The polygons on the periodic faces of the polyhedral mesh are
fan-triangulated and their images on the opposite face receive the
corresponding triangles, then TetGen runs with -Y (no Steiner points on
the boundary), so the nodes and the surface triangles on opposite faces
coincide. Raster meshes, the periodic node/facet pairs in the text files
and the Abaqus periodic node sets already worked in 3D and are now
tested there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cells of the same amorphous phase that share a facet are merged into one
region of the mesh. The Triangle/TetGen mesher labelled the region with
the seed number of its first cell while the raster mesher, the gmsh
mesher and the Abaqus writer used the smallest seed number of the region.
Both conventions agree when the regions of the polymesh are in seed
order, but not in a periodic mesh, where the pieces of the cells that
cross the periodic faces reorder the regions.

A single helper now computes the label of every region: the smallest
seed number of the merged region, which in a periodic mesh also joins the
pieces of one seed and the amorphous cells that touch across a periodic
face, since they form one region of the periodic medium.

The new tests check that the cells of a periodic mesh partition the
domain (closed convex cells, volumes adding up to the domain, random
points in exactly one cell) and that the elements partition the cells
(positive volumes, manifold faces, attribute volumes equal to the cell
volumes, element centroids inside the cells of their attribute).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With -Y (no Steiner points on the boundary), TetGen can leave some of
the sub-faces of a facet unmarked even though the tetrahedra conform to
it. The region attribute of the cell on one side then floods through the
unmarked sub-faces into the cell on the other side, and the facet is
incomplete in the output. This happened for one facet in 30 random
periodic meshes (two tetrahedra with the wrong seed number).

For periodic meshes, the element attributes and the facets are now
computed from the geometry of the polymesh: each element gets the label
of the convex cell that contains its centroid, the facets are the faces
between elements with different labels and the faces on the boundary,
and each is numbered by the polymesh facet it lies on, found from the
cells that contain the center of the face (merged amorphous cells are
not separated by facets of the mesh, so an element may span several of
them). A mesh that does not conform to the polymesh raises an error
instead of being mislabelled silently.

The geometry tests now also check that every face between elements with
different attributes is a facet of the mesh and that the facets cover
the facets of the polymesh exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The quality and size settings of the triangular/tetrahedral mesh
(min_angle, max_volume, the max_volume of each phase, max_edge_length)
now act on periodic meshes as on non-periodic ones. Fixing the boundary
with -Y is not an option: the bundled Triangle then degrades every
wall-adjacent triangle, and the bundled TetGen refuses any Steiner point
that encroaches a sub-face, interior facets included, unless every facet
is already at the target size.

In 2D, the cells that touch the periodic faces are copied outside the
faces while meshing, so that Triangle sees the same geometry on both
sides of each face and refines both faces of a pair the same way. The
mesh is built without -Y; when some nodes on the faces have no image,
the points Triangle added on the faces are put on both faces (as fine as
the finest of the two, not their union) and the mesh is built again,
which converges in one to three passes. The elements outside the domain
are removed, and the rare nodes still without an image are mirrored by
splitting the element behind them. The quality equals that of the free
mesh: the triangles below the minimum angle are those at cell corners
with smaller angles, in both.

In 3D, the domain is meshed once as usual (with all the facets given as
constrained Delaunay triangulations, since TetGen 1.5 crashes on polygon
facets with collinear vertices), then every facet is triangulated with
the points TetGen added on it, the facets on opposite periodic faces
with the points of both faces (merged by a spacing-aware rule) and with
a minimum angle of 20 degrees and the area given by the size settings,
and the mesh is built again with the facets fixed. Compared with the
free mesh of the same periodic polymesh, the mesh has 20-25 % more
tetrahedra and a 5th-percentile dihedral angle of 18-19 instead of
20-21 degrees; the maximum volumes, per phase too, and the maximum edge
length on the faces are met, and the nodes on opposite faces match
exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two CLI examples, periodic_2D.xml (a square periodic in both directions,
with circular and elliptical grains, edge optimization and mesh quality
settings) and periodic_3D.xml (a cube of two phases of spherical grains,
periodic in the three directions), and a package example,
periodic_tiling.py, which builds a 2D and a 3D periodic microstructure
with the API and plots them tiled, so that the grains continue across the
periodic faces and the paired nodes of the triangular mesh are visible.
Each has a page in the examples of the documentation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The coefficients of the rtol='fit' rational polynomial dated from the
original submission of the paper; these are the published ones. For very
wide size distributions they allow less overlap (2D asymptote 0.18
instead of 0.36), so some seeds of high-cv inputs may be rejected during
placement (basalt example: 41 of 1300).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
max_edge_length now acts in 3D on the grain boundaries: when it is set,
the facets of the polyhedral mesh are triangulated to that edge length
(with Triangle, minimum angle 20 degrees) before TetGen meshes the cells,
for periodic and non-periodic meshes alike, so that the elements can be
smaller at the interfaces than inside the grains. TetGen has no such
control of its own.

Meshing a periodic polymesh with many small cells showed that the
geometric tests of a mesh against its polymesh were too strict: the
snapping of the points to the periodic faces makes some facets
non-planar by up to a few 1e-7 of the domain size, more than the
tolerances of 1e-9 and 1e-8 used to find the cell of an element and the
facet of a face, so that a conforming mesh was rejected as
non-conforming. The tolerance is now four times the largest
non-planarity of the facets, each element is assigned to the cell in
which its centroid is deepest, and the extra points of a facet are
projected on its plane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aces

Voro++ computes each cell on its own, so two cells that share a vertex
hold copies of it that differ by its precision (a few 1e-9 of the domain
here), and in periodic mode the copies can lie in different images of
the domain. The pieces of the two cells were matched along their shared
edges with a tolerance of 1e-10, which failed for such a vertex during an
edge optimization: "Cannot resolve the neighbor of the edge ...".

The copies of the vertices that coincide, modulo the length of the domain
along the periodic axes, are now replaced by their mean before the cells
are cut, and snapped onto the periodic faces once for all cells, so that
the cells are cut consistently and their pieces match exactly; the pieces
are matched with the merge tolerance of the points. Verified on the
failing case, the tiled-reference tests and a stress test of random
realizations with edge optimization in 2D and 3D.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e refinement

pbx_2D.xml and pbx_3D.xml build a periodic particulate composite, such as
a plastic-bonded explosive: crystalline inclusions with lognormal sizes in
a binder, which is a matrix phase seeded with small circles or spheres.
pbx_interface_2D.xml and pbx_interface_3D.xml mesh the same material with
elements refined along the grain boundaries (the binder-inclusion
interfaces) and coarse inside the grains, through max_edge_length and a
larger max_volume. Each has a page in the examples of the documentation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A seed that barely crosses a periodic face, or ends just inside it,
leaves a thin piece of its grain on the opposite face, and the mesh has
elements much smaller than its target size there. The new periodic_margin
option of SeedList.position (and setting of the CLI) rejects the
positions where a seed ends within the margin of a periodic face or
crosses it by less than the margin, and tries another position; 'auto'
in the CLI sets the margin to half the target edge length of the mesh,
from mesh_max_edge_length or mesh_max_volume.

Over eight realizations of the periodic 2D example, a margin of half the
target edge length halves the number of boundary triangles with edges
below a quarter of the target and removes a third of the thin pieces;
the edge optimization, which does not act on the thin pieces, reduces
the small elements elsewhere, and the two combine. The thin pieces that
remain come from corners of cells crossing the faces, which the geometry
of the seeds cannot predict. The periodic 2D example uses the margin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The points of a periodic polymesh are snapped onto the periodic faces
within _SNAP_TOL of the domain size, which makes some facets non-planar
by up to that distance and the cell volumes of the two sides of such a
facet differ at the same level. The partition tests now use twice the
snapping tolerance for planarity, convexity and containment, compare the
volume sums at 1e-6 and check for gaps and overlaps with widened and
shrunk cells respectively, instead of tolerances of 1e-8 and 1e-9 that a
snapped vertex can exceed. A cube case with such a vertex is added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The edge optimization now works on the features of the mesh: its edges
and, in periodic meshes, the thickness of every piece of a cell at a
periodic face. With a positive periodic_margin (a new argument of
PolyMesh.from_seeds, passed on by the CLI from its setting), every piece
thinner than the margin is a target besides the shortest feature: the
seed of the piece is pushed so that the piece reaches the margin or
retracted so that the cell ends a margin inside the face, then it and the
seeds of its neighbors are moved randomly normal to the face and to their
facets with it. A trial is accepted when the shortest feature that it
changes gets longer (the features of the two meshes are matched by their
geometry), which is the previous criterion for the shortest edge and lets
the optimization move on to the next target when one is stuck. Seeds
moved across a periodic face are wrapped back into the domain.

The CLI writes and plots the seeds again after the optimization, so that
the seed file reproduces the polygonal mesh. The periodic tiling example
uses the margin, and the documentation describes the optimization.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 'auto' periodic_margin is now the smaller of half the target edge
length of the mesh and an eighth of the size of the smallest seed (its
smallest diameter or side): the smallest particle needs about four
elements across it, so the mesh cannot be coarser than a quarter of it,
and a seed cannot satisfy a margin larger than itself, which rejected the
small seeds near the faces at placement.

The periodic examples ship with periodic_margin auto and edge_opt (10
iterations in 3D, 25 in 2D), and the tiling example computes the same
margin from its seeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A corner of a cell at a periodic face that is narrower than the minimum
angle of the mesh cannot be meshed to that angle: the mesher refines it
in shells of very small elements instead (in the periodic meshes, each
pass deepens the shells further). Such corners are now features of the
edge optimization, with the size of the elements they force (a quarter
of the thickness of the wedge at the end of its shorter side), and are
targets like thin pieces when under the margin. The facet turns with the
line between the seeds on its two sides, so the first trial moves them
along the facet, in opposite directions, by the amount that opens the
corner to the minimum angle plus two degrees, and the others move them
along the facet randomly.

PolyMesh.from_seeds takes the minimum angle of the mesh to be built as
min_angle; the CLI passes its mesh_min_angle setting, so no new setting
is needed. The tiling example passes it too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the nodes on the periodic faces did not match after a pass, the next
pass put the points that Triangle had added on the faces back into the
facets and meshed the cells again from scratch. In a corner narrower than
the minimum angle, where Triangle refines in shells of small elements,
each pass then split the segment at the tip once more, one level per
pass, down to very small elements. Now all the points of the previous
mesh are the input of the next pass, with the points on a periodic face
and on its image merged and put on both faces, so that Triangle only
refines the mesh around the merged points, in the same surroundings (the
copies of the cells outside the faces) that produced them. The faces
match after two passes in the examples, nothing is mirrored, and the
quality is that of the first pass.

The copies of the cells at the corners of the domain, moved along both
axes, were open on the side of the periodic wall facet that was skipped
as if it lay on the opposite face; Triangle discarded part of them. The
copies are closed now, which also makes the first pass match the faces
better, and the mesher checks that Triangle kept its input points.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pages of the interface refinement examples have the Materials and
Domain Geometry sections of the other pages and point to the seed and
polygonal mesh figures of the binder and inclusions examples, which they
share; the tiling example page has the sections of the other package
examples (Domain, Phases, Seeds, Meshing, Plotting). Two numbers in the
pages that no longer matched the input files (the overlap tolerance of
the 3D binder example, the maximum edge length of the 3D interface
example) are corrected, the pages mention the narrow corners that the
optimization opens, the comments of the periodic 2D input file describe
the current margin rule, and the mesh comment of the 3D interface input
file sits next to the mesh settings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The periodic domain and settings pages referred to each other with the
labels cli_domain and cli_settings, which did not exist. The labels are
added at the top of the two pages; the documentation now builds without
warnings. The outputs that the documentation build writes into the
package (the example results, the gallery and the copied CSV files) are
ignored by git.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wall ids of a rectangular domain are decoded by one helper, the
check that a cell of a periodic tessellation is narrower than the domain
is one function for 2D and 3D, the points of a region are collected by
one helper, the CLI evaluates the periodic margin once, and the shortest
edge finder that the optimizer no longer uses is removed. No arithmetic
changes: the meshes of the examples and of the regression cases are
identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
rimoli and others added 8 commits September 23, 2026 15:45
The loop over the cells whose bounding box contains some points, with
the signed distances of those points to the facet planes, was written
three times in the attribute assignment and in the collection of the
facet points of the 3D meshes; it is one method of the cell geometry
helper now, with the same products in the same order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cutting a cell at the periodic faces (snap the vertices, check the
width, clip below and above each face, drop the flat pieces, translate
the outside pieces into the domain) was written once with the loop
clipper and once with the polyhedron clipper; the loop takes the clipper
as an argument now. Same operations in the same order: the regression
cases are identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Triangle and TetGen list the edges/faces of a mesh in an order, and with
an orientation, that vary from one run to the next, so the mesh files of
otherwise identical runs differed in their facet lists. The facets are
now sorted when the mesh is created, whatever the mesher: nodes in
ascending order within a facet and facets in lexicographic order, with
their attributes carried along. The orientation of a facet is not used
by the package. The facets of the periodic meshes, computed from the
polygonal mesh, were already in that order and are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The CI import-order check (isort, force_single_line, microstructpy as
the first-party section) rejected five files: the scipy import of the
mesh module belonged after the matplotlib imports, and the imports of
the shared test helpers belong with the third-party section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The workflow installed pytest 6.2.5 and a current pytest-cov, then the
requirements pinned tox 3.14, whose pluggy < 1 requirement downgraded
pluggy to 0.13; pytest-cov then failed to load ("unexpected keyword
argument 'wrapper'") and no test ran. The workflow installs the current
pytest, tox is no longer a requirement of the package (nothing imports
it; tox.ini stays for local use), and the jobs of the test matrix no
longer cancel each other when one fails, so that every platform reports
its own result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pyvoro-mmalahe bundles Voro++ 0.4.6, whose radical (Laguerre)
tessellation can return a cell uncut: the cells then overlap, and
Triangle and TetGen can crash on the polygonal mesh. This is why the
tests fail on Linux and macOS in the continuous integration. The
pyvoro-rimoli fork provides the same pyvoro module with the current
Voro++, where this is fixed, and has wheels for Linux, macOS and
Windows.

The README and the installation guide tell users to uninstall
pyvoro-mmalahe before upgrading, since both packages install the same
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_periodic_3d_matches_tiled_reference compares the periodic
tessellation of 83 seeds (about 3000 breakdown spheres) with a
non-periodic tessellation of the seeds copied across the periodic
faces. With all 27 copies, that reference needs about 10 GB of memory
(peak footprint on macOS, where memory compression hides part of it
from the resident size), in the test process and again in the pyvoro
probe subprocess. The Linux runners of the continuous integration
(16 GB, no memory compression) ran out of memory there and were shut
down, which is why the Linux test jobs failed.

tiled_reference_volumes takes an optional margin: only the copies whose
center is within that distance of the domain are kept, and the tiled
domain ends there, plus the extent of the largest breakdown. The 3D
test uses a margin of 1, half the side of the domain; the reference
volumes are the same as with all the copies to rounding (relative
difference 3e-15; a margin of 0.7 is not enough). The peak memory of
the test file goes from 10 GB to 3.3 GB and its time from 77 s to
32 s. The 2D tests keep all the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read the Docs used Python 3.8, for which pyvoro-rimoli has no release
(it supports Python 3.9 and later), so the build stopped when installing
the package; it already failed on 3.8 for other pull requests, when a
pinned dependency dropped 3.8. It now uses Python 3.10 and installs
requirements.txt as well, like the documentation check of the
continuous integration, so that the same versions (numpy < 2 among
them) are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kip-hart
kip-hart self-requested a review October 6, 2026 03:17
@kip-hart kip-hart added the enhancement New feature or request label Oct 6, 2026
@kip-hart

kip-hart commented Oct 6, 2026

Copy link
Copy Markdown
Owner

I'm getting ready to post feedback on this PR. Nothing major. Currently we're over the docs builder's (readthedocs) limit of 15 minutes. I'm testing if tweaking the parameters for the basalt circle example can get us under that limit. That testing is in #115 .

@kip-hart
kip-hart added this pull request to stack #116 October 8, 2026 04:27

@kip-hart kip-hart left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pulled the branch and ran it instead of reading the diff alone. Python 3.12.3,
a fresh venv built from this branch's requirements.txt, package installed with
pip install -e ..

The suite passes. 217 passed in 169s, no failures, which matches what the
description claims. The jump in coverage is in real tests rather than in
parametrization: master has 14 test functions across tests/, this branch has
191, and those 191 collect as the 217 cases.

CI claims that check out

The tox==3.14.0 diagnosis is right, and I reproduced it in the order the
workflow actually installs things. Installing flake8 pytest pytest-cov coveralls first and then requirements.txt pulls pluggy back down to 0.13.1,
and pytest then fails to import at all:

ImportError: cannot import name 'HookimplOpts' from 'pluggy'

One correction for the description. It says the old pluggy is one "that the
current pytest-cov cannot load". It is pytest itself that cannot load, not
pytest-cov. Same root cause, same fix.

The pyvoro fork is what it says it is. I diffed both sdists from PyPI.
pyvoro/__init__.py, pyvoro/vpp.cpp and pyvoro/vpp.h are byte identical to
pyvoro-mmalahe 1.3.4, so nothing changed in the Python facing layer. The bundled
Voro++ really is updated, 2396 lines of difference across the 27 sources in
src/.

CI's blocking lint passes. flake8 src tests setup.py --exclude=__init__.py --select=E9,F63,F7,F82 returns 0. The five remaining cosmetic warnings are all
in docs_banner.py and logo.py, which this branch does not touch.

What I found

Four defects I confirmed by running code, in the order I would fix them:

  1. The 2D orientation error statistics are wrong by up to 360 degrees, because
    the angles are subtracted without wrapping. A fit whose true worst error is
    0.8 degrees reports mae=359.50 and R^2=-2.997. Details in the inline
    comment on verification.py.
  2. <periodic>0</periodic> raises TypeError instead of turning periodicity
    off. periodic_axes has no integer branch.
  3. Six geometry classes lost hashability, because __eq__ was added without
    __hash__.
  4. One whole domain Triangle build per non converging input is computed and
    thrown away.

One more that is not a defect but is a cost, in the inline comment on
calc_rtol. The new fit lowers the tolerance on high cv inputs, which rejects
more seed placements. On basalt_circle.xml, 1300 seeds at cv 4.27 and no
explicit rtol, positioning goes from 6.1 s to 35.8 s with everything but the
tolerance held fixed. That accounts for 29.7 s of the 32.5 s that example got
slower overall. Every input with a long tailed size distribution and a default
rtol pays it.

Two more that are real but narrow. The two np.allclose(..., atol=tol) calls in
the periodic 2D boundary code leave rtol=1e-5 in place, which defeats the
tol = 1e-9 * scale the code sets deliberately. I worked out what it takes to
bite: a false match needs 1e-5 * |mins| >= lengths, so an offset to size ratio
of 1e5 or more. At exactly that ratio the periodic build does fail, with
RuntimeError: Triangle did not keep the input points., but I could not
attribute that failure to the tolerance leak rather than to double precision at
those coordinates. I am flagging the leak as latent rather than as a
demonstrated wrong answer.

Two things I want your input on

A note on the pyvoro reproducer, which does not change the conclusion. Four
discs of radius 0.1 at the quarter points of a unit square give the correct
[0.25, 0.25, 0.25, 0.25] under pyvoro-mmalahe here, not the four cells of area
1.0 the description reports. I ran a differential fuzz past that too, 200 2D
cases per periodicity setting at unequal radii plus 120 3D cases, identical
seeds against both builds, and the outputs are bitwise identical.

That is a statement about this machine and not a challenge to the swap. Two
things explain it. pyvoro-mmalahe publishes wheels only for manylinux x86_64,
so macOS and Windows have always compiled it from the sdist, with clang and
MSVC. And the upstream fix is a numerical robustness change: rad_option.hh
drops sqrt from the two radical cutting predicates and computes r_mul as a
difference of squares, while config.hh replaces the 1e-11 tolerance and the
whole marginal case buffer with 10*DBL_EPSILON and an extra search stack. In
strict IEEE double the old and new predicates agree on four million random
draws I threw at them. They diverge once a compiler contracts expressions or
keeps intermediates in wider precision, which is exactly how this ends up
showing on one platform and not another.

So the swap looks justified to me. The ask is smaller: say in the description
which platform the four disc case was observed on, since it reads as a flat
statement about the library and someone on Linux will try it and see 0.25.

The other item is calc_rtol. See the inline comment for the numbers.

On the new dependency

pyvoro-rimoli 1.4.0 is a single release uploaded 2026-09-30, with no
attestations or Trusted Publishing provenance in its PyPI metadata. Both
setup.py and requirements.txt point at it, so every pip install microstructpy and every CI job pulls it. The author_email in the metadata is
still Joe Jordan's, inherited from upstream, which is worth fixing on your side
so the package reports its actual maintainer.

The wheels cover cp39 through cp314 and requires_python is >=3.9, so the
3.9 entry in the test matrix is fine.

Documentation

The docs hold up. The new example pages follow the same section structure as
the existing ones, the docstring convention is unchanged, and coverage of it
went up rather than down: Returns: goes from 44 to 95 and Raises: from 1 to
10 across the library.

Three small things, all in inline comments. The first Added bullet of the
changelog runs 30 lines against a median of 4 and buries the gmsh limitation at
the end of it. The added prose joins clauses with semicolons 54 times where the
existing docs never do. And labelled appears four times in a repo that is
otherwise consistently US spelled.

The remaining red check is the Read the Docs timeout, and it is now handled
separately in #115, so nothing is needed here for it. The short version is that
the build runs every example and cannot finish inside the fifteen minutes Read
the Docs allows, so #115 moves that work to the documentation workflow and has
Read the Docs restore the figures instead. That took it from a 901 s timeout to
96 s.

Two things came out of that work which belong to this branch rather than to
#115. The numpy churn in the inline comment on .readthedocs.yaml, and an
invalid XML declaration in elliptical_grains.xml, which declared
version="0.5" where the specification allows only 1.x. The second is older
than this branch and I fixed it in #115, since it was failing the documentation
check here intermittently: Expat 2.6.1 accepts it and newer Expat rejects it, so
it passed locally and failed on some runners.

Comment thread src/microstructpy/verification.py Outdated
Comment thread src/microstructpy/_misc.py
Comment thread src/microstructpy/geometry/ellipse.py
Comment thread src/microstructpy/meshing/trimesh.py Outdated
Comment thread src/microstructpy/meshing/trimesh.py Outdated
Comment thread requirements.txt Outdated
numpy>=1.24.4,<2.0
pyquaternion==0.9.5
pyvoro-mmalahe==1.3.4
pyvoro-rimoli==1.4.0

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through the fork before commenting here. The Python facing layer is
byte identical to pyvoro-mmalahe 1.3.4, and the Voro++ update is real and
does what the changelog says, so the substance of this is fine by me.

For the record of why, since the four disc case in the description does not
fail on Linux: the fix is in rad_option.hh, which drops sqrt from
r_ctest and r_scale_check and computes r_mul as (r-mx)*(r+mx) instead
of r*r - mx*mx, and in config.hh, which replaces the hard coded 1e-11
tolerance and the marginal case buffer with 10*DBL_EPSILON and an extra
search stack. cell.cc carries 1282 substantive changed lines on top of
that. The old and new predicates agree in strict IEEE double across four
million draws, so the divergence needs a compiler that contracts expressions
or widens intermediates. That fits a failure on the macOS and Windows
runners, which is where this always built from source, given pyvoro-mmalahe
ships wheels for manylinux x86_64 only.

Three things I would still like.

Name the platform in the description for the four disc case. As written it
reads as a property of the library, and anyone on Linux will try it and get
0.25.

Keep the bitwise regression check you mention. Thirteen years of upstream
changes to the cell cutting core is a lot of moving parts under our meshes,
and a stored reference mesh is the cheapest guard against the next Voro++
bump moving results quietly.

Turn on Trusted Publishing in the fork's release workflow so the wheels carry
PyPI attestations. It is cheap to set up and this is now a dependency of a
published package. The author_email in the metadata is also still
joe.jordan@imperial.ac.uk, inherited from upstream, so the package does not
name its actual maintainer.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for going through the fork. Three answers.

Platform: you were right, and I confirmed your explanation. The 0.4.6 sources built with Apple clang's defaults on Apple silicon give the four cells of area 1.0. The same sources built with -ffp-contract=off give 0.25 and pass all the tests of the fork. The PR description, the changelog and the fork's README now say this.

Regression guard: pyvoro-rimoli 1.4.1 adds reference tessellations to its tests: the volumes and neighbors of the cells of four fixed configurations, 2D and 3D, bounded and periodic. They are compared to a relative 1e-10, since a bitwise comparison would not survive different compilers, which is the very issue here. The old build with fused multiply-adds fails them, and the same sources built without them pass. The bitwise regression cases of MicroStructPy stay too, and I reran them for this round.

Trusted publishing: 1.4.0 was already published with it, with attestations. PyPI's integrity API returns the provenance of each file, for example https://pypi.org/integrity/pyvoro-rimoli/1.4.0/pyvoro_rimoli-1.4.0.tar.gz/provenance, with publisher rimoli/pyvoro, workflow wheels.yml and environment pypi. The JSON metadata does not show it. The maintainer email is in 1.4.1.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming that with trusted publishing. My comment was an AI hallucination that I didn't check myself.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the last thing is to bump the version pin for pyvoro-rimoli to 1.4.1

Comment thread CHANGELOG.rst Outdated
Comment thread docs/source/cli/domain.rst Outdated
Comment thread CHANGELOG.rst Outdated
Comment thread .readthedocs.yaml
rimoli and others added 8 commits October 7, 2026 23:47
_misc.from_str reads <periodic> 0 </periodic> as the integer 0, and
periodic_axes had no branch for numbers, so it tried to iterate over it
and raised a TypeError instead of turning periodicity off (1 failed the
same way). Numbers now count as booleans: 0 makes no axis periodic and
any other number makes all of them periodic.

Reported by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Defining __eq__ without __hash__ sets __hash__ to None, so the
equality methods added on this branch made Ellipse, Ellipsoid and the
NBox family (Rectangle, Square, Box, Cube) unhashable: they could no
longer go in a set or be dictionary keys. Circle and Sphere had been
unhashable for the same reason since before this branch.

Equality allows for round-off, which no hash can follow, so all of
them now keep the identity-based hash of object, as they had before
__eq__ was defined.

Reported by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
np.allclose keeps its default relative tolerance (1e-5) when only atol
is given, so three checks of the 2D periodic mesher accepted
differences of 1e-5 times the coordinates instead of the intended
1e-9 times the size of the domain: whether a facet lies on a lower
periodic face, whether its image on the upper face runs in the same
direction, and whether Triangle kept the input points. Far from the
origin (offsets about 1e5 times the size of the domain), a facet of
the upper face would read as lying on the lower one. The checks now
pass rtol=0. No result changes for domains of ordinary placement.

Reported by Kip Hart in the review of PR #114 (the first two sites).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the nodes on the periodic faces still did not match at the last
pass, the loop prepared the input of another pass and built one more
mesh with Triangle, which was never used: the mesh of a pass is only
read at the start of the next one. The loop now stops before that
build, after checking that Triangle kept the input points of the mesh
it returns. The result is the same, without a whole-domain
triangulation on the inputs that are already the slowest.

Reported by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The error statistics of the 2D orientations (angle, angle_deg,
angle_rad, orientation and matrix) subtracted the angles as plain
numbers. A near-perfect fit across the 0/360 wrap reported errors of
almost 360 degrees, and an ellipse recovered with its major axis the
other way round reported 180 degrees, although it is the same ellipse.
The distribution statistics compared the raw angles with the input
distribution in the same way.

The angles are now compared modulo the period of the shape, a half turn
for ellipses and rectangles and a quarter turn for squares: the errors
are the smallest angles between the orientations, R^2 measures the
spread of the output angles around their circular mean, and the
distribution statistics put the output angles and the samples of the
input distribution on the same window of one period (with a two-sample
K-S test when the input is a distribution).

Reported by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/requirements.txt asked for numpy>=1.24.4 with no upper bound, so
Read the Docs installed numpy 2 first, then downgraded it to 1.26.4
when it installed requirements.txt, which pins numpy<2.0. Packages
resolved in between (matplotlib, contourpy) were chosen while numpy 2
was installed. Both files now ask for numpy below 2.

Reported by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prose added on this branch joined independent clauses with
semicolons, in the documentation, the changelog, docstrings and
comments, while the rest of the repository never does. Those clauses
are now separate sentences. "labelled" is now "labeled", as everywhere
else in the repository.

The first entry of the changelog, which covered the whole periodic
feature in 30 lines, is split into one entry per part, and the gmsh
limitation has an entry of its own. The changelog also says that the
old pluggy breaks pytest itself, not pytest-cov, that the Voro++ 0.4.6
bug needs a compiler that fuses multiply-adds (as Apple clang does by
default on Apple silicon), and records the fixes of this review that
users can see.

Suggested by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch had replaced the coefficients of the rtol='fit' tolerance
with Eqs. (14) and (15) of Hart and Rimoli, CMAME 370 (2020) 113242
(page 12). Those printed equations do not agree with the paper itself:
the fitted curves of its Fig. 10, on the same page, follow master's
coefficients (largest difference 0.04 in 2D and 0.02 in 3D, against
0.11 and 0.26 for the printed equations), and master's coefficients
fit the optimal values plotted in Fig. 10 much better (RMS residual
0.022 and 0.019, against 0.063 and 0.119). Eq. (15) reproduces the 3D
curve if its constant term is 0.289 instead of the printed 0.253, so
that looks like a typo. Eq. (14) matches the worked example in the
text (0.70 at cv = 0.53) but not the figure.

calc_rtol uses master's coefficients again, and its docstring cites
Fig. 10 and the discrepancy. Results are those of master. This also
removes the slowdown Kip Hart measured for wide size distributions
with the printed coefficients (positioning in basalt_circle.xml took
35.8 s instead of 6.1 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rimoli

rimoli commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful review, Kip, and for running everything rather than reading the diff. All of it is addressed in eight commits on develop, with a reply on each inline comment:

  • df1095c: <periodic> 0 </periodic> and 1 work.
  • c428e83: the geometry classes are hashable again, and Circle and Sphere now too.
  • fe7d152: rtol=0 in the tolerance checks of the 2D periodic mesher (your two sites, plus a third).
  • 35b7478: no unused Triangle build after the last pass.
  • 29eb126: the 2D orientation statistics compare angles modulo the symmetry of the shape.
  • 543684e: numpy<2.0 in docs/requirements.txt.
  • f8e2e41: the changelog entry split up, sentences instead of semicolons, "labeled", and your pytest correction.
  • 3697c37: rtol='fit' back to master's coefficients, which match Fig. 10 of the paper. Eqs. (14) and (15) as printed do not (details in the inline reply).

Checks: with all the code fixes but the last, the six bitwise regression cases are identical. The last one changes only the case that uses rtol='fit', as expected. The full suite (222 tests) and a nitpicky docs build pass locally.

On the pyvoro side, pyvoro-rimoli 1.4.1 is out, with the maintainer email and the reference tessellations. The four-disc case is Apple silicon, and your explanation with fused multiply-adds is right. The description now says so. Trusted publishing was on from the start, as explained in the inline reply.

One note for #115: these commits change the library and the periodic_3D.xml example, so the example digest changes, and the docs cache will need publishing for the new head.

@rimoli rimoli mentioned this pull request Oct 13, 2026
@kip-hart
kip-hart removed this pull request from stack #116 October 14, 2026 03:21
* Halve the docs build time of the basalt_circle example

The documentation build runs every example in src/microstructpy/examples
(docs/source/sphinx_gallery/plot_demos.py globs and runs them all), and
nothing is cached between Read the Docs builds. The periodic examples
added on this branch pushed that past the 15 minute limit of the Read
the Docs free plan.

basalt_circle is the single largest consumer at 216 s, 29% of the 741 s
the examples take in total, and more than any of the new examples. About
180 s of that is meshing, driven by mesh_max_edge_length on a domain of
diameter 10.

Measured on the same machine:

    0.01 (before)  216.2 s
    0.02            114.4 s
    0.03             83.7 s
    0.05             63.8 s

0.02 halves the example. Coarser values give much less back, since what
remains is positioning 1300 seeds, plotting them and verification. The
rendered figure is unchanged at publication size, with slightly coarser
triangles inside the large olivine grains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Restore the example figures on Read the Docs instead of rebuilding them

A build on Read the Docs is stopped at fifteen minutes on the free plan.
The documentation build runs every example in src/microstructpy/examples
through docs/source/sphinx_gallery/plot_demos.py, which takes about
twenty minutes on that class of hardware, so the build cannot finish. The
examples produce the same figures every time, so this moves the work to
the documentation workflow, which has no such limit, and has Read the
Docs restore what it produced.

Sphinx-Gallery already skips a script whose hash matches the .md5 beside
the output of an earlier build. The difficulty is that plot_demos.py
finds the examples with glob and names none of them, so its hash does not
move when an example is edited or added, nor when the library that meshes
them changes. Caching on that hash alone would serve stale figures and
never report it.

docs/example_digest.py closes that gap. It digests the example inputs and
the library source and keeps the result in plot_demos.py as
EXAMPLES_DIGEST, so editing any of them changes the hash of the script
and the examples run again. The documentation check runs it with --check
and fails if the stored digest is out of date.

docs/example_cache.py packs and restores the Sphinx-Gallery output and
the PNG files the examples write, which the pages embed with figure::.
An archive carries the digest it was built from and restore refuses one
that does not match the working tree, so a build that cannot be warm runs
the examples and may time out rather than publish stale figures.

Measured locally, with the examples run on the same machine:

    cold build   655.4 s
    warm build     6.7 s

The documentation workflow publishes an archive from its html job to the
docs-cache release, and asks Read the Docs to build again so the archive
is picked up. That rebuild needs a READTHEDOCS_TOKEN secret. Without it
the step says so and does nothing, and the build has to be restarted by
hand once the archive is published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fetch the example archive in one step on Read the Docs

The name of the archive was worked out in one job step and written to a
file, and fetched in the next. If the first step failed, the second
fetched an empty name, and the message about a missing archive hid it.

Keep the name in a variable in a single step, and say which of the two
things happened: no archive was published for this commit, or one was
published and refused because its digest is not the digest of this
commit. Both leave the examples to run, and the second should not happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let every documentation format report

The matrix cancels the other two formats as soon as one fails, so a html
build that stops partway through the examples takes latex and epub with
it and there is nothing to compare it against. python_package.yaml
already sets fail-fast: false for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Give elliptical_grains.xml a legal XML version

The file declared version="0.5". The XML specification allows only
'1.' followed by digits, production [26], so the declaration has never
been well formed. Older versions of Expat accepted it and newer ones
reject it, which is why this has only now started failing:

    xml.parsers.expat.ExpatError: XML declaration not well-formed:
    line 1, column 15

Column 15 is where the version value starts. The example is read by
xmltodict in cli.input2dict, so the failure stops the whole gallery and
takes the documentation build with it.

This has been in the file since before this branch, and it is not
reproducible everywhere: Expat 2.6.1 parses it without complaint, so a
developer can run the examples locally and see nothing wrong while the
same commit fails on a runner with a newer Expat. That is what made the
documentation check look flaky, failing in one format and passing in
another on the same commit.

Editing an example changes EXAMPLES_DIGEST, so the archive of figures is
invalidated and the examples run again, which is the intended behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update the example digest for the fixes on develop

The eight commits merged from develop change the library and
periodic_3D.xml, so the figures the examples produce are no longer the
ones in the published archive. The digest check caught it, which is what
it is for:

    EXAMPLES_DIGEST is 314fadcaec7cb6e5, the examples digest to
    2d39355b71ddd257.

The documentation workflow publishes an archive for the new digest and
asks Read the Docs to build again with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
kip-hart pushed a commit that referenced this pull request Oct 14, 2026
pyvoro-rimoli 1.4.1 has the same code as 1.4.0. It names its
maintainer in the package metadata and adds reference tessellations to
its tests. setup.py keeps requiring 1.4.0 or later, since both give the
same results.

Requested by Kip Hart in the review of PR #114.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +1970 to +1971
return [{'material_type': 'solid', 'max_volume': float('inf')}
for _ in range(n_phases)]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_volume is ignored in 2D whenever the caller does not pass phases.

These defaults set the key to infinity, so phase.get('max_volume', max_volume) at line 1485 finds it present and never falls back to the
max_volume argument. Every region is then given an area constraint of
infinity. On master that did not matter, because the global constraint was
passed to Triangle underneath it:

master   meshpy.triangle.build(info, volume_constraints=True,
                               max_volume=max_volume, ...)
this PR  meshpy.triangle.build(info, volume_constraints=True,
                               max_volume=None, ...)

_build_2d dropped the global in 24fa757 and relies on the per-region
values alone. That is right for the periodic mesher, which needs different
constraints for the cells and their copies, and a single global value would
override them. The non-periodic path goes through the same helper, and for
it the infinity in these defaults became load bearing.

Measured on this branch, same polygonal mesh, only phases differing:

phases=None, max_volume=0.4      elements=208   largest element 2.288
phases=[{max_volume: 0.4}]       elements=252   largest element 0.385

The cap is 0.4 and the largest element is 2.288, so it is not being applied
at all. With the suggestion below the first line gives 252 elements and a
largest element of 0.385, and the full suite still passes at 222.

This is visible in the documentation. docs_banner.py calls

TriMesh.from_polymesh(pmesh, min_angle=12, max_edge_length=0.2,
                      max_volume=0.4)

with no phases, and the third panel of banner.png now has a coarse
ellipse where the published one is finely triangulated.

Dropping the key lets the argument through, which is what every reader of a
.get with a default expects:

Suggested change
return [{'material_type': 'solid', 'max_volume': float('inf')}
for _ in range(n_phases)]
return [{'material_type': 'solid'} for _ in range(n_phases)]

Two things worth deciding rather than inheriting.

The same .get appears at lines 2036 and 2892, so this changes the fallback
in _facet_sizes and in the periodic region building as well. Only the
phases=None path is affected, and the new behavior is the intended one in
all three, but it is more than one call site.

An explicit {'max_volume': float('inf')} means unconstrained on this
branch, where on master the global still applied to it. The suggestion keeps
that, which I think is the better reading. It is a change from master either
way and belongs in the changelog.

The reason 222 tests did not catch this is that nothing exercises
phases=None with a finite max_volume. docs_banner.py is the only
caller that takes the path, and nothing asserts on what it draws. A test
that meshes one cell that way and checks the largest element against the cap
would close it.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't comment on this before, I only noticed the drift after seeing the new docs site.

pyvoro-rimoli 1.4.1 has the same code as 1.4.0. It names its
maintainer in the package metadata and adds reference tessellations to
its tests. setup.py keeps requiring 1.4.0 or later, since both give the
same results.

Requested by Kip Hart in the review of PR #114.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants